mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-19 16:27:37 +00:00
Use strict org principals for runtime access
This commit is contained in:
parent
002d68cef7
commit
ea0b20cd19
15 changed files with 286 additions and 19 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue