Tenant Access Reviews & Access Certification
An access review is the recurring control that proves every principal still needs the tenant-scoped permissions it holds, and it belongs inside the broader Auth & Cross-Tenant Access Control framework that decides who may act inside which tenant. Provisioning grants access; certification is the only mechanism that takes it back.
The multi-tenant twist is that a review is never one list. Each tenant is a separate review universe with its own reviewers, its own risk appetite, and โ for enterprise customers โ its own contractual cadence. A single global "who has admin?" export is useless to a tenant administrator who is accountable only for their own workspace, and dangerous to ship, because it discloses the existence and shape of other tenants. Certification therefore has to be partitioned exactly the way the data is partitioned.
This page builds the whole control: snapshotting entitlements per tenant, generating scoped campaigns, collecting reviewer decisions, revoking in a way that actually invalidates live sessions, and emitting an immutable evidence record that survives an auditor's sampling.
Prerequisites
- [ ] A tenant-scoped permission model with stable role and entitlement identifiers, as described in designing tenant-scoped permission models.
- [ ] An append-only audit stream that records every grant and revoke with actor, target, tenant, and timestamp.
- [ ] PostgreSQL 14+ (or equivalent) with the ability to store point-in-time snapshots โ table partitioning or a generated-history table.
- [ ] A job runner able to schedule per-tenant campaigns on independent cadences (Temporal, Sidekiq, Celery, or a cron-driven queue).
- [ ] Transactional email or in-app notification delivery addressed to reviewer identities, not to shared mailboxes.
- [ ] A session store that supports targeted invalidation so a revoke takes effect immediately.
- [ ] Defined reviewer roles per tenant: at minimum a tenant owner, plus an internal control owner for staff access to tenant data.
Certification Scope & Campaign Types
Not every entitlement carries the same risk, and reviewing all of them at the same cadence guarantees rubber-stamping. Split the universe into campaign types with different frequencies and different reviewers, then let the risk of the entitlement โ not the convenience of the scheduler โ pick the cadence.
| Campaign type | Scope | Reviewer | Cadence | Primary risk addressed |
|---|---|---|---|---|
| Tenant user certification | End users inside one tenant | Tenant owner / workspace admin | Quarterly | Departed employees retaining workspace access |
| Privileged role certification | Tenant admins, billing owners, API key holders | Tenant owner + internal control owner | Monthly | Standing privilege accumulation |
| Staff support access | Vendor employees with cross-tenant support roles | Internal security owner | Monthly | Unbounded support impersonation |
| Service principal review | Machine identities, integration tokens, webhooks | Tenant technical contact | Semi-annual | Forgotten integrations with stale scopes |
| Federation mapping review | External IdP group โ tenant role mappings | Tenant IT administrator | Semi-annual | Drifted group mappings granting silent elevation |
The federation row is the one teams most often forget. When roles arrive through an identity provider assertion, the authoritative grant lives outside your system, so the review has to certify the mapping rather than the resulting membership โ the mechanics of which are covered in mapping external IdP groups to tenant roles.
The figure below shows how one entitlement store fans into these campaign types, each landing with a different reviewer population.
Step-by-Step Implementation
Step 1 โ Snapshot entitlements immutably at campaign open
A review that reads live tables is unreviewable: rows change while reviewers work, and the auditor cannot reconstruct what was actually shown. Freeze a snapshot at campaign creation and make every decision reference a snapshot row identifier.
CREATE TABLE access_review_campaign (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
campaign_type text NOT NULL,
opened_at timestamptz NOT NULL DEFAULT now(),
due_at timestamptz NOT NULL,
closed_at timestamptz
);
CREATE TABLE access_review_item (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
campaign_id uuid NOT NULL REFERENCES access_review_campaign(id),
tenant_id uuid NOT NULL,
principal_id uuid NOT NULL,
principal_kind text NOT NULL, -- 'user' | 'service' | 'idp_group'
role_key text NOT NULL,
granted_at timestamptz NOT NULL,
last_used_at timestamptz,
decision text, -- NULL until reviewed
decided_by uuid,
decided_at timestamptz,
justification text
);
CREATE INDEX ON access_review_item (tenant_id, campaign_id) WHERE decision IS NULL;
last_used_at is what turns a checkbox exercise into a real control. A reviewer confronted with "this admin role has not been exercised in 214 days" revokes; a reviewer shown only a name and a role approves everything in ninety seconds.
Step 2 โ Generate campaigns per tenant on independent schedules
Enterprise contracts frequently specify their own review cadence, so the scheduler must hold per-tenant configuration rather than one global cron expression. Generate into the snapshot tables inside a single transaction so a partially built campaign is never visible.
// campaign-generator.ts
import { PoolClient } from 'pg';
interface CampaignSpec { tenantId: string; type: string; dueInDays: number; }
export async function openCampaign(db: PoolClient, spec: CampaignSpec): Promise<string> {
await db.query('BEGIN');
try {
const { rows } = await db.query(
`INSERT INTO access_review_campaign (tenant_id, campaign_type, due_at)
VALUES ($1, $2, now() + ($3 || ' days')::interval)
RETURNING id`,
[spec.tenantId, spec.type, spec.dueInDays],
);
const campaignId = rows[0].id;
await db.query(
`INSERT INTO access_review_item
(campaign_id, tenant_id, principal_id, principal_kind, role_key, granted_at, last_used_at)
SELECT $1, g.tenant_id, g.principal_id, g.principal_kind, g.role_key, g.granted_at, g.last_used_at
FROM tenant_role_grant g
WHERE g.tenant_id = $2
AND g.revoked_at IS NULL
AND g.risk_class = ANY($3::text[])`,
[campaignId, spec.tenantId, riskClassesFor(spec.type)],
);
await db.query('COMMIT');
return campaignId;
} catch (err) {
await db.query('ROLLBACK');
throw err;
}
}
function riskClassesFor(type: string): string[] {
switch (type) {
case 'privileged': return ['admin', 'billing', 'api_key'];
case 'service': return ['machine'];
case 'federation': return ['idp_mapping'];
default: return ['standard', 'admin', 'billing'];
}
}
Step 3 โ Serve the review UI through the same tenant boundary as the product
The review surface is tenant data. Route it through the identical scoping path the rest of the application uses so a reviewer physically cannot fetch another tenant's items, even with a forged campaign identifier. That means the tenant_id predicate comes from the request's verified context, never from the URL.
// review-api.ts
import { requireTenant } from './tenant-context';
export async function listOpenItems(db: PoolClient, campaignId: string) {
const { tenantId } = requireTenant(); // from the validated session/JWT
const { rows } = await db.query(
`SELECT id, principal_id, principal_kind, role_key, granted_at, last_used_at
FROM access_review_item
WHERE campaign_id = $1 AND tenant_id = $2 AND decision IS NULL
ORDER BY last_used_at NULLS FIRST, role_key`,
[campaignId, tenantId],
);
return rows;
}
Ordering by last_used_at NULLS FIRST puts never-used grants at the top of the reviewer's screen, which is where revocations actually come from.
Step 4 โ Record decisions as facts, then act on them asynchronously
Separate the reviewer's decision from its enforcement. The decision is a durable fact that must survive even if revocation fails; the revocation is an operation that may need retries across several systems (database grants, IdP mappings, API keys, cached sessions).
// decide.ts
type Decision = 'approve' | 'revoke';
export async function decide(
db: PoolClient, itemId: string, decision: Decision, reviewerId: string, why: string,
) {
const { tenantId } = requireTenant();
await db.query('BEGIN');
try {
const { rowCount } = await db.query(
`UPDATE access_review_item
SET decision = $1, decided_by = $2, decided_at = now(), justification = $3
WHERE id = $4 AND tenant_id = $5 AND decision IS NULL`,
[decision, reviewerId, why, itemId, tenantId],
);
if (rowCount === 0) throw new Error('item not found, not yours, or already decided');
if (decision === 'revoke') {
await db.query(
`INSERT INTO revocation_outbox (item_id, tenant_id, attempts) VALUES ($1, $2, 0)`,
[itemId, tenantId],
);
}
await db.query('COMMIT');
} catch (err) {
await db.query('ROLLBACK');
throw err;
}
}
The outbox row and the decision commit together, so a crash between "reviewer clicked revoke" and "access actually removed" is impossible โ the worker will find the outbox entry and retry.
Step 5 โ Make revocation invalidate live sessions, not just future logins
Deleting a grant row does nothing to a token that was minted five minutes ago and remains valid for another hour. Revocation must reach the session and token layers in the same operation, using the same machinery described in invalidating tenant sessions on role change.
// revocation-worker.ts
export async function processRevocation(db: PoolClient, redis: Redis, itemId: string) {
const { rows } = await db.query(
`SELECT tenant_id, principal_id, role_key FROM access_review_item WHERE id = $1`, [itemId],
);
const { tenant_id: tenantId, principal_id: principalId, role_key: roleKey } = rows[0];
await db.query(
`UPDATE tenant_role_grant SET revoked_at = now()
WHERE tenant_id = $1 AND principal_id = $2 AND role_key = $3 AND revoked_at IS NULL`,
[tenantId, principalId, roleKey],
);
// Bump the per-principal epoch so every existing token for this tenant fails validation.
await redis.incr(`authz:epoch:${tenantId}:${principalId}`);
await redis.del(`sess:${tenantId}:${principalId}`);
await db.query(`DELETE FROM revocation_outbox WHERE item_id = $1`, [itemId]);
}
Reviewer Decision Reference
| Signal on the item | Default decision | Reviewer prompt | Escalation |
|---|---|---|---|
| Never used since grant | Revoke | "This role has never been exercised" | Auto-revoke after two unreviewed campaigns |
| Unused > 90 days | Revoke | Show last-used date prominently | Notify tenant owner on approve |
| Granted by a departed admin | Revoke | Show granting actor's status | Require second approver |
| Shared/service credential | Approve with expiry | Force an explicit expiry date | Expire at 180 days regardless |
| Elevated via IdP group | Certify the mapping | Link to the group mapping record | Route to tenant IT contact |
| Break-glass staff access | Revoke | Show the incident that justified it | Alert internal security on approve |
Dynamic Query Scoping & Reviewer Isolation
Every query in the certification subsystem carries a tenant predicate that comes from the verified request context, and the database enforces the same boundary independently through Row-Level Security so an application bug cannot widen the scope. The pattern mirrors the one in Shared Database with Row-Level Security: the reviewer's session sets a transaction-local tenant variable, and the policy on the review tables reads it.
ALTER TABLE access_review_item ENABLE ROW LEVEL SECURITY;
ALTER TABLE access_review_item FORCE ROW LEVEL SECURITY;
CREATE POLICY review_item_tenant_isolation ON access_review_item
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid)
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
Staff campaigns are the exception that proves the rule. An internal control owner reviewing cross-tenant support access legitimately needs to see items spanning many tenants โ so that campaign type runs under a distinct role with its own policy, and every read it performs is itself written to the audit stream. The reviewer of staff access is audited more heavily than the access being reviewed, because that role is the one place where the tenant boundary is intentionally crossed.
Transaction boundaries matter as much as predicates. A decision, its outbox row, and the campaign progress counter must commit atomically; otherwise a reviewer sees "4 of 30 done" while the database holds five decisions, and reconciliation becomes guesswork at audit time. Wrap the whole decision in one transaction and let the asynchronous worker own everything that touches an external system.
The next figure traces one item from snapshot through decision to enforced revocation, showing where the transaction boundary sits.
Security Enforcement & Access Control
Certification is a security control that is itself a target: whoever can approve their own entitlements has defeated it. The access layer therefore needs its own separation of duties.
| Control | Implementation | Boundary guarantee |
|---|---|---|
| Self-review prevention | Reject decisions where decided_by = principal_id |
Nobody certifies their own access |
| Reviewer authorization | Reviewer must hold the review.certify entitlement in that tenant |
A member cannot certify an admin |
| Snapshot immutability | access_review_item rows are insert-once; decisions append, never rewrite grants |
Evidence cannot be retroactively edited |
| Decision non-repudiation | Store reviewer identity, IP, and assertion issuer with each decision | Auditor can attribute every approval |
| Auto-revoke on lapse | Items unreviewed past due_at fail closed for privileged classes |
Silence never means "approve" |
| Staff campaign audit | Every cross-tenant read logged with justification and ticket reference | Support access is itself reviewable |
The deadline policy is best expressed as a decision tree, because it branches on two independent facts: how risky the entitlement is, and whether anyone looked at it.
Failing closed is the decision teams argue about most. Auto-revoking unreviewed standard user access at the deadline will generate support tickets on Monday morning; auto-revoking unreviewed privileged access will not, because privileged holders notice immediately and re-request through a path that leaves a record. Split the policy by risk class rather than picking one behaviour for everything, and pair the trail with the artifact pipeline in generating SOC 2 audit artifacts per tenant.
Operational Overhead & Scaling Metrics
Certification is cheap to build and expensive to run badly. The metrics that predict an audit finding are all about reviewer behaviour, not system throughput.
| Metric | Threshold | Mitigation |
|---|---|---|
| Campaign completion rate at due date | < 95% | Escalate to tenant owner; auto-revoke privileged classes |
| Median seconds per item decision | < 3s | Rubber-stamping โ add usage evidence, reduce batch size |
| Approve rate on never-used entitlements | > 20% | Default the control to revoke; require justification text |
| Revocation outbox lag p99 | > 60s | Scale workers; alert โ a revoked user is still logged in |
| Items per campaign | > 300 | Split by team or role class; large campaigns are not read |
| Reviewers per tenant | < 2 | Single-reviewer tenants have no separation of duties |
| Reopened campaigns | > 0 | Snapshot or scoping defect; investigate before closing audit |
Plotting a single quarterly cycle as a funnel exposes where the control is actually working. A healthy privileged campaign loses a visible fraction of items to revocation; a funnel that is nearly flat from "shown" to "approved" is measuring reviewer fatigue, not risk.
Item volume is the scaling constraint that surprises people. A platform with 4,000 tenants averaging 40 principals and 3 roles each generates roughly half a million snapshot rows per quarterly cycle. That is trivial for Postgres to store but not trivial to review, which is why risk-classed campaigns exist: the quarterly privileged campaign should be a few thousand rows platform-wide, and the broad base of standard users should be certified in bulk by tenant owners with sensible defaults. When the volume genuinely outgrows a single table, partition access_review_item by campaign quarter rather than by tenant โ auditors sample by period, so period-aligned partitions make retrieval and retention pruning trivial.
Pitfalls & Anti-Patterns
-
Reviewing live tables instead of a snapshot: Rows mutate mid-campaign, so the reviewer approves one state and the auditor sees another. Freeze an immutable snapshot at campaign open and make every decision reference a snapshot row identifier.
-
Revocation that only deletes a database row: The grant disappears while the issued token and cached session stay valid until expiry, leaving a window where a "revoked" user still acts. Bump a per-principal authorization epoch and drop the session in the same worker that revokes the grant.
-
One global campaign across all tenants: It leaks tenant existence to reviewers, gives tenant owners items they have no authority over, and makes per-contract cadences impossible. Generate one campaign per tenant and scope every query to it.
-
Reviewer lists without usage evidence: A screen of names and role labels produces near-100% approval rates because there is nothing on which to base a revoke. Ship
last_used_at, grant age, and granting actor on every row. -
Treating unreviewed as approved: Silence at the deadline is the most common way standing privilege survives forever. Fail closed for privileged classes and escalate the rest, never auto-approve.
-
Letting an admin certify their own entitlements: Small tenants have one admin, so the control silently degrades to self-attestation. Require a second reviewer for privileged classes, falling back to the internal control owner when the tenant has only one admin.
Frequently Asked Questions
How often should tenant access reviews run? Risk class decides, not the calendar. Privileged roles โ tenant admins, billing owners, API key holders, and any staff role that can reach tenant data โ belong on a monthly cycle. Standard end-user membership is well served quarterly, which is also what SOC 2 auditors typically expect to see evidenced. Service principals and IdP group mappings change rarely enough that semi-annual is defensible, provided every change in between is captured in the audit stream.
Should unreviewed access be revoked automatically at the deadline? For privileged classes, yes: fail closed. The holder notices within minutes and re-requests through a path that leaves a record, which is exactly the outcome the control wants. For standard end users, auto-revocation creates a support surge without much risk reduction, so escalate to the tenant owner and revoke on a second missed cycle instead. The important part is that "nobody reviewed it" is never recorded as an approval.
How do we review access that is granted through an external identity provider?
Certify the mapping, not the membership. Your system does not own the group roster, so a review of resulting members is stale the moment the customer's directory changes. Review the rule โ "IdP group eng-leads grants tenant role admin" โ with the tenant's IT administrator, and treat any mapping that grants a privileged role as a privileged item on the monthly cadence.
What evidence does an auditor actually ask for? A sample of campaigns with, for each: when it opened and closed, the exact item list shown to the reviewer, who the reviewer was and what authority they held, the decision and timestamp per item, and proof that revocations were enforced. That last piece is where most implementations fail, so keep the revocation worker's completion event in the same append-only stream as the decision.
Does an access review replace least-privilege provisioning? No โ it audits it. Certification finds accumulated privilege after the fact, typically months after the grant, and every item it revokes represents a window during which the access existed unnecessarily. Time-bound grants and just-in-time elevation shrink that window at the source; reviews then become a much smaller, higher-signal exercise rather than the only line of defence.