package security import ( "sort" "testing" ) // allRoles and allCaps are the closed vocabularies. Adding either without updating the matrix below // fails the completeness test, which is the point: a new capability that nobody has explicitly assigned // is a permission decision made by omission. // Every role, superuser included. Leaving the top tier out of this fixture would make the matrix tests // pass vacuously for the one role that holds everything, which is the role whose mistakes cost most. var allRoles = []Role{RoleViewer, RoleMember, RoleRecruiter, RoleManager, RoleAdmin, RoleOwner, RoleSuperuser} var allCaps = []Capability{ CapCandidateRead, CapCandidateWrite, CapRequirementEdit, CapSlateEdit, CapSearchRun, CapExportRun, CapSourcingRead, CapPrivacyErase, CapUserInvite, CapUserRoleChange, CapUserDisable, CapOrgSettings, CapAuditRead, CapComplianceRead, CapComplianceWrite, CapStaffingRecommend, CapBrainQuery, CapCurationCurate, CapDeskReassign, } // wantMatrix is the entire permission model written out by hand. // // Every cell is asserted, so widening a role is impossible without editing this table, and a reviewer // comparing a diff here is comparing the actual authority granted rather than inferring it from a list // of role names buried in a map literal. var wantMatrix = map[Role][]Capability{ RoleViewer: {CapCandidateRead, CapSearchRun, CapBrainQuery}, RoleMember: {CapCandidateRead, CapSearchRun, CapBrainQuery}, RoleRecruiter: { CapCandidateRead, CapSearchRun, CapBrainQuery, CapCandidateWrite, CapRequirementEdit, CapSlateEdit, CapStaffingRecommend, CapExportRun, CapSourcingRead, }, // Manager is recruiter plus exactly two things: moving work between people, and reading the audit // trail for the team they are accountable for. Nothing else. If this list ever grows to include // user or privacy capabilities, the tier has become an admin with a different name. RoleManager: { CapCandidateRead, CapSearchRun, CapBrainQuery, CapCandidateWrite, CapRequirementEdit, CapSlateEdit, CapStaffingRecommend, CapExportRun, CapSourcingRead, CapDeskReassign, CapAuditRead, }, RoleAdmin: { CapCandidateRead, CapSearchRun, CapBrainQuery, CapCandidateWrite, CapRequirementEdit, CapSlateEdit, CapStaffingRecommend, CapExportRun, CapSourcingRead, CapDeskReassign, CapAuditRead, CapPrivacyErase, CapUserInvite, CapUserRoleChange, CapUserDisable, CapComplianceRead, CapComplianceWrite, // NOT CapCurationCurate: curating the global ontology is a platform operator action, superuser only. }, // Owner holds everything EXCEPT global ontology curation, which is platform wide and therefore // superuser only. Owner is the top of one organization; it is not the top of the platform. RoleOwner: withoutCap(allCaps, CapCurationCurate), // Superuser also holds everything. It is written out here rather than skipped so the exhaustive test // actually covers the top tier: Has resolves it by rule instead of by matrix row, and this row is what // proves the rule and the intended model agree. RoleSuperuser: allCaps, } // TestPermissionMatrixExhaustively asserts every role against every capability. func TestPermissionMatrixExhaustively(t *testing.T) { for _, role := range allRoles { granted := map[Capability]bool{} for _, c := range wantMatrix[role] { granted[c] = true } for _, cap := range allCaps { want := granted[cap] if got := Has(role, cap); got != want { t.Errorf("Has(%s, %s) = %v, want %v", role, cap, got, want) } } } } // TestEveryCapabilityIsAssignedToSomeone. A capability nobody holds is dead weight that reads as a // working permission, and one nobody remembered to assign is a decision made by omission. func TestEveryCapabilityIsAssigned(t *testing.T) { for _, cap := range allCaps { held := false for _, role := range allRoles { if Has(role, cap) { held = true break } } if !held { t.Errorf("capability %s is held by no role", cap) } } // And the declared matrix covers every capability the code defines, so a new capability cannot be // added without appearing in the test table above. if len(capabilityRoles) != len(allCaps) { t.Errorf("the code defines %d capabilities but the test enumerates %d; a new one is untested", len(capabilityRoles), len(allCaps)) } } // TestManagerCannotReachAdminAuthority is the containment property that justifies the tier existing. func TestManagerCannotReachAdminAuthority(t *testing.T) { forbidden := []Capability{ CapPrivacyErase, CapUserInvite, CapUserRoleChange, CapUserDisable, CapOrgSettings, CapComplianceWrite, CapCurationCurate, } for _, cap := range forbidden { if Has(RoleManager, cap) { t.Errorf("manager holds %s, which makes the tier an admin under another name", cap) } } // And it must actually be MORE than a recruiter, or it buys nothing. if !Has(RoleManager, CapDeskReassign) { t.Error("manager cannot reassign a desk, which is the only reason the tier exists") } if Has(RoleRecruiter, CapDeskReassign) { t.Error("recruiter can reassign a desk, so the manager tier grants nothing") } } // TestOrgSettingsIsOwnerOnly. Changing the organization's timezone moves what "today" means for every // count in the product, so it stays at the top. func TestOrgSettingsIsOwnerOnly(t *testing.T) { for _, role := range allRoles { // Superuser holds every capability by rule, so it is exempt here for the same reason owner is. if role == RoleOwner || role == RoleSuperuser { continue } if Has(role, CapOrgSettings) { t.Errorf("%s can change org settings", role) } } } // TestRankOrderAfterInsertingManager. The tier sits between recruiter and admin, and the ordering is // what every separation of duties check consults. func TestRankOrder(t *testing.T) { order := []Role{RoleViewer, RoleMember, RoleRecruiter, RoleManager, RoleAdmin, RoleOwner} for i := 0; i+1 < len(order); i++ { lower, higher := order[i], order[i+1] if !OutRanks(higher, lower) { t.Errorf("%s does not outrank %s", higher, lower) } if OutRanks(lower, higher) { t.Errorf("%s outranks %s, the hierarchy is inverted", lower, higher) } if !AtLeast(higher, lower) { t.Errorf("AtLeast(%s, %s) is false", higher, lower) } } // Nobody outranks themselves, which is what stops a peer acting on a peer. for _, r := range allRoles { if OutRanks(r, r) { t.Errorf("%s outranks itself, so a peer could act on a peer", r) } } } // TestOnlySuperuserOutranksOwner. Owner used to be the ceiling, and nothing could reach it; migration // 0062 puts exactly one tier above, held by a named few. // // The lockout concern that motivated the original rule still holds, and is now answered differently: an // owner cannot be demoted by an admin or by another owner, so ordinary operation cannot strand the // organization, while a superuser exists precisely so a stranded organization has a recovery path that // does not require direct database access. func TestOnlySuperuserOutranksOwner(t *testing.T) { for _, r := range allRoles { if r == RoleSuperuser { if !OutRanks(r, RoleOwner) { t.Error("superuser must outrank owner, or the tier cannot administer one") } continue } if OutRanks(r, RoleOwner) { t.Errorf("%s outranks owner; only superuser may", r) } if CanInvite(r, RoleOwner) { t.Errorf("%s can invite an owner, which would let authority be created sideways", r) } } } // TestPrivilegeEscalationIsRefused. Every role attempting to grant every role at or above itself. func TestPrivilegeEscalationIsRefused(t *testing.T) { for _, actor := range allRoles { for _, target := range allRoles { canInvite := CanInvite(actor, target) shouldInvite := roleRank[actor] > roleRank[target] // The single documented exception: superuser may reproduce its own tier, so the ladder can // survive losing its holders. Every other self grant stays refused, asserted just below. if actor == RoleSuperuser && target == RoleSuperuser { shouldInvite = true } if canInvite != shouldInvite { t.Errorf("CanInvite(%s, %s) = %v, want %v", actor, target, canInvite, shouldInvite) } // The consequence worth naming: an admin cannot create another admin. Only an owner can. if actor == RoleAdmin && target == RoleAdmin && canInvite { t.Error("an admin can create another admin, so admin authority self propagates") } } } } // TestUnknownRolesHoldNothing. A role string from a stale token or a hand edited row must fail closed. func TestUnknownRolesHoldNothing(t *testing.T) { // "superuser" was a bogus value here until it became a real tier in migration 0062. Case variants stay // in the list because role comparison is exact: "Admin" must never be treated as admin. for _, bogus := range []Role{"", "root", "Admin", "OWNER", "superadmin"} { for _, cap := range allCaps { if Has(bogus, cap) { t.Errorf("unknown role %q holds %s", bogus, cap) } } if ValidRole(bogus) { t.Errorf("unknown role %q passed validation", bogus) } if AtLeast(bogus, RoleViewer) { t.Errorf("unknown role %q is at least a viewer", bogus) } if OutRanks(bogus, RoleViewer) { t.Errorf("unknown role %q outranks a viewer", bogus) } } } // TestCapabilitiesOfMatchesHas. The list sent to a browser and the checks the server performs come from // the same matrix, so they cannot disagree about what a role may do. func TestCapabilitiesOfMatchesHas(t *testing.T) { for _, role := range allRoles { got := CapabilitiesOf(role) if !sort.StringsAreSorted(got) { t.Errorf("CapabilitiesOf(%s) is not sorted, so the response is not stable", role) } set := map[string]bool{} for _, c := range got { set[c] = true } for _, cap := range allCaps { if set[string(cap)] != Has(role, cap) { t.Errorf("CapabilitiesOf(%s) and Has disagree about %s", role, cap) } } } // An unknown role receives an empty list, never nil, so the JSON is [] and the client can iterate. if got := CapabilitiesOf("nobody"); got == nil || len(got) != 0 { t.Errorf("CapabilitiesOf on an unknown role = %v, want an empty slice", got) } } // TestRoleVocabularyMatchesMigration pins the Go roles against the CHECK constraint, now 0062. // The two must move together: a role the code knows but the constraint refuses fails at the INSERT with // a constraint violation rather than a clear refusal, and a role the constraint permits but the code does // not know ranks nowhere and silently outranks nothing. func TestRoleVocabularyMatchesMigration(t *testing.T) { inMigration := []string{"superuser", "owner", "admin", "manager", "recruiter", "member", "viewer"} if len(roleRank) != len(inMigration) { t.Fatalf("the code defines %d roles, migration 0062 permits %d", len(roleRank), len(inMigration)) } for _, r := range inMigration { if !ValidRole(Role(r)) { t.Errorf("role %q is in migration 0062 but not valid in code", r) } } } // withoutCap returns allCaps minus one, so the owner row states its single exclusion rather than // re listing eighteen capabilities that would then drift from allCaps. func withoutCap(caps []Capability, drop Capability) []Capability { out := make([]Capability, 0, len(caps)) for _, c := range caps { if c != drop { out = append(out, c) } } return out } // TestGlobalOntologyCurationIsSuperuserOnly. The skill graph this writes is shared by every tenant, so a // tenant administrator must not be able to change what another tenant's candidates score. func TestGlobalOntologyCurationIsSuperuserOnly(t *testing.T) { for _, r := range allRoles { if r == RoleSuperuser { continue } if Has(r, CapCurationCurate) { t.Errorf("%s holds curation.curate and can rewrite the ontology every tenant is scored against", r) } } if !Has(RoleSuperuser, CapCurationCurate) { t.Error("superuser must hold curation.curate, or nobody can curate the ontology at all") } }