mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-10 00:14:38 +00:00
Closes the only API-coverage gap from the Docker / Kubernetes IA maturity review: Roles, ClusterRoles, RoleBindings, and ClusterRoleBindings now flow from the Kubernetes agent through the canonical resource registry into the Kubernetes platform-page Configuration tab. Agent: pkg/agents/kubernetes/report.go gains four new report struct types that carry summary counts plus subject-kind sets; individual subject names and full PolicyRule contents are deliberately omitted so Pulse stays a "what permissions exist where" surface, not an RBAC enumeration tool. internal/kubernetesagent/agent.go gains four collectors that call rbacv1.RoleList/ClusterRoleList/etc. through the existing runKubernetesCallWithRetry wrapper, matching the ServiceAccount collector's RBAC-forbidden retry pattern. Canonical: internal/models mirrors with NormalizeCollections coverage; convert* funcs in internal/monitoring/kubernetes_agents.go translate agent report -> model; ResourceTypeK8sRole / K8sClusterRole / K8sRoleBinding / K8sClusterRoleBinding join the canonical type set; registry ingest* + adapter resourceFrom* functions emit one Resource per RBAC object with ruleCount / roleKind / roleName / subjectCount / subjectKinds / aggregationLabels on the K8s meta; search mapping in internal/api/resources.go and the privacy allow-list in internal/api/org_handlers.go pick up the four new type tokens; the K8s privacy category in unifiedresources/policy_metadata.go classifies them like the rest of K8s. Frontend: ResourceType union + ResourceKubernetesMeta carry the new kinds and RBAC summary fields; KubernetesPageSurface query asks for them; the page model buckets them into the Configuration group; KubernetesConfigTable renders Role / ClusterRole rule counts and the aggregated flag, plus RoleBinding / ClusterRoleBinding role refs and "N subjects · Kind1, Kind2 +overflow" subject summaries. Curated demo seeds per-namespace Roles + RoleBindings plus an aggregated ClusterRole + ClusterRoleBinding for pulse-demo-monitoring in each cluster so the Configuration tab renders 18 RBAC rows across the three demo clusters. Contracts updated for the canonical-shape guard: monitoring, api-contracts, unified-resources, frontend-primitives, organization-settings (canonical) plus agent-lifecycle and storage-recovery (dependent via Extension Points). Verification proofs extended: kubernetes_registry_test.go, kubernetes_agents_test.go, agent_inventory_test.go (new TestCollectRBACInventoryReportsSummaryCountsOnly that pins the subject-name-omission contract), demo_scenarios_test.go, adapter_coverage_test.go, contract_test.go, org_handlers_test.go, resourceIdentity.test.ts, reportingResourceTypes.test.ts, KubernetesConfigTable.test.tsx, and the subsystem_lookup_test.py line-anchor bumps that the contract edits shifted (api-contracts 246 -> 253, organization-settings 92 -> 93). Verified: - go build ./internal/... ./cmd/... clean - go test ./internal/unifiedresources/..., ./internal/mock/..., ./internal/kubernetesagent/..., ./internal/api/..., the K8s subset of ./internal/monitoring/... all clean (three pre-existing unrelated monitoring failures noted earlier remain unchanged by this commit) - npm run type-check, lint:eslint, lint:theme, lint:canonical-platforms clean - vitest: 70 K8s frontend tests pass including the new RBAC render coverage in KubernetesConfigTable.test.tsx - browser proof on /kubernetes/configuration: 36 config rows including 18 RBAC rows across three clusters; ClusterRole "pulse-demo-monitoring" shows "12 rules · Aggregated"; ClusterRoleBinding shows "3 subjects · Group, ServiceAccount +1"
1572 lines
49 KiB
Go
1572 lines
49 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
const (
|
|
orgRequestBodyLimit = 64 * 1024
|
|
licenseFeatureMultiTenantKey = "multi_tenant"
|
|
)
|
|
|
|
var organizationIDPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`)
|
|
|
|
var supportedOrganizationShareResourceTypes = map[string]struct{}{
|
|
"agent": {},
|
|
"node": {},
|
|
"docker-host": {},
|
|
"k8s-cluster": {},
|
|
"k8s-node": {},
|
|
"truenas": {},
|
|
"vm": {},
|
|
"system-container": {},
|
|
"app-container": {},
|
|
"oci-container": {},
|
|
"pod": {},
|
|
"jail": {},
|
|
"docker-service": {},
|
|
"docker-image": {},
|
|
"docker-volume": {},
|
|
"docker-network": {},
|
|
"docker-task": {},
|
|
"docker-swarm-node": {},
|
|
"docker-secret": {},
|
|
"docker-config": {},
|
|
"k8s-deployment": {},
|
|
"k8s-replicaset": {},
|
|
"k8s-service": {},
|
|
"k8s-namespace": {},
|
|
"k8s-statefulset": {},
|
|
"k8s-daemonset": {},
|
|
"k8s-job": {},
|
|
"k8s-cronjob": {},
|
|
"k8s-ingress": {},
|
|
"k8s-endpoint-slice": {},
|
|
"k8s-network-policy": {},
|
|
"k8s-persistent-volume": {},
|
|
"k8s-persistent-volume-claim": {},
|
|
"k8s-storage-class": {},
|
|
"k8s-configmap": {},
|
|
"k8s-secret": {},
|
|
"k8s-serviceaccount": {},
|
|
"k8s-role": {},
|
|
"k8s-cluster-role": {},
|
|
"k8s-role-binding": {},
|
|
"k8s-cluster-role-binding": {},
|
|
"k8s-resource-quota": {},
|
|
"k8s-limit-range": {},
|
|
"k8s-pod-disruption-budget": {},
|
|
"k8s-horizontal-pod-autoscaler": {},
|
|
"k8s-event": {},
|
|
"storage": {},
|
|
"datastore": {},
|
|
"pool": {},
|
|
"dataset": {},
|
|
"pbs": {},
|
|
"pmg": {},
|
|
"physical_disk": {},
|
|
"network-share": {},
|
|
"ceph": {},
|
|
"view": {},
|
|
}
|
|
|
|
type OrgHandlers struct {
|
|
persistence *config.MultiTenantPersistence
|
|
mtMonitor *monitoring.MultiTenantMonitor
|
|
rbacProvider *TenantRBACProvider
|
|
onDelete func(ctx context.Context, orgID string) error
|
|
hostedMode bool
|
|
}
|
|
|
|
func NewOrgHandlers(
|
|
persistence *config.MultiTenantPersistence,
|
|
mtMonitor *monitoring.MultiTenantMonitor,
|
|
rbacProvider ...*TenantRBACProvider,
|
|
) *OrgHandlers {
|
|
var provider *TenantRBACProvider
|
|
if len(rbacProvider) > 0 {
|
|
provider = rbacProvider[0]
|
|
}
|
|
|
|
return &OrgHandlers{
|
|
persistence: persistence,
|
|
mtMonitor: mtMonitor,
|
|
rbacProvider: provider,
|
|
}
|
|
}
|
|
|
|
// SetOnDelete configures an optional callback invoked after org deletion.
|
|
func (h *OrgHandlers) SetOnDelete(callback func(ctx context.Context, orgID string) error) {
|
|
if h == nil {
|
|
return
|
|
}
|
|
h.onDelete = callback
|
|
}
|
|
|
|
// SetHostedMode controls whether organization routes should follow hosted
|
|
// subscription gating instead of the self-hosted multi-tenant feature gate.
|
|
func (h *OrgHandlers) SetHostedMode(enabled bool) {
|
|
if h == nil {
|
|
return
|
|
}
|
|
h.hostedMode = enabled
|
|
}
|
|
|
|
type createOrganizationRequest struct {
|
|
ID string `json:"id"`
|
|
DisplayName string `json:"displayName"`
|
|
}
|
|
|
|
type updateOrganizationRequest struct {
|
|
DisplayName string `json:"displayName"`
|
|
}
|
|
|
|
type inviteMemberRequest struct {
|
|
UserID string `json:"userId"`
|
|
Role models.OrganizationRole `json:"role"`
|
|
}
|
|
|
|
type organizationAccessMutationResponse struct {
|
|
Kind string `json:"kind"`
|
|
Member *models.OrganizationMember `json:"member,omitempty"`
|
|
Invitation *models.OrganizationInvitation `json:"invitation,omitempty"`
|
|
}
|
|
|
|
type organizationUserInvitationResponse struct {
|
|
models.OrganizationInvitation
|
|
OrgID string `json:"orgId"`
|
|
OrgDisplayName string `json:"orgDisplayName"`
|
|
}
|
|
|
|
type createShareRequest struct {
|
|
TargetOrgID string `json:"targetOrgId"`
|
|
ResourceType string `json:"resourceType"`
|
|
ResourceID string `json:"resourceId"`
|
|
ResourceName string `json:"resourceName"`
|
|
AccessRole models.OrganizationRole `json:"accessRole"`
|
|
}
|
|
|
|
type incomingOrganizationShare struct {
|
|
models.OrganizationShare
|
|
SourceOrgID string `json:"sourceOrgId"`
|
|
SourceOrgName string `json:"sourceOrgName"`
|
|
}
|
|
|
|
func (h *OrgHandlers) HandleListOrgs(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if !h.requireMultiTenantGate(w, r) {
|
|
return
|
|
}
|
|
if h.persistence == nil {
|
|
writeErrorResponse(w, http.StatusServiceUnavailable, "orgs_unavailable", "Organization persistence is not configured", nil)
|
|
return
|
|
}
|
|
|
|
orgs, err := h.persistence.ListOrganizations()
|
|
if err != nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "list_failed", "Failed to list organizations", nil)
|
|
return
|
|
}
|
|
|
|
username := auth.GetUser(r.Context())
|
|
token := getAPITokenRecordFromRequest(r)
|
|
filtered := make([]*models.Organization, 0, len(orgs))
|
|
for _, org := range orgs {
|
|
if org == nil {
|
|
continue
|
|
}
|
|
normalizeOrganization(org)
|
|
if h.canAccessOrg(username, token, org) {
|
|
filtered = append(filtered, org)
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, filtered)
|
|
}
|
|
|
|
func (h *OrgHandlers) HandleCreateOrg(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if !h.requireMultiTenantGate(w, r) {
|
|
return
|
|
}
|
|
if h.persistence == nil {
|
|
writeErrorResponse(w, http.StatusServiceUnavailable, "orgs_unavailable", "Organization persistence is not configured", nil)
|
|
return
|
|
}
|
|
|
|
username := auth.GetUser(r.Context())
|
|
token := getAPITokenRecordFromRequest(r)
|
|
if token != nil || strings.HasPrefix(username, "token:") {
|
|
writeErrorResponse(w, http.StatusForbidden, "session_required", "Session-based user authentication is required", nil)
|
|
return
|
|
}
|
|
if username == "" {
|
|
writeErrorResponse(w, http.StatusUnauthorized, "authentication_required", "Authentication required", nil)
|
|
return
|
|
}
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, orgRequestBodyLimit)
|
|
var req createOrganizationRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body", nil)
|
|
return
|
|
}
|
|
|
|
req.ID = strings.TrimSpace(req.ID)
|
|
req.DisplayName = strings.TrimSpace(req.DisplayName)
|
|
if !isValidOrganizationID(req.ID) {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid organization ID", nil)
|
|
return
|
|
}
|
|
if req.DisplayName == "" {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_display_name", "Display name is required", nil)
|
|
return
|
|
}
|
|
if h.persistence.OrgExists(req.ID) {
|
|
writeErrorResponse(w, http.StatusConflict, "already_exists", "Organization already exists", nil)
|
|
return
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
org := &models.Organization{
|
|
ID: req.ID,
|
|
DisplayName: req.DisplayName,
|
|
CreatedAt: now,
|
|
OwnerUserID: username,
|
|
Members: []models.OrganizationMember{
|
|
{
|
|
UserID: username,
|
|
Role: models.OrgRoleOwner,
|
|
AddedAt: now,
|
|
AddedBy: username,
|
|
},
|
|
},
|
|
}
|
|
|
|
if err := h.persistence.SaveOrganization(org); err != nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "create_failed", "Failed to create organization", nil)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, org)
|
|
}
|
|
|
|
func (h *OrgHandlers) HandleGetOrg(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if !h.requireMultiTenantGate(w, r) {
|
|
return
|
|
}
|
|
|
|
orgID := strings.TrimSpace(r.PathValue("id"))
|
|
org, err := h.loadOrganization(orgID)
|
|
if err != nil {
|
|
h.writeLoadOrgError(w, err)
|
|
return
|
|
}
|
|
|
|
username := auth.GetUser(r.Context())
|
|
token := getAPITokenRecordFromRequest(r)
|
|
if !h.canAccessOrg(username, token, org) {
|
|
writeErrorResponse(w, http.StatusForbidden, "access_denied", "User is not a member of the organization", nil)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, org)
|
|
}
|
|
|
|
func (h *OrgHandlers) HandleUpdateOrg(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPut {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if !h.requireMultiTenantGate(w, r) {
|
|
return
|
|
}
|
|
|
|
orgID := strings.TrimSpace(r.PathValue("id"))
|
|
if orgID == "default" {
|
|
writeErrorResponse(w, http.StatusBadRequest, "default_org_immutable", "Default organization cannot be updated", nil)
|
|
return
|
|
}
|
|
|
|
org, err := h.loadOrganization(orgID)
|
|
if err != nil {
|
|
h.writeLoadOrgError(w, err)
|
|
return
|
|
}
|
|
|
|
username := auth.GetUser(r.Context())
|
|
token := getAPITokenRecordFromRequest(r)
|
|
if token != nil || strings.HasPrefix(username, "token:") {
|
|
writeErrorResponse(w, http.StatusForbidden, "session_required", "Session-based user authentication is required", nil)
|
|
return
|
|
}
|
|
if !org.CanUserIDManage(username) {
|
|
writeErrorResponse(w, http.StatusForbidden, "access_denied", "Admin role required for this organization", nil)
|
|
return
|
|
}
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, orgRequestBodyLimit)
|
|
var req updateOrganizationRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body", nil)
|
|
return
|
|
}
|
|
|
|
req.DisplayName = strings.TrimSpace(req.DisplayName)
|
|
if req.DisplayName == "" {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_display_name", "Display name is required", nil)
|
|
return
|
|
}
|
|
|
|
org.DisplayName = req.DisplayName
|
|
if err := h.persistence.SaveOrganization(org); err != nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "update_failed", "Failed to update organization", nil)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, org)
|
|
}
|
|
|
|
func (h *OrgHandlers) HandleDeleteOrg(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodDelete {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if !h.requireMultiTenantGate(w, r) {
|
|
return
|
|
}
|
|
|
|
orgID := strings.TrimSpace(r.PathValue("id"))
|
|
if orgID == "default" {
|
|
writeErrorResponse(w, http.StatusBadRequest, "default_org_immutable", "Default organization cannot be deleted", nil)
|
|
return
|
|
}
|
|
|
|
org, err := h.loadOrganization(orgID)
|
|
if err != nil {
|
|
h.writeLoadOrgError(w, err)
|
|
return
|
|
}
|
|
|
|
username := auth.GetUser(r.Context())
|
|
token := getAPITokenRecordFromRequest(r)
|
|
if token != nil || strings.HasPrefix(username, "token:") {
|
|
writeErrorResponse(w, http.StatusForbidden, "session_required", "Session-based user authentication is required", nil)
|
|
return
|
|
}
|
|
if !org.CanUserIDManage(username) {
|
|
writeErrorResponse(w, http.StatusForbidden, "access_denied", "Admin role required for this organization", nil)
|
|
return
|
|
}
|
|
|
|
if h.mtMonitor != nil {
|
|
h.mtMonitor.RemoveTenant(orgID)
|
|
}
|
|
|
|
if err := h.persistence.DeleteOrganization(orgID); err != nil {
|
|
if errors.Is(err, os.ErrNotExist) || errors.Is(err, errOrgNotFound) {
|
|
writeErrorResponse(w, http.StatusNotFound, "not_found", "Organization not found", nil)
|
|
return
|
|
}
|
|
writeErrorResponse(w, http.StatusInternalServerError, "delete_failed", "Failed to delete organization", nil)
|
|
return
|
|
}
|
|
|
|
if h.rbacProvider != nil && h.onDelete == nil {
|
|
_ = h.rbacProvider.RemoveTenant(orgID)
|
|
}
|
|
if mgr := GetTenantAuditManager(); mgr != nil {
|
|
mgr.RemoveTenantLogger(orgID)
|
|
}
|
|
if h.onDelete != nil {
|
|
if err := h.onDelete(r.Context(), orgID); err != nil {
|
|
log.Warn().
|
|
Err(err).
|
|
Str("org_id", orgID).
|
|
Msg("Org deletion cleanup callback failed")
|
|
}
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *OrgHandlers) HandleListMembers(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if !h.requireMultiTenantGate(w, r) {
|
|
return
|
|
}
|
|
|
|
orgID := strings.TrimSpace(r.PathValue("id"))
|
|
org, err := h.loadOrganization(orgID)
|
|
if err != nil {
|
|
h.writeLoadOrgError(w, err)
|
|
return
|
|
}
|
|
|
|
username := auth.GetUser(r.Context())
|
|
token := getAPITokenRecordFromRequest(r)
|
|
if !h.canAccessOrg(username, token, org) {
|
|
writeErrorResponse(w, http.StatusForbidden, "access_denied", "User is not a member of the organization", nil)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, normalizeOrganizationMembers(org.Members))
|
|
}
|
|
|
|
func (h *OrgHandlers) HandleInviteMember(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if !h.requireMultiTenantGate(w, r) {
|
|
return
|
|
}
|
|
|
|
orgID := strings.TrimSpace(r.PathValue("id"))
|
|
if orgID == "default" {
|
|
writeErrorResponse(w, http.StatusBadRequest, "default_org_immutable", "Default organization members cannot be managed", nil)
|
|
return
|
|
}
|
|
|
|
org, err := h.loadOrganization(orgID)
|
|
if err != nil {
|
|
h.writeLoadOrgError(w, err)
|
|
return
|
|
}
|
|
|
|
username := auth.GetUser(r.Context())
|
|
token := getAPITokenRecordFromRequest(r)
|
|
if token != nil || strings.HasPrefix(username, "token:") {
|
|
writeErrorResponse(w, http.StatusForbidden, "session_required", "Session-based user authentication is required", nil)
|
|
return
|
|
}
|
|
if !org.CanUserIDManage(username) {
|
|
writeErrorResponse(w, http.StatusForbidden, "access_denied", "Admin role required for this organization", nil)
|
|
return
|
|
}
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, orgRequestBodyLimit)
|
|
var req inviteMemberRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body", nil)
|
|
return
|
|
}
|
|
|
|
req.UserID = strings.TrimSpace(req.UserID)
|
|
if req.UserID == "" {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_user", "Member user ID is required", nil)
|
|
return
|
|
}
|
|
if req.Role == "" {
|
|
req.Role = models.OrgRoleViewer
|
|
}
|
|
req.Role = models.NormalizeOrganizationRole(req.Role)
|
|
if !models.IsValidOrganizationRole(req.Role) {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_role", "Role must be owner, admin, editor, or viewer", nil)
|
|
return
|
|
}
|
|
|
|
// Owner transfer is allowed only by the current owner.
|
|
if req.Role == models.OrgRoleOwner && username != org.OwnerUserID {
|
|
writeErrorResponse(w, http.StatusForbidden, "owner_required", "Only the organization owner can transfer ownership", nil)
|
|
return
|
|
}
|
|
// The current owner cannot be demoted through member updates.
|
|
if req.UserID == org.OwnerUserID && req.Role != models.OrgRoleOwner {
|
|
writeErrorResponse(w, http.StatusBadRequest, "owner_role_immutable", "Use an ownership transfer to change the owner's role", nil)
|
|
return
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
memberIndex := findOrganizationMemberIndex(org.Members, req.UserID)
|
|
invitationIndex := findOrganizationInvitationIndex(org.PendingInvitations, req.UserID)
|
|
|
|
// Ownership transfer: demote old owner to admin and promote target user to owner.
|
|
if req.Role == models.OrgRoleOwner && req.UserID != org.OwnerUserID {
|
|
if memberIndex < 0 {
|
|
writeErrorResponse(w, http.StatusBadRequest, "owner_transfer_requires_member", "Ownership can only be transferred to an existing member", nil)
|
|
return
|
|
}
|
|
if authErr := validateFreshBrowserSession(r, username, "transfer ownership", privilegedBrowserSessionMaxAge); authErr != nil {
|
|
writeErrorResponse(w, authErr.Status, authErr.Code, authErr.Message, nil)
|
|
return
|
|
}
|
|
for i := range org.Members {
|
|
if org.Members[i].UserID == org.OwnerUserID {
|
|
org.Members[i].Role = models.OrgRoleAdmin
|
|
org.Members[i].AddedBy = username
|
|
if org.Members[i].AddedAt.IsZero() {
|
|
org.Members[i].AddedAt = now
|
|
}
|
|
break
|
|
}
|
|
}
|
|
org.OwnerUserID = req.UserID
|
|
}
|
|
|
|
if memberIndex >= 0 {
|
|
org.Members[memberIndex].Role = req.Role
|
|
org.Members[memberIndex].AddedBy = username
|
|
if org.Members[memberIndex].AddedAt.IsZero() {
|
|
org.Members[memberIndex].AddedAt = now
|
|
}
|
|
if invitationIndex >= 0 {
|
|
org.PendingInvitations = append(org.PendingInvitations[:invitationIndex], org.PendingInvitations[invitationIndex+1:]...)
|
|
}
|
|
} else {
|
|
if enforceUserLimitForMemberAdd(w, r.Context(), org) {
|
|
return
|
|
}
|
|
invitation := models.OrganizationInvitation{
|
|
UserID: req.UserID,
|
|
Role: req.Role,
|
|
InvitedAt: now,
|
|
InvitedBy: username,
|
|
}
|
|
if invitationIndex >= 0 {
|
|
org.PendingInvitations[invitationIndex].Role = req.Role
|
|
org.PendingInvitations[invitationIndex].InvitedBy = username
|
|
if org.PendingInvitations[invitationIndex].InvitedAt.IsZero() {
|
|
org.PendingInvitations[invitationIndex].InvitedAt = now
|
|
}
|
|
invitation = org.PendingInvitations[invitationIndex]
|
|
} else {
|
|
org.PendingInvitations = append(org.PendingInvitations, invitation)
|
|
}
|
|
if err := h.persistence.SaveOrganization(org); err != nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "invite_failed", "Failed to update organization members", nil)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusAccepted, organizationAccessMutationResponse{
|
|
Kind: "invitation",
|
|
Invitation: organizationInvitationPointer(org.PendingInvitations, req.UserID),
|
|
})
|
|
return
|
|
}
|
|
|
|
if err := h.persistence.SaveOrganization(org); err != nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "invite_failed", "Failed to update organization members", nil)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, organizationAccessMutationResponse{
|
|
Kind: "member",
|
|
Member: organizationMemberPointer(org.Members, req.UserID),
|
|
})
|
|
}
|
|
|
|
func (h *OrgHandlers) HandleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodDelete {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if !h.requireMultiTenantGate(w, r) {
|
|
return
|
|
}
|
|
|
|
orgID := strings.TrimSpace(r.PathValue("id"))
|
|
memberUserID := strings.TrimSpace(r.PathValue("userId"))
|
|
if memberUserID == "" {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_user", "Member user ID is required", nil)
|
|
return
|
|
}
|
|
if orgID == "default" {
|
|
writeErrorResponse(w, http.StatusBadRequest, "default_org_immutable", "Default organization members cannot be managed", nil)
|
|
return
|
|
}
|
|
|
|
org, err := h.loadOrganization(orgID)
|
|
if err != nil {
|
|
h.writeLoadOrgError(w, err)
|
|
return
|
|
}
|
|
|
|
username := auth.GetUser(r.Context())
|
|
token := getAPITokenRecordFromRequest(r)
|
|
if token != nil || strings.HasPrefix(username, "token:") {
|
|
writeErrorResponse(w, http.StatusForbidden, "session_required", "Session-based user authentication is required", nil)
|
|
return
|
|
}
|
|
if !org.CanUserIDManage(username) {
|
|
writeErrorResponse(w, http.StatusForbidden, "access_denied", "Admin role required for this organization", nil)
|
|
return
|
|
}
|
|
if memberUserID == org.OwnerUserID {
|
|
writeErrorResponse(w, http.StatusBadRequest, "owner_role_immutable", "Organization owner cannot be removed", nil)
|
|
return
|
|
}
|
|
|
|
nextMembers := make([]models.OrganizationMember, 0, len(org.Members))
|
|
removed := false
|
|
for _, member := range org.Members {
|
|
if member.UserID == memberUserID {
|
|
removed = true
|
|
continue
|
|
}
|
|
nextMembers = append(nextMembers, member)
|
|
}
|
|
if !removed {
|
|
writeErrorResponse(w, http.StatusNotFound, "member_not_found", "Member not found", nil)
|
|
return
|
|
}
|
|
|
|
org.Members = nextMembers
|
|
if err := h.persistence.SaveOrganization(org); err != nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "member_remove_failed", "Failed to update organization members", nil)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *OrgHandlers) HandleListInvitations(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if !h.requireMultiTenantGate(w, r) {
|
|
return
|
|
}
|
|
|
|
orgID := strings.TrimSpace(r.PathValue("id"))
|
|
org, err := h.loadOrganization(orgID)
|
|
if err != nil {
|
|
h.writeLoadOrgError(w, err)
|
|
return
|
|
}
|
|
|
|
username := auth.GetUser(r.Context())
|
|
token := getAPITokenRecordFromRequest(r)
|
|
if token != nil || strings.HasPrefix(username, "token:") {
|
|
writeErrorResponse(w, http.StatusForbidden, "session_required", "Session-based user authentication is required", nil)
|
|
return
|
|
}
|
|
if !org.CanUserIDManage(username) {
|
|
writeErrorResponse(w, http.StatusForbidden, "access_denied", "Admin role required for this organization", nil)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, normalizeOrganizationInvitations(org.PendingInvitations, org.Members))
|
|
}
|
|
|
|
func (h *OrgHandlers) HandleRevokeInvitation(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodDelete {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if !h.requireMultiTenantGate(w, r) {
|
|
return
|
|
}
|
|
|
|
orgID := strings.TrimSpace(r.PathValue("id"))
|
|
userID := strings.TrimSpace(r.PathValue("userId"))
|
|
if userID == "" {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_user", "Invitation user ID is required", nil)
|
|
return
|
|
}
|
|
|
|
org, err := h.loadOrganization(orgID)
|
|
if err != nil {
|
|
h.writeLoadOrgError(w, err)
|
|
return
|
|
}
|
|
|
|
username := auth.GetUser(r.Context())
|
|
token := getAPITokenRecordFromRequest(r)
|
|
if token != nil || strings.HasPrefix(username, "token:") {
|
|
writeErrorResponse(w, http.StatusForbidden, "session_required", "Session-based user authentication is required", nil)
|
|
return
|
|
}
|
|
if !org.CanUserIDManage(username) {
|
|
writeErrorResponse(w, http.StatusForbidden, "access_denied", "Admin role required for this organization", nil)
|
|
return
|
|
}
|
|
|
|
index := findOrganizationInvitationIndex(org.PendingInvitations, userID)
|
|
if index < 0 {
|
|
writeErrorResponse(w, http.StatusNotFound, "invitation_not_found", "Invitation not found", nil)
|
|
return
|
|
}
|
|
|
|
org.PendingInvitations = append(org.PendingInvitations[:index], org.PendingInvitations[index+1:]...)
|
|
if err := h.persistence.SaveOrganization(org); err != nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "invite_failed", "Failed to update organization invitations", nil)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *OrgHandlers) HandleListMyInvitations(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if !h.requireMultiTenantGate(w, r) {
|
|
return
|
|
}
|
|
if h.persistence == nil {
|
|
writeErrorResponse(w, http.StatusServiceUnavailable, "orgs_unavailable", "Organization persistence is not configured", nil)
|
|
return
|
|
}
|
|
|
|
username := auth.GetUser(r.Context())
|
|
token := getAPITokenRecordFromRequest(r)
|
|
if token != nil || strings.HasPrefix(username, "token:") {
|
|
writeErrorResponse(w, http.StatusForbidden, "session_required", "Session-based user authentication is required", nil)
|
|
return
|
|
}
|
|
if username == "" {
|
|
writeErrorResponse(w, http.StatusUnauthorized, "authentication_required", "Authentication required", nil)
|
|
return
|
|
}
|
|
|
|
orgs, err := h.persistence.ListOrganizations()
|
|
if err != nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "list_failed", "Failed to list organizations", nil)
|
|
return
|
|
}
|
|
|
|
invitations := make([]organizationUserInvitationResponse, 0)
|
|
for _, org := range orgs {
|
|
if org == nil {
|
|
continue
|
|
}
|
|
normalizeOrganization(org)
|
|
if invitation, ok := organizationInvitationForUser(org.PendingInvitations, username); ok {
|
|
invitations = append(invitations, organizationUserInvitationResponse{
|
|
OrganizationInvitation: invitation,
|
|
OrgID: org.ID,
|
|
OrgDisplayName: org.DisplayName,
|
|
})
|
|
}
|
|
}
|
|
sort.Slice(invitations, func(i, j int) bool {
|
|
if invitations[i].OrgID == invitations[j].OrgID {
|
|
return invitations[i].InvitedAt.Before(invitations[j].InvitedAt)
|
|
}
|
|
return invitations[i].OrgID < invitations[j].OrgID
|
|
})
|
|
|
|
writeJSON(w, http.StatusOK, invitations)
|
|
}
|
|
|
|
func (h *OrgHandlers) HandleAcceptMyInvitation(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if !h.requireMultiTenantGate(w, r) {
|
|
return
|
|
}
|
|
|
|
orgID := strings.TrimSpace(r.PathValue("id"))
|
|
org, err := h.loadOrganization(orgID)
|
|
if err != nil {
|
|
h.writeLoadOrgError(w, err)
|
|
return
|
|
}
|
|
|
|
username := auth.GetUser(r.Context())
|
|
token := getAPITokenRecordFromRequest(r)
|
|
if token != nil || strings.HasPrefix(username, "token:") {
|
|
writeErrorResponse(w, http.StatusForbidden, "session_required", "Session-based user authentication is required", nil)
|
|
return
|
|
}
|
|
if username == "" {
|
|
writeErrorResponse(w, http.StatusUnauthorized, "authentication_required", "Authentication required", nil)
|
|
return
|
|
}
|
|
|
|
if memberIndex := findOrganizationMemberIndex(org.Members, username); memberIndex >= 0 {
|
|
if invitationIndex := findOrganizationInvitationIndex(org.PendingInvitations, username); invitationIndex >= 0 {
|
|
org.PendingInvitations = append(org.PendingInvitations[:invitationIndex], org.PendingInvitations[invitationIndex+1:]...)
|
|
if err := h.persistence.SaveOrganization(org); err != nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "invite_accept_failed", "Failed to update organization membership", nil)
|
|
return
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, organizationAccessMutationResponse{
|
|
Kind: "member",
|
|
Member: organizationMemberPointer(org.Members, username),
|
|
})
|
|
return
|
|
}
|
|
|
|
invitationIndex := findOrganizationInvitationIndex(org.PendingInvitations, username)
|
|
if invitationIndex < 0 {
|
|
writeErrorResponse(w, http.StatusNotFound, "invitation_not_found", "Invitation not found", nil)
|
|
return
|
|
}
|
|
if enforceUserLimitForMemberAdd(w, r.Context(), org) {
|
|
return
|
|
}
|
|
|
|
invitation := org.PendingInvitations[invitationIndex]
|
|
now := time.Now().UTC()
|
|
org.Members = append(org.Members, models.OrganizationMember{
|
|
UserID: username,
|
|
Role: invitation.Role,
|
|
AddedAt: now,
|
|
AddedBy: invitation.InvitedBy,
|
|
})
|
|
org.PendingInvitations = append(org.PendingInvitations[:invitationIndex], org.PendingInvitations[invitationIndex+1:]...)
|
|
if err := h.persistence.SaveOrganization(org); err != nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "invite_accept_failed", "Failed to update organization membership", nil)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, organizationAccessMutationResponse{
|
|
Kind: "member",
|
|
Member: organizationMemberPointer(org.Members, username),
|
|
})
|
|
}
|
|
|
|
func (h *OrgHandlers) HandleDeclineMyInvitation(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodDelete {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if !h.requireMultiTenantGate(w, r) {
|
|
return
|
|
}
|
|
|
|
orgID := strings.TrimSpace(r.PathValue("id"))
|
|
org, err := h.loadOrganization(orgID)
|
|
if err != nil {
|
|
h.writeLoadOrgError(w, err)
|
|
return
|
|
}
|
|
|
|
username := auth.GetUser(r.Context())
|
|
token := getAPITokenRecordFromRequest(r)
|
|
if token != nil || strings.HasPrefix(username, "token:") {
|
|
writeErrorResponse(w, http.StatusForbidden, "session_required", "Session-based user authentication is required", nil)
|
|
return
|
|
}
|
|
if username == "" {
|
|
writeErrorResponse(w, http.StatusUnauthorized, "authentication_required", "Authentication required", nil)
|
|
return
|
|
}
|
|
|
|
invitationIndex := findOrganizationInvitationIndex(org.PendingInvitations, username)
|
|
if invitationIndex < 0 {
|
|
writeErrorResponse(w, http.StatusNotFound, "invitation_not_found", "Invitation not found", nil)
|
|
return
|
|
}
|
|
|
|
org.PendingInvitations = append(org.PendingInvitations[:invitationIndex], org.PendingInvitations[invitationIndex+1:]...)
|
|
if err := h.persistence.SaveOrganization(org); err != nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "invite_decline_failed", "Failed to update organization invitations", nil)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *OrgHandlers) HandleListShares(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if !h.requireMultiTenantGate(w, r) {
|
|
return
|
|
}
|
|
|
|
orgID := strings.TrimSpace(r.PathValue("id"))
|
|
org, err := h.loadOrganization(orgID)
|
|
if err != nil {
|
|
h.writeLoadOrgError(w, err)
|
|
return
|
|
}
|
|
|
|
username := auth.GetUser(r.Context())
|
|
token := getAPITokenRecordFromRequest(r)
|
|
if !h.canAccessOrg(username, token, org) {
|
|
writeErrorResponse(w, http.StatusForbidden, "access_denied", "User is not a member of the organization", nil)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, normalizeOrganizationShares(org.SharedResources))
|
|
}
|
|
|
|
func (h *OrgHandlers) HandleListIncomingShares(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if !h.requireMultiTenantGate(w, r) {
|
|
return
|
|
}
|
|
if h.persistence == nil {
|
|
writeErrorResponse(w, http.StatusServiceUnavailable, "orgs_unavailable", "Organization persistence is not configured", nil)
|
|
return
|
|
}
|
|
|
|
targetOrgID := strings.TrimSpace(r.PathValue("id"))
|
|
targetOrg, err := h.loadOrganization(targetOrgID)
|
|
if err != nil {
|
|
h.writeLoadOrgError(w, err)
|
|
return
|
|
}
|
|
|
|
username := auth.GetUser(r.Context())
|
|
token := getAPITokenRecordFromRequest(r)
|
|
if !h.canAccessOrg(username, token, targetOrg) {
|
|
writeErrorResponse(w, http.StatusForbidden, "access_denied", "User is not a member of the organization", nil)
|
|
return
|
|
}
|
|
targetRole := organizationRoleForUser(targetOrg, username)
|
|
canManageTargetOrg := targetOrg.CanUserIDManage(username)
|
|
|
|
orgs, err := h.persistence.ListOrganizations()
|
|
if err != nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "list_failed", "Failed to list organizations", nil)
|
|
return
|
|
}
|
|
|
|
incoming := make([]incomingOrganizationShare, 0)
|
|
for _, sourceOrg := range orgs {
|
|
if sourceOrg == nil || sourceOrg.ID == targetOrgID {
|
|
continue
|
|
}
|
|
for _, share := range normalizeOrganizationShares(sourceOrg.SharedResources) {
|
|
if share.TargetOrgID != targetOrgID {
|
|
continue
|
|
}
|
|
if share.Status == models.OrganizationShareStatusPending {
|
|
if !canManageTargetOrg {
|
|
continue
|
|
}
|
|
} else if !models.OrganizationRoleAtLeast(targetRole, share.AccessRole) {
|
|
continue
|
|
}
|
|
incoming = append(incoming, incomingOrganizationShare{
|
|
OrganizationShare: share,
|
|
SourceOrgID: sourceOrg.ID,
|
|
SourceOrgName: sourceOrg.DisplayName,
|
|
})
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, incoming)
|
|
}
|
|
|
|
func (h *OrgHandlers) HandleAcceptIncomingShare(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if !h.requireMultiTenantGate(w, r) {
|
|
return
|
|
}
|
|
|
|
targetOrgID := strings.TrimSpace(r.PathValue("id"))
|
|
shareID := strings.TrimSpace(r.PathValue("shareId"))
|
|
if shareID == "" {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_share", "Share ID is required", nil)
|
|
return
|
|
}
|
|
|
|
targetOrg, err := h.loadOrganization(targetOrgID)
|
|
if err != nil {
|
|
h.writeLoadOrgError(w, err)
|
|
return
|
|
}
|
|
|
|
username := auth.GetUser(r.Context())
|
|
token := getAPITokenRecordFromRequest(r)
|
|
if token != nil || strings.HasPrefix(username, "token:") {
|
|
writeErrorResponse(w, http.StatusForbidden, "session_required", "Session-based user authentication is required", nil)
|
|
return
|
|
}
|
|
if !targetOrg.CanUserIDManage(username) {
|
|
writeErrorResponse(w, http.StatusForbidden, "access_denied", "Admin role required for the target organization", nil)
|
|
return
|
|
}
|
|
|
|
sourceOrg, shareIndex, share, err := h.findIncomingShare(targetOrgID, shareID)
|
|
if err != nil {
|
|
h.writeLoadOrgError(w, err)
|
|
return
|
|
}
|
|
|
|
share.Status = models.OrganizationShareStatusAccepted
|
|
share.AcceptedAt = time.Now().UTC()
|
|
share.AcceptedBy = username
|
|
sourceOrg.SharedResources[shareIndex] = share
|
|
|
|
if err := h.persistence.SaveOrganization(sourceOrg); err != nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "share_accept_failed", "Failed to accept incoming share", nil)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, share)
|
|
}
|
|
|
|
func (h *OrgHandlers) HandleDeclineIncomingShare(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodDelete {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if !h.requireMultiTenantGate(w, r) {
|
|
return
|
|
}
|
|
|
|
targetOrgID := strings.TrimSpace(r.PathValue("id"))
|
|
shareID := strings.TrimSpace(r.PathValue("shareId"))
|
|
if shareID == "" {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_share", "Share ID is required", nil)
|
|
return
|
|
}
|
|
|
|
targetOrg, err := h.loadOrganization(targetOrgID)
|
|
if err != nil {
|
|
h.writeLoadOrgError(w, err)
|
|
return
|
|
}
|
|
|
|
username := auth.GetUser(r.Context())
|
|
token := getAPITokenRecordFromRequest(r)
|
|
if token != nil || strings.HasPrefix(username, "token:") {
|
|
writeErrorResponse(w, http.StatusForbidden, "session_required", "Session-based user authentication is required", nil)
|
|
return
|
|
}
|
|
if !targetOrg.CanUserIDManage(username) {
|
|
writeErrorResponse(w, http.StatusForbidden, "access_denied", "Admin role required for the target organization", nil)
|
|
return
|
|
}
|
|
|
|
sourceOrg, shareIndex, _, err := h.findIncomingShare(targetOrgID, shareID)
|
|
if err != nil {
|
|
h.writeLoadOrgError(w, err)
|
|
return
|
|
}
|
|
|
|
nextShares := make([]models.OrganizationShare, 0, len(sourceOrg.SharedResources)-1)
|
|
for i, share := range normalizeOrganizationShares(sourceOrg.SharedResources) {
|
|
if i == shareIndex {
|
|
continue
|
|
}
|
|
nextShares = append(nextShares, share)
|
|
}
|
|
sourceOrg.SharedResources = nextShares
|
|
|
|
if err := h.persistence.SaveOrganization(sourceOrg); err != nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "share_decline_failed", "Failed to remove incoming share", nil)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *OrgHandlers) HandleCreateShare(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if !h.requireMultiTenantGate(w, r) {
|
|
return
|
|
}
|
|
|
|
sourceOrgID := strings.TrimSpace(r.PathValue("id"))
|
|
sourceOrg, err := h.loadOrganization(sourceOrgID)
|
|
if err != nil {
|
|
h.writeLoadOrgError(w, err)
|
|
return
|
|
}
|
|
|
|
username := auth.GetUser(r.Context())
|
|
token := getAPITokenRecordFromRequest(r)
|
|
if token != nil || strings.HasPrefix(username, "token:") {
|
|
writeErrorResponse(w, http.StatusForbidden, "session_required", "Session-based user authentication is required", nil)
|
|
return
|
|
}
|
|
if !sourceOrg.CanUserIDManage(username) {
|
|
writeErrorResponse(w, http.StatusForbidden, "access_denied", "Admin role required for this organization", nil)
|
|
return
|
|
}
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, orgRequestBodyLimit)
|
|
var req createShareRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body", nil)
|
|
return
|
|
}
|
|
|
|
req.TargetOrgID = strings.TrimSpace(req.TargetOrgID)
|
|
req.ResourceType = normalizeOrganizationShareResourceType(req.ResourceType)
|
|
req.ResourceID = strings.TrimSpace(req.ResourceID)
|
|
req.ResourceName = strings.TrimSpace(req.ResourceName)
|
|
req.AccessRole = models.NormalizeOrganizationRole(req.AccessRole)
|
|
if req.AccessRole == "" {
|
|
req.AccessRole = models.OrgRoleViewer
|
|
}
|
|
|
|
if req.TargetOrgID == "" || !isValidOrganizationID(req.TargetOrgID) {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_target_org", "Valid target organization ID is required", nil)
|
|
return
|
|
}
|
|
if req.TargetOrgID == sourceOrgID {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_target_org", "Target organization must differ from source organization", nil)
|
|
return
|
|
}
|
|
if req.ResourceType == "" || req.ResourceID == "" {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_resource", "Resource type and resource ID are required", nil)
|
|
return
|
|
}
|
|
if isUnsupportedOrganizationShareResourceType(req.ResourceType) {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_resource", fmt.Sprintf("unsupported resource type %q", req.ResourceType), nil)
|
|
return
|
|
}
|
|
if !models.IsValidOrganizationRole(req.AccessRole) || req.AccessRole == models.OrgRoleOwner {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_access_role", "Access role must be admin, editor, or viewer", nil)
|
|
return
|
|
}
|
|
|
|
if _, err := h.loadOrganization(req.TargetOrgID); err != nil {
|
|
h.writeLoadOrgError(w, err)
|
|
return
|
|
}
|
|
|
|
normalizedShares := normalizeOrganizationShares(sourceOrg.SharedResources)
|
|
for i := range normalizedShares {
|
|
if normalizedShares[i].TargetOrgID == req.TargetOrgID &&
|
|
normalizedShares[i].ResourceType == req.ResourceType &&
|
|
normalizedShares[i].ResourceID == req.ResourceID {
|
|
now := time.Now().UTC()
|
|
acceptedUnchanged := normalizedShares[i].Status == models.OrganizationShareStatusAccepted &&
|
|
normalizedShares[i].AccessRole == req.AccessRole
|
|
normalizedShares[i].AccessRole = req.AccessRole
|
|
normalizedShares[i].ResourceName = req.ResourceName
|
|
if !acceptedUnchanged {
|
|
normalizedShares[i].Status = models.OrganizationShareStatusPending
|
|
normalizedShares[i].AcceptedAt = time.Time{}
|
|
normalizedShares[i].AcceptedBy = ""
|
|
normalizedShares[i].CreatedBy = username
|
|
normalizedShares[i].CreatedAt = now
|
|
} else if normalizedShares[i].CreatedAt.IsZero() {
|
|
normalizedShares[i].CreatedAt = now
|
|
}
|
|
if normalizedShares[i].CreatedBy == "" {
|
|
normalizedShares[i].CreatedBy = username
|
|
}
|
|
sourceOrg.SharedResources = normalizedShares
|
|
if err := h.persistence.SaveOrganization(sourceOrg); err != nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "share_create_failed", "Failed to save organization share", nil)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, normalizedShares[i])
|
|
return
|
|
}
|
|
}
|
|
|
|
share := models.OrganizationShare{
|
|
ID: generateOrganizationShareID(),
|
|
TargetOrgID: req.TargetOrgID,
|
|
ResourceType: req.ResourceType,
|
|
ResourceID: req.ResourceID,
|
|
ResourceName: req.ResourceName,
|
|
AccessRole: req.AccessRole,
|
|
Status: models.OrganizationShareStatusPending,
|
|
CreatedAt: time.Now().UTC(),
|
|
CreatedBy: username,
|
|
}
|
|
sourceOrg.SharedResources = append(normalizedShares, share)
|
|
|
|
if err := h.persistence.SaveOrganization(sourceOrg); err != nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "share_create_failed", "Failed to save organization share", nil)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, share)
|
|
}
|
|
|
|
func (h *OrgHandlers) HandleDeleteShare(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodDelete {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if !h.requireMultiTenantGate(w, r) {
|
|
return
|
|
}
|
|
|
|
sourceOrgID := strings.TrimSpace(r.PathValue("id"))
|
|
shareID := strings.TrimSpace(r.PathValue("shareId"))
|
|
if shareID == "" {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_share", "Share ID is required", nil)
|
|
return
|
|
}
|
|
|
|
sourceOrg, err := h.loadOrganization(sourceOrgID)
|
|
if err != nil {
|
|
h.writeLoadOrgError(w, err)
|
|
return
|
|
}
|
|
|
|
username := auth.GetUser(r.Context())
|
|
token := getAPITokenRecordFromRequest(r)
|
|
if token != nil || strings.HasPrefix(username, "token:") {
|
|
writeErrorResponse(w, http.StatusForbidden, "session_required", "Session-based user authentication is required", nil)
|
|
return
|
|
}
|
|
if !sourceOrg.CanUserIDManage(username) {
|
|
writeErrorResponse(w, http.StatusForbidden, "access_denied", "Admin role required for this organization", nil)
|
|
return
|
|
}
|
|
|
|
shares := normalizeOrganizationShares(sourceOrg.SharedResources)
|
|
nextShares := make([]models.OrganizationShare, 0, len(shares))
|
|
removed := false
|
|
for _, share := range shares {
|
|
if share.ID == shareID {
|
|
removed = true
|
|
continue
|
|
}
|
|
nextShares = append(nextShares, share)
|
|
}
|
|
if !removed {
|
|
writeErrorResponse(w, http.StatusNotFound, "share_not_found", "Organization share not found", nil)
|
|
return
|
|
}
|
|
|
|
sourceOrg.SharedResources = nextShares
|
|
if err := h.persistence.SaveOrganization(sourceOrg); err != nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "share_delete_failed", "Failed to delete organization share", nil)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
var (
|
|
errOrgNotFound = errors.New("organization not found")
|
|
errShareNotFound = errors.New("organization share not found")
|
|
)
|
|
|
|
func (h *OrgHandlers) loadOrganization(orgID string) (*models.Organization, error) {
|
|
if !isValidOrganizationID(orgID) {
|
|
return nil, errOrgNotFound
|
|
}
|
|
if h.persistence == nil {
|
|
return nil, errors.New("organization persistence is not configured")
|
|
}
|
|
if orgID != "default" && !h.persistence.OrgExists(orgID) {
|
|
return nil, errOrgNotFound
|
|
}
|
|
|
|
org, err := h.persistence.LoadOrganization(orgID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if org == nil {
|
|
return nil, errOrgNotFound
|
|
}
|
|
if org.ID == "" {
|
|
org.ID = orgID
|
|
}
|
|
if strings.TrimSpace(org.DisplayName) == "" {
|
|
org.DisplayName = org.ID
|
|
}
|
|
normalizeOrganization(org)
|
|
return org, nil
|
|
}
|
|
|
|
func (h *OrgHandlers) requireMultiTenantGate(w http.ResponseWriter, r *http.Request) bool {
|
|
if h != nil && h.hostedMode {
|
|
orgID := GetOrgID(r.Context())
|
|
checkCtx := context.WithValue(r.Context(), OrgIDContextKey, orgID)
|
|
if !isHostedSubscriptionValid(checkCtx) {
|
|
writeHostedSubscriptionRequiredError(w)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
if !IsMultiTenantEnabled() {
|
|
writeMultiTenantDisabledError(w)
|
|
return false
|
|
}
|
|
if !hasMultiTenantFeatureForContext(r.Context()) {
|
|
WriteLicenseRequired(w, licenseFeatureMultiTenantKey, "Multi-tenant access requires an Enterprise license")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (h *OrgHandlers) canAccessOrg(username string, token *config.APITokenRecord, org *models.Organization) bool {
|
|
if org == nil {
|
|
return false
|
|
}
|
|
if token != nil {
|
|
return token.CanAccessOrg(org.ID)
|
|
}
|
|
if username == "" {
|
|
return false
|
|
}
|
|
if org.ID == "default" {
|
|
return true
|
|
}
|
|
return org.CanUserIDAccess(username)
|
|
}
|
|
|
|
func organizationRoleForUser(org *models.Organization, username string) models.OrganizationRole {
|
|
if org == nil || strings.TrimSpace(username) == "" {
|
|
return ""
|
|
}
|
|
if org.IsOwnerUserID(username) {
|
|
return models.OrgRoleOwner
|
|
}
|
|
return org.GetMemberRoleByUserID(username)
|
|
}
|
|
|
|
func (h *OrgHandlers) writeLoadOrgError(w http.ResponseWriter, err error) {
|
|
switch {
|
|
case errors.Is(err, errOrgNotFound):
|
|
writeErrorResponse(w, http.StatusNotFound, "not_found", "Organization not found", nil)
|
|
case errors.Is(err, errShareNotFound):
|
|
writeErrorResponse(w, http.StatusNotFound, "share_not_found", "Organization share not found", nil)
|
|
default:
|
|
writeErrorResponse(w, http.StatusInternalServerError, "org_load_failed", "Failed to load organization", nil)
|
|
}
|
|
}
|
|
|
|
func normalizeOrganization(org *models.Organization) {
|
|
if org == nil {
|
|
return
|
|
}
|
|
org.Members = normalizeOrganizationMembers(org.Members)
|
|
if strings.TrimSpace(org.OwnerUserID) == "" {
|
|
org.PendingInvitations = normalizeOrganizationInvitations(org.PendingInvitations, org.Members)
|
|
org.SharedResources = normalizeOrganizationShares(org.SharedResources)
|
|
return
|
|
}
|
|
foundOwner := false
|
|
for i := range org.Members {
|
|
if org.Members[i].UserID == org.OwnerUserID {
|
|
org.Members[i].Role = models.OrgRoleOwner
|
|
foundOwner = true
|
|
break
|
|
}
|
|
}
|
|
if !foundOwner {
|
|
org.Members = append(org.Members, models.OrganizationMember{
|
|
UserID: org.OwnerUserID,
|
|
Role: models.OrgRoleOwner,
|
|
AddedAt: time.Now().UTC(),
|
|
AddedBy: org.OwnerUserID,
|
|
})
|
|
}
|
|
org.PendingInvitations = normalizeOrganizationInvitations(org.PendingInvitations, org.Members)
|
|
org.SharedResources = normalizeOrganizationShares(org.SharedResources)
|
|
}
|
|
|
|
func normalizeOrganizationMembers(members []models.OrganizationMember) []models.OrganizationMember {
|
|
normalized := make([]models.OrganizationMember, 0, len(members))
|
|
for _, member := range members {
|
|
member.UserID = strings.TrimSpace(member.UserID)
|
|
if member.UserID == "" {
|
|
continue
|
|
}
|
|
member.Role = models.NormalizeOrganizationRole(member.Role)
|
|
if !models.IsValidOrganizationRole(member.Role) {
|
|
member.Role = models.OrgRoleViewer
|
|
}
|
|
normalized = append(normalized, member)
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
func normalizeOrganizationInvitations(invites []models.OrganizationInvitation, members []models.OrganizationMember) []models.OrganizationInvitation {
|
|
if len(invites) == 0 {
|
|
return nil
|
|
}
|
|
memberSet := make(map[string]struct{}, len(members))
|
|
for _, member := range members {
|
|
memberSet[strings.TrimSpace(member.UserID)] = struct{}{}
|
|
}
|
|
|
|
normalized := make([]models.OrganizationInvitation, 0, len(invites))
|
|
for _, invite := range invites {
|
|
invite.UserID = strings.TrimSpace(invite.UserID)
|
|
if invite.UserID == "" {
|
|
continue
|
|
}
|
|
if _, exists := memberSet[invite.UserID]; exists {
|
|
continue
|
|
}
|
|
invite.Role = models.NormalizeOrganizationRole(invite.Role)
|
|
if !models.IsValidOrganizationRole(invite.Role) || invite.Role == models.OrgRoleOwner {
|
|
continue
|
|
}
|
|
invite.InvitedBy = strings.TrimSpace(invite.InvitedBy)
|
|
normalized = append(normalized, invite)
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
func normalizeOrganizationShares(shares []models.OrganizationShare) []models.OrganizationShare {
|
|
normalized := make([]models.OrganizationShare, 0, len(shares))
|
|
for _, share := range shares {
|
|
share.ID = strings.TrimSpace(share.ID)
|
|
if share.ID == "" {
|
|
share.ID = generateOrganizationShareID()
|
|
}
|
|
share.TargetOrgID = strings.TrimSpace(share.TargetOrgID)
|
|
share.ResourceType = normalizeOrganizationShareResourceType(share.ResourceType)
|
|
share.ResourceID = strings.TrimSpace(share.ResourceID)
|
|
share.ResourceName = strings.TrimSpace(share.ResourceName)
|
|
share.AccessRole = models.NormalizeOrganizationRole(share.AccessRole)
|
|
if share.AccessRole == models.OrgRoleOwner || !models.IsValidOrganizationRole(share.AccessRole) {
|
|
share.AccessRole = models.OrgRoleViewer
|
|
}
|
|
share.Status = models.NormalizeOrganizationShareStatus(share.Status)
|
|
if !models.IsValidOrganizationShareStatus(share.Status) {
|
|
share.Status = models.OrganizationShareStatusAccepted
|
|
}
|
|
share.AcceptedBy = strings.TrimSpace(share.AcceptedBy)
|
|
if share.Status != models.OrganizationShareStatusAccepted {
|
|
share.AcceptedAt = time.Time{}
|
|
share.AcceptedBy = ""
|
|
}
|
|
if share.TargetOrgID == "" || share.ResourceType == "" || share.ResourceID == "" {
|
|
continue
|
|
}
|
|
// Unsupported resource-type entries are invalid in v6 and should not be retained.
|
|
if isUnsupportedOrganizationShareResourceType(share.ResourceType) {
|
|
continue
|
|
}
|
|
normalized = append(normalized, share)
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
func (h *OrgHandlers) findIncomingShare(targetOrgID, shareID string) (*models.Organization, int, models.OrganizationShare, error) {
|
|
if h.persistence == nil {
|
|
return nil, -1, models.OrganizationShare{}, errors.New("organization persistence is not configured")
|
|
}
|
|
|
|
orgs, err := h.persistence.ListOrganizations()
|
|
if err != nil {
|
|
return nil, -1, models.OrganizationShare{}, err
|
|
}
|
|
|
|
for _, sourceOrg := range orgs {
|
|
if sourceOrg == nil || sourceOrg.ID == targetOrgID {
|
|
continue
|
|
}
|
|
normalizeOrganization(sourceOrg)
|
|
for i, share := range sourceOrg.SharedResources {
|
|
if share.ID == shareID && share.TargetOrgID == targetOrgID {
|
|
return sourceOrg, i, share, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil, -1, models.OrganizationShare{}, errShareNotFound
|
|
}
|
|
|
|
func findOrganizationMemberIndex(members []models.OrganizationMember, userID string) int {
|
|
for i := range members {
|
|
if members[i].UserID == userID {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func findOrganizationInvitationIndex(invitations []models.OrganizationInvitation, userID string) int {
|
|
for i := range invitations {
|
|
if invitations[i].UserID == userID {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func organizationMemberPointer(members []models.OrganizationMember, userID string) *models.OrganizationMember {
|
|
memberIndex := findOrganizationMemberIndex(members, userID)
|
|
if memberIndex < 0 {
|
|
return nil
|
|
}
|
|
member := members[memberIndex]
|
|
return &member
|
|
}
|
|
|
|
func organizationInvitationPointer(invitations []models.OrganizationInvitation, userID string) *models.OrganizationInvitation {
|
|
invitationIndex := findOrganizationInvitationIndex(invitations, userID)
|
|
if invitationIndex < 0 {
|
|
return nil
|
|
}
|
|
invitation := invitations[invitationIndex]
|
|
return &invitation
|
|
}
|
|
|
|
func organizationInvitationForUser(invitations []models.OrganizationInvitation, userID string) (models.OrganizationInvitation, bool) {
|
|
invitationIndex := findOrganizationInvitationIndex(invitations, userID)
|
|
if invitationIndex < 0 {
|
|
return models.OrganizationInvitation{}, false
|
|
}
|
|
return invitations[invitationIndex], true
|
|
}
|
|
|
|
func generateOrganizationShareID() string {
|
|
return "shr-" + strconv.FormatInt(time.Now().UTC().UnixNano(), 36)
|
|
}
|
|
|
|
func normalizeOrganizationShareResourceType(raw string) string {
|
|
return strings.ToLower(strings.TrimSpace(raw))
|
|
}
|
|
|
|
func isUnsupportedOrganizationShareResourceType(resourceType string) bool {
|
|
_, ok := supportedOrganizationShareResourceTypes[strings.ToLower(strings.TrimSpace(resourceType))]
|
|
return !ok
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, statusCode int, payload any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(statusCode)
|
|
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
|
log.Error().Err(err).Msg("Failed to encode JSON response")
|
|
}
|
|
}
|
|
|
|
func isValidOrganizationID(orgID string) bool {
|
|
if orgID == "" || orgID == "." || orgID == ".." {
|
|
return false
|
|
}
|
|
if filepath.Base(orgID) != orgID {
|
|
return false
|
|
}
|
|
return organizationIDPattern.MatchString(orgID)
|
|
}
|