Skip to content

feat(identity): resolve an API key against a directory of records - #125

Merged
araujof merged 9 commits into
praxis-proxy:mainfrom
terylt:feat/identity-api-key-resolver
Sep 24, 2026
Merged

araujof merged 9 commits into
praxis-proxy:mainfrom
terylt:feat/identity-api-key-resolver

Conversation

@terylt

@terylt terylt commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator

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-key on the identity.resolve hook, with two directory backends behind one trait: a hash indexed file, and a service over HTTP written against the maas-api validate 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 reading subject.roles cannot tell which credential produced them.

Configuration

plugins:
  - name: maas-keys
    kind: identity/api-key
    hooks: [identity.resolve]
    config:
      credential:
        kind: header
        name: Authorization
      prefix: "Bearer sk-oai-"
      directory:
        kind: http
        url: https://maas-api.../internal/v1/api-keys/validate
      record_map:
        subject:
          id: username
          roles: groups
      claims:
        include: [subscription, tenant]
      cache:
        ttl_secs: 60
        negative_ttl_secs: 10

The file backend instead:

      directory:
        kind: file
        path: /etc/ppe/keys.yaml
        refresh_secs: 30
        max_staleness_secs: 300
keys:
  - hash: "sha256:9f86d0..."
    user: alice
    groups: [reader, writer]
    expires_at: 2027-01-01T00:00:00Z

What shaped it

Most of the non-obvious decisions came from reading maas-api rather than its prose docs, which disagree with the source in places that matter. Read 2026-09-22 at opendatahub-io/models-as-a-service.

userId is not the user. service.go fills both UserID and KeyID from metadata.ID, the key's database row id. Two keys for one person carry two different userId values, so a map wanting a stable subject reads username. A test pins that the two reach the mapper distinguishably, because subject.id: userId is 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-.*, send authorization.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 tokio or futures runtime 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's RefreshGate already 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-api puts no authentication on /internal/v1 at 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: HttpRequest carries url, headers, body and timeouts, so trust roots and client certificates belong to the host that injects the transport. A ca_bundle: key here would look natural and do nothing.

Header locations only. A query parameter cannot be read from a plugin today, because IdentityPayload carries no URI. PR #96 adds raw_query_string along with a shared Credential enum and a parser enforcing a length limit, control character rejection and duplicate name rejection. The credential: 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_map with 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; and cache: combined with directory.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 called initialize, 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 is ttl_secs. max_staleness_secs bounds how long failing reloads may keep serving before lookups fail closed.

Security properties, each with a test

  • The presented credential is held as octets, not text, so case folding or trimming it cannot compile. It has no Debug, Display or Serialize.
  • It never reaches raw_credentials, so nothing downstream can forward a caller's own key to an upstream that never authenticated it.
  • The cache is keyed by digest, never by the credential, so no live credential sits in a map for the entry's lifetime.
  • A backend's storage fields never reach the record: the file backend's hash (a credential equivalent for any hash indexed directory, and subject.claim.<name> is a legal assertion source) and the HTTP envelope's valid and reason.
  • A directory failure denies as auth.directory_unavailable, distinct from auth.key_unknown.
  • A response the HTTP backend cannot read is Malformed, not a refusal. Defaulting a missing verdict to false would deny a caller the directory accepted, and to true would admit one it did not.

Known gap in what was verified

Everything about maas-api here is read from its source. Nothing has been observed against a running deployment. tools/maas_stub.py reproduces 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 that tenant behaves as its struct tag says.

Verification

make check, make lint, make doc, make test, make audit and make coverage all 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.

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
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>
@araujof araujof self-assigned this Sep 24, 2026

@araujof araujof left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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://")) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread crates/ppe/Cargo.toml
# 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"]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@shaneutt

shaneutt commented Oct 2, 2026

Copy link
Copy Markdown
Member

I'm a bit late, but check these out for next iteration

mkoushni pushed a commit to mkoushni/policy that referenced this pull request Oct 6, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/security enhancement New feature or request

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

feat(identity-api-key): add an API key identity plugin

3 participants