Skip to content

feat(pam-access): add POST /pam/whoami so a server can read its own id - #94

Merged
guimard merged 2 commits into
mainfrom
feat/pam-whoami
Sep 6, 2026
Merged

guimard merged 2 commits into
mainfrom
feat/pam-whoami

Conversation

@guimard

@guimard guimard commented Sep 6, 2026

Copy link
Copy Markdown
Member

Closes the migration gap #86 opens: /pam/bastion-token's probe: true mode was the only way for an enrolled server to read the per-device id the portal gave it, and removing the endpoint took that with it.

Why the id matters

It is not a diagnostic. The per-device id is the bastion=<id> written into every hop certificate's key-id, and a backend's AuthorizedPrincipalsCommand matches it against /etc/open-bastion/allowed_bastions. It decides which bastion may reach which backend. open-bastion/scripts/ob-bastion-id reads it, and both deployment paths (local-test/deploy-shell.sh:57, deploy-ansible.sh:73) feed it straight into that allowlist.

Nothing else answered the question

Checked each candidate rather than assumed, and t/20 now pins all three as tripwires so the claim cannot quietly rot:

  • /pam/authorize returns no caller identity.
  • /pam/heartbeat returns none either.
  • /oauth2/introspect answers active, aud, exp, client_id, scope, nbf, iat, sub, iss, token_type — it does not export private session keys, and _deviceId is one.
  • The id is a sha256_hex('pam-device-id:' . session_id) computed portal-side; a bastion cannot derive it locally.

What this restores, and what it does not

The one part of the probe worth keeping, and nothing else. It signs no JWT — the probe signed one even when the user lookup had failed, which is why the endpoint went — writes no session, and leaves _pamSeen alone so running it does not look like a heartbeat. That last point is why this is a new route rather than a field bolted onto /pam/heartbeat: heartbeat slides the refresh token's expiry, stamps _pamLastSeen and mints an access token, and it needs the refresh token, which ob-bastion-id does not read today.

It goes through the standard caller gate, so pamAccessAllowedRps (#50) and request signing (#81) apply to it for free instead of being reimplemented. t/20 pins both.

POST /pam/whoami        Authorization: Bearer <server token>        {}

{ "server_id": "9f86d081...", "bastion_id": "9f86d081...",
  "client_id": "pam-access", "server_group": "bastion" }

server_id is canonical (the audit trail's vocabulary). bastion_id is the same value under the name the probe used, so ob-bastion-id only has to change its URL. server_group appears only when pamAccessServerGroups maps the client_id — never the legacy body-declared fallback: a server asking who it is must not be told back what it claimed.

POST rather than GET because every sibling endpoint is POST and GET /pam/* is already claimed by the catch-all that redirects browsers to the portal.

Notes

  • The per-device id needs oidcRPMetaDataOptionsDeviceOwnership = organization. Without it there is nothing per-device to report and the answer falls back to the client_id — which an allowlist must not be keyed on, since every machine of the project shares it. Documented in the README.
  • Stacked on feat(pam-access): verify X-Signature-256 / X-Timestamp / X-Nonce #93 to keep out of the CHANGELOG conflicts; say the word and I will rebase it onto main if you would rather merge it early.
  • Still to do on the open-bastion side: point ob-bastion-id at the new URL, and fix tests/test_integration_docker.sh, which tolerates a 403 but not a 404.

29 assertions in t/20; the four affected suites pass.

https://claude.ai/code/session_01GfBG36HfzjGy8W9rJQbxBL

@guimard
guimard force-pushed the feat/pam-request-signing branch from 504b099 to 1d5079f Compare September 6, 2026 15:38
Base automatically changed from feat/pam-request-signing to main September 6, 2026 16:00
Removing /pam/bastion-token (#57) took its `probe: true` mode with it, and
that mode was the only way for an enrolled server to learn the per-device id
the portal assigned it. The id is not cosmetic: it is the `bastion=<id>`
written into every hop certificate's key-id, which a backend's
AuthorizedPrincipalsCommand matches against /etc/open-bastion/allowed_bastions.
open-bastion's ob-bastion-id reads it and both deployment paths feed it into
that allowlist, so an operator adding a backend has to be able to read it.

Nothing else served the question. Verified, and now pinned as tripwires in
t/20 so the claim cannot rot: /pam/authorize returns no caller identity,
/pam/heartbeat returns none either, and /oauth2/introspect answers
active/aud/exp/client_id/scope/nbf/iat/sub/iss/token_type — it does not
export private session keys, and _deviceId is one.

This restores the one part of the probe worth keeping and nothing else: no
JWT is signed (the probe signed one even when the user lookup had failed,
which is why the endpoint went), no session is written, and _pamSeen is left
alone so running it does not look like a heartbeat. It goes through the
standard caller gate, so pamAccessAllowedRps (#50) and request signing (#81)
apply to it for free rather than needing to be reimplemented.

Response carries server_id (canonical, the audit trail's vocabulary),
bastion_id as the same value under the name the probe used so a client only
changes its URL, client_id, and server_group when pamAccessServerGroups maps
it — never the legacy body-declared fallback: a server asking who it is must
not be told back what it claimed.

POST, not GET: every sibling endpoint is POST, and GET /pam/* is already
claimed by the catch-all that redirects browsers to the portal.

Claude-Session: https://claude.ai/code/session_01GfBG36HfzjGy8W9rJQbxBL
… loses

Two follow-ups from review, neither changing behaviour beyond the log level.

Reading a server's identity is a rare operator action -- bringing a backend
up, filling an allowed_bastions list -- and the probe mode this replaces
logged it at info. debug hid it at production log levels, so the trail of who
asked for their id and when was lost. Back to info.

And the migration note claimed the response was compatible while two fields
quietly went away: `probe: true`, and a `server_group` that the probe returned
unconditionally where /pam/whoami returns it only when pamAccessServerGroups
maps the caller. Checked the consumer: scripts/ob-bastion-id reads
`.bastion_id` alone, so nothing breaks -- the two fields surface only in its
--verbose dump of the raw body. Say so rather than let "the bastion_id field
is kept" imply the rest was kept too.

Claude-Session: https://claude.ai/code/session_01GfBG36HfzjGy8W9rJQbxBL
@guimard
guimard merged commit eb3f683 into main Sep 6, 2026
@guimard
guimard deleted the feat/pam-whoami branch September 6, 2026 16:20
guimard added a commit that referenced this pull request Sep 6, 2026
The first version tried 3 times over 6s and still lost a job to a 403 that
outlasted it (oidc-global-scopes @ v2.23.2 on PR #94). Five attempts, 3s/6s/
12s/24s apart, gives ~45s of patience -- sized for an outage measured in tens
of seconds rather than a single dropped packet.

The retry history was also invisible on the failing path: `log` is only
rendered when prepare succeeds, so the job showed one 403 and no evidence that
anything had been retried. I read that job and concluded the retry had not run
at all; it had. Fold the attempts into the error message so the next reader
does not make the same mistake.

Verified with a fake git: five persistent 403s take ~45s and list every
attempt in the error; a 403 that clears on the third try logs the two
failures, says "clone succeeded on attempt 3/5" and completes a real clone.

Claude-Session: https://claude.ai/code/session_01GfBG36HfzjGy8W9rJQbxBL
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