Access governance
Security: access governance
Access governance (Cbox\Id\Governance\) exists to make over-provisioned access
visible and removable, so its own controls must be trustworthy. This page states
the threat model and the honest limits.
Controls
| Control | Mechanism | Where |
|---|---|---|
| Real application | close() applies revokes via Roles::unassign() / Memberships::remove() — not a paper decision |
DatabaseAccessReviews::applyRevoke() |
| Safe-by-default pending | items un-reviewed at close are revoked by default (PendingPolicy::Revoke) |
DatabaseAccessReviews::settle() |
| Refused revoke is surfaced | a domain-guard refusal (last owner) → item marked un-applied + reason + audit, never dropped | DatabaseAccessReviews::applyRevoke(), governance.access.revoke_blocked |
| Closed = frozen | no certify/revoke after close; re-close is idempotent (never re-applies) | DatabaseAccessReviews::decide() / close() |
| SoD gate | reasoned Decision (deny carries the policy id) before a grant completes a toxic combo |
DatabaseSegregationOfDuties::evaluate() |
| Environment scope | campaigns, items and policies are BelongsToEnvironment — cross-env invisible |
all Governance\Models\* |
| Audit correlation | every decision + application audited, correlated by campaign_id in context |
hash-chained audit trail |
Why pending-defaults-to-revoke
An access review that leaves un-reviewed grants in place is theatre: the grants no
one looked at are exactly the ones most likely to be stale. So the default
PendingPolicy is Revoke — closing a campaign removes any access nobody
certified, matching the deny-by-default posture of the rest of the platform. A host
that wants a softer review (flag, don't cut) sets PendingPolicy::Certify
explicitly and owns that choice.
The last-owner case, and why it's visible
Removing an organization's sole owner would orphan the org, so Memberships::remove()
refuses it (LastOwner). Governance does not swallow that: the certification item
is recorded as applied = false with the reason, and a governance.access.revoke_blocked
event is written to the audit trail. A reviewer's revoke that could not be carried out
is a finding, not a no-op — the campaign's evidence shows exactly what was and wasn't
enforced.
Auditing
governance.campaign_opened, governance.item_certified, governance.item_revoked,
governance.access.revoked, governance.access.revoke_blocked and
governance.campaign_closed are recorded on the hash-chained trail. Decision events
carry the reviewer (AuditEvent::forUser), and every event carries the
campaign_id in its context, so a campaign is fully reconstructable — who reviewed
what, what they decided, and what the system actually applied — from the audit log.
The downstream Roles::unassign() / Memberships::remove() calls emit their own
role.unassigned / organization.member_removed events too, so the enforcement is
double-recorded. See core-concepts/audit-streaming.md.
Honest limits
- Scope: roles + memberships. Entitlements (billing-fed) and ReBAC tuples are out of v1 — see core-concepts/access-governance.md.
- Direct grants only. Inherited (rolled-down) access is governed at the ancestor where the assignment lives; a campaign does not reach up the hierarchy on the descendant's behalf. SoD evaluates direct assignments at the org.
- SoD is an explicit gate, not an ambient guarantee. It denies a proposed grant only
when the host calls
evaluate()/wouldViolate()before assigning. It does not retroactively block a role already assigned out-of-band —scan()/violationsFor()are how you find those, and a campaign is how you remediate them. - A primitive, not a policy. Review cadence, reviewer selection and what counts as a toxic combination are the host's to decide.