mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 18:45:53 +00:00
c73e78aeea
Contract-Neutral: RC qualification fixes preserve existing public API, tenant, monitoring, and organization contracts while correcting canonical runtime ownership and test fixtures.
1574 lines
49 KiB
Go
1574 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.BeginTenantDeletion(orgID)
|
|
defer h.mtMonitor.FinishTenantDeletion(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)
|
|
}
|