Access governance
Access governance
The governance module (Cbox\Id\Governance\) is the Identity Governance &
Administration (IGA) layer: it governs who holds what access over time, on top of
the RBAC roles and organization memberships the platform already tracks. Two
capabilities:
- Access certification campaigns — periodic access reviews. Snapshot the access grants in an organization, put each in front of a reviewer to certify or revoke, and on close apply every revoke against the real access contracts.
- Segregation of Duties (SoD) — policies that forbid toxic role combinations, with a pre-grant gate and a detector for conflicts that already exist.
It is a server-side primitive (contracts you resolve and call), not a UI.
Access certification campaigns
use Cbox\Id\Governance\Contracts\AccessReviews;
$reviews = app(AccessReviews::class);
// Open a campaign — snapshots every DIRECT role assignment and membership in the org.
$campaign = $reviews->open('acme', 'Q3 access review', dueAt: now()->addWeek());
foreach ($reviews->itemsFor($campaign->id) as $item) {
// A reviewer decides on each grant ($item->subject_id holds $item->access_ref).
$stillNeeded
? $reviews->certify($item->id, reviewerId: $manager->id)
: $reviews->revoke($item->id, reviewerId: $manager->id, note: 'left the team');
}
// Close applies every revoke (Roles::unassign / Memberships::remove) and marks the
// campaign closed. Items still un-reviewed follow the campaign's PendingPolicy.
$reviews->close($campaign->id);
- Decisions are reversible until close.
revoke()only records intent; the actual removal happens atclose(), so a reviewer can change their mind while the campaign is open. - Pending items are safe-by-default. Anything left un-reviewed at close takes the
campaign's
PendingPolicy— the default is Revoke (access no one vouched for is removed). PassPendingPolicy::Certifyto keep them instead. - A refused revoke is never silently dropped. If the domain rejects a removal — the
classic case is removing an organization's last owner — the item is recorded as
un-applied with the reason and audited (
governance.access.revoke_blocked). - Overdue campaigns auto-close.
cbox-id:governance:close-overdue(scheduled every minute, config-gated) closes any open campaign past itsdue_at, reconstructing each campaign's environment first.
Environment-wide grants
A role held environment-wide (Roles::assignEverywhere() — typically a staff role
across every customer) belongs to no organization, so no organization's campaign includes
it. A null organization opens the environment's own review of those grants:
$campaign = $reviews->open(null, 'Staff access, Q3'); // every environment-wide grant
foreach ($reviews->itemsFor($campaign->id) as $item) { // AccessKind::EnvironmentRole
$reviews->revoke($item->id, reviewerId: $envAdmin->id, organizationId: null);
}
$reviews->close($campaign->id, null); // Roles::unassignEverywhere()
Null means the environment plane on every call — certify, revoke and close take it —
and it matches only the environment's campaign, never "any". The two kinds never mix: a
tenant's campaign never lists the vendor's staff grants (its reviewer must not see or
revoke them), and naming the environment's campaign with an organization id is refused like
another tenant's campaign. Revoking an item takes the grant back in every organization at
once.
Segregation of Duties
use Cbox\Id\Governance\Contracts\SegregationOfDuties;
$sod = app(SegregationOfDuties::class);
// "Raise a purchase order" and "approve payment" must never be held together.
$sod->definePolicy('acme', 'PO vs payment', [$createPoRoleId, $approvePayRoleId]);
// Pre-grant gate — call BEFORE assigning a role:
if ($sod->wouldViolate('acme', $userId, $approvePayRoleId)) {
// refuse — the user already holds the conflicting role
}
// Or get a reasoned decision (the same Decision value object the authorization PDP uses):
$decision = $sod->evaluate('acme', $userId, $approvePayRoleId); // deny reason: "sod:{policyId}"
// Detect conflicts that already exist (a report, or to seed a campaign):
$sod->violationsFor('acme', $userId); // this subject's violations
$sod->scan('acme'); // every violation in the org
A policy names a mutually-exclusive set of roles; holding two or more at once is a
violation. Policies can be scoped to one organization or made environment-wide
(organizationId: null). Inactive policies are ignored.
Honest scope
- v1 governs roles and memberships only — organization role assignments, memberships,
and environment-wide role grants. These are the subject-centric grants
that are cleanly enumerable and immediately revocable. Deliberately out of scope:
- Entitlements are a billing-fed projection — governed at the billing source, not
re-certified here (and a
Claims-mode entitlement's revoke isn't immediate anyway). - ReBAC relationship tuples have no enumeration or audit surface yet; certifying them needs that groundwork first.
- Entitlements are a billing-fed projection — governed at the billing source, not
re-certified here (and a
- Certification acts on DIRECT grants. Role roll-down is computed at read time, so a role inherited from an ancestor org is reviewed and revoked at that ancestor, where the assignment physically lives — revoking there removes the inherited access from the whole subtree at once. A campaign scoped to org X governs the grants made at X. SoD evaluation likewise considers direct assignments at the org.
- This is a primitive, not a policy. Who reviews, how often, and what combinations are toxic is the host's decision; the module enforces the mechanics — snapshot, decision, application, conflict detection — and audits all of it.
Where to go next
- Run an access review — the campaign recipe.
- Security: access governance — the threat model.