feat(027): decompose Global Admin into 13 purpose-specific platform roles — Slice A (additive) - #6322
feat(027): decompose Global Admin into 13 purpose-specific platform roles — Slice A (additive)#6322hero101 wants to merge 43 commits into
Conversation
…le engine
Slice A [server], Phase 1-2 + start of Phase 4 (US3):
- role.name.ts / authorization.credential.ts / authorization.credential.global.ts:
+12 target role identifiers (9 platform-*, 3 feature-*), identical strings
per role (research D2).
- authorization.privilege.ts: +11 new privileges (T007).
- ROLE_CREDENTIAL_MAP (platform.roles.access.service.ts): single canonical
role->credential map replacing the local switch; RoleSetService now
resolves credential TYPE through it rather than trusting a stored row's
credential.type verbatim, closing the C1 silent-void defect class
structurally (global-spaces-reader / global-community-reader).
- role.credential.map.spec.ts: FR-011/SC-008 anti-drift guard, asserting the
map REPAIRS the two known defect rows (diverges from their wrong stored
value) and agrees with every healthy row.
- Shared seed-definitions module + seed migration (T012) + idempotent
AddPlatformRolesRedesign migration (T013) for the 12 target role rows,
correct per-role actor-policy triples (platform-* orgPolicy {0,0},
feature-* {0,-1}, platform-roles-admin userPolicy {1,-1}).
- Audit foundation (T014-T026): subjectUserId nullable + subjectOrganizationId
(AlterPlatformAuditEntrySubject, the one budgeted table change), 4 new
audit categories/outcomes/initiator-roles (enum migrations + TS mirrors),
4 typed details shapes, 4 new audit writer services (role-assignment
fail-closed/fail-open dual semantics; user-record, configuration, resource
fail-open), PlatformOperationsAuditService placeholder retirement (T025,
edits already-delivered 032 code + its spec).
- PlatformRoleAssignmentRulesService: the five assignment rules (research D7),
evaluated in order, first failure wins; contract error strings.
- resolveInitiatorRole (T058a, FR-025): shared attribution helper, empty
intersection falls back to platform_admin/system per the eighth
clarification pass's carve-out, throws on a genuine defect.
- platform.role.resolver.mutations.ts: wires the rule engine + fail-closed
audit into assignPlatformRoleToUser/removePlatformRoleFromUser for the
NEW role families only (legacy roles keep their exact prior gating —
Slice A additive invariant); adds assignPlatformRoleToOrganization /
removePlatformRoleFromOrganization (T032a).
- A19 site 1 (audit-log-analyze MCP tool) + sites 2/3 (email-change audit
GraphQL fields) re-anchored PLATFORM_ADMIN -> PLATFORM_AUDIT_READ (T050/T050a).
typecheck:native clean; full test:ci:no:coverage green (7339 passed).
workspace#027-platform-role-redesign
Slice A [server], continuing Phase 4 (US3) from the resumed session: - Root policy (T036): platform-content-full-access READ + its own privilege, cascading alongside (not replacing) the global-admin god-mode rule. CREATE/UPDATE/DELETE and UPDATE_NAMEID deliberately absent per the eleventh analyze pass (would satisfy the owner branch of every dual-path gate this feature adds). - Platform policy (T034, T035): GRANT_GLOBAL_ADMINS widened to platform-roles-admin; new FEATURE_ROLE_ASSIGN / PLATFORM_ROLE_HOLDERS_READ / FEATURE_ROLE_HOLDERS_READ rules; PLATFORM_AUDIT_READ, SET_SERVICE_PROFILE, PLATFORM_FORUM_MANAGE added; PLATFORM_SETTINGS_ADMIN / CREATE_ORGANIZATION / ACCESS_VIRTUAL_ASSISTANT re-anchored additively. - Organization policy (T039, T041): DELETE_ORGANIZATION dual-gated alongside plain DELETE at registration.resolver.mutations.ts — the A6 create/delete split, keeping feature-organization-creator off the delete half. - Account policy (T037): PLATFORM_SUPPORT_ORG_RESOURCES (new, A7), ACCOUNT_LICENSE_MANAGE + TRANSFER_RESOURCE_OFFER/_ACCEPT extended. - Space platform-roles-access (T038, T049 partial): platform-spaces-reader (A16) and platform-support's in-space privilege set (A15), gated on the same allowPlatformSupportAsAdmin flag as legacy global-support. - Callout/post/callout-contribution (T038): MOVE_POST/MOVE_CONTRIBUTION extended with platform-resource-admin; UPDATE_CALLOUT_PUBLISHER re-anchored onto platform-content-full-access. - User policy (T060 partial): PLATFORM_USERS_ADMIN grant added additively (narrowing READ_USER_PII/READ_USER_SETTINGS deferred to Slice B per inline comment — narrowing a legacy grant is forbidden in Slice A). - Licensing framework (T040, T040a): plan-definition CRUD re-anchored onto platform-settings-admin; beta/trial entitlement rule extended to feature-beta-tester so it isn't inert once Slice B drops beta-tester. - Updated 5 stale spec expectations broken by the additive widening (post move rule now always fires for platform-resource-admin, account policy gained a 7th credential rule, dual-path deleteOrganization needs isAccessGranted mocked, licensing grant-set list gained an entry, and the L0 space platform-roles-access role count moved 7->9). Lint cleanup: unsafe-fixed unused imports/import ordering (biome), incl. one pre-existing unused import in role.set.resolver.mutations.membership.spec.ts unrelated to this feature. pnpm lint, pnpm build and the full pnpm test:ci:no:coverage suite (7341 passed) all green. workspace#027-platform-role-redesign
The four grant/revokeCredentialTo{User,Organization} mutations
(admin.authorization.resolver.mutations.ts) check against a resolver-local,
hardcoded IN_MEMORY authorization policy built once in the constructor from
a fixed one-element [GLOBAL_ADMIN] array — entirely decoupled from the
shared platform policy's GRANT_GLOBAL_ADMINS credential rule that T034
widens to platform-roles-admin. Widening that shared rule therefore cannot
reach these four mutations: no resolver-level pin needed to be added, the
existing hardcoded local policy object already is the pin.
Added a doc comment at the constructor recording this finding (so a future
"simplification" replacing it with the shared platform policy doesn't
silently reopen FR-022's hole), plus a real-instance test suite
(AuthorizationPolicyService + AuthorizationService, no mocked grant check)
proving a platform-roles-admin-only actor is denied all four and legacy
global-admin still succeeds.
workspace#027-platform-role-redesign
…, global-support-manager) Slice A [server], continuing Phase 4 (US3) explicit-gate re-anchoring: - T044 (A9, space/VC move family): conversion.resolver.mutations.ts's six space convert/move mutations + convertVirtualContributorToUseKnowledgeBase share one resolver-local hardcoded policy scoped to global-admin only; extended additively to platform-resource-admin. Required extending AuthorizationPolicyService.createCredentialRuleGlobalAdmins' switch with the 12 target roles (each string-identical to its credential, research D2) — previously only 4 legacy AuthorizationRoleGlobal members were mapped, so any new role passed to createGlobalRolesAuthorizationPolicy threw ForbiddenException. - T044/T066 (A9 + US4): callouts-set transfer-offer/accept rules extended with platform-resource-admin — this is global-support-manager's ONLY wiring anywhere in the codebase, so re-anchoring it here (additively) is what lets that near-inert legacy role be deleted with zero capability loss in Slice B. - T045 (A10, platform settings family): consolidated 5 surfaces (updatePlatformSettings + 4 iframe/notification-blacklist mutations + the well-known-VC mapping mutation) onto PLATFORM_SETTINGS_ADMIN, off the PLATFORM_ADMIN catch-all. Its legacy grant set (GLOBAL_ADMIN, GLOBAL_SUPPORT, GLOBAL_LICENSE_MANAGER) differs from updatePlatformSettings' own (GLOBAL_ADMIN, GLOBAL_PLATFORM_MANAGER) — union of both preserved, nothing narrowed. - T046 (A12 usage): assign/revoke license plan on Account/Space check GRANT directly on the licensing framework's authorization — added a dedicated GRANT-only rule for platform-license-manager, kept separate from the `licensings` CRUD/GRANT bundle so this role does not also acquire A13's plan-definition CRUD. - T047 (A13) verified already satisfied by T040: create/update/deleteLicensePlan and adminLicensePolicy*CredentialRule check the licensing framework's own authorization (directly, or via inheritParentAuthorization cascade for LicensePolicy), which T040 already extended with platform-settings-admin. No further code change needed. - T048 (A14): updateSpacePlatformSettings re-anchored off PLATFORM_ADMIN onto ACCOUNT_LICENSE_MANAGE (space.service.authorization.ts's spacePlatformSettingsAdmin rule extended with platform-license-manager, additively; legacy global-admin/global-support keep their prior reach). Slice-A member name confirmed still updateSpacePlatformSettings per T048's note (rename to adminUpdateSpaceVisibility is Slice B, T078). Updated 3 stale spec expectations (conversion resolver mock indices, space.resolver.mutations privilege assertion, licensing-framework rule count/cascade assertions). Full pnpm test:ci:no:coverage green (7346 passed), pnpm lint clean. workspace#027-platform-role-redesign
…mplates, content deletions) Slice A [server], Phase 4 (US3) — the D5 dual-path shape (owner keeps ordinary CRUD, the platform role reaches the same mutation via its own privilege) applied to every A7/A8 surface named in tasks.md: - T042 (A7, PLATFORM_SUPPORT_ORG_RESOURCES ∨ UPDATE/CREATE/DELETE): updateInnovationPack, updateInnovationHub, and the full template CRUD family inside an org-owned pack/hub (createTemplate/FromSpace/ FromContentSpace, updateTemplate/FromSpace, deleteTemplate). Harmless no-op for space-owned templates, whose authorization tree never carries PLATFORM_SUPPORT_ORG_RESOURCES. - T043 (A8, PLATFORM_CONTENT_FULL_ACCESS ∨ DELETE): deleteInnovationPack, deleteInnovationHub, deleteCallout, deleteContribution, deleteSpace. updateCalloutPublishInfo already gated on UPDATE_CALLOUT_PUBLISHER (verified, no change needed). Both privileges cascade down from the root policy (T036) / account policy (T037) via inheritParentAuthorization, so the resolver-level dual isAccessGranted check is what actually exercises the cascade — a single-privilege grantAccessOrFail call would have left platform-support/platform-content- full-access unable to reach these mutations despite holding the privilege on the entity's authorization. Updated 8 spec files: added/mocked isAccessGranted (auto-mocked AuthorizationService defaults to a truthy sentinel for unconfigured calls, so the owner-check branch needed an explicit false to exercise the fallback grantAccessOrFail path). Full pnpm test:ci:no:coverage green (7346 passed), pnpm lint clean. (current-actor.decorator.spec.ts is a known order-dependent flake, unrelated file, passes in isolation.) workspace#027-platform-role-redesign
…AGE dual-path Slice A [server], A15 completion (research D5, FR-007(e)): updateDiscussion and deleteDiscussion (discussion.resolver.mutations.ts) now dual-gate on ordinary UPDATE/DELETE (today's reach, including the GLOBAL_SUPPORT platform-subtree cascade that Slice B's T073 removes) alongside platform-support's own PLATFORM_FORUM_MANAGE privilege (T035's grant) — so Support does not silently lose forum access at Slice B. Additive, never narrows the existing reach. pnpm test:ci:no:coverage green (7346 passed), pnpm lint clean. workspace#027-platform-role-redesign
…hole Slice A [server], A20/A20b (research D9, sixth clarification pass): usersInRole/usersInRoles/organizationsInRole/organizationsInRoles (role.set.resolver.fields.ts) no longer carry the static @AuthorizationActorHasPrivilege(READ) decorator — the privilege now depends on the requested ROLE, checked in-method via a new checkHolderListAccessOrFail helper: - an ordinary (non-target) role — every per-space/organization role, and the pre-existing legacy `platform-*` roles this feature does not touch (platform-operations-admin, platform-beta-tester, platform-vc-campaign, platform-assistant-access) — keeps plain READ, unchanged (FR-032). - one of the 9 new `Platform …` target roles requires PLATFORM_ROLE_HOLDERS_READ. - one of the 3 new `Feature …` target roles requires PLATFORM_ROLE_HOLDERS_READ **or** FEATURE_ROLE_HOLDERS_READ. Target roles are matched against EXPLICIT sets (PLATFORM_TARGET_ROLES / FEATURE_TARGET_ROLES), not a `platform-`/`feature-` string-prefix test — a prefix test would also catch the 4 legacy `platform-*` roles above, silently re-gating their holder-list reads onto a privilege they never needed, which would be exactly the narrowing Slice A forbids. T051a: the two list-taking fields (usersInRoles, organizationsInRoles) evaluate every requested role BEFORE fetching any data and fail closed as a whole on the first denied role — no partial/filtered result, which would make a withheld role indistinguishable from an empty one (the read-side twin of the C1 silent-void defect). Added 6 new unit tests (per-role privilege selection both ways, the feature-role OR-path, fail-closed-as-a-whole for the list variant) plus updated 8 call sites in the existing spec for the new leading actorContext parameter. Full pnpm test:ci:no:coverage green (7350 passed), pnpm lint clean. workspace#027-platform-role-redesign
…_PROFILE Slice A [server], A21 (FR-002, twelfth analyze pass, eighth clarification pass): UserService.updateUser no longer applies the `serviceProfile` field unconditionally. When present on the payload: - SET_SERVICE_PROFILE is checked on the PLATFORM authorization policy (platform-roles-admin ∪ legacy, T035's grant) — the marker is a platform-wide precondition of a Platform Spaces Reader grant, not a per-user attribute the target's own admins control. - Authorized: applies the change and writes a platform_role_assignment audit row (service_profile_changed, previous/newServiceProfile) via the already-built PlatformRoleAssignmentAuditService.recordServiceProfileChange. - Denied: does NOT silently drop the field (a dropped field would be indistinguishable from a permitted no-op, leaving A21's denial cells nothing to assert against) — throws ForbiddenException naming the required capability, AND writes a role_grant_rejected audit row via recordServiceProfileRejected (same class of event as a rejected role assignment, no new outcome value). The `serviceProfile` field STAYS on UpdateUserInput (removing it would be a breaking input-field removal, forbidden in the additive slice) — only the enforcement moved. Wiring: UserService gained AuthorizationService, PlatformAuthorizationPolicyService (already imported into UserModule) and PlatformRoleAssignmentAuditService (newly imported) as dependencies; updateUser gained a required actorContext parameter, threaded through from the one call site (UserResolverMutations.updateUser, which already has it). No circular imports — pnpm build and the full unit suite both confirm. Added 2 new unit tests (authorized change + audit, denied change + audit + no side effect) and fixed 12 call sites across user.service.spec.ts and user.resolver.mutations.spec.ts for the new parameter. Full pnpm test:ci:no:coverage green (7351 passed), pnpm lint clean. workspace#027-platform-role-redesign
…_USERS_ADMIN Slice A [server], US2 (research D5): the four A4/A5 surfaces re-anchored off their prior catch-all privileges onto PLATFORM_USERS_ADMIN: - T061 (A4): adminUserEmailChange / adminUserEmailChangeDriftResolve (admin.user.email.change.resolver.mutations.ts), off PLATFORM_ADMIN. - T062 (A5): adminIdentityDeleteKratosIdentity, off PLATFORM_SETTINGS_ADMIN; adminUserAccountDelete, off PLATFORM_ADMIN. Both check the privilege directly against the PLATFORM's own authorization policy. - T062 (A5): deleteUser (registration.resolver.mutations.ts), off the DELETE-cascade — dual-gated DELETE ∨ PLATFORM_USERS_ADMIN, not a replacement: DELETE also covers a user's OWN self-service account deletion (USER_SELF_MANAGEMENT, resource-scoped to that user's ID), which must keep working. Added a new PLATFORM_USERS_ADMIN grant on the PLATFORM's own authorization tree (platform.service.authorization.ts) — distinct from, but kept in sync with, T060's per-USER grant in user.service.authorization.ts. Three of the four re-gated surfaces check the privilege against the platform policy directly, not a per-user object, so they needed this second grant site. Grant set is the union of A4's legacy reachers (today's PLATFORM_ADMIN: GLOBAL_ADMIN, GLOBAL_SUPPORT, GLOBAL_LICENSE_MANAGER) and A5's (today's PLATFORM_SETTINGS_ADMIN: adds GLOBAL_PLATFORM_MANAGER) — no narrowing on any of the four surfaces. Updated 1 stale integration-test assertion (admin-user-account-delete.spec.ts) and 1 unit test needing the dual-path isAccessGranted mock (registration.resolver.mutations.spec.ts). Full pnpm test:ci:no:coverage green (7351 passed), pnpm lint clean. workspace#027-platform-role-redesign
The 12 new role/credential enum values (T004-T006), 11 new privilege enum values (T007), and the two organization-target platform-role mutations (T032a) were committed to source across the earlier commits in this slice but schema.graphql was never regenerated to match — this was the first time pnpm schema:print/sort ran since. Verified against schema-baseline.graphql via schema:diff: 56 additive entries, 3 info, ZERO breaking changes; schema:validate passes on both change-report.json and deprecations.json. workspace#027-platform-role-redesign
…ft detector Creates src/platform/platform-role/verification/ — the model half of the FR-033/FR-034 verification layer, deliberately scoped to model-only this wave (reachability.spec.ts / T070m, the check, is a later wave): - gate.model.ts: GateExpr, a closed union of four gate shapes (requires/anyOf/credential/condition) plus type guards and a privilege extractor. - cascade.model.ts: ROOT_CASCADE and the two Slice-A-only LEGACY_CASCADES (global-admin root CRUD+GRANT, global-support platform-subtree CRUD), keyed on the seven root-inheriting trees (research C3, verified by grepping every inheritRootAuthorizationPolicy() call site). - privilege.grants.ts: PRIVILEGE_GRANTS, a hand-maintained mirror of the grant sets T034-T040a write, including the pre-existing GRANT_GLOBAL_ADMINS (re-scoped, not new — the fourteenth/fifteenth-pass finding). - a.row.surfaces.ts: A_ROW_SURFACES, the executable census for all 21 live A-rows (A18 retired) plus A17's two Slice-B-deferred surfaces. Several `~`-pinned counts in contracts/privilege-map.md were corrected by grepping the tree per T040b's own instruction: A3 7->10, A7 5->8, A9 MOVE_POST resolved as ungated (dead privilege, no resolver consumes it — A9 stays 9), A10 2->6, A11 10->13, A12 2->6 (added createWingbackAccount and split assign/revoke into account+space variants), A13 4->5, A15 2->3 (deleteDiscussion joins updateDiscussion). This is a real, evidence-based shift in the ~76/~988-cell scale figures the workspace spec's Scale section and test-suites' matrix depend on — flagged for the orchestrator, not silently absorbed. - reachability.ts: reachers(surface, slice), pure and derivation-only for the two privilege-shaped gates; the two non-privilege shapes return the surface's own declared fields by design (surface.drift.spec.ts's rule 3 is what checks those against the code instead). - scanned.privileges.ts + surface.drift.spec.ts: the T052a drift detector. SCANNED_PRIVILEGES is derived from the census (never hand-listed) but deliberately excludes the five baseline CRUD verbs and the retiring PLATFORM_ADMIN catch-all — scanning those by literal privilege name was verified empirically to flag 28+ unrelated files having nothing to do with this feature's eight admin families. Documented as a third stated limit alongside FR-024's "wholly ungated surface" blind spot. 76 assertions green (rules 1-3 across every censused file, plus a shape-exhaustiveness check). Deviation from the task text: A1/A2's SurfaceRef.file points at platform.role.assignment.rules.service.ts (the rule engine, where GRANT_GLOBAL_ADMINS/FEATURE_ROLE_ASSIGN are literally checked for the target-role-model path) rather than the resolver — SurfaceRef.file is documented to mean "where the gate is enforced". The resolver's own literal else-branch check (legacy global-* roles only) and the unrelated generic role.set.resolver.mutations.ts are both listed in INDIRECT_ENFORCEMENT_FILES with inline rationale. Gates green: pnpm lint, pnpm build, pnpm test:ci:no:coverage (7427 tests). migration:validate deliberately not run (destructive against an ambient DB of unknown provenance; deferred to Forge Phase V's isolated stack). workspace#027-platform-role-redesign Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… T058 A8/A9/A14 audit wiring T053/T054/T055 (bootstrap.service.ts, users.json): the seeded credential grant loop now runs on EVERY start for EVERY configured account, granting only what is missing (idempotent, FR-013b out-of-band-lockout repair). Every target-role-vocabulary credential routes through a new evaluateSeedOrFail() on PlatformRoleAssignmentRulesService (rules 2-5; rule 1/assigner-capability does not apply to a seed with no assigner) — a violation is a fatal BootstrapException naming the account and rule, never silently skipped. Seeded audit writes are fail-open via the existing PlatformRoleAssignmentAuditService. admin@alkem.io now seeds as platform-roles-admin (break-glass, FR-013b) instead of global-admin; a new spaces-reader@alkem.io service account seeds platform-spaces-reader with serviceProfile:true set directly on the entity before its grant is evaluated (rule 3 keys on it). notifications@alkem.io's global-community-read is left as-is — it is a live, functional grant (READ cascade + PII/settings read in user.service.authorization.ts), not a silent-void row; recorded as evidence rather than replaced. T056 (actor.context.service.ts): populateFromActorID now expands an ORGANIZATION_ADMIN/OWNER's credential list with that organization's own feature-* credentials (Feature roles are grantable to organizations, T032a) — platform-* credentials are never expanded (D2's prefix filter). T057 (role.set.service.ts): explicit ActorContextCacheService.deleteByActorID on both the assign and remove ORGANIZATION branches of assignActorToRole/removeActorFromRole, so a promotion/demotion/departure denies or grants the org-inherited feature-* credentials on the NEXT request rather than surviving the 60s TTL — on top of (not instead of) the invalidation ActorService already performs for the actor's own credential change. T058 (FR-018a write-boundary discriminator): wired PlatformResourceAuditService into A8's five dual-path content deletions (deleteCallout, deleteContribution, deleteSpace, deleteInnovationPack, deleteInnovationHub) plus updateCalloutPublishInfo, A9's moveContributionToCallout, and A14's space visibility change — every write takes its authorizing-branch flag from the authorization RESULT already computed at the gate (canDeleteAsContentFullAccess etc.), never re-derived from the actor's roles. Added recordEventForActor / recordChangeForActor convenience wrappers to PlatformResourceAuditService / PlatformConfigurationAuditService that resolve FR-025 attribution via resolveInitiatorRole and fail open on an attribution error too (never let a defect in attribution fail an already-applied mutation). Updated bootstrap.service.spec.ts for the new existence+credentials-in-one-call flow (userService.getUserByEmail replaces isRegisteredUser as the gate) and added a case proving only MISSING credentials are (re-)granted. Remaining T058 wiring (A9's other 6 mutations, A10, A12, A13) and T063-T070m continue in follow-up commits. workspace#027-platform-role-redesign
Wires PlatformResourceAuditService into A9's remaining 7 mutations
(transferCallout, the four transfer*ToAccount mutations, and the three
cross-L0 conversion moves using the census's declared
intendedOwners/legacyReachers per the known modelling gap around their
resolver-local synthetic PLATFORM_ADMIN policy) and A12
(assignLicensePlanToAccount/Space, revokeLicensePlanFromAccount/Space,
updateBaselineLicensePlanOnAccount).
Wires PlatformConfigurationAuditService into A10 (updatePlatformSettings,
add/removeIframeAllowedURL, add/removeNotificationEmailFromBlacklist,
setPlatformWellKnownVirtualContributor) and A13 (deleteLicensePlan,
updateLicensePlan, adminLicensePolicy{Create,Update,Delete}CredentialRule).
All single-path surfaces (no ordinary-owner branch) per the census, so
every successful call is audited unconditionally.
workspace#027-platform-role-redesign
…rage T063: wires PlatformUserRecordAuditService into A5's three surfaces — deleteUser (dual-path: audited only on the PLATFORM_USERS_ADMIN branch, never the self-service DELETE branch), adminIdentityDeleteKratosIdentity (resolves the target user via getUserByAuthenticationID BEFORE deletion, since the Kratos<->user binding is gone after), and adminUserAccountDelete. Added a recordActionForActor convenience wrapper to PlatformUserRecordAuditService mirroring the pattern used for the other two audit services (FR-025 attribution via resolveInitiatorRole, fail-open on an attribution error). T057/T070g test coverage: added org-inheritance expansion specs to actor.context.service.spec.ts (feature-* credentials inherited from an administered organization, platform-* never expanded, plain ASSOCIATE unaffected, a dangling ORGANIZATION_ADMIN resourceID doesn't fail the whole build) and cache-invalidation assertions to role.set.service.spec.ts for both the ORGANIZATION assign and remove paths. Updated the pre-existing admin-user-account-delete.spec.ts integration test for AdminUsersMutations' new constructor parameter. workspace#027-platform-role-redesign
…ate specs T070c: platform.role.assignment.rules.service.spec.ts — the five rules in both outcomes, the evaluation-order case (first failure wins, exact message asserted), evaluateOrFail/evaluateSeedOrFail, and T064's FR-003 one-way-separation proof (platform-users-admin reaches FEATURE_ROLE_ASSIGN, never GRANT_GLOBAL_ADMINS alone). T070e gap-fills (permitted + denied path per SC-018) for the four A-row surfaces that had no co-located resolver spec yet: - admin.identity.resolver.mutations.spec.ts (A5) - admin.user.email.change.resolver.fields.spec.ts (A19 sites 2+3) - callout.contribution.move.resolver.mutations.spec.ts (A8 dual-path + A9 single-path, in one file) - callout.transfer.resolver.mutations.spec.ts (A9) workspace#027-platform-role-redesign
T070d: succeeds/fails specs for all four new audit writers (PlatformRoleAssignmentAuditService — fail-closed operator-initiated vs fail-open seeded, plus the FR-025 legacy-broad-credential attribution fallback and a rejection write; PlatformUserRecordAuditService, PlatformConfigurationAuditService, PlatformResourceAuditService — all fail-open) including each service's recordXForActor convenience wrapper, both the happy path and the fail-open-on-attribution-defect path. T070e gap-fill: license.plan.resolver.mutations.spec.ts and license.policy.resolver.mutations.spec.ts (A13) — permitted + denied path for all five mutations. workspace#027-platform-role-redesign
Adds the two FR-024 stateful-flow cases bootstrap.service.spec.ts was missing: a seeded credential that violates the shared rule engine raises a FATAL BootstrapException naming the violated rule and never forces the grant through (flow 3), and a seeded grant applies before the audit write which is marked seeded:true so the writer's own fail-open branch — proven separately in platform.role.assignment.audit.service.spec.ts — applies regardless of audit-store health (flow 4). workspace#027-platform-role-redesign
…and reachability inventories
T070a: unit.coverage.inventory.ts — the five exhaustive Records (D21),
each keyed on a union derived from the runtime enumeration it inventories
(ASSIGNMENT_RULE_COVERAGE off the rule engine's own ruleId type,
AUDIT_WRITER_COVERAGE off PlatformAuditCategory, A_ROW_GATE_COVERAGE off
ARowId, PRIVILEGE_COVERAGE off keyof typeof PRIVILEGE_GRANTS plus an
explicit UPDATE_NAMEID:{deferred:'B'} extra key, STATEFUL_FLOW_COVERAGE
off the two FR-024 flows this layer owns) so adding a rule/category/
A-row/privilege/flow without an entry is a tsc error.
T070b: unit.coverage.spec.ts walks every declared path in all five Records
and asserts the file exists, is non-empty and ends .spec.ts.
T070m: reachability.spec.ts — the FR-034/SC-019 equality (derived reacher
set via reachers() ≡ declared intent) for every one of the census's 99
surfaces, in both slices. Running it surfaced SIX real model/census
defects, fixed rather than the assertion relaxed (per the task's explicit
instruction):
- privilege.grants.ts: AUTHORIZATION_RESET/LICENSE_RESET/PLATFORM_OPERATIONS_ADMIN
(A3/A11, pre-existing 032 privileges) and bare READ (A16, T038's
spaces-reader grant) had no ManagedPrivilege entry at all, so every A3/A11
surface and A16 derived to an empty set.
- New TREE_SCOPED_PRIVILEGE_GRANTS mechanism for the three rows whose
literal gate is a baseline CRUD verb reused too promiscuously elsewhere to
manage globally (A9's three cross-L0 moves via a resolver-local synthetic
PLATFORM_ADMIN policy, A12's GRANT and A13's CREATE/UPDATE/DELETE on the
licensing-framework tree) — scoped so it cannot leak into A6/A7/A8's
unrelated anyOf gates over the same literal privileges.
- Census fixes (a.row.surfaces.ts): A4 was missing global-platform-manager
(the single PLATFORM_USERS_ADMIN credential rule grants it to A4's AND
A5's legacy reachers undifferentiated); A7 was missing global-admin (still
holds ordinary UPDATE on the account tree via the Slice-A-only legacy
cascade); A14 was missing global-license-manager (already held
ACCOUNT_LICENSE_MANAGE pre-T037); A16 was missing global-admin/
global-support (both hold plain READ on the space tree via the root
cascade today, same as the already-declared global-spaces-reader).
reachability.ts extended to consult TREE_SCOPED_PRIVILEGE_GRANTS for the
surface's own tree, on top of the existing global ManagedPrivilege check.
Full suite green: 7763 tests, typecheck clean.
workspace#027-platform-role-redesign
… the SC-001 sign-off gate Adds docs/evidence/027-platform-role-redesign.md, the human-readable evidence a security owner needs to sign off Slice A: - T028: the god-mode evidence table — every A-row's reach through the root cascade / GLOBAL_SUPPORT cascade before this feature, read off the census. Flags A7 as the one row where T037 closes a previously inheritance-only capability (research C2) rather than merely re-anchoring an existing one. - T029: the four structural findings (near-inert global-support-manager, redundant global-community-reader, the two C1 silent-void seeded rows, overlapping Support/License Manager grants) with remediation status. - T065: FR-005 negative-space table — platform-operations-admin absent from all six boundary privileges. - T067: the 13-row FR-009 table proving every target role grants >=1 enforced capability, including feature-beta-tester's licensing-policy-only entitlement (the row most likely to look empty). - T068: the FR-010 consistency sweep read directly off A_ROW_SURFACES, with A16's accepted exception and A15's own-privilege resolution called out. - T069: verified research C1 against the current tree (both void seed rows quoted from platform.role.seed.definitions.ts) and confirmed no repair migration ships (D1). - T070/T070l: mutation-test evidence — the anti-drift guard and reachability.spec.ts each caught a live, applied-then-reverted perturbation (a skewed ROLE_CREDENTIAL_MAP entry, DELETE restored to ROOT_CASCADE reproducing the eleventh-pass finding on A5/A6, A16's acceptedExtraReachers removed, A14's intendedOwners emptied) — all four reverted, suite confirmed green after each. Also records T070m's six reachability-model findings (see the prior commit) as an explicit review item. Exit gates re-run clean this session: pnpm lint, pnpm build, pnpm test:ci:no:coverage (7763 tests), pnpm schema:print/sort/diff/validate (zero breaking changes). workspace#027-platform-role-redesign
… service-profile extraction coverage T070h: extend organizationsInRole/organizationsInRoles with the same platform-*/feature-*/ordinary-role coverage usersInRole/usersInRoles already had, plus an explicit non-target-role READ assertion on usersInRole. Closes the gap where the shared checkHolderListAccessOrFail was only exercised from one of its two call sites. T070i: add a typed (non-`as any`) UpdateUserInput construction so the "serviceProfile stays on the DTO" invariant is a real tsc guard, not just an untyped literal that would still compile if the field were removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…Slice A grant-set widenings Direct literal-array guards for the credential rules T034-T040a wrote, complementing reachability.spec.ts's indirect set-equality coverage: - platform.authorization.policy.service.spec.ts: the root cascade grants platform-content-full-access EXACTLY [READ, PLATFORM_CONTENT_FULL_ACCESS], length 2 — never CREATE/UPDATE/DELETE/UPDATE_NAMEID (T036). - platform.service.authorization.spec.ts: GRANT_GLOBAL_ADMINS widening (+platform-roles-admin), FEATURE_ROLE_ASSIGN, PLATFORM_ROLE_HOLDERS_READ, FEATURE_ROLE_HOLDERS_READ (D9 subsumption), PLATFORM_AUDIT_READ, SET_SERVICE_PROFILE, PLATFORM_USERS_ADMIN, PLATFORM_FORUM_MANAGE (asserting platform-content-full-access is NOT a reacher — the FR-007(e) distinguishing assertion), PLATFORM_SETTINGS_ADMIN, CREATE_ORGANIZATION. - account.service.authorization.spec.ts: TRANSFER_RESOURCE_OFFER/_ACCEPT, ACCOUNT_LICENSE_MANAGE, PLATFORM_SUPPORT_ORG_RESOURCES. - organization.service.authorization.spec.ts: DELETE_ORGANIZATION, kept separate from the ordinary owner DELETE rule and never merged with CREATE_ORGANIZATION. - user.service.authorization.spec.ts: PLATFORM_USERS_ADMIN on the per-user tree, READ_USER_SETTINGS and READ_USER_PII extensions. - space.service.authorization.spec.ts: ACCOUNT_LICENSE_MANAGE on the space tree (A14). - platform.role.resolver.mutations.spec.ts: FEATURE_BETA_TESTER carries the same beta/trial license entitlement as legacy PLATFORM_BETA_TESTER (T040a). licensing.framework.service.authorization.spec.ts already had equivalent exact-args coverage for A12/A13 — verified sufficient, left unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
grantability.it-spec.ts caught that usersInRole(PLATFORM_OPERATIONS_ADMIN) was denied for Platform Roles Admin with FORBIDDEN_POLICY: the role was missing from PLATFORM_TARGET_ROLES in role.set.resolver.fields.ts, so its holder-list read fell through to the ordinary-role branch requiring plain READ instead of PLATFORM_ROLE_HOLDERS_READ (A20). platform.role.assignment.rules.service.ts already carried all 10 `Platform …` roles including this one in its own PLATFORM_FAMILY_ROLES set, confirming platform-operations-admin is one of the 10 target roles this feature repurposes, not a pre-existing legacy holdover — the resolver's set (and its explanatory comment) was simply out of sync. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…E_PROFILE role-action-matrix.it-spec.ts caught that ALLOW: PLATFORM_ROLES_ADMIN x A21 (updateUser) was denied: UserResolverMutations.updateUser required plain UPDATE on the target user's own authorization for EVERY call, before ever reaching UserService.updateUser's SET_SERVICE_PROFILE check (T052). PLATFORM_ROLES_ADMIN holds no path to that outer UPDATE gate — by design, per FR-003 it holds no user-record CRUD — so A21, its sole-owned surface, was structurally unreachable. Rejected the fix_direction floated by the live-verification track (granting PLATFORM_ROLES_ADMIN a path into CREDENTIAL_RULE_USER_READ / PRIVILEGE_RULE_READ_USER_SETTINGS): that rule grants ordinary READ/UPDATE across the WHOLE user record (nameID, firstName, lastName, phone, profileData), which is exactly the "user-record actions" the role table (row 1) says Platform Roles Admin "Cannot" do — it would have closed F-2 by reopening FR-003. Instead, the resolver now skips the blanket UPDATE gate only when the input's sole substantive field is `serviceProfile` (ID is addressing, not a change) and defers entirely to the already-correct SET_SERVICE_PROFILE check against the PLATFORM authorization policy. Any input that also touches another field still goes through the ordinary UPDATE gate, so this cannot be used to smuggle a general user-record edit through — A21's declared surface (user.service.ts#updateUser, gated on SET_SERVICE_PROFILE per a.row.surfaces.ts) is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…schema Found by the gql-live verification track (SC-006 reachability): the census grants MOVE-adjacent admin whiteboard surfaces, but AdminWhiteboardResolverMutations.adminUploadFilesFromContentToStorageBucket never registered with Apollo — AdminWhiteboardModule was declared but never imported by any parent module, so the resolver was dead code and the mutation was absent from the live schema entirely. Wired the module into app.module.ts and the schema-bootstrap module. The mutation is now present on the printed schema (live-confirmed: 221 mutations, name present via __schema introspection). Additive only — schema.graphql gains the operation, nothing is removed, so slice A stays additive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… guard) countActorsWithCredentials filtered the to-many `credentials` relation through a plain FindOptionsWhere, which TypeORM compiles to an INNER JOIN — the exact row-duplication hazard findMentionableContributors already documents and avoids with an EXISTS subquery. An actor holding more than one credential row of the same type+resourceID (nothing forbids it; Credential carries no unique constraint) was therefore counted once PER ROW, not once per actor. platform.role.assignment.rules.service.ts rule 5 — the last-Platform-Roles-Admin guard — depends on that number being an exact actor headcount. An inflated count silently defeats the guard and lets the platform's last Platform Roles Admin revoke themselves, locking the platform out of its own role administration. Switched to the same EXISTS-subquery pattern so the result is always a distinct actor count, immune to relation-join fan-out. Also adds a real-engine integration block to the resolver spec: the existing suite auto-mocks PlatformRoleAssignmentRulesService, so it structurally cannot catch a wiring defect between the resolver and the actual rule engine. The new block wires the real rules service (stubbing only AuthorizationService) and exercises rule 5's revoke path end to end. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both call sites invoke notifyPlatformGlobalRoleChange WITHOUT awaiting (pre-existing, bd8b9d8 / bd8314b, also present on develop) — deliberately, so a notification outage cannot fail a role change. But a floating promise that REJECTS is an unhandled rejection, and Node's default --unhandled-rejections=throw turns that into a hard process exit. Observed live TWICE during 027 verification: revoking a platform role from a user whose `profile` relation resolves null crashes the entire server with `TypeError: Cannot read properties of null (reading 'displayName')` in NotificationExternalAdapter.getUserPayloadOrFail. Both Phase V attempts collapsed to env-degraded because the test-suites fixtures killed the server they were testing — 13 roles revoked across many users reproduces it every run. 027 did not introduce this, but it makes the path hot: this feature's entire subject is platform role grant/revoke. Containing the rejection here restores the intended fire-and-forget semantics — failure logged, mutation still succeeds, process survives. The null-profile cause itself is a SEPARATE defect in the notification adapter (getUserPayloadOrFail requests relations: {profile: true}, still gets null, then dereferences it unguarded; createUserPayloadFromUser has the identical unguarded dereference). Reported, not absorbed here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… space-admin REGRESSION INTRODUCED BY THIS BRANCH. On develop, getCredentialForRole returned `roleDefinition.credential` — the stored, per-role-set credential. 027 replaced that with `ROLE_CREDENTIAL_MAP[roleName]` to close the C1 silent-void defect (research C1/D3), but that map is `Record<RoleName, AuthorizationCredential>`, keyed by role name ALONE. It cannot carry the role-set dimension, and `[RoleName.ADMIN]` is `SPACE_ADMIN` unconditionally. `RoleName.ADMIN` is the one real collision in the model: SPACE -> space-admin, ORGANIZATION -> organization-admin (organization.role.definitions.ts). Every other shared name is unambiguous (MEMBER/LEAD space-only, ASSOCIATE/OWNER organization-only), which is why only ADMIN broke. Impact: promoting an organization ADMIN wrote a `space-admin` credential row against the org's actor id. That mis-credentials EVERY organization-admin promotion, including the implicit one in OrganizationService.createOrganization, and it breaks FR-002/FR-031 feature-role inheritance — ActorContextService.expandWithOrganizationInheritedFeatureCredentials filters strictly for organization-admin/organization-owner, so it never matched and the org's feature-* credentials were never expanded onto the admin. Live-confirmed by DB query during Phase V (immediacy.it-spec.ts and flows/org-inheritance-demotion.it-spec.ts, both FORBIDDEN_POLICY on createOrganization for a promoted org admin). Independently, this is the same cross-role-set credential confusion the earlier review panel rated critical on both spec-compliance and security — that claim was recorded as unverified because its verifiers never ran. It was real. Fix: ROLE_CREDENTIAL_OVERRIDES_BY_ROLE_SET_TYPE + resolveRoleCredential(roleName, roleSetType), applied at BOTH call sites — getCredentialForRole and its pre-loaded sync twin getCredentialForRoleSync, which carried the identical defect. bootstrap.service.ts keeps the flat map: it seeds the platform role-set, where names are globally unique and no override exists. Adds 4 regression tests to the FR-011 anti-drift spec, which passed throughout because it only ever asserted the map against PLATFORM seed definitions — the collapse is invisible unless the role-set dimension is asserted explicitly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ent-access cascade Addresses confirmed review findings corr-server-1, sec-server-2, corr-server-5, spec-server-2, sec-server-1, corr-server-2, sec-server-3, spec-server-1, corr-server-6 and the sec-server-4 identity/account-delete surfaces: - Legacy `global-*` role assign/revoke (assign/removePlatformRoleFromUser) is pinned to a resolver-local, hardcoded [GLOBAL_ADMIN] policy instead of the shared roleSet.authorization, which T034 widened to also admit platform-roles-admin (corr-server-1/sec-server-2). Success audit writes now happen AFTER assignActorToRole/removeActorFromRole actually completes, not before, so a failed grant no longer leaves a phantom "granted" audit row (corr-server-5). - Assignment rule engine gains rule 6 (self-assignment, FR-015): a Platform Roles Admin can no longer grant or revoke any Platform/Feature role on itself, on both grant and revoke, excluded from bootstrap seeding (spec-server-2/sec-server-1). - The root content-access rule is reversed to full CREATE/READ/UPDATE/DELETE per the ninth clarification pass (FR-004/SC-004), but with GLOBAL_SUPPORT correctly excluded — it never held blanket CRUD across the seven root-inheriting trees and would otherwise bypass the per-space allowPlatformSupportAsAdmin consent gate (corr-server-2/sec-server-3, spec-server-1). A6/A7 declare the accepted SC-004 overlap explicitly. The reversal's ripple onto A5's deleteUser (Content Full Access gaining arbitrary user deletion via the now-cascaded DELETE) is closed by pinning deleteUser's legacy-admin branch to actor-identity + a [GLOBAL_ADMIN]-only policy. - Three A4/A5 surfaces consolidated onto one PLATFORM_USERS_ADMIN privilege (adminIdentityDeleteKratosIdentity, adminUserAccountDelete, deleteUser) are each pinned to their own pre-feature reacher set instead of the shared, additively-widened grant set, so global-support/ global-license-manager/global-platform-manager cannot reach identity or account deletion they never held (sec-server-4). - updateSpacePlatformSettings's nameID (URL rename) field requires an additional pin to [GLOBAL_ADMIN, GLOBAL_SUPPORT] so platform-license-manager cannot rename spaces via the widened ACCOUNT_LICENSE_MANAGE gate (corr-server-6). Verification model (a.row.surfaces.ts, cascade.model.ts, privilege.grants.ts, unit.coverage.inventory.ts) updated to match: new credential-pin census entries for the legacy-role and deleteUser pins, GLOBAL_SUPPORT removed from the root cascade's declared legacy reachers, and the self-assignment rule added to the FR-033 coverage inventory. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ution/contract fixes Addresses confirmed review findings corr-server-4, spec-server-3, qual-server-2, spec-server-4, corr-server-3, qual-server-1 and spec-server-6: - Bootstrap seeding reconciles the seeded `serviceProfile` marker for BOTH a freshly-created user and a pre-existing one missing it, on every restart — not only at creation time — so a pre-existing spaces-reader account without the marker no longer makes the server refuse to start permanently (corr-server-4). - `grantSeededCredentials` now tracks the target's held `Platform …` roles across the seed loop and passes them to `evaluateSeedOrFail`, so rule 4 (Audit Reader mutual exclusion) is no longer inert on the bootstrap seed path (spec-server-3/qual-server-2). - Granting or revoking a `Feature …` role on an ORGANIZATION (via the PLATFORM role-set) now invalidates the cached ActorContext of every ORGANIZATION_ADMIN/OWNER of that org, not only the organization's own cache — closing the up-to-60s stale-credential window for a standing operator (spec-server-4). - The A21 service-profile rejection path now uses a shared, extracted `resolveInitiatorRoleBestEffort` (resolve.initiator.role.ts) instead of calling the strict, throwing `resolveInitiatorRole` raw, so a realistic denied actor (holding neither the owning role nor a legacy credential) gets the intended ForbiddenException and its rejection audit row, rather than an internal error with the audit write silently skipped (corr-server-3/qual-server-1). The platform-role resolver's own best-effort wrapper is refactored to delegate to the same shared helper. - Two error strings now match `contracts/graphql-contract.md` verbatim: the A21 service-profile denial, and the A20/A20b holder-list read denial (both the platform-target and feature-target branches), which previously delegated to `grantAccessOrFail`'s generic internal-id-leaking message (spec-server-6). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ascade leaks, audit-write integrity
Security:
- sec-server-6 (critical): assignPlatformRoleToOrganization/removePlatformRoleFromOrganization
accepted any RoleName (incl. legacy global-*) and never verified the target
actor resolved to an ORGANIZATION, letting a platform-roles-admin mint
global-admin via the organization surface. Added
assertOrganizationSurfaceOrFail: rejects non-FEATURE_FAMILY_ROLES and
verifies ActorType.ORGANIZATION before any rule evaluation or write.
- sec-server-7: adminUserEmailChange/adminUserEmailChangeDriftResolve checked
the shared, Slice-A-widened PLATFORM_USERS_ADMIN policy, newly admitting
global-platform-manager to a one-call account-takeover primitive. Pinned to
a resolver-local policy carrying A4's own pre-feature reacher set
{PLATFORM_USERS_ADMIN, GA, GS, GLM}, mirroring the sec-server-4 pattern.
- sec-server-8: resolved by sec-server-6's ActorType verification — targetKind
can no longer diverge from the actual resolved actor type on the two
organization mutations.
- sec-server-5: escalated (not fixed) — the FR-034 reachability check is
self-certifying against declared `legacyReachers` rather than a derived
pre-feature baseline. A full merge-base-derived baseline is a structural
redesign this pass cannot safely complete blind; the two concrete misses
it named (sec-server-6, sec-server-7) are fixed above.
Correctness:
- corr-server-7/corr-server-10: the root `platform-content-full-access` CRUD
cascade (T036a) reached A15 (forum) and A13 (license-plan/policy
definitions) beyond the accepted A6/A7 exception. A15 now gates solely on
PLATFORM_FORUM_MANAGE (its own grant set already covers every legacy
reacher). A13's five mutations now check a resolver-local synthetic policy
{platform-settings-admin, global-admin, global-license-manager,
global-platform-manager} instead of the entity's root-cascade-inheriting
authorization; global-admin is now an explicit, declared reacher instead of
an accidental cascade one.
- corr-server-9: transferCallout's audit call and census declared
GLOBAL_SUPPORT as a legacy reacher; the callouts-set's own credential rule
actually grants GLOBAL_SUPPORT_MANAGER (GLOBAL_SUPPORT never reaches it —
the account-tree rules that grant it are cascade:false). Fixed the audit
call and split TRANSFER_RESOURCE_OFFER/_ACCEPT out of the flat
PRIVILEGE_GRANTS entry into per-tree (`account` vs `callouts-set`) grants
so the two independent credential rules are no longer conflated.
- corr-server-8: moveContributionToCallout and the four account resource
transfers (transferSpaceToAccount et al.) were declared single-path but are
reachable by ordinary space admins / any ACCOUNT_ADMIN respectively.
Audit writes now fire only when the actor holds one of the surface's own
platform-level credentials (FR-018a), not merely whoever the shared
privilege happens to admit.
- corr-server-11/spec-server-8: the fail-closed role-assignment and
service-profile audit writes ran AFTER the grant/revoke had already landed,
with no rollback — an audit-store failure left the change applied while
telling the caller it was not. Added compensation: a failed audit write
now reverts the just-applied grant/revoke/service-profile change before
re-throwing.
- spec-server-7: A15's census declared a single-privilege gate while the code
ran a UPDATE/DELETE ∨ PLATFORM_FORUM_MANAGE dual path; closed by the
corr-server-7 fix (now a genuine single-privilege gate matching the
declaration).
- spec-server-10: the conversion resolver's three promote/demote mutations
and convertVirtualContributorToUseKnowledgeBase shared the same
platform-resource-admin-reachable synthetic policy as the three declared
cross-L0 moves but had no census entry. Declared all four.
- spec-server-9: several `gateSpecs` entries pointed at specs asserting
nothing about the named privilege (A15, A7 x3, A19, A3/A9/A12's shared
account.resolver.mutations.spec.ts). Added real gate assertions
(owning-privilege-checked / dual-path-branch) to each.
- spec-server-13: notifications@alkem.io's seeded `global-community-read`
credential is NOT the same identifier as the retired
RoleName.GLOBAL_COMMUNITY_READER (off-by-"ER"), so it is not automatically
in Slice B's removal set. Recorded the decision and the required Slice B
verification step in docs/evidence rather than silently keeping or
dropping it.
Quality:
- qual-server-3: added PLATFORM_SUPPORT's twin coverage to
space.service.platform.roles.access.spec.ts (L0 flag-gated CRUD, PUBLIC_SHARE,
L1/L2 parent-inheritance keyed on the role's OWN cell, PLATFORM_ADMIN
absence) — previously only GLOBAL_SUPPORT had this coverage despite
PLATFORM_SUPPORT being copy-adapted from it.
Also fixes two real estate DI wiring gaps this pass's own changes would have
introduced (ActorLookupModule missing from PlatformRoleModule,
AuthorizationPolicyModule missing from LicensePlanModule/
AdminUserEmailChangeModule) — caught by actually running `pnpm schema:print`
(full Nest bootstrap), not by the mocked unit-test gate.
Full gate pass green: lint, build, 7868 unit tests, schema:print/sort/diff/validate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…cade V-prime live verification (2026-07-30) found the A16 cell denying `platform-spaces-reader` plain READ on a private space's collaboration. The role IS registered with `[READ]` in `space.service.platform.roles.access.ts` (T038), but `space.service.authorization.ts` built its "Global Spaces Read" credential rule wired ONLY to `AuthorizationCredential.GLOBAL_SPACES_READER` — so the new role had no path into the space-tree READ cascade at all. That breaks the Slice A additive invariant: every new role must reach what its legacy counterpart already reaches. - space.service.authorization.ts: derive one rule per spaces-reader role, each from its OWN declared privileges and its OWN credential via `resolveRoleCredential`, so the two roles can never silently inherit each other's grants. - account.service.authorization.ts: the same gap on the account tree, which V-prime did not flag. Wiring only the space side would leave the role able to read a space but not its account-level contents. Verified: `ALLOW: PLATFORM_SPACES_READER x A16 -> allow` now passes in the canonical role-action matrix (24 passed, up from 23), after authorizationPolicyResetAll + authorizationPolicyResetOnPlatform. Also guards `rolesUser.spaces` against incomplete rows: `about.profile` IS requested by the query, so a null means the row itself is broken (a credential resolving to a space whose about/profile no longer exists). `RolesResultSpace`'s constructor dereferenced it unguarded, so ONE such row threw and took the whole query down instead of omitting a single entry — found by the gql-live track. Skipped with a warning, matching the file's existing `!space.authorization` handling. Same treatment for subspaces. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…server-12/13) - corr-server-12: license-plan/license-policy definition mutations now include global-support in their resolver-local synthetic policy — it reached these surfaces pre-feature via globalSupportPlatformAdmin's cascade off platform.authorization into licensingFramework.authorization, an implicit reach the corr-server-7 fix's synthetic policy dropped. - corr-server-13: split the licensing-framework's `licensings` CRUD rule so it no longer also carries GRANT — that leaked A12's assign/revoke-license- plan privilege to platform-settings-admin, a holder A12's own declared owner/legacy-reacher lists never named, silently voiding the audit row on every such call. GLOBAL_LICENSE_MANAGER/GLOBAL_PLATFORM_MANAGER's legacy A12 reach moves to the dedicated licensePlanUsage rule instead.
…le-1 DoS precheck (corr-server-14/15, sec-server-11, qual-server-4) - corr-server-14: recordGrantSuccess/recordRevokeSuccess now take a wasNoOp flag (captured via isInRole before the assign/remove call) and skip compensation when the grant/revoke was an idempotent no-op — compensating a no-op grant previously stripped a role the target legitimately held before the call; compensating a no-op revoke granted a role nobody asked for. - corr-server-15: assertOrganizationSurfaceOrFail's two rejection branches now write a role_grant_rejected audit row before throwing, matching the engine's own rule-2 rejection path — this guard runs ahead of evaluateGrantOrFail/evaluateRevokeOrFail, so it was the one place a holder-kind rejection left no trace. - sec-server-11: evaluateGrantOrFail/evaluateRevokeOrFail now probe rule 1 (assigner capability) via the new PlatformRoleAssignmentRulesService .hasAssignerCapability() BEFORE getHeldPlatformRoles' ~10 isInRole round trips and before any rejection-audit write — an actor with no assignment capability at all is a probe, not an auditable rejected attempt. updateUser's serviceProfile-only branch now gates SET_SERVICE_PROFILE in the resolver itself before delegating, so an anonymous/unprivileged caller is rejected without the redundant DB lookup whose fail-closed audit writer previously errored on an empty-string actorID. - qual-server-4: adds the previously-missing negative-path coverage for assertOrganizationSurfaceOrFail (legacy-role rejection, non-organization target rejection, and the corr-server-15 audit-write assertion). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… accounts (spec-server-14) extendAuthorizationPolicy now takes the account's accountType and only pushes the platformSupportOrgResources credential rule when it is ORGANIZATION — the rule was previously appended to every account regardless of host kind, letting Platform Support edit/CRUD a USER-hosted account's innovation packs/hubs/templates too. A7 (spec row 7) and FR-008(b) grant this right only over resources "belonging to an organization".
…-list bypasses; seed break-glass global-admin (sec-server-9/10/15) - sec-server-9: grantCredentialToActor/revokeCredentialFromActor (actor.resolver.mutations.ts) reject any of the 13 platform-*/feature-* role credentials outright, before the PLATFORM_ADMIN check — those credentials were grantable through this generic, un-censused surface the moment they joined the shared AuthorizationCredential/CredentialType enums, bypassing every assignment rule and the audit trail. - sec-server-10: extracts role.set.resolver.fields.ts's holder-list predicate into the shared platform.role.holder.list.access.ts and applies it to actorsWithCredential/usersWithAuthorizationCredential (admin.authorization.resolver.queries.ts) whenever the requested credential is a platform-*/feature-* target role — previously gated only on READ_USERS, which every registered user holds, a complete bypass of the A20/A20b holder-list gate. - sec-server-15: seeds admin@alkem.io with global-admin alongside platform-roles-admin so a freshly-bootstrapped environment always has at least one holder of the several [GLOBAL_ADMIN]-pinned policies this feature's round-1/round-2 fixes hardcoded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t coverage, and refresh the stale evidence doc (sec-server-5, qual-server-9, spec-server-17) - sec-server-5: adds a per-file PLATFORM_ADMIN_SCAN_ALLOWLIST (rule 1b) so the two credential-admin mutations sec-server-9 fixed are scanned for a PLATFORM_ADMIN gate hit without flooding the ~24 unrelated files that reference the same privilege — plus an inverse completeness check (rule 4) that flags any @Mutation/@query resolver writing a credential directly or accepting a CredentialType/AuthorizationCredential/RoleName- typed argument that the census doesn't yet know about, independent of which privilege literal (if any) is scannable at the call site. - qual-server-9: adds the missing subject-column-discriminator (subjectUserId XOR subjectOrganizationId) and outcome-mapping assertions to the audit writer's spec, plus dedicated coverage for resolveInitiatorRoleBestEffort's SELF fallback — none of the four had any assertion anywhere in the repo, so a swap in the writer or the fallback logic would have passed every existing test. - spec-server-17: regenerates the evidence doc's census counts (112 entries / 39 files / 101 distinct surfaces, with the 11-entry gap fully accounted for) and re-runs the T070l(b) root-cascade mutation-test perturbation against the post-reversal model (UPDATE_NAMEID, the one privilege the root rule still withholds by design) since the original "add DELETE back to ROOT_CASCADE" experiment is no longer performable — DELETE has been there permanently since the ninth analyze pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…olicy (corr-server-16) actorsWithCredential/usersWithAuthorizationCredential checked the new PLATFORM_ROLE_HOLDERS_READ/FEATURE_ROLE_HOLDERS_READ privileges against getPlatformAuthorizationPolicy() — but those two privileges are granted only on the platform role-set's authorization (createAdditionalRoleSetCredentialRules), never on the platform entity's own policy. The gate therefore denied every actor unconditionally, including both declared owners, while the RoleName route (roleSet.usersInRole) worked correctly through the same predicate. Fetch the role-set via platformService.getRoleSetOrFail() and check the holder-list predicate against roleSet.authorization, mirroring platform.role.resolver.mutations.ts. AdminAuthorizationModule needed PlatformModule added to its imports to resolve the new PlatformService dependency — missing this broke real Nest bootstrap (schema:print) even though every unit test passed, since Test.createTestingModule's useMocker auto-supplies any missing token and can't catch a real DI wiring gap. Added a real-policy regression pin (two distinct hand-built IAuthorizationPolicy objects — one mirroring the platform entity policy with no holder-list rule, one mirroring the role-set policy that actually grants it) so a regression back to checking platformAuthorization fails the build instead of only a mocked isAccessGranted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…9 surfaces (corr-server-17, spec-server-18, corr-server-18) The rule-1 (assigner-capability) DoS precheck in evaluateGrantOrFail/ evaluateRevokeOrFail used a role-specific hasAssignerCapability check — so an actor holding ONE of the two assigner privileges (e.g. a Platform Users Admin holding only FEATURE_ROLE_ASSIGN) attempting to grant a role outside its family (a platform-* role, requiring GRANT_GLOBAL_ADMINS) was misclassified as a fully unprivileged probe: the rejection-audit write was skipped and the self-assignment-first rule ordering was bypassed. Replaced with hasAnyAssignerCapability, which only exempts an actor holding NEITHER assigner privilege at all — a genuine probe. A privileged actor's cross-family escalation attempt now falls through to evaluateOrFail + recordGrantRejected as intended, restoring both the audit row and the self-assignment-first ordering. Added real-rules- engine tests distinguishing the two cases. Also: conversion.resolver.mutations.ts's recordCrossL0MoveAudit only fired for the three cross-L0 space moves, though the resolver's own constructor comment (and the A9 census) declares all seven mutations on this file — the three convertSpace* conversions and convertVirtualContributorToUseKnowledgeBase too — as sharing the SAME synthetic platform-resource-admin policy. Renamed to recordResourceMoveAudit(actorContext, resourceKind, resourceId) and wired it into the remaining four surfaces, with tests asserting each writes its platform_resource audit row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t re-grant to break-glass role (spec-server-19, sec-server-18, spec-server-22)
sec-server-15 (round-1/round-2 hardening) had seeded admin@alkem.io with
global-admin alongside platform-roles-admin, reasoning that several
[GLOBAL_ADMIN]-pinned legacy policies would otherwise be unreachable on a
fresh environment. That directly contradicted FR-013 ("no god-mode role
may be reintroduced... the seeded account holds Platform Roles Admin
only") and T055's identical requirement (spec-server-19), and combined
with the always-on restart re-grant loop, made every seeded credential of
every seeded account non-deprovisionable — a standing, cross-space-read-
capable principal an operator's revocation could never make durable
(sec-server-18).
Reverted rather than merely documented as an exception: the FR-013b
break-glass drill (T071) exercises platform-roles-admin recovery
specifically and never requires global-admin; the pinned legacy surfaces
this seed was propping open are themselves Slice-B-retiring god-mode
paths this feature exists to remove. admin@alkem.io now holds
platform-roles-admin only, matching T055.
Narrowed the restart-time credential re-grant (T053-T055's "grant loop
runs on every start" design) to the FR-013b break-glass recovery
credential (platform-roles-admin) alone — every other seeded credential
(platform-spaces-reader, any legacy role) is granted only when the
account is first created; a missing one on a pre-existing account is
treated as a deliberate, durable revocation, not silently reinstated.
Added a real-rules-engine bootstrap test (T054a/spec-server-22) proving
rule 4 (Audit Reader mutual exclusion) actually fires on the seed path
when two mutually exclusive Platform roles are configured for one
account, rather than only through a mocked evaluateSeedOrFail.
Residual risk recorded in the evidence doc: the seeded-account list
remains compiled-in (not environment-configurable) and the identity-
resolve legacy email-link flow is unchanged — both pre-existing,
orthogonal hardening items for follow-up work, not regressions this
feature introduces.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…VERAGE gap (qual-server-10, spec-server-20) Five PRIVILEGE_COVERAGE entries (USER_POLICY_SPEC, ORGANIZATION_POLICY_SPEC, MOVE_CONTRIBUTION, UPDATE_CALLOUT_PUBLISHER, READ/A16) declared spec files that never once mention the privilege they claim to cover — deleting the real covering spec, or the real grant-set widening, failed no build. Repointed at the specs that actually own each grant set (user.service.authorization.spec.ts, organization.service.authorization. spec.ts, callout.contribution/callout.service.authorization.spec.ts, space.service.platform.roles.access.spec.ts) and added the two genuinely missing exact-credential-array assertions (MOVE_CONTRIBUTION's platform-resource-admin widening, UPDATE_CALLOUT_PUBLISHER's platform-content-full-access widening) that had no coverage anywhere in the repo. Strengthened assertRealSpec (as assertRealSpecCovers) for the PRIVILEGE_COVERAGE loop to also scan the declared spec's file content for the privilege's enum key name, via a runtime value->key reverse map (PRIVILEGE_KEY_BY_VALUE) — so a mis-pointed entry fails the build instead of reporting false coverage. Added a sixth inventory Record, MECHANISM_COVERAGE, closing the FR-033 gap for five load-bearing mechanisms that fit none of the prior five Records: the role->credential map, the organization-inheritance prefix filter and its cache invalidation, and two of the four bootstrap seeding paths (rule evaluation, fatal conflict). All five specs already existed; only the inventory entry and its build-time closure were missing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…olation (spec-server-22) T054a's runtime fix (seeding heldPlatformRoles from the target's existing credentials, updated as the seed loop progresses) landed, but neither mandated test case existed: no rules-service case exercising rule 4 (Audit Reader mutual exclusion) through evaluateSeedOrFail in either direction. Added both — granting platform-audit-reader to a holder of platform-support, and the reverse — asserting each throws naming the mutual-exclusion violation. The paired real-rules-engine bootstrap integration test landed in the previous commit alongside the seed data fix it was written to prove. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s before skipping Two defects the round-4 sec-server-18 narrowing introduced, both confirmed against the spec rather than inferred. spec-server-23 — FR-012's first clarification pass is explicit: "service accounts are re-seeded automatically. Existing seeding grants their target roles on every server start; only HUMAN holders fall under manual re-grant." sec-server-18 scoped restart-eligibility to the break-glass `platform-roles-admin` alone, so a revoked `platform-spaces-reader` on the seeded service account stayed revoked across restarts. The correct discriminator is the ACCOUNT KIND, not the credential: a revoked credential on a service account is a broken integration, not an operator decision to respect. sec-server-18's actual concern — durable revocation for humans — is preserved unchanged. spec-server-24 — `evaluateSeedOrFail` ran AFTER the restart-eligibility `continue`, so on a pre-existing account a rule-violating users.json entry was silently skipped instead of failing startup. That made the FR-028 Audit Reader mutual exclusion, and every other seed rule, unenforceable on any environment already bootstrapped once — i.e. every real one. Validation now runs before the skip: a misconfigured seed is fatal wherever it is read, whether or not this particular start would have acted on it. lint clean (3123 files, tsc + biome). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
📊 PR Metrics SummaryTitle: feat(027): decompose Global Admin into 13 purpose-specific platform roles — Slice A (additive)
Flags
Thresholds{
"critical_loc": 200,
"simple_loc": 100,
"file_count": 10
} |
|
Schema Diff Summary: No blocking changes
Baseline branch: develop |
…rver-25/27 + audit-write coverage
Six review findings, each with the test that would have caught it.
PRODUCT FIXES
- corr-server-19: `assertOrganizationSurfaceOrFail` wrote a
`role_grant_rejected` audit row BEFORE any assigner-capability check, so any
authenticated user could drive one attacker-chosen `platform_audit_entry`
INSERT per request. It re-opened on the organization surface exactly what
sec-server-11 closed on the user surface — the guard never got the probe
because it sat upstream of `getRoleSetOrFail()` and had no policy to probe
against. Both call sites now resolve the role set first, and the guard opens
with the same NARROWED probe: it fires only for an actor holding neither
assigner privilege, so a privileged cross-family attempt still falls through
and is audited.
- sec-server-20: `removePlatformRoleFromUser` asserted `targetActorType:
'user'` without verifying it. An organization id produced a real credential
revocation, an audit row filed under `subjectUserId`, and an EntityNotFound
telling the caller nothing happened — the first thing that checked the claim
was the lookup at the END of the method. Now resolved up front, mirroring
the grant surface.
- sec-server-23: the A10 consolidation onto one PLATFORM_SETTINGS_ADMIN
privilege handed `setPlatformWellKnownVirtualContributor` the family's UNION
of legacy reachers, and GLOBAL_PLATFORM_MANAGER gained a mutation it never
held (this surface was on the PLATFORM_ADMIN catch-all: {GA,GS,GLM}; its
siblings on PLATFORM_SETTINGS_ADMIN: {GA,GPM}). Pinned to a resolver-local
policy carrying this surface's own pre-feature set plus the owning role —
same shape as sec-server-7's emailChangePolicy. The audit call's
legacyReachers list drops GPM too, so attribution cannot describe a caller
the gate rejects.
- spec-server-27: `deleteUser`'s audit row was written only on the
`platform-users-admin` branch, so a deletion by legacy `global-admin` — the
normal path for the whole of Slice A, since FR-012 migrates no assignments —
was recorded nowhere, contradicting FR-018. The writer could always
attribute it (A5_LEGACY_REACHERS exists for exactly this case); only the
condition was wrong. Now audited iff administrative, i.e. not self-service.
- spec-server-25: new `lifecycle: {declarationOnly: true}` marker. A4's
`deleteUser` legacy pin and A5's entry both declared the same member, so the
matrix generated `PLATFORM_USERS_ADMIN x A4 -> deny` alongside
`x A5 -> allow`: one mutation, two contradictory expectations, and the DENY
cell invoked a real destructive mutation against the shared fixture user.
corr-ts-16's DENY-before-ALLOW ordering could not help — it orders cells
WITHIN a surface and these are two. Reachability and pin-drift still walk
the entry; only cell generation is suppressed.
TEST COVERAGE
- qual-server-11: FR-027's fail-closed compensation had ZERO coverage in this
repo — deleting either the compensation or corr-server-14's no-op guard left
the suite green. 8 tests pin the full truth table on both sides.
- qual-server-12: all 15 `recordEventForActor` sites now asserted (callout,
innovation-hub, innovation-pack, account, space, admin-licensing), plus 31
previously-unasserted `recordOperation`/`recordChangeForActor` sites in
platform, ai-server, admin-authorization and admin-communication —
including every FAILURE-outcome row, which is the half an operator actually
needs after something half-ran and threw.
- qual-server-13: four A8 dual-path surfaces (`deleteSpace`, `deleteCallout`,
`deleteInnovationHub`, `deleteInnovationPack`) were exercised only in the
DENIED direction — every spec stubbed `isAccessGranted` to false, so the
PLATFORM branch and its FR-018a audit write never executed. Both directions
now asserted, via a single-privilege `grantOnly()` helper so which branch
authorized the call is unambiguous.
Mutation-tested: reverting the spec-server-27 condition, the qual-server-11
no-op guard, the revoke-side compensation, or the corr-server-19 probe each
fails exactly its own tests and nothing else.
Verified: 8000 passed / 0 failed (687 files, up from 7932/686). Lint + tsc
clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HZSiMjuPXnj2Un3pd8WKC3
Implements the
serverslice of workspace#027-platform-role-redesign. Story #4764, Epic #6320, ADR 0007.What this does
Decomposes
global-admininto 13 single-purpose global roles across two families — 10Platform …(assignable only by Platform Roles Admin) and 3Feature …(also assignable by Platform Users Admin) — with per-family privileges replacing thePLATFORM_ADMINcatch-all and the root CRUD+GRANT god mode.Also lands: an assignment rule engine (6 rules, shared with bootstrap seeding), 4 new audit categories with per-category fail-open/fail-closed semantics, and a build-failing surface census (~76 administrative surfaces) whose declared intent must equal derived reachability.
Slice A is strictly additive
Legacy broad grants still work. Every new role reaches what its legacy counterpart reaches; nothing is removed. Removal is Slice B, a hard break shipped as one coordinated release across
server+client-web+test-suites, and it is gated on the FR-013b break-glass recovery drill.That means denial is not testable here — only positive reachability is meaningful at Slice A, by design.
Verification
test:ci:no:coverage)test:rolesetTwo integration specs that had never once reached their assertions (
immediacy.it-spec.ts,flows/org-inheritance-demotion.it-spec.ts) now pass 3/3 — they were self-poisoning on fixture displayName collisions, fixed in the test-suites PR.Status — why this is a draft
Five adversarial review rounds: 141 findings confirmed, 135 fixed. Round 5 left 22 residual (5 high, 20 medium) with no fix round remaining, and the security dimension returned
failforserverandclient-web.Open human gates — none of these are automatable:
Known and deliberate: R4 (migration safety) reports
unmitigated—migration:validatedoes snapshot/restore against a shared reference DB and was not run.Companion PRs
client-web— admin UI: roles filtered by assigner capability, server rejections surfacedtest-suites— A-row × surface matrix, rule-engine specs, stateful flowsSeparately: #6321 fixes a pre-existing
developcrash this feature's verification surfaced.🤖 Generated with Claude Code
https://claude.ai/code/session_01HZSiMjuPXnj2Un3pd8WKC3