Fail closed on ambiguous email principal resolution

This commit is contained in:
rcourtman
2026-05-05 09:26:10 +01:00
parent d91c2afedb
commit fed3b776e0
5 changed files with 64 additions and 8 deletions
@@ -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`;
@@ -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
@@ -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",
},
}
+28 -7
View File
@@ -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
@@ -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",