Skip to content

feat: allow header assertions from secrets - #174

Merged
araujof merged 6 commits into
praxis-proxy:mainfrom
terylt:feat/assertion-secret-source
Oct 6, 2026
Merged

araujof merged 6 commits into
praxis-proxy:mainfrom
terylt:feat/assertion-secret-source

Conversation

@terylt

@terylt terylt commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

This adds secret.<name> as an assertion source, so the engine renders the header from a value declared under secrets.values.

Closes: #93

secrets:
  providers:
    local: { kind: file, base_dir: /etc/ppe }
  values:
    legacy_api_key: { provider: local, ref: legacy.key }

global:
  assertions:
    request:
      headers:
        - name: X-API-Key
          from: secret.legacy_api_key

Rules

  • The name must resolve to a secrets.values entry at config load. An unknown name fails startup.
  • Request direction only. The direction is about the audience rather than about where a value comes from: a request entry asserts toward the upstream, which is the party the credential authenticates to, while a response entry asserts toward the client. This is why claim.tenant is a legitimate response source and a secret is not, even though the engine originates both.
  • The read is synchronous from the value resolved at startup, and it goes through the store at the point of use. Nothing fetches on the request path, and a rotation reaches the wire on the next request after a refresh.
  • on_missing applies as it does to any other source, and a denial names secret.<name> rather than what it held.

Containment

The value reaches the asserted header and nothing else: not a PDP, not a plugin payload, not a deny message, and not the effective policy artifact, which prints the name only.

Two separate mechanisms hold that up:

  • A pipeline plugin runs before the contract injects, so it sees the client's headers. Ordering.
  • The audit emit runs after injection, so it is the one place the value could reach a record. The request header map a sink is handed carries <redacted secret.<name>> in place of a secret-sourced entry. This holds on the response pass too, because the request header map travels with the exchange and a response contract has no secret of its own to rediscover it from. Only secret-sourced request entries are touched: the set comes from the resolved contract rather than from header names, so an asserted identity value, the client's own headers and every response header reach a sink unchanged, and the upstream still receives the credential.

A sink's on_effect view is clean for the first reason rather than the second, since an effect dispatches from inside perform_effect while the pipeline is still running. There is a test pinning that, because it is ordering and not enforcement, and a change that moved effect dispatch later would leak without it.

Breaking changes

Rust callers of praxis_policy_core::assertions only. A host reaching these through the praxis-policy facade is unaffected, since the facade does not re-export assertions.

  • SourcePath::capability returns Option<Capability> instead of Capability. A secret answers None, because no plugin-facing read of a declared secret exists for a capability to gate; naming one would tell an operator that some grant reaches the value. The artifact prints "no plugin capability reads this" instead.
  • SourcePath::resolve and assertions::render each take the resolved SecretStore, where None is a host that resolved none. AssertionsConfig::validate takes the document's SecretsConfig, which is what makes an undeclared name a load error.
  • SourcePath gains a Secret(String) variant and is now #[non_exhaustive], so an out-of-crate match needs a _ arm. Marking it in the same release that already changes the enum means the next source added is not a breaking change.

Deliberate omissions

  • No capability gates this slot. feat(secrets): add a SecretProvider trait, a named registry, and env and file backends #92 declined a blanket read_secrets that would have to be broken later, and a per-secret grant wants a consumer to scope it against. capability() returning Option is the shape that admits one later without a further break.
  • A secret is accepted under members: as well as from:, rendering inside the JSON object. The issue shows only from:. Chosen for uniformity, since there is no security difference. render.rs has a test pinning it, so refusing it is cheap if a reviewer prefers that. A members: entry mixing a secret with other sources has its whole value redacted in a record, because redacting one member would mean rendering the object twice.

Verification

No live backend is involved. The file provider is what makes rotation testable without a network, so a rotation here is the file being rewritten and a refresh driven through the store.

The acceptance criteria are covered one for one in crates/ppe-core/tests/assertions_e2e.rs: injection onto the upstream request, an unknown name refused at load, a response-direction entry refused, rotation after a refresh, redaction in the artifact, and the value reaching the asserted header and nothing else. Four more cover the audit seam: the sink view, that only the secret entry is redacted, that the upstream still receives the value after the emit, and the effect view.

make lint, make test (both feature passes) and make doc pass. make lint-extra fails on items that predate this branch and are untouched by it.

Summary by CodeRabbit

  • New Features
    • Request assertion headers can use declared secrets with secret.<name>, including refreshed values on subsequent requests.
    • Secret-backed header values are redacted in audit and plugin views and excluded from effective-policy output.
  • Bug Fixes
    • Configuration loading rejects undeclared secret references and secret sources in response assertions.
    • Secret-backed credentials are preserved across hooks and header updates, including changes in header casing.
  • Documentation
    • Added guidance on secret-backed request headers, refresh behavior, redaction, and missing secrets.

terylt added 2 commits October 5, 2026 15:50
Signed-off-by: Teryl Taylor <terylt@ibm.com>
@terylt
terylt requested a review from a team October 6, 2026 15:00
@terylt
terylt requested a review from araujof as a code owner October 6, 2026 15:00
@terylt terylt changed the title Feat/assertion secret source feat: allow header assertions from secrets Oct 6, 2026
@coderabbitai

This comment was marked as resolved.

Signed-off-by: Teryl Taylor <terylt@ibm.com>
coderabbitai[bot]

This comment was marked as resolved.

@araujof araujof self-assigned this Oct 6, 2026
@araujof araujof added this to the 0.5.0 milestone Oct 6, 2026
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 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.

Approved.

  • Keep injected secrets out of plugin, PDP, and audit views across hooks.
  • Preserve redaction through config reloads and header case changes.
  • Preserve upstream credentials when plugins modify other headers.
  • Add regression tests and correct the facade compatibility notes.

Merged the latest main. All GitHub Actions checks are green.

@araujof
araujof merged commit dbcc726 into praxis-proxy:main Oct 6, 2026
9 of 10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

feat(assertions): add a secret source for downstream header injection

2 participants