Pulse/internal/api/org_handlers.go
rcourtman 052e344e1b
Some checks are pending
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run
Add Kubernetes RBAC inventory to the agent + canonical + UI
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"
2026-05-25 09:25:03 +01:00

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)
}