Repository navigation
feat(identity): resolve an API key against a directory of records - #125
Conversation
The claim map compiler and the mapper it drives are not JWT specific. They take a JSON object and fill the typed identity slots, which is what an API key directory record needs too. Moving them to core lets a second resolver reuse the compiled paths, the candidate chains, and the merge and split rules instead of growing a parallel implementation that drifts. What moved, unchanged: claim_path.rs, claim_map_config.rs and configured_mapper.rs, plus the ClaimMapper trait and the SPIFFE helpers. What stayed: StandardClaimMap and the Keycloak, Auth0 and Cognito presets, which are the parts that know OIDC. The JWT plugin re-exports every moved type at its crate root, so a host naming them through that crate is unaffected. No operator config changes: the YAML key is each plugin's own field name, so claim_map: still reads claim_map:. The two test modules that capture tracing output now share one helper. Each installed its own global default subscriber, which was fine while they were in separate binaries and is not once both are in core's: the global default is a single slot, so the loser captured nothing. Signed-off-by: Teryl Taylor <terylt@ibm.com>
Two JWT specifics were constants inside the mapper. Sharing the mapper means sharing them, and both are wrong for a credential that is not a JWT. The claims bag drops the registered JWT claims so token validation properties do not reach a policy as subject attributes. A directory record naming a field `exp` or `iss` means its own thing by it, and dropping that loses an attribute silently, with no diagnostic. `with_reserved_names` takes the list, and an empty one keeps every field the map did not consume. A mapped workload identity recorded `attestor: jwt` whatever established it. A policy gating on how an identity was proven would read that an API key was a JWT. `with_attestor` names the credential the resolver verified. `new` keeps both JWT defaults, so the JWT plugin is unchanged. Signed-off-by: Teryl Taylor <terylt@ibm.com>
Signed-off-by: Teryl Taylor <terylt@ibm.com>
…ke MAAS api-key mapping Signed-off-by: Teryl Taylor <terylt@ibm.com>
Signed-off-by: Teryl Taylor <terylt@ibm.com>
…-resolver Signed-off-by: Teryl Taylor <terylt@ibm.com> # Conflicts: # Cargo.toml # crates/ppe-core/src/lib.rs
58eae83 to
1dec493
Compare
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
There was a problem hiding this comment.
Reviewed after merging the latest main. The branch now denies when every API key population declines while preserving multi-population resolution, rejects unsupported file expiry delegation and unsafe resolver execution settings, rejects misplaced credential fields, redacts malformed hash values from configuration errors, and documents the feature in the changelog.
Verification: make ci passed, including both workspace test passes; all API key plugin tests passed.
| // A directory lookup is a network round trip and an unknown credential costs | ||
| // the same as a known one. Without a negative entry, a caller working through | ||
| // guesses turns PPE into a load generator pointed at the key service, one | ||
| // request per guess, indefinitely. The negative TTL puts a ceiling on that. |
There was a problem hiding this comment.
Negative caching only absorbs retries for the same unknown or revoked key; distinct guesses still reach the directory. We should state that guessing floods require rate limiting before the resolver, and that negative_ttl_secs disables negative caching by default rather than being “short by default.”
| // answer. That is upstream's posture to hold, not ours, but it is the reason | ||
| // negative caching is a requirement here rather than a nicety: without it a | ||
| // guessing flood against PPE becomes the same flood against the directory. | ||
| // The cache is not in this slice. |
There was a problem hiding this comment.
We should point to the cache: block rather than saying misses aren’t cached, and retain the property that follows “tier 3.” Also remove the “Slice B” work-state note, replace the reference to the unavailable design document, and describe the #96 relationship without its outdated review status.
| /// A URL that is not absolute HTTP(S), an empty field name, or a zero | ||
| /// timeout, which would fail every lookup before it left the process. | ||
| pub fn validate(&self) -> Result<(), String> { | ||
| if !(self.url.starts_with("http://") || self.url.starts_with("https://")) { |
There was a problem hiding this comment.
Please require an explicit insecure_http: true opt-in for http:// directory URLs, consistent with other credential-bearing builtins.
| /// | ||
| /// Two phases, named differently on purpose. | ||
| /// | ||
| /// **Extraction** codes are shared with the JWT resolver, spelled as PR #96 |
There was a problem hiding this comment.
The JWT resolver for header credentials, missing or empty values currently produce auth.malformed_header, so the documented auth.missing_credential runbook won’t fire for JWT routes. Either use auth.malformed_header here too, or remove the claims that codes and runbooks are shared.
| # workspace's default-members. | ||
|
|
||
| [package] | ||
| name = "praxis-policy-plugin-identity-api-key" |
There was a problem hiding this comment.
Please add praxis-policy-plugin-identity-api-key to tools/publish.sh’s ORDER, after praxis-policy-core and before praxis-policy, alongside praxis-policy-plugin-identity-jwt. Otherwise the publish guard will fail after the release tag is pushed. Verify with tools/publish.sh --dry-run.
| # One feature per builtin crate. The names are the public API: a host writes | ||
| # these, so they are deliberately shorter than the crate names they pull. | ||
| jwt = ["_builtin", "dep:praxis-policy-plugin-identity-jwt"] | ||
| api-key = ["_builtin", "dep:praxis-policy-plugin-identity-api-key"] |
There was a problem hiding this comment.
Please update all builtin inventories to include identity/api-key: docs/content/builtins.md, docs/content/crates.md, README.md, AGENTS.md, and .claude/CLAUDE.md, including counts and feature tables. Add user-facing docs for its config, denial codes, and revocation windows, ideally with a checked YAML example in docs/content/apl/identity.md.
|
I'm a bit late, but check these out for next iteration |
…axis-proxy#125) * refactor(core): move the identity field mapper out of the JWT plugin The claim map compiler and the mapper it drives are not JWT specific. They take a JSON object and fill the typed identity slots, which is what an API key directory record needs too. Moving them to core lets a second resolver reuse the compiled paths, the candidate chains, and the merge and split rules instead of growing a parallel implementation that drifts. What moved, unchanged: claim_path.rs, claim_map_config.rs and configured_mapper.rs, plus the ClaimMapper trait and the SPIFFE helpers. What stayed: StandardClaimMap and the Keycloak, Auth0 and Cognito presets, which are the parts that know OIDC. The JWT plugin re-exports every moved type at its crate root, so a host naming them through that crate is unaffected. No operator config changes: the YAML key is each plugin's own field name, so claim_map: still reads claim_map:. The two test modules that capture tracing output now share one helper. Each installed its own global default subscriber, which was fine while they were in separate binaries and is not once both are in core's: the global default is a single slot, so the loser captured nothing. Signed-off-by: Teryl Taylor <terylt@ibm.com> * refactor(core): let a resolver name its own reserved fields and attestor Two JWT specifics were constants inside the mapper. Sharing the mapper means sharing them, and both are wrong for a credential that is not a JWT. The claims bag drops the registered JWT claims so token validation properties do not reach a policy as subject attributes. A directory record naming a field `exp` or `iss` means its own thing by it, and dropping that loses an attribute silently, with no diagnostic. `with_reserved_names` takes the list, and an empty one keeps every field the map did not consume. A mapped workload identity recorded `attestor: jwt` whatever established it. A policy gating on how an identity was proven would read that an API key was a JWT. `with_attestor` names the credential the resolver verified. `new` keeps both JWT defaults, so the JWT plugin is unchanged. Signed-off-by: Teryl Taylor <terylt@ibm.com> * feat(identity): resolve an API key against a directory of records Signed-off-by: Teryl Taylor <terylt@ibm.com> * feat: added implementation of the HTTP directory to support things like MAAS api-key mapping Signed-off-by: Teryl Taylor <terylt@ibm.com> * feat: added directory caching and update test coverage Signed-off-by: Teryl Taylor <terylt@ibm.com> * fix(identity): close API key validation gaps Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com> * docs: note API key resolver in changelog Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com> --------- Signed-off-by: Teryl Taylor <terylt@ibm.com> Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com> Co-authored-by: Frederico Araujo <frederico.araujo@ibm.com>
Summary
An API key is the opposite of a JWT. A JWT carries signed claims, so verifying one is local and stateless. An API key is an opaque handle that carries nothing, so authenticating one is a lookup: find the record this credential keys, and that record's fields become the identity.
This adds
kind: identity/api-keyon theidentity.resolvehook, with two directory backends behind one trait: a hash indexed file, and a service over HTTP written against themaas-apivalidate contract. An optional cache sits in front of either.Closes: #65
Stacked on the mapper relocation PR. The projection here is the shared compiler in
ppe-core, the same one the JWT plugin runs, so a policy readingsubject.rolescannot tell which credential produced them.Configuration
The file backend instead:
What shaped it
Most of the non-obvious decisions came from reading
maas-apirather than its prose docs, which disagree with the source in places that matter. Read 2026-09-22 atopendatahub-io/models-as-a-service.userIdis not the user.service.gofills bothUserIDandKeyIDfrommetadata.ID, the key's database row id. Two keys for one person carry two differentuserIdvalues, so a map wanting a stable subject readsusername. A test pins that the two reach the mapper distinguishably, becausesubject.id: userIdis the mistake that looks fine until somebody issues a second key.An invalid key answers HTTP 200 with
valid: false, per their design doc section 7.7. Only a malformed body is 400 and only an internal fault is 500. So the client treats a non-200 as the directory failing to answer, not as an unknown credential: those are the same denial to a caller and very different to whoever is paged.A prefix is two things.
sk-oai-is part of the key, and the record is stored under a digest of the whole string, so a prefix splits into an auth scheme, which is stripped and matched without case per RFC 7235, and a leader, which is required and kept. Stripping the leader would hash the wrong string and every credential would come back unknown. This is what Authorino does: gate on^Bearer sk-oai-.*, sendauthorization.replace("Bearer ", "").Deliberate omissions
No single-flight on the cache. When a busy key's entry expires, every concurrent lookup of it misses at once and each reaches the directory: one expiry, N requests, repeating every TTL. Coalescing them needs an in-flight map and something for waiters to park on, which means a
tokioorfuturesruntime dependency this crate does not otherwise have at runtime. That is a real cost for a benefit whose size depends on a traffic shape nobody has measured yet, so it is written down rather than built. The JWT plugin'sRefreshGatealready solves the same herd problem for JWKS and is the right prior art when this is picked up, particularly for what it does when the fetch the waiters are parked on fails.No credential or TLS configuration.
maas-apiputs no authentication on/internal/v1at all (cmd/main.go: "Internal routes (no auth required - called by Authorino / CronJob)"), so the endpoint is protected by not being routable. When a deployment does need a credential it is one PPE holds and renders outbound, which is the secret provider's job rather than an inline literal here. TLS is not configurable on purpose:HttpRequestcarries url, headers, body and timeouts, so trust roots and client certificates belong to the host that injects the transport. Aca_bundle:key here would look natural and do nothing.Header locations only. A query parameter cannot be read from a plugin today, because
IdentityPayloadcarries no URI. PR #96 addsraw_query_stringalong with a sharedCredentialenum and a parser enforcing a length limit, control character rejection and duplicate name rejection. Thecredential:block here already matches #96's tag and field names exactly, so adopting the shared type is a delete and an import with no operator visible change.Things that fail at config load rather than at runtime
A resolver that builds and then denies every request reads as an outage, and the operator is reading the wrong logs. So: a records file that is unreadable, holds a plaintext key where a digest belongs, or carries two records under one digest; a
record_mapwith no section for the configured role; a prefix that is empty or starts with a space; a staleness ceiling below the refresh interval; a zero cache TTL; andcache:combined withdirectory.kind: file, which would give two revocation windows for one property.Revocation windows are named, not emergent
Every path that can serve a stale answer says so in the configuration that controls it. The file backend's is
refresh_secs, and reloading happens on the request path rather than on a timer: a spawned ticker binds to whichever runtime calledinitialize, and a host driving async init on a short lived runtime has it cancelled before it ticks once, so rotation never happens and nothing says so. The cache's isttl_secs.max_staleness_secsbounds how long failing reloads may keep serving before lookups fail closed.Security properties, each with a test
Debug,DisplayorSerialize.raw_credentials, so nothing downstream can forward a caller's own key to an upstream that never authenticated it.hash(a credential equivalent for any hash indexed directory, andsubject.claim.<name>is a legal assertion source) and the HTTP envelope'svalidandreason.auth.directory_unavailable, distinct fromauth.key_unknown.Malformed, not a refusal. Defaulting a missing verdict tofalsewould deny a caller the directory accepted, and totruewould admit one it did not.Known gap in what was verified
Everything about
maas-apihere is read from its source. Nothing has been observed against a running deployment.tools/maas_stub.pyreproduces the contract well enough to develop and demo against, including the salted key format and the status codes, and the HTTP backend has been run against it over real hyper. A request and response pair from a live service would still be worth having, particularly to confirm the status codes and thattenantbehaves as its struct tag says.Verification
make check,make lint,make doc,make test,make auditandmake coverageall pass. 62 cases in the plugin's own harness, covering extraction, the file backend under concurrent reload, the HTTP contract, projection, expiry, caching, and several key populations on one route.