Skip to content

prismor enroll --aws: workload identity enrollment via AWS IAM - #359

Open
Ar9av wants to merge 1 commit into
mainfrom
feat/enroll-aws
Open

Ar9av wants to merge 1 commit into
mainfrom
feat/enroll-aws

Conversation

@Ar9av

@Ar9av Ar9av commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Problem

A deployed agent on AWS (EC2, ECS/EKS task, Lambda) has no developer machine and no safe place for a shared credential, yet today the only ways onto the control plane are prismor enroll <token> (a one-time token someone has to hand to the workload) or a long-lived PRISMOR_AGENT_KEY baked into the deployment. Both are secrets that must be distributed, rotated and revoked by hand. The workload already has a verifiable identity, its IAM role, and Prismor cannot use it.

docs/connecting-to-the-platform.md already flagged "auth on the enroll UX" as a known follow-up for this path.

Prior art — what already exists

  • I extended an existing pattern. Enrollment already exchanges a credential for a device identity through identity.enroll()POST /api/devices/enrollsave_identity(). This PR keeps that flow and swaps what is presented: instead of a token, a SigV4-signed sts:GetCallerIdentity request. enroll() was refactored onto a shared _enroll_request(path, payload, ...) so both paths share the POST, the response validation and the identity file. The CLI reuses the existing enroll subcommand (--aws), so enroll-status, logout, the post-enroll policy pull and the discover seed run unchanged.
  • An existing mechanism was close but not sufficient. PRISMOR_AGENT_KEY (identity._env_identity) is the deviceless path, but it is itself a long-lived secret the operator must distribute; it cannot derive identity from the platform the workload runs on. There is no HTTP/JWT/OIDC client anywhere in the runtime (only IMDS blocking in egress.py / policies.py), so nothing existing could sign a request.

Tangential updates: docs/cli-reference.md row for enroll; docs/connecting-to-the-platform.md gains an "AWS workloads" subsection; new customer-facing docs/sso-and-provisioning.md documents the console side (OIDC self-serve, SCIM Users/Groups role mapping, LDAP/AD via the IdP) that ships in the control-plane counterpart PR.

Solution — high level

  • prismor enroll --aws --org <orgId> signs sts:GetCallerIdentity with whatever role credentials the host has (env → ECS/EKS container endpoint → IMDSv2 → ~/.aws/credentials) and posts only the signed request to POST /api/devices/enroll/aws.
  • The control plane replays that request to STS, reads the caller's role ARN and, if an org admin bound that role, mints a service identity with the same payload as a token enrollment. Credentials never leave the workload.
  • Two headers are folded into the signature, X-Prismor-Server-Id (control-plane host) and X-Prismor-Org, so a captured request cannot be replayed against another server or into another org; STS also enforces the 5-minute date window.
  • Stdlib only (hmac, hashlib, urllib, configparser): no boto, no cryptography, consistent with the rest of enterprise/.

Deep dive — how it works

  1. aws_identity.resolve_credentials() walks the sources the AWS SDKs use, minus config-file role chains. Each source returns {access_key, secret_key, token} or None; the first hit wins. IMDSv2 is token-required (PUT /latest/api/token first) with a 1 s timeout so a non-AWS host fails fast.
  2. sign_get_caller_identity(creds, region, server_id, org_id) builds a canonical request over content-type;host;x-amz-date;[x-amz-security-token;]x-prismor-org;x-prismor-server-id and returns {method, url, headers, body}. The signing key is derived with the standard four-step HMAC and checked in the tests against the AWS documentation vector. Region selects sts.<region>.amazonaws.com (credential scope uses that region; the global endpoint scopes to us-east-1).
  3. identity.enroll_aws(org_id, base, label, region) derives server_id from the --api-base host, builds the signed request and calls _enroll_request("/api/devices/enroll/aws", {"aws": signed}, ...), which persists identity.json with source: "aws".
  4. Failure modes handled: no credentials anywhere → RuntimeError("no AWS credentials found (checked env, container endpoint, IMDSv2, ~/.aws/credentials)"), exit 1; --aws without --org/$PRISMOR_ORG_ID → usage error before any network call; control-plane 401/403 surface with the server's error and message (for example caller is not an IAM role, role_not_bound).
  5. Deliberately not done: role chains from ~/.aws/config (env/container/IMDS/profile cover the deploy targets), and IMDS hop-limit-1 containers (documented: prefer task/pod roles).

Files changed

File Why it changed
prismor/runtime/enterprise/aws_identity.py New: credential resolution + SigV4 signing, stdlib only
prismor/runtime/enterprise/identity.py Extract _enroll_request() from enroll(); add enroll_aws()
prismor/runtime/cli.py enroll gains --aws, --org, --aws-region; error text points at the AWS path
tests/test_aws_identity.py AWS docs signing-key vector, header shape, binding sensitivity, credential resolution, enroll round-trip, no-credentials error
docs/sso-and-provisioning.md New customer-facing doc: OIDC self-serve, SCIM Users/Groups → roles, LDAP/AD via IdP, AWS workload identity
docs/connecting-to-the-platform.md "AWS workloads" subsection next to the deviceless-agent section
docs/cli-reference.md enroll row lists the new flags

Testing

python3 -m pytest tests/test_aws_identity.py -q            # 8 passed
python3 -m pytest tests/ -q -p no:randomly                  # st3ve: 21 failed / 2408 passed on main vs identical FAILED set on this branch
bash scripts/run_security_tests.sh                          # st3ve: All security regression checks passed
bash scripts/verify_registry.sh                             # ✓ registry verified

Live on st3ve against the control-plane counterpart (real STS round trip, screenshots in the comment below): no credentials → clear error; IAM user credentials → 401 caller is not an IAM role; assumed bound role → service identity minted, enroll-status verified by the control plane; same role against an org that did not bind it → 403 role_not_bound.

  • bash scripts/run_security_tests.sh passes
  • Added or updated tests covering the new behavior
  • If a policy rule changed: n/a
  • If an integration changed: bash scripts/verify_registry.sh (no registry change; still green)

Security checklist

  • No detection patterns hardcoded in Python — all rules live in YAML (no rules touched)
  • No real secrets, keys, or credentials in code, tests, fixtures, or docs (test uses the public AWS documentation example key)
  • No real secret values printed, logged, or serialized (credentials are read and used in-process only; the identity file stores the minted device key exactly as token enrollment does)
  • No guardrail weakened
  • Docs that describe this behavior are still accurate

Diff size

Lines changed: +557 / −33 · Justification: one new 200-line module is the SigV4 signer + credential resolution, both stdlib; a third of the diff is documentation and tests.

…aws)

Sign sts:GetCallerIdentity with the workload's role credentials (env, container
endpoint, IMDSv2, ~/.aws/credentials; stdlib only) and post the signed request
to /api/devices/enroll/aws. The control-plane host and org id are folded into
the signature so a captured request cannot be replayed elsewhere. Credentials
never leave the workload. enroll() refactored onto a shared _enroll_request().
Docs: sso-and-provisioning.md, connecting-to-the-platform.md, cli-reference.md.
@Ar9av

Ar9av commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Tested on st3ve (real AWS STS round trip)

Fresh disposable clone of this PR on the Lightsail box, run against the control-plane counterpart (prismor-web PR #197) on a local dev server backed by the Neon staging DB.

Live prismor enroll --aws — no credentials → clear error; the host's IAM user credentials → 401 caller is not an IAM role; assumed bound role → service identity minted and enroll-status verified by the control plane; same role against an org that did not bind it → 403 role_not_bound. The box also has IMDS DNAT'd to a local mock, which the resolver correctly falls through.

enroll --aws live

Suites — full pytest on main and on this branch: identical FAILED set (21 pre-existing, none introduced); scripts/run_security_tests.sh passes; scripts/verify_registry.sh green.

test suites

Screenshots live on the throwaway branch assets/pr-359-screenshots; delete it after merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant