diff --git a/docs/release-control/v6/internal/IDENTITY_INVARIANTS.md b/docs/release-control/v6/internal/IDENTITY_INVARIANTS.md index 7b683db86..0eadb98a1 100644 --- a/docs/release-control/v6/internal/IDENTITY_INVARIANTS.md +++ b/docs/release-control/v6/internal/IDENTITY_INVARIANTS.md @@ -83,7 +83,9 @@ principal once a stable user ID exists. contact email at verification time, not to the email embedded in the token. If the matching organization owner/member has no stored principal, the magic-link flow must fail closed instead of synthesizing a principal from - contact email. + contact email. If one contact email maps to multiple distinct stored + principals, email-based resolution must also fail closed instead of choosing + a principal by row order. 3. Hosted checkout and tenant provisioning seed organization membership from stable Pulse user IDs. Registry-backed paths must create or resolve the registry user before writing hosted tenant `OwnerUserID` or member `UserID`; diff --git a/docs/release-control/v6/internal/subsystems/organization-settings.md b/docs/release-control/v6/internal/subsystems/organization-settings.md index acb8d64fb..8c9c16938 100644 --- a/docs/release-control/v6/internal/subsystems/organization-settings.md +++ b/docs/release-control/v6/internal/subsystems/organization-settings.md @@ -184,6 +184,9 @@ helper so handlers do not duplicate membership lookup or accidentally bind sessions to email. A matching owner/member contact email without a stored `OwnerUserID` or member `UserID` is not a principal; the helper must fail closed instead of manufacturing a session key from email. +If one contact email matches multiple distinct stored principals, the model +must also fail closed instead of selecting a principal by owner/member row +order. Organization-principal canonicalization may use email only when the stored owner/member principal is itself legacy email-keyed. A contact email on an already-stable owner/member must not authorize, replace, or migrate a different diff --git a/internal/api/identity_invariant_contract_test.go b/internal/api/identity_invariant_contract_test.go index 590badcc6..83cf664c9 100644 --- a/internal/api/identity_invariant_contract_test.go +++ b/internal/api/identity_invariant_contract_test.go @@ -139,6 +139,7 @@ func TestContract_HostedIdentityUsesStablePrincipals(t *testing.T) { "CanonicalizePrincipalIdentity", "ownerMatchesLegacyEmailPrincipal", "memberMatchesLegacyEmailPrincipal", + "mergePrincipalEmailCandidate", "return \"\", \"\", false", }, "../../docs/release-control/v6/internal/IDENTITY_INVARIANTS.md": { @@ -147,6 +148,7 @@ func TestContract_HostedIdentityUsesStablePrincipals(t *testing.T) { "SSO provider subject", "reject caller-supplied metadata", "contact email attached to an already-stable owner/member principal must not", + "maps to multiple distinct stored", "Self-hosted SSO sessions now use provider-scoped", }, } diff --git a/internal/models/organization.go b/internal/models/organization.go index f1a677f51..e0811f941 100644 --- a/internal/models/organization.go +++ b/internal/models/organization.go @@ -330,6 +330,26 @@ func memberMatchesLegacyEmailPrincipal(member OrganizationMember, email string) return normalizeOrganizationEmail(userID) == normalizeOrganizationEmail(email) } +func mergePrincipalEmailCandidate(resolvedUserID *string, resolvedRole *OrganizationRole, userID string, role OrganizationRole) bool { + userID = normalizeOrganizationIdentityValue(userID) + if userID == "" { + return false + } + role = NormalizeOrganizationRole(role) + if *resolvedUserID == "" { + *resolvedUserID = userID + *resolvedRole = role + return true + } + if *resolvedUserID != userID { + return false + } + if OrganizationRoleAtLeast(role, *resolvedRole) { + *resolvedRole = role + } + return true +} + // HasMember checks if a user is a member of the organization. func (o *Organization) HasMember(userID string) bool { for _, member := range o.Members { @@ -443,24 +463,25 @@ func (o *Organization) ResolvePrincipalByEmail(email string) (string, Organizati if email == "" { return "", "", false } + userID := "" + role := OrganizationRole("") if ownerMatchesEmail(o, email) { - userID := normalizeOrganizationIdentityValue(o.OwnerUserID) - if userID == "" { + if !mergePrincipalEmailCandidate(&userID, &role, o.OwnerUserID, OrgRoleOwner) { return "", "", false } - return userID, OrgRoleOwner, true } for _, member := range o.Members { if !memberMatchesEmail(member, email) { continue } - userID := normalizeOrganizationIdentityValue(member.UserID) - if userID == "" { + if !mergePrincipalEmailCandidate(&userID, &role, member.UserID, member.Role) { return "", "", false } - return userID, NormalizeOrganizationRole(member.Role), true } - return "", "", false + if userID == "" { + return "", "", false + } + return userID, role, true } // CanonicalizePrincipalIdentity upgrades legacy email-keyed membership records diff --git a/internal/models/organization_additional_test.go b/internal/models/organization_additional_test.go index fe02374fb..b5181adce 100644 --- a/internal/models/organization_additional_test.go +++ b/internal/models/organization_additional_test.go @@ -169,6 +169,34 @@ func TestOrganizationResolvePrincipalByEmail(t *testing.T) { } } +func TestOrganizationResolvePrincipalByEmailRejectsAmbiguousStoredPrincipals(t *testing.T) { + ownerCollision := &Organization{ + ID: "org-owner-collision", + OwnerUserID: "u_owner", + OwnerEmail: "shared@example.com", + Members: []OrganizationMember{ + {UserID: "u_owner", Email: "shared@example.com", Role: OrgRoleOwner}, + {UserID: "u_admin", Email: "shared@example.com", Role: OrgRoleAdmin}, + }, + } + userID, role, ok := ownerCollision.ResolvePrincipalByEmail("shared@example.com") + if ok || userID != "" || role != "" { + t.Fatalf("owner collision principal = (%q, %q, %v), want rejection", userID, role, ok) + } + + memberCollision := &Organization{ + ID: "org-member-collision", + Members: []OrganizationMember{ + {UserID: "u_admin", Email: "shared@example.com", Role: OrgRoleAdmin}, + {UserID: "u_viewer", Email: "shared@example.com", Role: OrgRoleViewer}, + }, + } + userID, role, ok = memberCollision.ResolvePrincipalByEmail("shared@example.com") + if ok || userID != "" || role != "" { + t.Fatalf("member collision principal = (%q, %q, %v), want rejection", userID, role, ok) + } +} + func TestOrganizationResolvePrincipalByEmailRejectsBlankStoredPrincipal(t *testing.T) { ownerOnlyEmail := &Organization{ ID: "org-blank-owner",