BeyondTrust/POV

← Back to dashboard

Design — Entitle User-Based JIT Authorization (Entra Quickstart)

Audience: operator · Profile: both · Read this when: you are wiring the dashboard into an existing Entra ID and Entitle deployment, or migrating off that path.

⚠️ This documents the LEGACY (Entra-group) mechanism

There are now two ways Entitle grants dashboard permissions, and this design covers the older one. It is still supported and still works — but it grants via Entra group membership, so it only serves Entra users and a grant or revoke does not take effect until the user's next login.

The current mechanism is a REST integration that grants any user (local, Entra, any OIDC) immediately. Both can run at once.

Start here instead: integrations/beyondtrust/entitle-dashboard-permissions.md — it compares the two, and covers migrating from this one.

Status: Design + execution plan, v1. Written for: enterprise operators wiring the dashboard into an existing Entra ID + Entitle deployment. Community/dev installs that don't run Entra can skip it; the feature is purely additive. Companion to integrations/beyondtrust/entitle.md (the human-approval-gate feature already shipping) and design/cloud-identity-jit.md (the machine-identity story). This doc is the third leg: user-level dashboard authorization granted just-in-time. Applies to: community + dev + prod + QA. Same shape everywhere; prod's multi-tenancy story adds the per-tenant variation described in §6.


1. Problem

The dashboard already has two pieces of Entitle integration:

  1. Approval gate (integrations/beyondtrust/entitle.md) — a human operator hits a privileged endpoint, the dashboard opens an Entitle request, and the endpoint executes once approved. Per-action.
  2. Machine identity JIT (design/cloud-identity-jit.md) — the dashboard's own service principals request cloud-side privileges from Entitle for the duration of a workflow.

What's missing is the third leg: a user-level authorization JIT. Today the dashboard's permissions are statically assigned — either via admin → RBAC → Users → set permissions, or via OAuth group mapping seeding default_permissions on auto-created users. Either way, once a user has aws:write, they keep it indefinitely until an admin removes it.

For enterprise environments, this is a recurring audit gap: - Long-tail standing privilege ("Alice last used aws:write six months ago but still has it"). - No paper trail for why Alice has the privilege. - Removal requires an admin to remember + act.

What enterprises want from Entitle is the exact same shape as their cloud-IAM JIT story: "I need aws:write on the dashboard for 4 hours" → Entitle workflow → time-bound grant → automatic expiry. The dashboard's role is to recognise the grant when the user presents their token.

2. Goal

A user requests dashboard permissions via Entitle's normal workflow; on approval, Entitle adds them to a matching Entra group with an expiry; the next dashboard request (or relog) reflects the new permissions. When the group membership expires, the permissions go with it. No admin intervention required to grant or revoke.

Concretely:

3. Non-goals

4. Threat model & what this buys

Threat Before After
Stale standing privileges Admin must remember to remove Group expiry handles it; "stale grant" is no longer a category
Unauthorised admin escalation Admin promotion is a manual change with weak audit Admin grant goes through Entitle's policy + approval log
Audit "why does Alice have this?" "Check the user_audit_log + ask the admin who set it" Entitle's request log has the requester, approver, justification, TTL
Compromised user credential Attacker inherits all standing privileges immediately Attacker inherits only currently-active grants; the next refresh window drops them
Insider abuse of admin Admin can grant themselves anything indefinitely Admin can still self-grant, but every grant has a TTL ceiling + audit entry

The dashboard is still a privileged box — what changes is that the user privileges within it are no longer the ceiling on blast radius for a single compromised account.

5. How the pieces connect

5.1 The full grant flow

1. Alice opens Entitle → searches "VM Dashboard / aws:write".
2. Entitle workflow:
     - Auto-approve if policy matches (e.g. Alice is in the
       data-engineering group AND requested TTL ≤ 4h)
     - Otherwise routes to a human approver
3. On approval, Entitle calls Microsoft Graph:
       POST /groups/{dashboard-aws-write}/members
       /$ref { @odata.id: ".../users/{alice}" }
     with a roleAssignmentSchedule expiry of 4h from now.
4. Alice's next login (or her next token refresh) picks up the
   new group membership from the Entra ID token claim.
5. The dashboard's OAuth callback writes her permissions row
   from the group membership.
6. 4 hours later, Entitle auto-revokes the Entra group
   membership. Alice's next login sees the group gone; her
   permissions drop back to the union of remaining grants.

Steps 1-4 happen entirely outside the dashboard. Steps 5-6 are the dashboard's only direct interaction.

5.2 Entra group inventory

The dashboard quickstart provisions:

Group name pattern Mapping in oauth_group_mappings Notes
dashboard-admin default_permissions = {"is_admin": true} Highest-value grant; recommend non-auto-approve policy
dashboard-<scope>-<level> for each level a scope offers in PERMISSION_SCOPE_LEVELS default_permissions = {"<scope>": ["<level>"]} 98 groups today. Per-scope levels, not the scopes × levels cross product — a level a scope does not offer would be a permanent group in the tenant that can never grant anything.
dashboard-workgroup-<wg> for each workstream the operator manages workgroup = "<wg>" (uses the existing workstream field) Per-workgroup membership grant
dashboard-baseline (optional) default_permissions = {"vms": ["read"], "jobs": ["read"]} Default safe-read role for any authenticated user; auto-approve recommended

The naming convention is the operator's, not the dashboard's — the dashboard just reads oauth_group_mappings.entra_group_id to ID-match the JWT's group claim. The quickstart's value is doing the consistent naming + provisioning automatically.

5.3 Dashboard-side resolution

Today's OAuth callback (api/auth.py Azure login flow): 1. Receives the OIDC token from Entra. 2. Reads the groups claim (object IDs). 3. Looks up matching rows in oauth_group_mappings. 4. For auto-created users, applies default_permissions + workgroup. 5. For existing users, currently does nothing with the group claim — permissions are whatever the admin previously set.

The minimum change for JIT:

That's the load-bearing change. Everything else is provisioning plumbing.

5.4 Entitle virtual application config

A "VM Dashboard" Entitle application with:

Operators with stricter postures can override per-resource — e.g. require human approval even for *-read.

6. Multi-tenancy variation (prod)

Per the multi-tenancy plan, prod runs N tenants. The Entitle integration needs per-tenant groups + per-tenant virtual applications:

Single-tenant (community / pre-MT) Multi-tenant (prod, post-MT Phase 4)
dashboard-aws-write dashboard-acme-aws-write, dashboard-globex-aws-write, etc.
One Entitle virtual application One virtual application per tenant (or one app with a tenant selector — Entitle solutions team confirms which is cleaner)
One oauth_group_mappings row per group Same rows, but with the tenant_id column populated (already added by MT Phase 0)

The dashboard's OAuth callback reads the tenant from the resolved context (MT Phase 4), filters oauth_group_mappings to that tenant, and applies the union of matched mappings.

7. Phased execution

                  community/dev   QA          prod
Phase 0           ✓ scaffold      —           —
Phase 1           ✓               ✓ smoke     —
Phase 2           ✓               ✓ smoke     —
Phase 3           ✓               ✓ smoke     —
Phase 4           ✓               ✓ rehearsal ✓ rollout
Phase 5           —               ✓           ✓
Phase 6           ✓               ✓           ✓

Some phases require real Entra + real Entitle (the bootstrap needs Microsoft Graph + the Entitle API). Community / dev can exercise the dashboard-side resolver with a mock Graph response or a small dev Entra tenant; the "real bootstrap" lives in QA onwards.

Phase 0 — Resolver behaviour update (community + prod)

Goal: make the OAuth callback re-apply group → permissions on every login, not just on first user creation. Pure dashboard code change; no Entra / Entitle dependency.

Adds: - api/auth.py Azure-OAuth callback now reads the JWT's groups claim on every login. - Builds the union of default_permissions JSON across all matched oauth_group_mappings. - Writes the union to User.permissions (or a new User.session_permissions field — see open question §9). - Existing admin-set users (whose group claim is empty or whose groups don't match any mapping) are unaffected.

Exit criteria: 1. A test user with two groups mapped to {"aws": ["read"]} and {"vms": ["read"]} ends up with the union as their permissions. 2. Removing one of those groups (simulate via DB row delete) and re-logging-in drops the matching permission. 3. Existing admin-set users keep working with no group claim present.

Runbook stub: entitle-user-jit-phase-0-resolver.md.

Test environments: community + dev. Mock Entra groups (just populate oauth_group_mappings directly + use a dev OIDC mock or a real dev Entra tenant). No Entitle required yet.

Phase 1 — Bootstrap script: provision Entra groups

Goal: a one-shot operator-runnable script that creates the ~27 Entra security groups + populates the matching oauth_group_mappings rows.

Adds: - web_dashboard/scripts/bootstrap_entitle_groups.py — PowerShell-equivalent or Python (using azure-identity + msgraph-sdk or raw Graph REST). - Idempotent: skip groups that already exist; skip oauth_group_mapping rows that already exist. - --dry-run + --yes mirror the multi-tenancy migration script's flags. - Three sub-modes: - --scope=permissions — the 27 scope/level groups only. - --scope=workgroups — the per-workgroup groups (reads from the workstreams table; needs to run AFTER workstreams exist). - --scope=all — everything (admin + permissions + workstreams + baseline). - For prod (multi-tenant), accepts --tenant=<slug> and prefixes group names with the tenant.

Exit criteria: 1. Running with --dry-run against an empty Entra tenant lists exactly the groups it would create. 2. Running for real creates them; re-running is a no-op. 3. oauth_group_mappings table has one row per provisioned group.

Runbook stub: entitle-user-jit-phase-1-bootstrap-entra.md.

Test environments: QA (needs real Entra tenant + Graph API access). Community ships the script but most community installs won't run it.

Phase 2 — Entitle virtual application provisioning

Goal: Terraform module that provisions the Entitle side (virtual application + resources + workflows) using the entitle-terraform-provider.

Adds: - terraform/entitle_user_jit/ Terraform module: - Three entitle_workflow definitions (auto-approve / single-approver / two-approver). - entitle_resource_synced "<group>" for each Entra group from Phase 1: it adopts the resource Entitle's Entra sync created (its external_id is the group's object id) and sets its tier's workflow. - No entitle_policy. In Entitle a policy is a birthright grant, not routing; the tier is the workflow on the resource itself. - Auth via ENTITLE_API_KEY env var (per the Terraform provider docs). - A wrapper script bootstrap_entitle_app.py that calls terraform apply with the right vars.

Exit criteria: 1. terraform plan against a clean Entitle tenant prints the expected resources. 2. terraform apply provisions them; terraform apply again is a no-op. 3. In the Entitle UI, the "VM Dashboard" app appears with the resource list.

Runbook stub: entitle-user-jit-phase-2-bootstrap-entitle.md.

Test environments: QA (needs real Entitle tenant).

Phase 3 — End-to-end JIT request flow

Goal: prove the loop works against real Entra + real Entitle.

The runbook's narrative test: 1. Operator A logs into the dashboard via OIDC — currently has no permissions. 2. Operator A opens Entitle's portal, requests dashboard-aws-read for 1 hour. 3. Approver clicks Approve. 4. Operator A logs out + back into the dashboard. 5. Operator A's permissions now include aws:read; they can view the AWS Images tab. 6. After 1 hour, Entitle revokes the group membership. 7. Operator A re-logs-in; AWS Images tab returns to forbidden.

Exit criteria: 1. Steps 1-5 work end-to-end with sub-10-minute latency between approval and effective permission. 2. Step 6's revoke fires automatically without admin action. 3. Audit log records the grant + revoke (visible via dashboard audit log; Entitle has its own).

Runbook stub: entitle-user-jit-phase-3-e2e.md.

Test environments: QA + a dedicated test user.

Phase 4 — UI affordances + operator polish

Goal: small UI touches that make the JIT experience discoverable.

Adds: - "Request access" link in the dashboard nav (admin-configurable URL — operator points it at their Entitle portal). - On a 403 page, surface a "You don't have <scope>:<level> — request it" link that deep-links to the matching Entitle resource. - /settings → Integrations → Entitle gains a "User JIT enabled" toggle + URL field for the request portal. - Documentation note: shorter JWT TTL = tighter grant cycle (recommend ≤30min for high-sensitivity tenants; default is several hours today).

Exit criteria: 1. Nav link shows when the operator has configured the URL. 2. 403 page deep-links land on the right Entitle resource. 3. JWT TTL is documented as a tuning knob.

As built — how the 403 deep link reaches the screen. Four hops, and it was broken at two of them for a long time without any symptom, so read this before touching any of them:

  1. api/auth.py::require_permission → on a denial, asks _build_request_access_link for a URL and, if it gets one, returns a dict detail (message, missing_scope, missing_level, request_access_url) instead of the usual plain string. Both shapes are live — an operator without user-JIT configured still gets the string, and the frontend handles both.
  2. static/js/app.js::API.request → lifts those fields off the body onto the thrown Error as requestAccessUrl etc.
  3. templates/base.html::toast() → re-attaches requestAccessUrl to the toast. It cannot simply read the Error: every one of the ~142 toast() call sites passes a string built from it (toast(e.message, 'error'), toast('Deploy failed: ' + e.message, 'error')), so the Error is gone by then. API.request therefore stashes the Error on window.__lastApiError and toast() adopts the link when the string it was handed contains that error's message, consuming the stash and ignoring one older than 5s so a later unrelated toast can't inherit it. Rewriting the 142 call sites to pass the Error would remove the need for this — it is the deliberate trade for not doing that.
  4. The x-for in base.html renders the <a> when t.requestAccessUrl is set.

The two failures worth knowing about, because neither produced an error anywhere: _build_request_access_link referenced config_service with no import in scope, so it raised NameError on every call and the caller's except Exception read that as "no link"; and hop 3 discarded the URL as described above. Hops 2 and 4 were correct the whole time, which is why the feature looked finished. Pinned now in tests/test_entitle_request_access_link.py (hop 1 — the payload shape) and tests/toast_request_access_check.js (hops 2–4 — 403 body through to the rendered toast object).

Runbook stub: entitle-user-jit-phase-4-ui.md.

Test environments: community + dev + QA + prod.

Phase 5 — Multi-tenancy hooks (prod only)

Goal: per-tenant group naming, per-tenant Entitle apps, tenant-scoped oauth_group_mappings.

Adds: - Bootstrap script gains the --tenant=<slug> flag. - Group naming becomes dashboard-<tenant>-<scope>-<level>. - oauth_group_mappings.tenant_id populated (column already exists from MT Phase 0). - OAuth callback resolves user's tenant via MT Phase 4's resolver, filters oauth_group_mappings to that tenant. - One Entitle virtual application per tenant (decision to confirm with Entitle solutions: alternative is one app with a tenant request parameter).

Exit criteria: 1. A user in tenant A who is a member of dashboard-acme-aws-write gets aws:write only when they log in via tenant A's subdomain. 2. The same group membership in tenant B doesn't grant aws:write to a user logging in via tenant B (no cross-tenant leakage).

Depends on: multi-tenancy plan Phases 4 (auth + memberships) + 5 (tenant CRUD).

Runbook stub: entitle-user-jit-phase-5-multi-tenancy.md.

Test environments: QA + prod (after MT Phase 8 lands first real second tenant).

Phase 6 — Docs + adoption playbook

Goal: operator-facing playbook.

Test environments: all.

8. Interactions with in-flight parity plans

Other plan / phase This plan's phase that depends on it Why
Multi-tenancy Phase 0 (oauth_group_mappings.tenant_id) Phase 5 Already shipped — the column exists
Multi-tenancy Phase 4 (auth + tenant resolver) Phase 5 The OAuth callback needs the resolved tenant to filter mappings
Multi-tenancy Phase 5 (tenant CRUD) Phase 5 Per-tenant bootstrap needs CRUD to know which tenants to provision
Secrets parity 5.7 (prod setup wizard) Phase 1 The wizard can optionally trigger bootstrap_entitle_groups.py as a setup step for enterprise installs
Cloud-identity JIT (machine-flow) None directly Both use Entitle but the surfaces don't overlap — user JIT grants Entra group membership; machine JIT grants cloud IAM. Documented as complementary, not coupled
Approval gate (existing) None directly Both layers stay — approval gate gates actions, this plan gates authorizations

9. Open questions

10. Sizing

Phase Engineer-days Wall-clock
0 (resolver) 0.5 2 days
1 (Entra bootstrap script) 1.5 1 week (waiting on Graph API access in QA)
2 (Entitle app provisioning) 1 1 week (Terraform module + Entitle tenant)
3 (E2E test) 0.5 1 week (QA observation)
4 (UI polish) 1 3 days
5 (multi-tenancy hooks) 1 2 weeks (waiting on MT Phase 4+5)
6 (docs + playbook) 1 3 days
Total ~6.5 eng-days ~6 weeks wall-clock

Wall-clock dominated by waiting on QA Entra/Entitle access and the multi-tenancy dependency.

11. Test environment matrix

Capability Community Dev QA Prod
Resolver re-applies group → permissions on every login ✓ ✓ ✓ ✓
Removing a group claim drops the matching permission ✓ ✓ ✓ ✓
Bootstrap script provisions Entra groups — mock ✓ load-bearing ✓
Bootstrap script provisions Entitle app via Terraform — mock ✓ load-bearing ✓
End-to-end Entitle request → group → permission loop — — ✓ load-bearing ✓
Group expiry revokes permission within JWT TTL — — ✓ ✓
Per-tenant groups + Entitle apps — — ✓ load-bearing ✓
"Request access" UI affordances ✓ ✓ ✓ ✓

The load-bearing-in-QA tests are the end-to-end grant loop (Phase 3) and the per-tenant variation (Phase 5). Don't ship Phase 4's prod rollout without Phase 3 green in QA against a real Entitle tenant.


Appendix — Why this complements (doesn't replace) the

approval gate

Today's approval gate makes the dashboard ask Entitle for permission on each privileged action: "Operator A is trying to delete secret X — is this approved?" Entitle says yes / no, the action proceeds or 403s.

This plan adds a layer below that: "Operator A wants the ability to attempt delete-secret actions on this dashboard in the first place." Both layers can be active simultaneously:

Note, 2026-09. The example is the design intent, not what shipped: the Secrets endpoints were always administrator-only, so secrets:write never reached them, and it has since been retired as a level nothing enforced (see Permissions → The sections). The layering argument holds for any scope that does gate a route, aws:delete for instance.

Defence in depth: authorization is gated; action is gated. Compromised credential gets reduced blast radius from both sides.