diff --git a/docs/release-control/v6/internal/IDENTITY_INVARIANTS.md b/docs/release-control/v6/internal/IDENTITY_INVARIANTS.md index 5cd556d31..38fb26940 100644 --- a/docs/release-control/v6/internal/IDENTITY_INVARIANTS.md +++ b/docs/release-control/v6/internal/IDENTITY_INVARIANTS.md @@ -97,9 +97,14 @@ principal once a stable user ID exists. Stripe contact email through current server-side org membership first; matching contact metadata with a blank owner/member principal is not enough to send a sign-in link. -5. Legacy email-keyed records may be accepted only at migration boundaries and +5. Live organization authorization paths must use strict user-ID membership + checks against `OwnerUserID` and member `UserID`. Email-aware organization + helpers are migration/delivery compatibility only; they must not authorize + a request just because a session user string matches `OwnerEmail` or member + `Email` after a stable principal exists. +6. Legacy email-keyed records may be accepted only at migration boundaries and should be canonicalized to stable user IDs when the stable ID is known. -6. Self-hosted OIDC and SAML sessions bind to an opaque principal derived from +7. Self-hosted OIDC and SAML sessions bind to an opaque principal derived from provider type, provider ID, and the provider subject (`sub` or `NameID`). RBAC may copy a legacy username/email assignment to that principal during compatibility migration, but the browser session and tracked active-session diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 6a613933a..bc786e3ae 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -205,6 +205,11 @@ profile and assignment columns, but embedded table framing must route through after server-owned org linkage, but they must not treat Stripe contact email as fleet authority unless the API-owned organization resolver maps it to a stored owner/member principal first. + Runtime org authorization consumed by lifecycle-adjacent setup and fleet + routes must use strict `OwnerUserID`/member `UserID` membership checks; the + API-owned email-aware organization helpers are migration/delivery helpers + only and must not let contact email become setup, install, or fleet + authority after a stable principal exists. API-token owner metadata follows the same rule: lifecycle-adjacent setup or mobile-pairing token flows may consume the shared token helper, but they must not pass extension metadata that authors or overwrites `owner_user_id`. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index af778378e..b203f9762 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -411,6 +411,11 @@ the canonical monitored-system blocked payload. delivery after server-owned org linkage is validated, and it must not send when the linked org row has matching contact metadata but no stored owner/member principal. + Live org authorization is stricter than those delivery and migration + helpers: `internal/api/authorization.go`, `internal/api/org_handlers.go`, + `internal/api/cloud_org_admin_auth.go`, and settings-scope checks must use + strict `OwnerUserID`/member `UserID` membership helpers for request access, + not the email-aware compatibility accessors. Public hosted signup must therefore keep the generated owner user ID server-side for org metadata and RBAC assignment while using returned contact email only for `GenerateToken`/`SendMagicLink`; the accepted signup diff --git a/docs/release-control/v6/internal/subsystems/organization-settings.md b/docs/release-control/v6/internal/subsystems/organization-settings.md index a29480e61..7c3588989 100644 --- a/docs/release-control/v6/internal/subsystems/organization-settings.md +++ b/docs/release-control/v6/internal/subsystems/organization-settings.md @@ -170,6 +170,13 @@ control-plane user IDs exist, while `OwnerEmail` and member `Email` carry delivery/display email. Email-shaped owner/member IDs remain only as legacy fallback and migration input, and hosted handoff may canonicalize those records to stable user IDs without changing the stored role. +Live organization authorization must use strict user-ID checks from +`internal/models/organization.go`: `internal/api/authorization.go`, +`internal/api/org_handlers.go`, `internal/api/cloud_org_admin_auth.go`, and +settings-scope admin checks may authorize only against `OwnerUserID` or member +`UserID`. The legacy email-aware organization helpers remain migration and +delivery compatibility only, so a stable-principal org cannot be entered by a +session whose user string merely matches `OwnerEmail` or member `Email`. Email-delivery flows that need to mint sessions, including hosted magic links, must resolve contact email back through this organization model before authorizing. `internal/models/organization.go` owns the email-to-principal diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index eeaf31711..55c42336f 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -118,6 +118,10 @@ controls as normal product settings. sessions must bind to the durable principal rather than a delivery address. For SSO, the durable principal is the provider-scoped subject, and mutable username/email/display claims may not be written as the session owner. + Live organization authorization follows the same trust boundary: contact + email can support display, delivery, or migration, but request access must + match the authenticated principal against stored `OwnerUserID` or member + `UserID`. 5. Change security/privacy settings presentation through the shared `frontend-modern/src/components/Settings/APIAccessPanel.tsx`, `frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx`, `frontend-modern/src/components/Settings/SecurityAuthPanel.tsx`, `frontend-modern/src/components/Settings/SecurityOverviewPanel.tsx`, `frontend-modern/src/components/Settings/QuickSecuritySetup.tsx`, `frontend-modern/src/components/Settings/SecurityPostureSummary.tsx`, `frontend-modern/src/components/Settings/SSOProviderTypeIcon.tsx`, `frontend-modern/src/constants/apiScopes.ts`, `frontend-modern/src/utils/apiTokenPresentation.ts`, `frontend-modern/src/utils/securityAuthPresentation.ts`, `frontend-modern/src/utils/securityScorePresentation.ts`, `frontend-modern/src/utils/auditLogPresentation.ts`, and `frontend-modern/src/utils/auditWebhookPresentation.ts` boundary. 6. Change operator-facing telemetry/adoption reporting through `scripts/telemetry_adoption_report.py` together with the privacy disclosure whenever release-identity interpretation changes. 7. Change data-at-rest encryption-key or control-plane magic-link HMAC key and storage-root hardening semantics through `internal/crypto/crypto.go`, `internal/cloudcp/auth/magiclink.go`, `internal/cloudcp/auth/magiclink_store.go`, and `internal/securityutil/secure_storage_dir.go` together so writable-but-not-owned runtime storage mounts stay supported without weakening file-level secrecy. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 58af911ea..8cf46724f 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -87,6 +87,10 @@ boundary: storage/recovery may observe billing activation for a server-linked org, but Stripe contact email must not become recovery or storage authority unless the shared API organization resolver maps it to a stored owner/member principal first. +Runtime org authorization on that shared API boundary must also stay strict: +storage/recovery-adjacent routes may consume accepted org access only after +`OwnerUserID` or member `UserID` matches the authenticated principal, not after +the session user string matches `OwnerEmail` or member `Email`. The canonical actor vocabulary for those shared sessions is `docs/release-control/v6/internal/IDENTITY_INVARIANTS.md`; recovery and storage work may consume accepted org access, but must not mint or widen access from a diff --git a/internal/api/authorization.go b/internal/api/authorization.go index 8c3a9506a..50479c1be 100644 --- a/internal/api/authorization.go +++ b/internal/api/authorization.go @@ -120,7 +120,7 @@ func (c *DefaultAuthorizationChecker) UserCanAccessOrg(userID, orgID string) boo return false } - canAccess := org.CanUserAccess(userID) + canAccess := org.CanUserIDAccess(userID) if !canAccess { log.Debug(). Str("user_id", userID). diff --git a/internal/api/cloud_org_admin_auth.go b/internal/api/cloud_org_admin_auth.go index 7adde78a2..1a0ecbd4c 100644 --- a/internal/api/cloud_org_admin_auth.go +++ b/internal/api/cloud_org_admin_auth.go @@ -162,7 +162,7 @@ func RequireOrgOwnerOrPlatformAdmin(cfg *config.Config, orgs OrgPersistenceProvi return } - if !strings.EqualFold(strings.TrimSpace(org.OwnerUserID), strings.TrimSpace(userID)) { + if !org.IsOwnerUserID(userID) { writeErrorResponse(w, http.StatusForbidden, "access_denied", "Organization owner or platform admin required", nil) return } diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 6bcd3efe8..e66a52801 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -153,6 +153,76 @@ func TestContract_CheckoutMagicLinkDeliveryRequiresStoredPrincipalProof(t *testi } } +func TestContract_OrganizationRuntimeAccessUsesStrictUserIDProof(t *testing.T) { + modelSource, err := os.ReadFile(filepath.Clean("../models/organization.go")) + if err != nil { + t.Fatalf("read organization model: %v", err) + } + modelText := string(modelSource) + for _, required := range []string{ + "HasMemberUserID", + "GetMemberRoleByUserID", + "IsOwnerUserID", + "CanUserIDAccess", + "CanUserIDManage", + } { + if !strings.Contains(modelText, required) { + t.Fatalf("organization model must contain strict user ID helper %q", required) + } + } + + requiredByFile := map[string][]string{ + "authorization.go": { + "org.CanUserIDAccess(userID)", + }, + "org_handlers.go": { + "org.CanUserIDAccess(username)", + "org.CanUserIDManage(username)", + "org.IsOwnerUserID(username)", + "org.GetMemberRoleByUserID(username)", + }, + "security_setup_fix.go": { + "org.CanUserIDManage(sessionUser)", + }, + "cloud_org_admin_auth.go": { + "org.IsOwnerUserID(userID)", + }, + } + for file, required := range requiredByFile { + source, err := os.ReadFile(filepath.Clean(file)) + if err != nil { + t.Fatalf("read %s: %v", file, err) + } + text := string(source) + for _, needle := range required { + if !strings.Contains(text, needle) { + t.Fatalf("%s must contain strict runtime authorization proof %q", file, needle) + } + } + for _, forbidden := range []string{ + ".CanUserAccess(username)", + ".CanUserAccess(userID)", + ".CanUserManage(username)", + ".CanUserManage(sessionUser)", + ".IsOwner(username)", + ".GetMemberRole(username)", + } { + if strings.Contains(text, forbidden) { + t.Fatalf("%s must not use legacy email-aware runtime accessor %q", file, forbidden) + } + } + } + + identityDoc, err := os.ReadFile(filepath.Clean("../../docs/release-control/v6/internal/IDENTITY_INVARIANTS.md")) + if err != nil { + t.Fatalf("read identity invariant contract: %v", err) + } + identityText := string(identityDoc) + if !strings.Contains(identityText, "strict user-ID") || !strings.Contains(identityText, "OwnerUserID") { + t.Fatal("identity invariant contract must require strict user-ID membership checks for live authorization") + } +} + func TestContract_HostedHandoffRequiresStableSubjectProof(t *testing.T) { directSource, err := os.ReadFile(filepath.Clean("cloud_handoff.go")) if err != nil { diff --git a/internal/api/org_handlers.go b/internal/api/org_handlers.go index ae24deade..bf2f592e3 100644 --- a/internal/api/org_handlers.go +++ b/internal/api/org_handlers.go @@ -295,7 +295,7 @@ func (h *OrgHandlers) HandleUpdateOrg(w http.ResponseWriter, r *http.Request) { writeErrorResponse(w, http.StatusForbidden, "session_required", "Session-based user authentication is required", nil) return } - if !org.CanUserManage(username) { + if !org.CanUserIDManage(username) { writeErrorResponse(w, http.StatusForbidden, "access_denied", "Admin role required for this organization", nil) return } @@ -349,7 +349,7 @@ func (h *OrgHandlers) HandleDeleteOrg(w http.ResponseWriter, r *http.Request) { writeErrorResponse(w, http.StatusForbidden, "session_required", "Session-based user authentication is required", nil) return } - if !org.CanUserManage(username) { + if !org.CanUserIDManage(username) { writeErrorResponse(w, http.StatusForbidden, "access_denied", "Admin role required for this organization", nil) return } @@ -438,7 +438,7 @@ func (h *OrgHandlers) HandleInviteMember(w http.ResponseWriter, r *http.Request) writeErrorResponse(w, http.StatusForbidden, "session_required", "Session-based user authentication is required", nil) return } - if !org.CanUserManage(username) { + if !org.CanUserIDManage(username) { writeErrorResponse(w, http.StatusForbidden, "access_denied", "Admin role required for this organization", nil) return } @@ -586,7 +586,7 @@ func (h *OrgHandlers) HandleRemoveMember(w http.ResponseWriter, r *http.Request) writeErrorResponse(w, http.StatusForbidden, "session_required", "Session-based user authentication is required", nil) return } - if !org.CanUserManage(username) { + if !org.CanUserIDManage(username) { writeErrorResponse(w, http.StatusForbidden, "access_denied", "Admin role required for this organization", nil) return } @@ -640,7 +640,7 @@ func (h *OrgHandlers) HandleListInvitations(w http.ResponseWriter, r *http.Reque writeErrorResponse(w, http.StatusForbidden, "session_required", "Session-based user authentication is required", nil) return } - if !org.CanUserManage(username) { + if !org.CanUserIDManage(username) { writeErrorResponse(w, http.StatusForbidden, "access_denied", "Admin role required for this organization", nil) return } @@ -676,7 +676,7 @@ func (h *OrgHandlers) HandleRevokeInvitation(w http.ResponseWriter, r *http.Requ writeErrorResponse(w, http.StatusForbidden, "session_required", "Session-based user authentication is required", nil) return } - if !org.CanUserManage(username) { + if !org.CanUserIDManage(username) { writeErrorResponse(w, http.StatusForbidden, "access_denied", "Admin role required for this organization", nil) return } @@ -916,7 +916,7 @@ func (h *OrgHandlers) HandleListIncomingShares(w http.ResponseWriter, r *http.Re return } targetRole := organizationRoleForUser(targetOrg, username) - canManageTargetOrg := targetOrg.CanUserManage(username) + canManageTargetOrg := targetOrg.CanUserIDManage(username) orgs, err := h.persistence.ListOrganizations() if err != nil { @@ -979,7 +979,7 @@ func (h *OrgHandlers) HandleAcceptIncomingShare(w http.ResponseWriter, r *http.R writeErrorResponse(w, http.StatusForbidden, "session_required", "Session-based user authentication is required", nil) return } - if !targetOrg.CanUserManage(username) { + if !targetOrg.CanUserIDManage(username) { writeErrorResponse(w, http.StatusForbidden, "access_denied", "Admin role required for the target organization", nil) return } @@ -1031,7 +1031,7 @@ func (h *OrgHandlers) HandleDeclineIncomingShare(w http.ResponseWriter, r *http. writeErrorResponse(w, http.StatusForbidden, "session_required", "Session-based user authentication is required", nil) return } - if !targetOrg.CanUserManage(username) { + if !targetOrg.CanUserIDManage(username) { writeErrorResponse(w, http.StatusForbidden, "access_denied", "Admin role required for the target organization", nil) return } @@ -1081,7 +1081,7 @@ func (h *OrgHandlers) HandleCreateShare(w http.ResponseWriter, r *http.Request) writeErrorResponse(w, http.StatusForbidden, "session_required", "Session-based user authentication is required", nil) return } - if !sourceOrg.CanUserManage(username) { + if !sourceOrg.CanUserIDManage(username) { writeErrorResponse(w, http.StatusForbidden, "access_denied", "Admin role required for this organization", nil) return } @@ -1209,7 +1209,7 @@ func (h *OrgHandlers) HandleDeleteShare(w http.ResponseWriter, r *http.Request) writeErrorResponse(w, http.StatusForbidden, "session_required", "Session-based user authentication is required", nil) return } - if !sourceOrg.CanUserManage(username) { + if !sourceOrg.CanUserIDManage(username) { writeErrorResponse(w, http.StatusForbidden, "access_denied", "Admin role required for this organization", nil) return } @@ -1305,17 +1305,17 @@ func (h *OrgHandlers) canAccessOrg(username string, token *config.APITokenRecord if org.ID == "default" { return true } - return org.CanUserAccess(username) + return org.CanUserIDAccess(username) } func organizationRoleForUser(org *models.Organization, username string) models.OrganizationRole { if org == nil || strings.TrimSpace(username) == "" { return "" } - if org.IsOwner(username) { + if org.IsOwnerUserID(username) { return models.OrgRoleOwner } - return org.GetMemberRole(username) + return org.GetMemberRoleByUserID(username) } func (h *OrgHandlers) writeLoadOrgError(w http.ResponseWriter, err error) { diff --git a/internal/api/org_handlers_test.go b/internal/api/org_handlers_test.go index 033d32cf5..8c78d929b 100644 --- a/internal/api/org_handlers_test.go +++ b/internal/api/org_handlers_test.go @@ -313,6 +313,58 @@ func TestOrgHandlersViewerCannotManageOrg(t *testing.T) { } } +func TestOrgHandlersRejectContactEmailWithoutStoredPrincipalMatch(t *testing.T) { + t.Setenv("PULSE_DEV", "true") + defer SetMultiTenantEnabled(false) + SetMultiTenantEnabled(true) + + persistence := config.NewMultiTenantPersistence(t.TempDir()) + h := NewOrgHandlers(persistence, nil) + + if err := persistence.SaveOrganization(&models.Organization{ + ID: "acme", + DisplayName: "Acme", + OwnerUserID: "u_owner", + OwnerEmail: "owner@example.com", + Members: []models.OrganizationMember{ + {UserID: "u_owner", Email: "owner@example.com", Role: models.OrgRoleOwner, AddedAt: time.Now()}, + {UserID: "u_admin", Email: "admin@example.com", Role: models.OrgRoleAdmin, AddedAt: time.Now()}, + }, + }); err != nil { + t.Fatalf("save organization: %v", err) + } + + getReq := withUser(httptest.NewRequest(http.MethodGet, "/api/orgs/acme", nil), "owner@example.com") + getReq.SetPathValue("id", "acme") + getRec := httptest.NewRecorder() + h.HandleGetOrg(getRec, getReq) + if getRec.Code != http.StatusForbidden { + t.Fatalf("expected 403 for owner contact-email read, got %d: %s", getRec.Code, getRec.Body.String()) + } + + updateReq := withUser( + httptest.NewRequest(http.MethodPut, "/api/orgs/acme", bytes.NewBufferString(`{"displayName":"Nope"}`)), + "admin@example.com", + ) + updateReq.SetPathValue("id", "acme") + updateRec := httptest.NewRecorder() + h.HandleUpdateOrg(updateRec, updateReq) + if updateRec.Code != http.StatusForbidden { + t.Fatalf("expected 403 for admin contact-email update, got %d: %s", updateRec.Code, updateRec.Body.String()) + } + + principalReq := withUser( + httptest.NewRequest(http.MethodPut, "/api/orgs/acme", bytes.NewBufferString(`{"displayName":"Renamed"}`)), + "u_admin", + ) + principalReq.SetPathValue("id", "acme") + principalRec := httptest.NewRecorder() + h.HandleUpdateOrg(principalRec, principalReq) + if principalRec.Code != http.StatusOK { + t.Fatalf("expected 200 for stored admin principal update, got %d: %s", principalRec.Code, principalRec.Body.String()) + } +} + func TestOrgHandlersTokenListAllowedButWriteForbidden(t *testing.T) { t.Setenv("PULSE_DEV", "true") defer SetMultiTenantEnabled(false) diff --git a/internal/api/security_setup_fix.go b/internal/api/security_setup_fix.go index 0691c428c..b749d4487 100644 --- a/internal/api/security_setup_fix.go +++ b/internal/api/security_setup_fix.go @@ -69,7 +69,7 @@ func ensureAdminSession(cfg *config.Config, w http.ResponseWriter, req *http.Req // privileges for settings-bound routes. if org := GetOrganization(req.Context()); org != nil { orgID := strings.TrimSpace(org.ID) - if orgID != "" && orgID != "default" && org.CanUserManage(sessionUser) { + if orgID != "" && orgID != "default" && org.CanUserIDManage(sessionUser) { return true } } diff --git a/internal/api/tenant_user_membership_test.go b/internal/api/tenant_user_membership_test.go index 296954eb8..ab6224915 100644 --- a/internal/api/tenant_user_membership_test.go +++ b/internal/api/tenant_user_membership_test.go @@ -121,6 +121,54 @@ func TestTenantMiddlewareRejectsNonMemberSession(t *testing.T) { } } +func TestTenantMiddlewareRejectsContactEmailWithoutStoredPrincipalMatch(t *testing.T) { + defer SetMultiTenantEnabled(false) + SetMultiTenantEnabled(true) + t.Setenv("PULSE_DEV", "true") + + dataDir := t.TempDir() + hashed, err := internalauth.HashPassword("Password!1") + if err != nil { + t.Fatalf("hash password: %v", err) + } + + cfg := &config.Config{ + DataPath: dataDir, + ConfigPath: dataDir, + AuthUser: "admin", + AuthPass: hashed, + } + + org := &models.Organization{ + ID: "org-a", + DisplayName: "Org A", + OwnerUserID: "u_owner", + OwnerEmail: "owner@example.com", + Members: []models.OrganizationMember{ + {UserID: "u_owner", Email: "owner@example.com", Role: models.OrgRoleOwner, AddedAt: time.Now()}, + }, + } + mtp := config.NewMultiTenantPersistence(dataDir) + if err := mtp.SaveOrganization(org); err != nil { + t.Fatalf("save organization: %v", err) + } + + router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") + + sessionToken := "contact-email-session-token" + GetSessionStore().CreateSession(sessionToken, time.Hour, "agent", "127.0.0.1", "owner@example.com") + + req := httptest.NewRequest(http.MethodGet, "/api/config", nil) + req.Header.Set("X-Pulse-Org-ID", "org-a") + req.AddCookie(&http.Cookie{Name: "pulse_session", Value: sessionToken}) + rec := httptest.NewRecorder() + router.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("expected 403 for contact-email session, got %d: %s", rec.Code, rec.Body.String()) + } +} + func TestMultiTenantListOrgsShowsOnlyMemberOrganizations(t *testing.T) { defer SetMultiTenantEnabled(false) SetMultiTenantEnabled(true) diff --git a/internal/models/organization.go b/internal/models/organization.go index a0b5a4263..1ae0ee714 100644 --- a/internal/models/organization.go +++ b/internal/models/organization.go @@ -321,6 +321,16 @@ func (o *Organization) HasMember(userID string) bool { return false } +// HasMemberUserID checks membership using only the stored durable user ID. +func (o *Organization) HasMemberUserID(userID string) bool { + for _, member := range o.Members { + if memberMatchesUserID(member, userID) { + return true + } + } + return false +} + // GetMemberRole returns the role of a user in the organization. // Returns empty string if the user is not a member. func (o *Organization) GetMemberRole(userID string) OrganizationRole { @@ -332,11 +342,26 @@ func (o *Organization) GetMemberRole(userID string) OrganizationRole { return "" } +// GetMemberRoleByUserID returns a role using only the stored durable user ID. +func (o *Organization) GetMemberRoleByUserID(userID string) OrganizationRole { + for _, member := range o.Members { + if memberMatchesUserID(member, userID) { + return NormalizeOrganizationRole(member.Role) + } + } + return "" +} + // IsOwner checks if a user is the owner of the organization. func (o *Organization) IsOwner(userID string) bool { return ownerMatchesUserID(o, userID) || ownerMatchesEmail(o, userID) } +// IsOwnerUserID checks ownership using only the stored durable owner user ID. +func (o *Organization) IsOwnerUserID(userID string) bool { + return ownerMatchesUserID(o, userID) +} + // CanUserAccess checks if a user has any level of access to the organization. func (o *Organization) CanUserAccess(userID string) bool { if o.IsOwner(userID) { @@ -345,6 +370,14 @@ func (o *Organization) CanUserAccess(userID string) bool { return o.HasMember(userID) } +// CanUserIDAccess checks access using only durable owner/member user IDs. +func (o *Organization) CanUserIDAccess(userID string) bool { + if o.IsOwnerUserID(userID) { + return true + } + return o.HasMemberUserID(userID) +} + // CanUserManage checks if a user can manage the organization (owner or admin). func (o *Organization) CanUserManage(userID string) bool { if o.IsOwner(userID) { @@ -353,6 +386,14 @@ func (o *Organization) CanUserManage(userID string) bool { return OrganizationRoleAtLeast(o.GetMemberRole(userID), OrgRoleAdmin) } +// CanUserIDManage checks management access using only durable user IDs. +func (o *Organization) CanUserIDManage(userID string) bool { + if o.IsOwnerUserID(userID) { + return true + } + return OrganizationRoleAtLeast(o.GetMemberRoleByUserID(userID), OrgRoleAdmin) +} + // GetMemberRoleForPrincipal resolves a role using the durable user ID first, // then email only as a legacy/contact fallback. func (o *Organization) GetMemberRoleForPrincipal(userID, email string) OrganizationRole { diff --git a/internal/models/organization_additional_test.go b/internal/models/organization_additional_test.go index a82c0d83c..21b77a1d3 100644 --- a/internal/models/organization_additional_test.go +++ b/internal/models/organization_additional_test.go @@ -62,6 +62,32 @@ func TestOrganizationPrincipalIdentityCanonicalization(t *testing.T) { } } +func TestOrganizationStrictUserIDAccessRejectsContactEmail(t *testing.T) { + org := &Organization{ + ID: "org-1", + OwnerUserID: "u_owner", + OwnerEmail: "owner@example.com", + Members: []OrganizationMember{ + {UserID: "u_owner", Email: "owner@example.com", Role: OrgRoleOwner}, + {UserID: "u_admin", Email: "admin@example.com", Role: OrgRoleAdmin}, + {UserID: "u_viewer", Email: "viewer@example.com", Role: OrgRoleViewer}, + }, + } + + if !org.CanUserIDAccess("u_owner") || !org.CanUserIDManage("u_admin") { + t.Fatal("strict user ID access should accept stored owner/admin principals") + } + if org.CanUserIDAccess("owner@example.com") || org.CanUserIDManage("admin@example.com") { + t.Fatal("strict user ID access must not authorize contact email") + } + if got := org.GetMemberRoleByUserID("admin@example.com"); got != "" { + t.Fatalf("strict member role for contact email = %q, want empty", got) + } + if got := org.GetMemberRole("admin@example.com"); got != OrgRoleAdmin { + t.Fatalf("legacy member role for contact email = %q, want %q", got, OrgRoleAdmin) + } +} + func TestOrganizationResolvePrincipalByEmail(t *testing.T) { org := &Organization{ ID: "org-1",