BeyondTrust/POV

← Back to dashboard

Design — Machine-Identity JIT Cloud Access via Entitle

Audience: contributor · Profile: both · Read this when: you are changing how the dashboard's own cloud writes are authorised.

Status: Design draft v2 — revised after validation against Entitle's public API and Terraform-provider docs. No code changes yet. Scope: Community + prod/dev. Community is the reference target. Depends on: the existing human-facing Entitle approval gate already shipping for secret read / update / delete (see integrations/beyondtrust/entitle.md). This doc extends the same Entitle tenant to cover the dashboard's machine identity elevations. Validation notes: see Appendix D for the per-claim check against Entitle's docs.


1. Problem

Today the dashboard holds three long-lived cloud identities with broad standing privilege:

Cloud Identity Standing privilege today
AWS IAM user (access key) ec2:*, iam:PassRole, s3:* on dashboard buckets, ssm:*, tag:*
Azure Service Principal Contributor on the subscription (sometimes scoped to RGs)
GCP Service Account Compute Admin, IAM Service Account User, Storage Admin on the dashboard project

That is convenient but violates least-privilege: a stolen access key, leaked SP secret, or compromised SA JSON gives an attacker the union of every privilege the dashboard ever uses, indefinitely, without an approval trail.

The human-facing approval gate we already shipped (see integrations/beyondtrust/entitle.md) covers the case where a human reads / updates / deletes a secret. It does not cover the case where the dashboard's own service principal performs a privileged cloud action on behalf of a workflow (deploy, destroy, tag-rewrite, Packer capture, etc.).

2. Goal

Move the three cloud identities to a baseline read-only footing and make every privileged operation an on-demand, time-bound elevation orchestrated by Entitle, with the elevation request auto-approved by policy when it satisfies (principal, action, TTL ≤ ceiling).

Concretely:

3. Non-goals

4. Threat model & what this buys

The value of this design is not a human-in-the-loop second pair of eyes. It is:

  1. Zero standing write privilege — a stolen credential alone unlocks nothing destructive.
  2. Hard TTL ceiling per elevation — even a successful elevation is bounded to ≤ 60min, no extension, no renewal. A new operation gets a new request.
  3. Anomaly signal — every request asking for more than the policy ceiling, or for an action not in the operation matrix, is denied and alerted. Legitimate dashboard code never produces these, so any occurrence is high-confidence signal that something is wrong.
Threat Before After
Leaked AWS access key Full ec2:* until rotated Read-only inventory only; writes need a fresh Entitle activation each time
Leaked Azure SP secret Subscription Contributor Reader only; same
Leaked GCP SA JSON Project-wide Admin Project Viewer only; same
Attacker tries to extend TTL n/a Denied + alerted — policy ceiling is fixed; no API path to raise it
Attacker uses creds off-hours Indistinguishable from app Every activation is a discrete logged event with timestamp, action, and TTL
Compromised dashboard host Same as standing creds Bounded — even with full host control, attacker is capped by ceiling per request
Insider misuse (rogue admin) No bound Same ceiling applies; can't grant themselves longer creds via the dashboard
Bug → runaway deploy loop Unbounded blast radius Each elevation is its own request; rate-of-requests visible in Entitle audit log

The dashboard is already a privileged box — what changes is that the credentials it holds are no longer the ceiling on what an attacker can do, and every escalation past the policy is a high-confidence detection event.

5. How Entitle actually grants the elevation

Validated against Entitle's public docs (Appendix D). This section replaces the v1 draft's "AssumeRoleWithWebIdentity + PIM activation + IAM Conditions" mechanics, which assumed a synchronous policy-decision API Entitle does not provide.

5.0 The Entitle grant model — common shape

Entitle is a grant orchestrator, not a policy decision point that issues JWTs. The flow is:

  1. Dashboard submits an access request to Entitle via REST (POST /v1/access-requests — see Appendix D for the exact path). Body identifies the requesting principal (a synthetic Entitle "user" per machine identity — see §6.8), the resource/role/bundle, and the requested duration.
  2. Entitle runs the configured workflow. Our workflow has a single "Automatic Approval" stage with max_duration ≤ machine_ttl_ceiling (default 60min). If the request matches policy, the workflow approves in milliseconds; if not, it denies.
  3. On approval, the Entitle agent (running in the customer's environment) calls the cloud's IAM API to attach the role / permission to the dashboard's baseline identity for the requested duration. The exact API call depends on the cloud — see §5.1–§5.3.
  4. The dashboard polls the request status (or subscribes to the workflow webhook), and once status is approved AND the agent has confirmed the grant, calls the cloud SDK / Terraform as normal. The baseline credential it already holds now has the elevated permission.
  5. At TTL expiry, Entitle's agent revokes the grant. The dashboard's identity drops back to read-only.

Two consequences worth calling out:

5.1 AWS — Entitle attaches an IAM policy to the dashboard's IAM user

5.2 Azure — Entitle assigns a role with endDateTime

5.3 GCP — Entitle adds/removes an IAM binding via setIamPolicy

Community installs without GKE can use the cloud-hosted model and avoid running an agent. Document the egress IP allow-list step. - Audit option to enable: set iam_policy_auditing: true in the Entitle GCP integration config — this writes the grant timestamp and Entitle request ID into the IAM policy itself, joining cleanly with the entitle:{request_id} user-agent tag the dashboard already appends (§6.7). - Drift risk same as AWS. Because revocation is Entitle-driven (cloud-hosted or agent), an Entitle outage between attach and revoke can leave bindings active past TTL. The hourly sweeper (§6.7) reconciles this; checks active bindings against EntitleActivation rows and reports orphans. - Cross-project: the binding lives on the workload project, not the dashboard project. The Entitle integration must have resourcemanager.projects.setIamPolicy on each workload project.

6. Dashboard-side design

6.1 Request submitter (replaces "credential resolver" from v1)

The v1 draft had a cloud_identity_service.get_credential() that returned a new SDK-ready credential. That was wrong — there is no new credential; the baseline credential's privilege simply changes.

Endpoint: POST <entitle_api_url host>/public/v1/accessRequests — the host is regional (api.us.entitle.io by default; api.entitle.io and api.ca.entitle.io are other deployments), bearer-token auth. Required fields per the Entitle reference:

Status discovery: polling, not webhook. After submission the submitter polls GET /public/v1/accessRequests/{id} at 250–500ms intervals until status reaches a terminal state.

Confirmed response shape:

{
  "id": "string",
  "number": 0,
  "status": "string",
  "duration": 0,
  "justification": "string",
  "roles": ["string"],
  "target": {"type": "string"},
  "user": {"id": "string", "email": "string"},
  "behalfOf": {"id": "string", "email": "string"}
}

Confirmed status enum and our state-machine mapping:

Entitle status Dashboard action
waitingForApproval Keep polling (workflow stage in progress)
approved Keep polling (decision made, agent has not granted yet)
waitingForIT Keep polling (cloud-side grant in flight)
permissionInProgress Keep polling (cloud-side grant in flight)
granted Proceed — cloud-side permission is in place
rejected Fail loudly → security alert (policy denied)
failed Fail loudly → operator alert (cloud-side error)
cancelled Fail (admin or caller cancelled)
revoked Cache evict + fail any in-flight call. Covers BOTH explicit revocation AND natural TTL expiry — Entitle uses one terminal state for both

Crucially, granted is distinct from approved — the latter just means the workflow decision is in, while the agent may still be attaching the IAM policy. The dashboard MUST wait for granted before calling the cloud SDK, or it will race and see AccessDenied.

Polling is preferred over webhook here because:

Webhooks (POST /public/v1/workflowsWebhooks) are used for the out-of-band denial / revocation path (§6.4, §6.5) — events that need to reach the operator's alert sink even when no worker is polling. These reuse the existing HMAC-verified /api/approvals/webhook handler from the human-gate plumbing.

The new shape is a request submitter:

async def elevate(
    cloud: Literal["aws", "azure", "gcp"],
    operation: str,                 # e.g. "aws:ec2:deploy"
    *,
    duration_minutes: int = 15,
    payload_hash: str,              # binds the elevation to the request body
    requester_user_id: str,         # who triggered the dashboard operation
) -> ElevationHandle

ElevationHandle is a context-manager-style object the caller async withs; it represents an active Entitle activation. On enter:

  1. Cache check: if an EntitleActivation for (cloud, operation) is already active and now < expires_at - safety_margin, return it.
  2. Else POST /v1/access-requests to Entitle.
  3. Poll the request status (or subscribe to the workflow webhook). Auto- approve typically completes within 1–3s for AWS/GCP, 1–5s for Azure.
  4. Once status is granted AND the agent has confirmed the cloud-side grant, return the handle.

On exit: nothing to clean up locally; Entitle revokes server-side at TTL. If the caller explicitly wants to release early, the handle has a release() method that posts to Entitle's revoke endpoint.

The handle does NOT carry the SDK client. The dashboard's existing SDK factory yields the same client as before — it just has more privilege while the handle is active.

6.2 Where it's called

Every code path that writes to a cloud wraps its SDK calls in an elevate():

async with cloud_identity.elevate(
    "aws", "aws:ec2:deploy",
    duration_minutes=15,
    payload_hash=hash_request_body(body),
    requester_user_id=current_user.id,
):
    ec2 = boto3.client("ec2", region_name=region)
    ec2.run_instances(...)
    ec2.create_tags(...)

For read-only paths, there is no elevate() — the baseline credential already has read access. This is a deliberate API shape: if you forget to wrap a write, it will fail with AccessDenied against the read-only baseline, which is loud rather than silent.

Files that change (community repo):

File Change
services/cloud_identity_service.py new — request submitter + ElevationHandle + cache + audit-log writer
services/entitle_service.py Add submit_machine_request(), poll_request_status(), release_request() alongside the existing human-flow helpers. Reuse HMAC webhook handler
services/aws_service.py Wrap all RunInstances/TerminateInstances/CreateTags paths in elevate(...). No client-factory refactor needed — clients are unchanged
services/azure_service.py Same; plus invalidate MSAL in-process cache on elevate-enter
services/gcp_service.py Same
services/terraform.py Wrap terraform apply / destroy calls in elevate(...). Terraform reads creds from env vars / SDK chain — no changes needed there because the credential is unchanged
api/approvals.py Add principal_kind discriminator (user / machine); webhook flow is shared
database.py New EntitleActivation table (operation, role, requester_user_id, status, expires_at, denial_reason, payload_hash, entitle_request_id)

6.3 Operation → Entitle resource matrix

The mapping lives in config_service as a JSON blob editable from Settings → Integrations → Entitle (Machine Roles). Each entry maps a dashboard operation to an Entitle resource (which in turn bundles the cloud role / policy / permission set). Default seeded values:

Operation key AWS (Entitle resource → IAM policy) Azure (Entitle resource → role) GCP (Entitle resource → role)
aws:ec2:read (baseline — no elevation) n/a n/a
aws:ec2:deploy dashboard-ec2-deploy n/a n/a
aws:ec2:destroy dashboard-ec2-destroy n/a n/a
aws:ec2:tag-rewrite dashboard-tag-rewrite n/a n/a
aws:image-builder dashboard-image-builder n/a n/a
azure:vm:read n/a (baseline — Reader) n/a
azure:vm:deploy n/a Virtual Machine Contributor (RG) n/a
azure:vm:destroy n/a Virtual Machine Contributor (RG) n/a
azure:storage:write n/a Storage Blob Data Contributor n/a
gcp:compute:read n/a n/a (baseline — Viewer)
gcp:compute:deploy n/a n/a Compute Instance Admin (v1)
gcp:compute:destroy n/a n/a Compute Instance Admin (v1)
gcp:storage:write n/a n/a Storage Object Admin

Anything not in the matrix is denied rather than defaulting to the baseline — fail closed.

6.4 Auto-approval policy (no human modal)

Validated: Entitle natively supports "Automatic Approval" as an approver type ("The step is approved automatically, without human intervention") and workflow rules with if/then semantics ("A condition (if) defines when the rule applies. An approval process (then) defines how the request is approved"), evaluated top-to-bottom. Workflows can enforce a maximum duration ("Less than: Select a maximum duration for requests").

Our workflow:

Rule 1 (auto-approve machine identity requests):
  IF requester ∈ {dashboard-baseline-aws, dashboard-baseline-azure, dashboard-baseline-gcp}
     AND resource ∈ {configured operation matrix}
     AND requested_duration ≤ machine_ttl_ceiling   (default 60min)
  THEN
     approver: Automatic Approval
     max_duration: machine_ttl_ceiling

Rule 2 (catch-all deny):
  IF requester ∈ {dashboard-baseline-*}
  THEN
     deny + fire webhook

This is ordinary Entitle configuration — no custom code in Entitle, no hosting requirement beyond what the existing human gate already has.

Denials are alerts. Every denial fires:

  1. A row in EntitleActivation with status="denied" and the reason.
  2. A webhook to the operator's configured alert sink (Slack / email / PagerDuty — wired through the existing notification framework).
  3. A banner on the dashboard's admin home for the next 24h.

A legitimate dashboard never produces these. Any occurrence is worth investigating.

6.5 Cache invalidation

Cached ElevationHandles are released by:

  1. TTL — now ≥ expires_at - 30s safety margin. The dashboard's in-process clock; Entitle separately auto-revokes at the same point and flips status to revoked.
  2. Webhook revocation — Entitle posts status=revoked to the workflow webhook (covers both natural TTL expiry and explicit admin revoke; Entitle uses the single revoked terminal state for both). Cache evicted on receipt.
  3. Manual — POST /api/cloud-identity/{cloud}/{operation}/revoke (admin only) for the break-glass case.

6.6 Graceful failure

Two distinct failure modes.

Entitle unreachable (network / outage):

Entitle denies (policy refusal — TTL exceeds ceiling, unknown action, payload-hash mismatch):

6.7 Audit trail + agent-revoke sweeper

Each EntitleActivation row records:

In addition, on every cloud write the resolver tags the call with entitle:{request_id} (AWS session tag / Azure correlation-request-id header / GCP user-agent suffix) so CloudTrail / Activity Log / Audit Logs can be joined to the Entitle request post-hoc.

Sweeper. Because Entitle's AWS revocation is agent-driven (and GCP might be, depending on §5.3 resolution), a once-hourly sweeper compares active cloud-side grants against the dashboard's EntitleActivation view of "what should be active." Orphans get reported as warnings (/admin/cloud-identity/orphans). Azure usually self-cleans via endDateTime, so its sweeper output is informational only.

6.8 Machine-identity encoding

Entitle's UI/workflows are user-centric and have no native "service account" identity type. To use Entitle for machine flows we encode each cloud principal as a synthetic Entitle "user":

Cloud Dashboard principal Entitle synthetic user (directory)
AWS IAM user dashboard-baseline-aws dashboard-baseline-aws@machine.internal
Azure SP dashboard-baseline-azure dashboard-baseline-azure@machine.internal
GCP SA dashboard-baseline@project.iam.* dashboard-baseline-gcp@machine.internal

These three synthetic users are members of an Entitle directory group dashboard-machine-identities. The workflow rule in §6.4 filters on membership of this group.

This pattern is natively supported by Entitle. The POST /v1/accessRequests endpoint accepts a behalfOf field that "enables requests on behalf of another user/service account" (per the API reference). The dashboard authenticates to Entitle with its own bearer token, and each request specifies behalfOf pointing at the synthetic cloud-principal user. The workflow then evaluates the rule against the behalfOf identity — not the API caller — so the auto-approve rule fires only for these synthetic users, never for a human admin's token reused against the same endpoint.

A consequence: the audit trail visible inside Entitle shows these synthetic users as the requestors. The original human user who triggered the dashboard operation is recorded inside the dashboard's EntitleActivation.requester_user_id and surfaced when joining the two audit logs. Documented in the runbook.

7. Setup wizard additions

The wizard's Entitle entry gains a sub-section Machine identity gate:

Field Notes
cloud_identity_gate_enabled Master toggle
machine_ttl_ceiling_minutes Hard upper bound Entitle will honour per activation; default 60. Requests above this are denied + alerted
AWS baseline access key ID/secret RO-only
AWS Entitle agent role ARN Role Entitle's agent assumes to attach policies
AWS operation→role matrix JSON (§6.3 AWS columns)
Azure baseline SP credentials The existing SP, now Reader-only
Azure operation→role matrix JSON (§6.3 Azure columns)
GCP baseline SA JSON Existing SA, now Viewer-only
GCP Entitle activator SA email The SA Entitle uses to flip bindings
GCP operation→role matrix JSON (§6.3 GCP columns)
Alert sink (Slack/email/PagerDuty) Where denial events are routed

These fields are admin-only post-wizard at Settings → Integrations → Entitle. Most operators won't fill in the JSON matrices by hand — they'll run the Terraform module described in §8.0 below.

8. Migration path

8.0 Setup IaC — Entitle config + cloud IAM in one Terraform module

Entitle ships a Terraform provider (entitle-terraform-provider) that exposes the configuration entities we need: entitle_integration, entitle_workflow, entitle_policy, entitle_resource, entitle_role, entitle_bundle, entitle_permission. Auth is a bearer token (ENTITLE_API_KEY).

We ship a Terraform module terraform/entitle_setup/ that creates, in one terraform apply:

Operators run:

terraform -chdir=terraform/entitle_setup apply \
  -var "entitle_api_key=$ENTITLE_API_KEY" \
  -var "machine_ttl_ceiling_minutes=60" \
  -var "aws_account_id=..." \
  -var "azure_subscription_id=..." \
  -var "gcp_project_id=..."

…and get a fully-configured Entitle tenant + matching cloud IAM in a single command. No clicking through Entitle's UI to define a dozen resources/workflows/policies.

What the provider does not do: there is no Terraform-native way to submit an access request and pause for approval inside a terraform apply for a deploy run. The dashboard still submits requests via the Entitle REST API at runtime. The provider is setup-time only.

8.1 Phase 0 — pre-work (no behaviour change)

8.2 Phase 1 — AWS, opt-in

8.3 Phase 2 — Azure

8.4 Phase 3 — GCP

8.5 Phase 4 — tighten baseline

Once all three are gated and verified, separately tighten the baseline identity permissions (strip writes off the baseline IAM user / SP / SA). This is its own flip because a bug that falls back to baseline must fail, not silently use over-privileged baseline creds.

9. Open questions

10. Trade-offs we're accepting

11. What we're NOT doing in v1


Appendix A — Sequence diagram (AWS deploy, revised)

operator                dashboard                Entitle           Entitle agent   AWS IAM         AWS EC2
   |                       |                       |                  |               |               |
   | POST /api/aws/deploy  |                       |                  |               |               |
   |---------------------->|                       |                  |               |               |
   |                       | elevate(aws:ec2:deploy, 15min, hash)     |               |               |
   |                       |                                                                          |
   |                       | POST /v1/access-requests                                                 |
   |                       |---------------------->|                  |               |               |
   |                       |   request_id, status=pending             |               |               |
   |                       |<----------------------|                  |               |               |
   |                       |   (workflow runs: auto-approval matches policy)                          |
   |                       |                       | trigger agent    |               |               |
   |                       |                       |----------------->|               |               |
   |                       |                       |                  | AttachUserPolicy              |
   |                       |                       |                  |-------------->|               |
   |                       |                       |                  |  done         |               |
   |                       |                       |                  |<--------------|               |
   |                       |                       |   status=granted |               |               |
   |                       |                       |<-----------------|               |               |
   |                       | poll GET /v1/access-requests/{id} → granted             |               |
   |                       |<----------------------|                                  |               |
   |                       |   (now elevation handle is active; baseline IAM user has the policy)    |
   |                       | RunInstances + CreateTags(ManagedBy, Workgroup, entitle:<id>)            |
   |                       |--------------------------------------------------------------------> |
   |                       |   instance-id                                                          |
   |                       |<-------------------------------------------------------------------- |
   |   200 + job id        |                                                                          |
   |<----------------------|                                                                          |
   |                       |                                                                          |
   |                       |   (15 min later — TTL expiry)                                            |
   |                       |                       | trigger agent    |               |               |
   |                       |                       |----------------->|               |               |
   |                       |                       |                  | DetachUserPolicy              |
   |                       |                       |                  |-------------->|               |
   |                       |                       |   webhook: revoked                               |
   |                       |<----------------------|                                                  |

If Entitle DENIES (e.g. ttl request exceeded ceiling):

   |                       |   status=denied, reason=ttl_exceeds_ceiling                              |
   |                       |<----------------------|                                                  |
   |   403 + reason        |                                                                          |
   |<----------------------|                                                                          |
   (Alert sink fires; admin-home banner; EntitleActivation row inserted with status=denied)

Appendix B — Why not just shrink the three IAM users?

We could write tighter IAM policies on the three existing identities and skip Entitle. That helps but doesn't change the leak-and-it's-permanent property — a stolen key still gets the union of everything the dashboard ever does, until rotation. JIT's value is time-boundedness and request-boundedness, which static policies cannot give.

Appendix C — Why Entitle and not native cloud JIT?

Each cloud has a JIT story — AWS Identity Center session policies, Azure PIM, GCP IAM Conditions — but they don't compose across providers and they're each hard to put a uniform approval/audit workflow on. Entitle's value is being the one policy + audit engine fronting all three. If a customer prefers cloud-native JIT in only one cloud, the design lets them flip cloud_identity_gate_enabled per-cloud — but when the gate is on, the path always goes through Entitle.

Appendix D — Validation against Entitle's public docs

This section records each design claim and the doc evidence backing it. The v1 draft made several wrong architectural assumptions which v2 corrects.

Claim (v2) Evidence
Entitle supports auto-approve workflows "Automatic Approval: The step is approved automatically, without human intervention." — approval-workflows
Workflows support if/then conditional rules "A condition (if) defines when the rule applies. An approval process (then) defines how the request is approved." — same
Workflows enforce a max duration "Less than: Select a maximum duration for requests." — same
AWS grant mechanism is policy-attach, not JWT issuance (corrects v1) "Entitle automatically creates a policy with the required permissions and attaches it to the corresponding IAM user." — aws-iam-pod-based-identity
AWS integration runs an agent pod in the customer's env "the role is assumed by the agent pod's IAM role" — same
Azure grant mechanism is direct role assignment with expiry, not PIM (corrects v1) "permissions are granted, the employee who requested them gets temporary access" — entitle-integration-azure
Azure role assignments are time-bound "Alice received an hour of access to an SQL database in subscription 1" — same
Entitle has Workflow webhooks Webhook payload includes stageNumber / stageAmount — approval-workflows
No synchronous policy-decision API (corrects v1) REST integration is outbound from Entitle to target system via Give Access / Revoke Access POSTs — entitle-integration-rest
No native machine-identity type All workflow examples reference "employee" / "Alice" — same; no service-account predicate documented
Terraform provider is setup-time only, no runtime access-request resource Provider resources: integrations, workflows, policies, resources, roles, bundles, permissions, agent_token, access_request_forward (forward, not create). No entitle_access_request resource — entitle-terraform-provider
Terraform provider auth is bearer-token "API bearer authorizations via an Entitle API token… api_key or ENTITLE_API_KEY" — same
GCP grant mechanism is setIamPolicy add/remove, not IAM Conditions "resourcemanager.projects.setIamPolicy / storage.buckets.setIamPolicy / iam.serviceAccounts.setIamPolicy" permissions; iam_policy_auditing: true option embeds grant_time + request_number in the policy (only meaningful if Entitle is the one editing it) — entitle-integration-gcp
GCP supports cloud-hosted (no agent) deployment Cloud-hosted mode with known egress IPs documented for allow-listing — same
Access-request endpoint exists with our needed fields POST /public/v1/accessRequests with duration, justification, target (bundle or role), and behalfOf (optional) — accessrequests_create
behalfOf field natively supports machine-identity pattern "Enables requests on behalf of another user/service account" — same. Resolves the §6.8 encoding cleanly; no workaround needed
Access-request status endpoint exists for polling GET /public/v1/accessRequests/{id} returns the request with status — accessrequests_show
Workflow webhooks can be registered POST /public/v1/workflowsWebhooks with name, url, optional headers and additionalParams — workflowswebhooks_create
Status enum exposes granted distinct from approved Confirmed values: waitingForApproval, approved, waitingForIT, permissionInProgress, granted, rejected, failed, cancelled, revoked. Dashboard waits for granted before invoking the cloud SDK
Response shape includes behalfOf echo Response behalfOf: {id, email} lets the dashboard verify the request it polled is the one it submitted, defending against id-confusion bugs

v1 assumptions that were wrong and are now removed: