Pulse/internal/api/org_handlers.go
2026-03-18 16:06:30 +00:00

1018 lines
30 KiB
Go

package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"regexp"
"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 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.CanUserManage(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.CanUserManage(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.CanUserManage(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()
// Ownership transfer: demote old owner to admin and promote target user to owner.
if req.Role == models.OrgRoleOwner && req.UserID != org.OwnerUserID {
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
}
updated := false
for i := range org.Members {
if org.Members[i].UserID == req.UserID {
org.Members[i].Role = req.Role
org.Members[i].AddedBy = username
if org.Members[i].AddedAt.IsZero() {
org.Members[i].AddedAt = now
}
updated = true
break
}
}
if !updated {
// Enforce max_users limit only for new member additions.
if enforceUserLimitForMemberAdd(w, r.Context(), org) {
return
}
org.Members = append(org.Members, models.OrganizationMember{
UserID: req.UserID,
Role: req.Role,
AddedAt: now,
AddedBy: username,
})
}
if err := h.persistence.SaveOrganization(org); err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "invite_failed", "Failed to update organization members", nil)
return
}
normalizedMembers := normalizeOrganizationMembers(org.Members)
for _, member := range normalizedMembers {
if member.UserID == req.UserID {
writeJSON(w, http.StatusOK, member)
return
}
}
writeErrorResponse(w, http.StatusInternalServerError, "invite_failed", "Failed to update organization members", nil)
}
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.CanUserManage(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) 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)
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 !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) 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.CanUserManage(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 {
normalizedShares[i].AccessRole = req.AccessRole
normalizedShares[i].ResourceName = req.ResourceName
normalizedShares[i].CreatedBy = username
if normalizedShares[i].CreatedAt.IsZero() {
normalizedShares[i].CreatedAt = time.Now().UTC()
}
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,
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.CanUserManage(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")
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.CanUserAccess(username)
}
func organizationRoleForUser(org *models.Organization, username string) models.OrganizationRole {
if org == nil || strings.TrimSpace(username) == "" {
return ""
}
if org.IsOwner(username) {
return models.OrgRoleOwner
}
return org.GetMemberRole(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)
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)
org.SharedResources = normalizeOrganizationShares(org.SharedResources)
if strings.TrimSpace(org.OwnerUserID) == "" {
return
}
for i := range org.Members {
if org.Members[i].UserID == org.OwnerUserID {
org.Members[i].Role = models.OrgRoleOwner
return
}
}
org.Members = append(org.Members, models.OrganizationMember{
UserID: org.OwnerUserID,
Role: models.OrgRoleOwner,
AddedAt: time.Now().UTC(),
AddedBy: org.OwnerUserID,
})
}
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 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
}
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 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)
}