feat(pam-access): add POST /pam/whoami so a server can read its own id - #94
Merged
Merged
Conversation
guimard
force-pushed
the
feat/pam-request-signing
branch
from
September 6, 2026 15:38
504b099 to
1d5079f
Compare
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
guimard
force-pushed
the
feat/pam-whoami
branch
from
September 6, 2026 16:05
68bfb27 to
929a5a8
Compare
… 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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the migration gap #86 opens:
/pam/bastion-token'sprobe: truemode 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'sAuthorizedPrincipalsCommandmatches it against/etc/open-bastion/allowed_bastions. It decides which bastion may reach which backend.open-bastion/scripts/ob-bastion-idreads 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/20now pins all three as tripwires so the claim cannot quietly rot:/pam/authorizereturns no caller identity./pam/heartbeatreturns none either./oauth2/introspectanswersactive, aud, exp, client_id, scope, nbf, iat, sub, iss, token_type— it does not export private session keys, and_deviceIdis one.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
_pamSeenalone 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_pamLastSeenand mints an access token, and it needs the refresh token, whichob-bastion-iddoes 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/20pins both.server_idis canonical (the audit trail's vocabulary).bastion_idis the same value under the name the probe used, soob-bastion-idonly has to change its URL.server_groupappears only whenpamAccessServerGroupsmaps theclient_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
oidcRPMetaDataOptionsDeviceOwnership = organization. Without it there is nothing per-device to report and the answer falls back to theclient_id— which an allowlist must not be keyed on, since every machine of the project shares it. Documented in the README.mainif you would rather merge it early.ob-bastion-idat the new URL, and fixtests/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