Final StretchOrdered learning track

Build Admin Permission Console

Learn React Authentication, Authorization, Identity & Permission/ACL - Part 118

Build an admin permission console from scratch: role/grant/policy editing, safe diff preview, blast-radius analysis, approval guardrails, delegated administration, audit trails, permission simulation, React UI architecture, API contracts, and tests.

12 min read2358 words
PrevNext
Lesson 118130 lesson track108–130 Final Stretch
#react#authentication#authorization#identity+6 more

Part 118 — Build Admin Permission Console

Part 117 built access request workflows.

Now we build the most dangerous UI in the system: the admin permission console.

A permission console is not just an admin CRUD page.

It is a policy mutation surface.

If built carelessly, it becomes the easiest way to create broken access control at scale.

The console can:

  • grant access;
  • revoke access;
  • change roles;
  • edit role permissions;
  • assign users to groups;
  • create temporary grants;
  • delegate administration;
  • alter policy behavior;
  • affect many tenants, resources, and users at once.

So the console must be designed like an operational control plane.

A good admin permission console does not merely ask:

“Can this admin save this form?”

It asks:

“What exactly will change, who will be affected, what is the blast radius, does this violate policy, who approved it, can we roll it back, and can we explain it later?”

Mental model

The console has three jobs:

JobMeaning
AuthorLet authorized admins propose permission changes.
ExplainShow semantic diff and blast radius before applying.
ControlEnforce guardrails, approval, audit, rollback, and cache invalidation.

It is not enough to render a form and call POST /roles.

What the console manages

A mature console usually manages several authorization primitives.

PrimitiveExampleRisk
Rolecase_supervisor, tenant_adminRole explosion, overbroad permissions.
Role assignmentassign case_supervisor to user:maya in tenant acmeExcess privilege.
Permissioncase.approve, case.exportDangerous action exposure.
ACL grantuser:maya viewer case:123Object-level leak.
Relationship tuplecase:123#reviewer@user:mayaInherited graph access.
Temporary grantcase.review for 7 daysExpiry/revocation drift.
Delegation ruleteam lead can grant queue reviewerAdmin privilege expansion.
Policy versionupdate condition for case.approveGlobal behavior change.

Do not force all of these into one table.

Build a control plane with typed change operations.

Console invariants

InvariantReason
Admin UI is not an enforcement boundary.Server validates every mutation.
Admin can only edit within delegated scope.Prevent global admin creep.
Every change is represented as a draft before apply.Enables diff, validation, approval.
Every change has semantic diff.Avoid hidden policy mutations.
High-risk changes require step-up or multi-approval.Reduce accidental/compromised admin risk.
No admin can silently grant themselves high-risk access.Prevent self-escalation.
Every change bumps permission epoch.Prevent stale client/server caches.
Every change emits audit events.Support compliance and incident response.
Policy changes are versioned.Support rollback and explanation.
Delete is usually revoke/deactivate, not physical delete.Preserve auditability.

Permission console architecture

The UI calls admin APIs.

Admin APIs do not directly mutate policy from UI payload.

They create, validate, preview, approve, and apply change drafts.

Change draft model

Everything starts as a draft.

type PermissionChangeKind =
  | 'role_assignment.add'
  | 'role_assignment.remove'
  | 'role_definition.update'
  | 'acl_grant.add'
  | 'acl_grant.revoke'
  | 'relationship_tuple.add'
  | 'relationship_tuple.delete'
  | 'temporary_grant.create'
  | 'delegation_rule.update'
  | 'policy_version.publish';

type PermissionChangeDraft = {
  id: string;
  tenantId: string;
  kind: PermissionChangeKind;
  status: 'draft' | 'validated' | 'awaiting_approval' | 'approved' | 'applied' | 'rejected' | 'cancelled';
  createdBy: SubjectRef;
  createdAt: string;
  updatedAt: string;
  payload: unknown;
  semanticDiff?: SemanticDiff;
  blastRadius?: BlastRadiusAnalysis;
  validation?: ValidationResult;
  approval?: ApprovalRequirement;
  correlationId: string;
  policyVersion: string;
  idempotencyKey: string;
};

Why draft-first?

Because permission changes are not ordinary form edits.

They need preview, approval, validation, and traceability.

Typed operations

Do not expose arbitrary policyJson unless the user is in an expert policy editor with strong validation.

Use typed operations.

type AddRoleAssignmentPayload = {
  user: SubjectRef;
  roleId: string;
  scope: {
    type: 'tenant' | 'project' | 'case_queue';
    id: string;
    tenantId: string;
  };
  expiresAt?: string;
  reason: string;
};

type RemoveRoleAssignmentPayload = {
  assignmentId: string;
  reason: string;
};

type AddAclGrantPayload = {
  subject: SubjectRef;
  relation: 'viewer' | 'editor' | 'reviewer' | 'owner';
  object: ResourceRef;
  expiresAt?: string;
  reason: string;
};

type UpdateRoleDefinitionPayload = {
  roleId: string;
  expectedVersion: string;
  addPermissions: ActionId[];
  removePermissions: ActionId[];
  reason: string;
};

A typed operation makes validation possible.

A raw JSON editor makes mistakes easy.

Semantic diff

A textual diff is insufficient.

You need semantic diff.

type SemanticDiff = {
  summary: string;
  addedCapabilities: CapabilityRef[];
  removedCapabilities: CapabilityRef[];
  addedSubjects: SubjectRef[];
  removedSubjects: SubjectRef[];
  affectedResources: ResourceRef[] | { kind: 'many'; estimate: number };
  affectedTenants: string[];
  riskChanges: RiskChange[];
  reversible: boolean;
};

type CapabilityRef = {
  action: ActionId;
  scope: 'tenant' | 'project' | 'resource' | 'global';
  sensitivity: 'low' | 'medium' | 'high' | 'critical';
};

type RiskChange = {
  kind:
    | 'adds_export'
    | 'adds_delete'
    | 'adds_admin'
    | 'adds_pii_access'
    | 'broadens_scope'
    | 'removes_approval_gate'
    | 'extends_duration'
    | 'cross_tenant_risk';
  severity: 'low' | 'medium' | 'high' | 'critical';
  message: string;
};

Example diff response:

{
  "summary": "Assign case_supervisor role to Maya in tenant_acme for 7 days.",
  "addedCapabilities": [
    { "action": "case.approve", "scope": "tenant", "sensitivity": "high" },
    { "action": "case.assign", "scope": "tenant", "sensitivity": "medium" }
  ],
  "removedCapabilities": [],
  "affectedTenants": ["tenant_acme"],
  "affectedResources": { "kind": "many", "estimate": 842 },
  "riskChanges": [
    {
      "kind": "broadens_scope",
      "severity": "high",
      "message": "Role applies to all cases in tenant_acme."
    }
  ],
  "reversible": true
}

The console should show this before applying.

Blast radius analysis

Blast radius is the question:

If we apply this change, how much authority changes?

type BlastRadiusAnalysis = {
  riskLevel: 'low' | 'medium' | 'high' | 'critical';
  affectedUsers: number;
  affectedGroups: number;
  affectedResources: number;
  affectedTenants: number;
  addsHighRiskActions: boolean;
  addsExportCapability: boolean;
  addsAdminCapability: boolean;
  grantsSelfAccess: boolean;
  affectsProductionData: boolean;
  requiresStepUp: boolean;
  requiresApproval: boolean;
  warnings: string[];
};

For role definition changes, blast radius can be large.

Adding case.export to case_reviewer affects every user assigned to case_reviewer.

Implementation sketch:

async function analyzeRoleDefinitionChange(payload: UpdateRoleDefinitionPayload) {
  const role = await roleRepo.get(payload.roleId);
  const assignments = await roleRepo.findAssignments(payload.roleId);

  const added = payload.addPermissions.map((action) => classifyCapability(action));
  const affectedScopes = summarizeAssignmentScopes(assignments);

  const affectedResources = await estimateAffectedResources(affectedScopes);

  const riskLevel = calculateRiskLevel({
    addedCapabilities: added,
    affectedUsers: assignments.length,
    affectedResources,
  });

  return {
    riskLevel,
    affectedUsers: assignments.length,
    affectedResources,
    affectedTenants: countTenants(assignments),
    addsHighRiskActions: added.some((x) => x.sensitivity === 'high' || x.sensitivity === 'critical'),
    addsExportCapability: payload.addPermissions.some((x) => x.endsWith('.export')),
    addsAdminCapability: payload.addPermissions.some((x) => x.includes('.admin')),
    grantsSelfAccess: false,
    affectsProductionData: true,
    requiresStepUp: riskLevel !== 'low',
    requiresApproval: riskLevel === 'high' || riskLevel === 'critical',
    warnings: buildWarnings(added, assignments, affectedResources),
  } satisfies BlastRadiusAnalysis;
}

Estimates do not have to be perfect.

They must be honest and conservative.

When uncertain, mark risk higher.

Admin authorization

The admin console must authorize admin operations by action and scope.

Examples:

can(actor, 'role_assignment.add', {
  roleId: 'case_supervisor',
  scope: { type: 'tenant', id: 'tenant_acme' },
});

can(actor, 'role_definition.update', {
  roleId: 'case_reviewer',
  tenantId: 'tenant_acme',
});

can(actor, 'acl_grant.revoke', {
  grantId: 'grant_123',
  tenantId: 'tenant_acme',
});

Do not use one broad permission like:

can(actor, 'admin.permissions.manage', tenant)

That is too coarse for serious systems.

Use fine-grained admin capabilities.

Admin actionMeaning
role_assignment.addAssign role to subject.
role_assignment.removeRemove role assignment.
role_definition.updateChange permissions inside role.
acl_grant.addGrant object relation.
acl_grant.revokeRevoke object relation.
temporary_grant.createCreate temporary grant.
delegation_rule.updateChange who can delegate.
policy_version.publishPublish policy version.
permission_change.approveApprove high-risk permission change.

Then add constraints:

type AdminConstraint =
  | { kind: 'within_tenant'; tenantId: string }
  | { kind: 'cannot_assign_roles_above_own_level' }
  | { kind: 'cannot_grant_self' }
  | { kind: 'requires_step_up_for_high_risk' }
  | { kind: 'requires_second_approval_for_critical' }
  | { kind: 'max_duration_days'; value: number };

Prevent self-escalation

Self-escalation is a top console risk.

Examples:

  • admin assigns themselves tenant_admin;
  • admin adds case.export to a role they already have;
  • admin changes group membership of a group they belong to;
  • admin modifies delegation rule that lets them approve their own future access;
  • admin removes approval gate for high-risk permissions.

Self-escalation detection must analyze indirect effects.

async function detectsSelfEscalation(draft: PermissionChangeDraft, actor: SubjectRef) {
  const before = await permissionSimulator.snapshot(actor, draft.tenantId, 'before');
  const after = await permissionSimulator.snapshotWithDraft(actor, draft);

  const addedHighRisk = difference(after.capabilities, before.capabilities).filter(
    (cap) => cap.sensitivity === 'high' || cap.sensitivity === 'critical',
  );

  return addedHighRisk.length > 0;
}

If detected:

  • block outright for critical changes;
  • require independent approval for high-risk changes;
  • require step-up for medium-risk changes;
  • emit audit event either way.

Admin API lifecycle

Use a multi-step API.

Draft creation:

POST /api/admin/permission-change-drafts
Content-Type: application/json
Idempotency-Key: 6b918...
{
  "kind": "role_assignment.add",
  "payload": {
    "user": { "type": "user", "id": "user_123" },
    "roleId": "case_supervisor",
    "scope": { "type": "tenant", "id": "tenant_acme", "tenantId": "tenant_acme" },
    "expiresAt": "2026-07-15T00:00:00.000Z",
    "reason": "Temporary supervisor coverage."
  }
}

Apply:

POST /api/admin/permission-change-drafts/draft_123/apply
Content-Type: application/json
{
  "expectedDraftVersion": 3,
  "confirmationText": "ASSIGN CASE_SUPERVISOR TO MAYA FOR 7 DAYS",
  "stepUpToken": "stepup_..."
}

The confirmation text is not security by itself.

It prevents accidental clicks.

The server still validates authorization, step-up, approval, concurrency, and policy version.

Create draft handler

async function createPermissionChangeDraft(
  input: CreatePermissionChangeDraftInput,
  ctx: RequestContext,
) {
  const actor = await requireAuthenticatedSubject(ctx);
  const normalized = normalizePermissionChange(input);

  const adminDecision = await policy.can(actor, `${normalized.kind}.draft`, normalized.payload, ctx);
  if (!adminDecision.allowed) {
    throw new ProblemError(403, 'admin_permission_denied', {
      reason: adminDecision.publicReason,
    });
  }

  const validation = await validatePermissionChange(normalized, ctx);
  if (!validation.valid) {
    throw new ProblemError(400, 'invalid_permission_change', { issues: validation.publicIssues });
  }

  const semanticDiff = await diffService.diff(normalized, ctx);
  const blastRadius = await impactAnalyzer.analyze(normalized, ctx);
  const approval = await approvalPolicy.resolvePermissionChangeApproval({
    actor,
    change: normalized,
    semanticDiff,
    blastRadius,
  });

  const draft = await draftRepo.create({
    actor,
    normalized,
    validation,
    semanticDiff,
    blastRadius,
    approval,
    correlationId: ctx.correlationId,
    idempotencyKey: ctx.idempotencyKey,
  });

  await audit.emit(buildPermissionDraftCreatedEvent(draft, actor, ctx));

  return draft;
}

Apply draft handler

async function applyPermissionChangeDraft(
  draftId: string,
  input: ApplyDraftInput,
  ctx: RequestContext,
) {
  const actor = await requireAuthenticatedSubject(ctx);

  return db.transaction(async (tx) => {
    const draft = await draftRepo.getForUpdate(tx, draftId);
    if (!draft) throw new ProblemError(404, 'draft_not_found');

    assertVersion(draft.version, input.expectedDraftVersion);
    assertDraftApplyable(draft);

    const decision = await policy.can(actor, `${draft.kind}.apply`, draft.payload, ctx);
    if (!decision.allowed) throw new ProblemError(403, 'admin_permission_denied');

    if (draft.blastRadius?.requiresStepUp) {
      await stepUpVerifier.requireRecentStepUp(actor, input.stepUpToken, 'admin_permission_change');
    }

    if (draft.approval?.required && !draft.approval.satisfied) {
      throw new ProblemError(409, 'approval_required');
    }

    const stillValid = await validatePermissionChange(draft, ctx);
    if (!stillValid.valid) {
      throw new ProblemError(409, 'draft_no_longer_valid', { issues: stillValid.publicIssues });
    }

    const result = await permissionChangeApplier.apply(tx, draft);

    await permissionEpoch.bump(tx, draft.tenantId);
    await draftRepo.markApplied(tx, draft.id, result);
    await audit.emit(tx, buildPermissionChangeAppliedEvent(draft, result, actor, ctx));

    return result;
  });
}

React page architecture

Route loader should check admin access before rendering console shell.

export async function loader({ request }: LoaderFunctionArgs) {
  const session = await requireSession(request);
  const tenantId = await resolveTenant(request);

  const decision = await can(session.subject, 'permission_console.view', {
    type: 'tenant',
    id: tenantId,
  });

  if (!decision.allowed) {
    throw forbiddenProblem(decision);
  }

  return {
    tenantId,
    adminCapabilities: await loadAdminCapabilities(session.subject, tenantId),
    policyVersion: await loadPolicyVersion(tenantId),
  };
}

Console shell:

function PermissionConsolePage() {
  const data = useLoaderData<typeof loader>();

  return (
    <PermissionConsoleProvider
      tenantId={data.tenantId}
      adminCapabilities={data.adminCapabilities}
      policyVersion={data.policyVersion}
    >
      <h1>Permission console</h1>
      <PermissionConsoleTabs />
    </PermissionConsoleProvider>
  );
}

Role assignment editor

function RoleAssignmentEditor() {
  const [user, setUser] = React.useState<SubjectRef | null>(null);
  const [roleId, setRoleId] = React.useState('');
  const [scope, setScope] = React.useState<PermissionScope | null>(null);
  const [expiresAt, setExpiresAt] = React.useState<string | undefined>();
  const [reason, setReason] = React.useState('');

  const createDraft = useCreatePermissionChangeDraft();

  const canSubmit = user && roleId && scope && reason.trim().length >= 20;

  return (
    <section>
      <h2>Assign role</h2>

      <SubjectPicker value={user} onChange={setUser} />
      <RolePicker value={roleId} onChange={setRoleId} />
      <ScopePicker value={scope} onChange={setScope} />
      <ExpiryPicker value={expiresAt} onChange={setExpiresAt} />

      <label>
        Reason
        <textarea value={reason} onChange={(event) => setReason(event.target.value)} />
      </label>

      <button
        type="button"
        disabled={!canSubmit || createDraft.isPending}
        onClick={() => {
          if (!user || !scope) return;

          createDraft.mutate({
            kind: 'role_assignment.add',
            payload: {
              user,
              roleId,
              scope,
              expiresAt,
              reason,
            },
          });
        }}
      >
        Preview change
      </button>

      {createDraft.data && <PermissionChangePreview draft={createDraft.data} />}
    </section>
  );
}

Notice the button says Preview change, not Save.

Permission changes should be previewed before applied.

Permission change preview

function PermissionChangePreview({ draft }: { draft: PermissionChangeDraftView }) {
  return (
    <section aria-labelledby="permission-change-preview">
      <h2 id="permission-change-preview">Review permission change</h2>

      <DiffSummary diff={draft.semanticDiff} />
      <BlastRadiusPanel analysis={draft.blastRadius} />
      <ValidationPanel validation={draft.validation} />
      <ApprovalRequirementPanel approval={draft.approval} />
      <AuditPreview draft={draft} />

      <ApplyPermissionChangePanel draft={draft} />
    </section>
  );
}

The preview should be opinionated.

High-risk changes should look high-risk.

function BlastRadiusPanel({ analysis }: { analysis: BlastRadiusAnalysis }) {
  return (
    <aside data-risk={analysis.riskLevel}>
      <h3>Blast radius</h3>
      <dl>
        <dt>Risk level</dt>
        <dd>{analysis.riskLevel}</dd>

        <dt>Affected users</dt>
        <dd>{analysis.affectedUsers}</dd>

        <dt>Affected resources</dt>
        <dd>{analysis.affectedResources}</dd>

        <dt>Affected tenants</dt>
        <dd>{analysis.affectedTenants}</dd>
      </dl>

      {analysis.warnings.length > 0 && (
        <ul>
          {analysis.warnings.map((warning) => (
            <li key={warning}>{warning}</li>
          ))}
        </ul>
      )}
    </aside>
  );
}

Do not hide warnings below the fold.

Apply panel

function ApplyPermissionChangePanel({ draft }: { draft: PermissionChangeDraftView }) {
  const [confirmation, setConfirmation] = React.useState('');
  const [stepUpToken, setStepUpToken] = React.useState<string | null>(null);
  const applyDraft = useApplyPermissionChangeDraft(draft.id);

  const requiredConfirmation = draft.requiredConfirmationText;
  const confirmationOk = confirmation === requiredConfirmation;
  const requiresStepUp = draft.blastRadius.requiresStepUp;
  const approvalBlocked = draft.approval.required && !draft.approval.satisfied;

  return (
    <div>
      {requiresStepUp && (
        <StepUpPrompt
          purpose="admin_permission_change"
          onComplete={(token) => setStepUpToken(token)}
        />
      )}

      {approvalBlocked && <ApprovalRequiredNotice approval={draft.approval} />}

      <label>
        Type confirmation text
        <input value={confirmation} onChange={(event) => setConfirmation(event.target.value)} />
      </label>

      <code>{requiredConfirmation}</code>

      <button
        type="button"
        disabled={
          !confirmationOk ||
          approvalBlocked ||
          (requiresStepUp && !stepUpToken) ||
          applyDraft.isPending
        }
        onClick={() => {
          applyDraft.mutate({
            expectedDraftVersion: draft.version,
            confirmationText: confirmation,
            stepUpToken: stepUpToken ?? undefined,
          });
        }}
      >
        Apply permission change
      </button>
    </div>
  );
}

The apply button is intentionally friction-heavy.

For low-risk changes, you can reduce friction.

For high-risk changes, friction is a feature.

Delegated administration

Not every admin should be global admin.

Delegated admin means an actor can manage specific permission surfaces within limited scope.

Examples:

Delegated adminAllowed actions
Team leadassign queue reviewer within their queue
Project owneradd project viewer/editor
Data stewardapprove export access
Tenant security adminrevoke suspicious grants
Support leadapprove constrained impersonation

Model delegation explicitly.

type DelegationRule = {
  id: string;
  tenantId: string;
  delegatorRole: string;
  allowedAdminActions: AdminActionId[];
  targetConstraints: AdminConstraint[];
  maxDurationDays?: number;
  requiresApprovalAboveRisk?: 'medium' | 'high' | 'critical';
  createdAt: string;
  createdBy: SubjectRef;
};

Delegation is itself a permission change.

Changing delegation rules should be treated as high-risk.

Policy simulation

Admins need to preview effective access.

Build a simulation feature:

POST /api/admin/permission-simulations
Content-Type: application/json
{
  "subject": { "type": "user", "id": "user_123" },
  "action": "case.approve",
  "resource": { "type": "case", "id": "case_9231", "tenantId": "tenant_acme" },
  "draftId": "draft_123"
}

Response:

{
  "withoutDraft": {
    "allowed": false,
    "reason": "missing_case_supervisor_role"
  },
  "withDraft": {
    "allowed": true,
    "reason": "role_assignment.case_supervisor"
  },
  "explanation": [
    "Draft adds case_supervisor role scoped to tenant_acme.",
    "case_supervisor includes case.approve."
  ]
}

The explanation shown to admins can be richer than user-facing denial reasons.

Still avoid leaking secrets, raw internal IDs beyond scope, or sensitive policy internals to admins without need.

Search and browsing UX

Permission consoles need powerful search.

But search must be scoped.

SearchRequired boundary
User searchtenant/directory scope
Role searchtenant/global policy scope
Resource searchtenant/resource visibility
Grant searchadmin permission scope
Audit searchaudit viewer permission

Do not let tenant admin search all users globally unless policy allows it.

Subject picker should show safe identity projection:

type AdminSubjectSearchResult = {
  subject: SubjectRef;
  displayName: string;
  emailMasked?: string;
  department?: string;
  status: 'active' | 'suspended' | 'deprovisioned';
  tenantMembershipsVisibleToActor: string[];
};

Do not expose unnecessary PII in admin search results.

Audit model

Every admin operation should emit audit.

type PermissionAdminAuditEvent =
  | {
      type: 'permission_change.draft_created';
      draftId: string;
      actor: SubjectRef;
      kind: PermissionChangeKind;
      tenantId: string;
      semanticDiff: SemanticDiff;
      blastRadius: BlastRadiusAnalysis;
      occurredAt: string;
      correlationId: string;
    }
  | {
      type: 'permission_change.applied';
      draftId: string;
      actor: SubjectRef;
      kind: PermissionChangeKind;
      tenantId: string;
      resultId: string;
      permissionEpoch: number;
      occurredAt: string;
      correlationId: string;
    }
  | {
      type: 'permission_change.rejected';
      draftId: string;
      actor: SubjectRef;
      reason: string;
      occurredAt: string;
      correlationId: string;
    };

Audit should include diff and blast radius summary.

Do not only log:

Admin updated role.

That is useless during incident response.

Log:

{
  "type": "permission_change.applied",
  "actor": { "type": "user", "id": "user_admin_1" },
  "tenantId": "tenant_acme",
  "kind": "role_definition.update",
  "roleId": "case_reviewer",
  "addedPermissions": ["case.export"],
  "removedPermissions": [],
  "affectedUsers": 142,
  "affectedResourcesEstimate": 12040,
  "riskLevel": "critical",
  "approvalIds": ["approval_1", "approval_2"],
  "permissionEpoch": 9842,
  "correlationId": "corr_123"
}

Rollback

Not every permission change is safely reversible.

The draft should say whether it is reversible.

ChangeReversible?Notes
Add role assignmentUsually yesRemove assignment.
Remove role assignmentMaybeRe-add if old scope/expiry known.
Add ACL grantUsually yesRevoke grant.
Delete relationship tupleMaybeRecreate if old tuple known.
Update role definitionMaybe riskyCould affect many users and conflict with later changes.
Publish policy versionNeeds migration planRollback may break new state.

Create rollback plan at apply time.

type RollbackPlan = {
  changeId: string;
  reversible: boolean;
  operations: PermissionChangeKind[];
  constraints: string[];
  expiresAt?: string;
};

For critical changes, require a rollback plan before apply.

Permission epoch

Any permission change that can alter decisions must bump permission epoch.

async function applyRoleAssignmentAdd(tx: Tx, draft: PermissionChangeDraft) {
  const assignment = await roleAssignmentRepo.create(tx, draft.payload);
  const epoch = await permissionEpoch.bump(tx, draft.tenantId);

  return {
    kind: 'role_assignment.added',
    assignmentId: assignment.id,
    permissionEpoch: epoch,
  };
}

The client should reconcile:

function onPermissionChangeApplied(result: PermissionChangeResult) {
  queryClient.invalidateQueries({ queryKey: ['permissions', result.tenantId] });
  queryClient.invalidateQueries({ queryKey: ['admin-permissions', result.tenantId] });
  queryClient.invalidateQueries({ queryKey: ['navigation', result.tenantId] });
}

Admin consoles often create stale-cache bugs because the admin expects immediate effect.

Make permission epoch visible in debugging tools.

Policy editor mode

Some teams need raw policy editing.

Treat it as a separate expert mode.

Minimum guardrails:

  • schema validation;
  • syntax validation;
  • semantic validation;
  • test case runner;
  • simulation before publish;
  • blast radius estimate;
  • approval for publish;
  • versioned rollout;
  • rollback plan;
  • audit event;
  • no direct edit in production without draft.

Policy editor flow:

Policy tests should be first-class objects.

type PolicyTestCase = {
  name: string;
  subject: SubjectRef;
  action: ActionId;
  resource: ResourceRef;
  context?: Record<string, unknown>;
  expected: 'allowed' | 'denied' | 'requires_step_up';
};

Admin console route structure

Example React Router routes:

/admin/permissions
/admin/permissions/users/:userId
/admin/permissions/roles
/admin/permissions/roles/:roleId
/admin/permissions/grants
/admin/permissions/requests
/admin/permissions/drafts/:draftId
/admin/permissions/simulate
/admin/permissions/audit

Route metadata:

export const handle = {
  auth: {
    requiresAuth: true,
    permission: {
      action: 'permission_console.view',
      resourceFrom: 'tenant',
    },
  },
};

Action metadata:

export const roleAssignmentActionPolicy = {
  action: 'role_assignment.add',
  requiresStepUpWhen: ['high_risk', 'self_escalation'],
  requiresApprovalWhen: ['critical_risk'],
};

Admin table design

Role assignment table columns:

ColumnPurpose
SubjectWho has access.
RoleWhat role.
ScopeWhere it applies.
SourceDirect, group, access request, sync.
ExpiryTemporary/permanent.
Last usedHelps remove stale access.
Created byAudit context.
ActionsRevoke, extend, inspect, simulate.

Last used is useful but must be interpreted carefully.

Absence of use is not proof of no business need.

It is a signal for access review.

Access review integration

The console should support periodic access reviews.

type AccessReviewItem = {
  id: string;
  subject: SubjectRef;
  grantOrAssignmentId: string;
  accessSummary: string;
  riskLevel: 'low' | 'medium' | 'high' | 'critical';
  lastUsedAt?: string;
  reviewer: SubjectRef;
  decision?: 'keep' | 'revoke' | 'modify';
  decisionReason?: string;
};

Access review is not the same as one-off access request approval.

It asks:

Should this existing access still exist?

For regulated systems, this is critical.

Error and denial UX

Admin errors must be specific enough to recover but not leak unnecessary data.

ErrorUX
admin_permission_deniedYou cannot manage this permission in this scope.
self_escalation_blockedThis change would grant you new high-risk access and requires independent approval.
approval_requiredThis change requires approval before it can be applied.
draft_no_longer_validThe underlying role/resource changed. Re-preview the change.
policy_version_conflictPolicy changed since preview. Re-run impact analysis.
subject_not_in_tenantSelected user is not a member of this tenant.
role_not_assignableThis role cannot be assigned manually.
max_duration_exceededDuration exceeds allowed policy limit.

Do not show stack traces, raw SQL, or internal policy paths.

Anti-patterns

Anti-pattern 1: admin role can do everything

if (user.role === 'admin') {
  allow();
}

This destroys least privilege.

Use delegated admin capabilities.

Anti-pattern 2: save without preview

<button onClick={saveRole}>Save</button>

Permission changes need diff and blast radius preview.

Anti-pattern 3: role definition changes without affected-user count

Adding one permission to one role may affect thousands of users.

Always show affected assignments.

Anti-pattern 4: physical delete of grants

Revocation should be auditable.

Use revokedAt, not hard delete.

Anti-pattern 5: no version conflict handling

A draft created against policy version 12 may be unsafe under policy version 13.

Revalidate before apply.

Anti-pattern 6: no self-escalation detection

Direct assignment checks are not enough.

Analyze effective permission change for the actor.

Testing matrix

TestExpected result
Admin without console view permission opens route.Loader returns 403.
Admin creates role assignment outside delegated tenant.Server rejects.
Draft creation returns semantic diff.UI shows preview, not save.
Role definition update affecting many users.Blast radius shows affected user/resource count.
High-risk permission added.Step-up required.
Critical permission added.Approval required.
Admin grants themselves high-risk permission.Block or require independent approval.
Draft applied after policy version changed.Revalidation fails; re-preview required.
Apply draft bumps permission epoch.Client invalidates permission cache.
Revoke assignment preserves audit.Assignment/grant marked revoked, not deleted.
Policy editor syntax error.Publish blocked.
Policy test failure.Publish blocked.
Audit log missing.Apply fails or raises incident depending policy.

Example test:

it('requires approval when role definition update adds critical permission', async () => {
  const draft = await createPermissionChangeDraft(
    {
      kind: 'role_definition.update',
      payload: {
        roleId: 'case_reviewer',
        expectedVersion: 'v12',
        addPermissions: ['case.export'],
        removePermissions: [],
        reason: 'Need export for quarterly compliance reporting.',
      },
    },
    contextFor(user('tenant-admin')),
  );

  expect(draft.blastRadius.riskLevel).toBe('critical');
  expect(draft.approval.required).toBe(true);

  await expect(
    applyPermissionChangeDraft(
      draft.id,
      {
        expectedDraftVersion: draft.version,
        confirmationText: draft.requiredConfirmationText,
      },
      contextFor(user('tenant-admin')),
    ),
  ).rejects.toMatchObject({ code: 'approval_required' });
});

Self-escalation test:

it('detects indirect self-escalation through role definition update', async () => {
  await assignRole(user('admin-1'), 'case_reviewer', tenant('acme'));

  const draft = await createPermissionChangeDraft(
    {
      kind: 'role_definition.update',
      payload: {
        roleId: 'case_reviewer',
        expectedVersion: 'v1',
        addPermissions: ['case.approve'],
        removePermissions: [],
        reason: 'Adjust reviewer role.',
      },
    },
    contextFor(user('admin-1')),
  );

  expect(draft.blastRadius.grantsSelfAccess).toBe(true);
  expect(draft.validation.issues).toContainEqual(
    expect.objectContaining({ code: 'potential_self_escalation' }),
  );
});

Production checklist

Before shipping admin permission console:

  • Route loader checks permission_console.view.
  • Every mutation is server-authorized by fine-grained admin action.
  • Mutations use draft → preview → apply lifecycle.
  • Drafts include semantic diff.
  • Drafts include blast radius analysis.
  • High-risk changes require step-up.
  • Critical changes require approval.
  • Self-escalation detection exists.
  • Delegated admin scope is enforced server-side.
  • Apply revalidates draft against current policy/version.
  • Apply uses optimistic concurrency or row locks.
  • Apply bumps permission epoch.
  • Revocation preserves audit history.
  • Audit events include actor, target, diff, risk, and correlation ID.
  • Policy editor mode has tests, validation, simulation, approval, rollback.
  • Admin search is tenant/scoped and PII-minimized.
  • E2E tests cover route denial, draft preview, approval required, apply, rollback/revoke.

Key takeaways

The admin permission console is a control plane.

Treat it like one.

A safe console is not defined by having many form fields.

It is defined by:

  • precise admin authorization;
  • delegated scope;
  • draft-first workflow;
  • semantic diff;
  • blast radius preview;
  • self-escalation detection;
  • approval and step-up for high-risk changes;
  • permission epoch invalidation;
  • auditability;
  • rollback planning;
  • strong tests.

The more powerful the console, the more it must explain and constrain itself.

If a permission console can silently create broad permanent access, it is not an admin tool.

It is an access-control vulnerability with a UI.

References

Lesson Recap

You just completed lesson 118 in final stretch. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.