Skip to content

fix: correctness and fail-closed gaps - #63

Open
araujof wants to merge 20 commits into
praxis-proxy:mainfrom
araujof:feat/improvements
Open

fix: correctness and fail-closed gaps#63
araujof wants to merge 20 commits into
praxis-proxy:mainfrom
araujof:feat/improvements

Conversation

@araujof

@araujof araujof commented Sep 2, 2026

Copy link
Copy Markdown
Member

Summary

This fixes correctness and fail-closed behavior across policy evaluation, credential handling, runtime merging, field transforms, configuration, and orchestration. It carries forward the parts of #54 that still apply after #55 and #56, with regression coverage for each fix.

Changes

  • CEL preserves whole-valued floats, allowing expressions such as confidence * 100.0 to evaluate correctly.
  • OPA rejects inline modules whose package falls within a globally read data subtree.
  • identity-jwt redacts HMAC secrets from Debug output and strips Bearer case-insensitively, as required by RFC 9110.
  • Valkey reports a timed-out TTL refresh as a failure so the refresh alarm fires.
  • Delegation chain validation includes authorization_details, ttl_seconds, and timestamp, preventing an existing hop from being widened.
  • The sequential executor halts when a plugin blocks without attaching a violation, matching the concurrent path.
  • Runtime extension tracking includes http and custom, preserving changes such as header rewrites during route merging.
  • AttenuationConfig rejects unknown keys so misspelled constraints cannot produce broader tokens.
  • Raw delegated credentials now round-trip through JSON even though delegated_tokens uses a structured key.
  • Security-label merging no longer discards valid edits from plugins that can append labels but cannot read them. Capability enforcement remains in labels_ok.
  • A panic in a parallel branch now fails closed instead of dropping that branch's halt and taints.
  • Configuration validation rejects unknown authentication step names.
  • Field pipelines fan out across arrays and support numeric path segments, so paths such as result.rows.ssn | redact update every matching element. Oversized structures fail closed rather than being partially transformed.
  • Configuration validation rejects multiple elicitation steps in one phase, preventing later steps from reusing an earlier step's ID and verdict.

Behavior changes

Two changes deserve closer review:

  • A panic in a parallel branch now makes the overall result fail closed. This reverses the previous behavior of omitting the failed branch's result.
  • The OPA collision check now covers package subtrees, so an inline module cannot live beneath a package read globally by the policy.

Scope

Changes from #54 that are already covered on main are not included: parser shorthand support from #55, RouteCacheKey tags made obsolete by route resolution changes, and the concurrent-path synthesized denial. YAML duplicate keys are already rejected by the current parser, so that case only adds a regression test.

shaneutt and others added 20 commits September 1, 2026 14:32
Narrowing a whole-valued double to an int broke float arithmetic: a
confidence of exactly 1.0 became int 1, so `confidence * 100.0` failed
with "no such overload" and denied the maximum-confidence case while
allowing lower ones. CEL already compares an int literal against a
double operand, so the narrowing bought nothing.

Ported from praxis-proxy#54.

Signed-off-by: Shane Utt <shaneutt@linux.com>
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
The collision check was exact-match, so an inline `package authz.exceptions`
slipped past a global `package authz` and still fed the `data.authz.*`
subtree that the global rule reads. The check is now prefix-aware on a path
boundary, so `data.authz` still does not collide with `data.authznext`.

Ported from praxis-proxy#54.

Signed-off-by: Shane Utt <shaneutt@linux.com>
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
The derived Debug reached `DecodingKeySource::Secret`, so a `{:?}` of the
resolver printed HMAC secrets in plaintext. For HS* those are signing keys,
which is token-forgery material. KeyStore and TrustedIssuer already redact
theirs; the resolver now matches.

The auth scheme is also case-insensitive per RFC 9110, so a `bearer <token>`
header no longer falls through to the parser as a malformed token.

Ported from praxis-proxy#54.

Signed-off-by: Shane Utt <shaneutt@linux.com>
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
A timeout mapped to Ok(false), so the refresh-failed alarm never fired for
the most likely failure under an overloaded backend. A persistently timing
out EXPIRE would silently stop the sliding TTL and let session taint expire
mid-session with no signal.

Ported from praxis-proxy#54.

Signed-off-by: Shane Utt <shaneutt@linux.com>
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
The append-only check ignored authorization_details, ttl_seconds, and
timestamp, so an existing hop could have its RFC 9396 grant rewritten wider
or its lifetime extended and still pass as an append. DelegationHop already
documents that each hop's details must be structurally narrowed.

Ported from praxis-proxy#54.

Signed-off-by: Shane Utt <shaneutt@linux.com>
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
…tion

The sequential phase halted only when a violation was present, so a blocking
plugin that set continue_processing=false without one fell through to the
modification path. The concurrent phase already synthesizes concurrent_deny
for the same case.

Ported from praxis-proxy#54.

Signed-off-by: Shane Utt <shaneutt@linux.com>
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
The change gate compared only security, delegation, and raw_credentials, so
a route whose sole mutation was a header rewrite or a custom-extension write
reported unchanged and the edit was dropped before the merge.

Ported from praxis-proxy#54.

Signed-off-by: Shane Utt <shaneutt@linux.com>
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
AttenuationConfig accepted unknown fields silently, so a typo in an
attenuation: block dropped that constraint and minted a broader token than
the route author wrote. The invoker comment claiming unknown keys flow
through to the plugin was wrong and is corrected.

Ported from praxis-proxy#54.

Signed-off-by: Shane Utt <shaneutt@linux.com>
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
DelegationKey is a struct, so the delegated_tokens map could not round trip
through serde_json. Any serialization carrying a minted token failed.

Ported from praxis-proxy#54.

Signed-off-by: Shane Utt <shaneutt@linux.com>
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
A plugin holding append_labels but not read_labels sees an empty filtered
label set, so its returned set is never a superset of canonical and the
whole edit was discarded, silently disabling write-only tainting and DLP
plugins. Monotonicity is already structural here because the returned labels
are folded in rather than assigned, and the laundering check that needs
capability context lives in the executor's labels_ok.

Ported from praxis-proxy#54.

Signed-off-by: Shane Utt <shaneutt@linux.com>
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
A panicking branch contributed no taints and no Halt, so a branch that would
have denied was silently dropped and the parallel block continued. In a
policy engine that is a fail-open, so a panic now halts the block. This
reverses the earlier choice to keep sibling branches running, and drops the
comment that claimed the panic was logged when the crate has no tracing dep.

Ported from praxis-proxy#54.

Signed-off-by: Shane Utt <shaneutt@linux.com>
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
RouteIdentityStep documents that a step name must match a top-level plugins:
entry registered under identity.resolve, but nothing enforced it. An
unresolvable name found no entry at dispatch and was dropped with no error,
so the route ran with that authentication step missing. Global, group, and
route authentication share one shape, so one check covers all three.

Reworked from praxis-proxy#54 for the renamed authentication and bundles fields.

Co-authored-by: Shane Utt <shaneutt@linux.com>
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
A `result:` naming the same field twice must not keep one pipeline silently,
since dropping a redact for a passthrough leaks the field. Every route
reaches RouteYaml through a serde_yaml::Value and serde_yaml rejects a
repeated key there, so the property already holds; praxis-proxy#54 proposed a custom
deserializer for it, which the current parse path makes unnecessary. Pinned
because a refactor deserializing RouteYaml from a string would be last-wins.

Co-authored-by: Shane Utt <shaneutt@linux.com>
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
`result.rows.ssn | redact` silently redacted nothing when `rows` was an
array: the dotted helpers required every segment to be an object, so the
walk fell off at the array and the pipeline never ran. A rule that reads as
redacting every row's SSN passed them all through, which is a PII fail-open.

Paths now expand to one concrete path per array element before the pipeline
runs, and the dotted read, write, and remove helpers follow numeric segments
so the expansion resolves. A terminal array is still one path, so whole-value
pipelines keep their semantics. Expansion is bounded in depth and width, and
an over-large shape denies rather than half-redacting the head and passing
the tail through. The same fan-out applies to `do:`-embedded field ops.

Reworked from praxis-proxy#54 against the current route and evaluator shape.

Co-authored-by: Shane Utt <shaneutt@linux.com>
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
The elicitation id is a single flat bag key and a present id means "already
dispatched, poll it", so two elicit steps in one phase collide inside a single
request: the first dispatches and writes the key, the second reads it, skips
its own dispatch, and adopts the first's verdict. A `require_approval` written
after a `confirm` never reaches an approver, and which step wins is only
evaluation order.

The config expresses two independent gates the engine can enforce as one, so
it is rejected at load. The parser checks each block and the visitor rechecks
the fully-stacked route, which is where a global or group elicit combined with
a route one first becomes visible.

Reworked from praxis-proxy#54 for the renamed phase fields.

Co-authored-by: Shane Utt <shaneutt@linux.com>
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
Drop em dashes and trim the wordier doc blocks across the ported commits.
Comment-only, except for one real fix: `get_dotted` is public and its doc
linked `expand_field_paths`, which is `pub(crate)`, so rustdoc under
`-D warnings` failed. That link is now plain code formatting.

Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
Two fixtures named an `authentication:` step whose plugin was absent from
`plugins:`, which the new load-time check refuses. Both were under-specified
rather than wrong to reject: engine.rs resolves an authentication step by
finding a matching entry and drops it with no else branch, so such a route
would resolve no identity plugin and authenticate with nothing.

delegation_identity_warning also needed an inert factory, since a declared
plugin's `kind:` must resolve to one.

Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
Missed running fmt after the previous commit. No behavior change.

Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
Comment-only: no code or doctest lines change.
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
Name the fields `chain_extends` compares, since the bug it fixed was three of
them missing and an unnamed set invites dropping one again. Restore the
`DeclassifierToken` requirement and a line on each label test saying what
regressed, so neither reads as redundant later.

Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
@araujof
araujof requested a review from terylt as a code owner September 2, 2026 02:02
@araujof araujof changed the title fix: correctness and fail-closed gaps carried forward from #54 fix: correctness and fail-closed gaps Sep 2, 2026
@araujof araujof moved this from Backlog to Review in Praxis Policy Engine (PPE) Sep 2, 2026
@araujof araujof added this to the 0.1.1 milestone Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Review

Development

Successfully merging this pull request may close these issues.

3 participants