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": {}, "k8s-deployment": {}, "k8s-service": {}, "storage": {}, "datastore": {}, "pool": {}, "dataset": {}, "pbs": {}, "pmg": {}, "physical_disk": {}, "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) }