Use strict org principals for runtime access

This commit is contained in:
rcourtman 2026-05-04 23:16:15 +01:00
parent 002d68cef7
commit ea0b20cd19
15 changed files with 286 additions and 19 deletions

View file

@ -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

View file

@ -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`.

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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).

View file

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

View file

@ -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 {

View file

@ -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) {

View file

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

View file

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

View file

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

View file

@ -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 {

View file

@ -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",