Repository navigation
Add a Teleport-authenticated human token path for one exact repository - #73
portswigger-amason wants to merge 1 commit into
Conversation
…ository
Lets a person authenticated by Teleport, holding an approved access request,
obtain a GitHub App installation token for one exact repository. The existing
workload paths are unchanged.
The human path is separate code, not a mode on the workload ones. A request that
does not match exactly one `[[human-policy]]` is refused rather than retried
against `allowed-roles`, there are no repository wildcards, `permissions` is
required, and `--disable-auth` does not apply.
Validation lives in idcat rather than authzoo, because authzoo cannot express
what a Teleport token needs and `RoleConfig` denies unknown fields. Checked
again against 0.1.5, which changes only its default algorithm list and two log
levels, so all four gaps remain:
* `ValidatedClaims::claim_value` ends in `.and_then(Value::as_str)`, so an
array-valued `roles` claim reads back as absent. Membership is now tested
against the array by exact string equality; a scalar claim of the same name
is refused rather than accepted as a single-element array, and a role that
merely contains the required name does not match.
* Key discovery is only ever `GET {issuer}/.well-known/openid-configuration`.
`[[human-role]]` takes an explicit `jwks-url`, which must end in
/.well-known/jwks.json.
* `validate` returns `Vec<String>` of role names, discarding `sub`. The human
validator returns the claims, so an issuance record can name the person.
* authzoo validates once per configured role, and idcat's
`validate_role_with_claims` builds a fresh validator per candidate policy.
The human path selects one issuer from configuration and parses once,
against a cached JWKS.
The audience is the Teleport application uri, which is not the human-facing
hostname; tests pin both directions. Only asymmetric algorithms parse for a
human role, since a shared secret able to verify such a token could mint one.
The installation-token cache is now keyed by requester. Workload callers keep
sharing an entry; a person's entry is partitioned by validated subject and
access-request id, so two people never receive the same token and a fresh
approval does not revive the token issued under the previous one. A human
request always uses an `InstallationTarget::Repository`; the human path has no
owner-wide equivalent and must never acquire one.
Each allow and deny is recorded at info with subject, Teleport request, App,
exact repository, exact permissions, expiry and a truncated token digest. A
test captures the emitted line and asserts the token is absent from it.
The credential helper replaces `/bin/sh -c` with a fixed executable and an
argument array. `token-command` still works and warns, per the chosen
backwards-compatible migration. A new `repository` setting scopes the helper to
one repository; for anything else it prints nothing, so Git receives no
credential from it and falls through.
`examples/human_route_load_test.rs` is the bounded invalid-token load test. It
requires the owner-supplied ceiling, duration, permitted latency change and
environment, and refuses to run without them or against a production-looking
target. It is built and not run: those values have not been supplied.
Startup validation also now accepts a deployment configuring only
`[[human-role]]`, and a GitHub App authorized only by a human-policy. The guard
that every App must authorize something now names all four policy kinds.
docs/human-token-pilot.md no longer claims `DELETE /installation/token` is a
dependable emergency stop. idcat opts into GitHub's stateless (JWT-format)
installation tokens, which are verified from their own signature, so whether
revocation or installation suspension affects an already-issued token is now
something to confirm rather than assume. The one-hour expiry is what the
timebox actually rests on, and that is unaffected.
Deployment inputs are deliberately absent: no repository, role, App, issuer,
application uri, JWKS URL or hostname is invented. The doc lists what must be
supplied, and states plainly that the timebox stops new sessions and expires
tokens but does not remove already-cloned source.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Closing this in favour of a much smaller approach — thank you for the steer, it was the right call and I should have asked before writing this much. The point I'd missed: Teleport's own application access control already performs the role check. If only the owning team's role grants access to the idcat application, then a valid JWT carrying that That removes three of the four reasons this existed:
Existing Four questions before I draft that config. Concrete case: the research team getting 1. One Teleport app per repository, or one shared app? If per-repo, each gets its own [[role]]
name = "teleport-desktop"
issuer = "<cluster>"
audience = "https://idcat-desktop.<cluster>/"
validation-key = "<CA public key>"
[[installation-policy]]
github-app = "<dedicated app>"
role = "teleport-desktop"
repository = "portswigger-engineering/desktop"
[installation-policy.permissions]
contents = "read"If one shared app, nothing in the token distinguishes research-wanting-desktop from another team wanting another repo, and the repository is just a URL path — so any user who can reach the app could request any configured repository. 2. What goes in
3. Should idcat independently check the group, or is Teleport app gating enough? Gating alone means a Teleport misconfiguration — granting the app to an additional role — silently widens access to source, and idcat has no way to notice. A second independent check in idcat would catch that, but it needs Worth it for Restricted source, or over-engineering? 4. Two things from this PR may be worth keeping regardless, though neither is needed for a single-repository pilot and I'm not proposing them now:
Both become relevant at repository number two. Happy to raise them separately then. Context in PLAT-834. |
|
Closing — superseded by the configuration-only approach described above. The branch stays if any of it is wanted later; the per-person caching and issuance-record pieces are the only parts likely to come back, and only once a second repository is in scope. |
Lets a person authenticated by Teleport, holding an approved access request, obtain a GitHub App installation token for one exact repository. The existing workload paths are unchanged.
Why this is separate code, not a mode on the existing paths
/installation-token/.../human/installation-token/...authzoo, against[[role]][[human-role]]allowed-roles,[[installation-policy]],[[owner-policy]][[human-policy]]onlyallowed-roles--disable-authA request that does not match exactly one
[[human-policy]]is refused before the token is validated and before GitHub is contacted. A workload token cannot satisfy a human policy, and a human token cannot satisfy a workload one.Why validation does not go through authzoo
authzoocannot express what a Teleport token needs, andRoleConfigisdeny_unknown_fields. Re-checked against 0.1.5 — whose entire diff from 0.1.4 is a default-algorithm list gaining ES256 and two log levels swapping — so all four gaps stand:ValidatedClaims::claim_valueends in.and_then(Value::as_str), so Teleport'srolesarray reads back as absent. Membership is now tested against the array by exact string equality. A scalar claim of the same name is refused rather than treated as a single-element array, and a role that merely contains the required name does not match.GET {issuer}/.well-known/openid-configuration.[[human-role]]takes ajwks-url, which must end in/.well-known/jwks.json.validatereturnsVec<String>, discardingsub. A person-level issuance record needs the subject.authzoovalidates once per configured role, andvalidate_role_with_claimsbuilds a fresh validator per candidate policy. The human path picks one issuer from configuration and parses once, against a cached JWKS.The audience is the Teleport application uri, which is not the human-facing hostname; tests pin both directions. Only asymmetric algorithms parse for a human role — a shared secret able to verify such a token could also mint one.
Other changes
InstallationTarget::Repository.infowith subject, Teleport request, App, exact repository, exact permissions, expiry and a truncated token digest. A test captures the emitted line and asserts the token is absent from it./bin/sh -creplaced by a fixed executable plus an argument array.token-commandstill works and warns — a backwards-compatible deprecation. A newrepositorysetting scopes the helper to one repository; for anything else it prints nothing, so Git falls through to other helpers.examples/human_route_load_test.rs) for the invalid-token path. It requires an explicit ceiling, duration, permitted latency change and environment, and refuses to run without them or against a production-looking target. Built, not run.[[human-role]], and an App authorized only by a human-policy. The "this App authorizes nothing" guard names all four policy kinds.Testing
178 tests (148 idcat + 30 helper), up from 74 before this work.
cargo fmt --check,cargo clippy --no-deps --all-targetsand all three CI matrices clean.Every token in the tests is synthetic, signed with an RSA key generated in-process (
src/testkeys.rs). No test touches the network or needs a real credential.The two properties that matter were mutation-tested rather than assumed: removing the cache partition fails 3 tests, and adding an
allowed-rolesfallback to the human path fails 2. An earlier round of this found a real gap — the fail-closed check only held for Apps with noallowed-roles— which is now covered.Not included
No real issuer, application uri, hostname, App id, role or repository: the deployment values are not in this PR and the placeholders are not real. Teleport role configuration, ingress and the App installation are separate, owner-approved changes.