Skip to content

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 at close(), 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). Pass PendingPolicy::Certify to 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 its due_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.
  • 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