mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Fail closed on ambiguous email principal resolution
This commit is contained in:
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user