Just-in-Time Elevation Instead of Standing Admin

Access reviews find privilege that should never have persisted; just-in-time elevation stops it persisting in the first place. This page is a focused part of Tenant Access Reviews & Certification: making the privileged role a temporary grant that expires by itself, so certification becomes a small exercise rather than the only line of defence.

Problem Framing

Standing admin exists because it is convenient. Someone needs to change a billing setting once a quarter, so they hold the admin role permanently — and for the other eighty-nine days it is a credential that can be phished, reused by a stale session, or inherited by whoever takes over the account. Every control downstream, from certification campaigns to orphan detection, exists to manage the consequences of that decision.

Just-in-time elevation removes the standing state. The principal holds a base role permanently and requests elevation when a task actually requires it: a reason, a duration bounded to hours, and an approval path appropriate to the risk. The grant is written with an expiry, the token carries that expiry, and nothing has to run for it to end.

The property that makes this work operationally is that expiry is a read-time fact rather than a scheduled job. A grant with expires_at in the past simply stops matching the query that resolves permissions. There is no cleanup task to fail silently, which is the failure mode that turns time-bound access back into standing access.

Step-by-Step Guide

1. Model elevation as a request with a mandatory expiry

CREATE TABLE elevation_request (
  id           uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id    uuid NOT NULL,
  principal_id uuid NOT NULL,
  role_key     text NOT NULL,                 -- 'admin' | 'billing_owner' | 'security_admin'
  reason       text NOT NULL,
  ticket_ref   text,
  requested_at timestamptz NOT NULL DEFAULT now(),
  approved_by  uuid,
  approved_at  timestamptz,
  starts_at    timestamptz,
  expires_at   timestamptz,
  revoked_at   timestamptz,
  CONSTRAINT bounded CHECK (expires_at IS NULL OR expires_at <= starts_at + interval '8 hours'),
  CONSTRAINT approved_shape CHECK ((approved_by IS NULL) = (approved_at IS NULL))
);

CREATE INDEX ON elevation_request (tenant_id, principal_id, expires_at)
  WHERE revoked_at IS NULL;

The eight-hour ceiling in the schema is the control that stops elevation drifting back toward permanence one exception at a time.

2. Resolve effective roles at read time, expiry included

CREATE OR REPLACE VIEW effective_role AS
SELECT tenant_id, principal_id, role_key, 'standing' AS source
FROM   tenant_role_grant
WHERE  revoked_at IS NULL AND role_key NOT IN (SELECT role_key FROM privileged_role)
UNION ALL
SELECT tenant_id, principal_id, role_key, 'elevated' AS source
FROM   elevation_request
WHERE  revoked_at IS NULL
  AND  approved_at IS NOT NULL
  AND  now() >= starts_at AND now() < expires_at;      -- expiry is a predicate, not a job

Privileged roles are deliberately excluded from the standing half of the union, so a legacy standing admin grant cannot quietly coexist with the new model.

3. Route approval by risk, and never to the requester

// approve.ts
const APPROVERS: Record<string, ApproverRule> = {
  admin:          { kind: 'other_tenant_admin', fallback: 'internal_control_owner' },
  billing_owner:  { kind: 'other_tenant_admin', fallback: 'internal_control_owner' },
  security_admin: { kind: 'internal_control_owner' },      // always two-party
};

export async function approve(requestId: string, approverId: string) {
  const req = await getRequest(requestId);
  if (req.principalId === approverId) throw new Error('self-approval is not permitted');
  if (!await canApprove(approverId, req)) throw new Error('approver lacks authority');

  const startsAt = new Date();
  await db.query(
    `UPDATE elevation_request
     SET approved_by = $1, approved_at = now(), starts_at = $2,
         expires_at = $2::timestamptz + make_interval(mins => $3)
     WHERE id = $4 AND approved_at IS NULL`,
    [approverId, startsAt, req.requestedMinutes, requestId],
  );
  await bumpAuthEpoch(req.tenantId, req.principalId);      // force a fresh token
}

Bumping the authorization epoch at approval is what makes the new privilege take effect immediately, using the same mechanism that revocation uses in invalidating tenant sessions on role change.

4. Bind the token's lifetime to the grant's

// mint.ts
export async function mintElevated(principal: Principal, grant: Elevation, key: CryptoKey) {
  const expSeconds = Math.floor(grant.expiresAt.getTime() / 1000);
  return new SignJWT({
    sub: principal.id,
    tid: grant.tenantId,
    roles: [grant.roleKey],
    elev: grant.id,                                // ties the token to one elevation record
    epoch: principal.authEpoch,
  })
    .setProtectedHeader({ alg: 'EdDSA', kid: 'auth-2026-07' })
    .setIssuedAt()
    .setExpirationTime(Math.min(expSeconds, nowSeconds() + 900))   // ≤15 min, and never past expiry
    .sign(key);
}

Capping the token at fifteen minutes while the grant may last hours means the elevated capability is re-derived from the database several times during a session — so a revocation mid-window takes effect on the next refresh rather than at the end of the grant.

5. Make requesting frictionless, because friction is what creates exceptions

// request.ts — the path a tenant admin actually uses
export async function requestElevation(input: {
  tenantId: string; principalId: string; roleKey: string; reason: string; minutes: number;
}) {
  if (input.minutes > 480) throw new Error('maximum elevation is 8 hours');
  const req = await createRequest(input);
  const approvers = await resolveApprovers(input.tenantId, input.roleKey, input.principalId);
  await notifyAll(approvers, 'elevation_requested', { req, approveUrl: url(req) });
  return req;
}

If elevation takes twenty minutes to obtain, teams will ask for a standing exception and get one. A request that reaches an approver instantly, and is approvable in one click from a phone, is what keeps the model intact under operational pressure.

Verification

-- 1. Nobody holds a privileged role permanently.
SELECT g.tenant_id, g.principal_id, g.role_key
FROM   tenant_role_grant g
JOIN   privileged_role p USING (role_key)
WHERE  g.revoked_at IS NULL;
-- expected: no rows once migration is complete

-- 2. No elevation outlived its window.
SELECT id, principal_id, expires_at
FROM   elevation_request
WHERE  revoked_at IS NULL AND expires_at < now()
  AND  EXISTS (SELECT 1 FROM privileged_action a
               WHERE a.elevation_id = elevation_request.id AND a.at > elevation_request.expires_at);
-- expected: no rows — an action after expiry means the predicate was bypassed somewhere

-- 3. No self-approvals.
SELECT id FROM elevation_request WHERE approved_by = principal_id;
-- expected: no rows
// jit.spec.ts
it('loses the role the moment the window closes', async () => {
  const grant = await elevate('acme', 'sam', 'admin', { minutes: 15 });
  await asUser('sam').post('/api/settings/billing').expect(200);
  await advanceTime(minutes(16));
  await refreshToken('sam');
  await asUser('sam').post('/api/settings/billing').expect(403);
});

Failure Modes & Gotchas

FAQ

Does this replace access reviews entirely? No, it shrinks them. Certification still has to cover base roles, service principals and identity-provider mappings, none of which are elevation-shaped. What disappears is the largest and least useful part of a campaign: pages of standing admin grants that reviewers approve without evidence. When privileged access is measured in hours per quarter, the review that remains is small enough to be done properly.

What about emergency access when the approval path is unavailable? Keep an explicit break-glass path with a different shape: self-approved, heavily alerted, capped at one hour, and reviewed by a human within one business day. The point is not to prevent emergency access but to make it visibly different from the routine path, so break-glass use is countable and its growth is a signal rather than a mystery.

Is just-in-time elevation worth it for a small platform? The database work is a table, a view and an approval endpoint — perhaps a week. The ongoing cost is the approval friction, which is real but bounded. The payoff arrives at the first security questionnaire that asks how long privileged access persists, and again at every certification cycle that no longer has to relitigate a hundred standing grants. For a platform with regulated customers it pays for itself almost immediately.

How should elevation work for the platform's own staff? The same way, with a shorter ceiling and a heavier audit trail. Staff elevation into a tenant is impersonation, so it carries the additional requirement of being visible to the customer, and its approval should route to an internal control owner rather than to the tenant. Sharing the request, approval and expiry machinery between the two cases is worth doing — it means one implementation to review, one set of evidence to produce, and one place where the ceiling is enforced.

Does this pattern apply to database and infrastructure access too? It applies especially well there, and the mechanics are identical: a request with a reason, a second approver, a short window and an expiry enforced at read time. The difference is where the grant is enforced — a database role granted temporarily, or a cloud role assumed with a session duration — but the record, the approval and the evidence look the same. Keeping one elevation model across application, database and infrastructure access means an auditor sees one control rather than three, which is materially less work to evidence.