Skip to content

security: isolate JWT fact-backed tenants (red-steel S3c) - #745

Merged
CueCrux-Myles merged 4 commits into
mainfrom
redsteel/s3c-jwt-fact-tenants
Aug 22, 2026
Merged

CueCrux-Myles merged 4 commits into
mainfrom
redsteel/s3c-jwt-fact-tenants

Conversation

@CueCrux-Myles

Copy link
Copy Markdown
Contributor

Continues the red-steel replay (#630) per redsteel-remediation-replay-2026-08-07. Replays 426ea8a0 onto current main.

The measurement matters more than the diff

The plan estimated 9 conflicts for this slice. Replayed in branch order after #686 landed, the actual count is 2 — both in generated files (CHANGELOG.md, llms-full.txt), zero code conflicts. The remaining slices should be re-measured before scheduling rather than costed off the 2026-08-07 figures.

Three commits, deliberately separate

1. 426ea8a0 cherry-picked. JWT HTTP fact surfaces default to verified tenant stamping; the posture is frozen at startup, ambiguous authority fails closed, and paired reads/caches/receipts/overlays/candidates/replay/result-envelopes stay on one authorized tenant.

The CHANGELOG resolution kept only 426ea8a0's own entry. The three-way merge pulled in adjacent text belonging to c093c006 (S5, not landed), which would have published a claim that coordination and punchcard binding had shipped. llms-full.txt was regenerated with scripts/build-llms-full.sh, never hand-merged.

2. Migrating main's call sites. It applies cleanly but does not compile: main has grown call sites and tests against the weaker API this commit replaces. Each migration is noted at the site.

  • escrow.rs — this route landed after the branch was written, so it never learned that tenant_hash_for_read_context is fail-closed; it now propagates the problem response like every other fact-backed route.
  • replay.rsstore_answer_capsule takes an explicit tenant instead of hardcoding "default"; the five test call sites pass "default", the value it used to bake in, so their behaviour is unchanged.

Tests that asserted the old contract, migrated rather than deleted:

Was Now pins
tenant_stamp_mode_from_env_reads_the_real_env Under JWT an unset variable resolves to On, not Off, and an unrecognised value is an error rather than a silent fall to Off. A typo in a deployment used to disable tenant isolation and say nothing.
scope_context_write_and_read_tenant_follow_the_env_posture The posture is frozen into Authz at startup — the old name described the behaviour this commit removes. A late set_var can no longer move an authenticated caller between tenants.
multi_tenant_token_needs_a_selector_when_stamping_is_on A multi-tenant token naming no tenant is refused on the read side, in lockstep with the write side, instead of falling back to reading default.

3. Resolving one genuine disagreement. 426ea8a0 routes fact-backed reads through fact_visible_to_agent, which hides born-private reserved namespaces from a caller with no passport. Main's b36a72b1 guarantees the opposite for the addressed path: internal namespaces are out of undirected recall but stay reachable when named. Both are right; they met on one code path. On an auth-off daemon passport_id is None and the operator is still the owner, so applying the first rule everywhere made the daemon's own seeded manual unreadable through /v1/context even by name.

Isolated by instrumentation, not inference — the fact is present in the tenant and entity_matches_for_agent(…, None) is true, while fact_visible_to_agent(fact, None) is false.

Operator decision, 2026-08-22: exempt seeded documentation only. The distinction is content, not prefix shape. __bootstrap__:: is documentation the daemon seeded about itself, safe for anyone who can already reach the port; __agent:: and __ops:: are private state whose disclosure would be wider than the problem being solved. The exemption is one namespace wide and addressed-only; undirected recall still excludes every internal namespace.

Both directions are tested, because testing only the permissive one would let the exemption widen silently. The boundary test is positive-controlled: widening the helper to is_internal_namespace returns __agent::alice::secret and fails it.

Behaviour changes reviewers should expect

  • JWT daemons with CORECRUXD_TENANT_WRITE_STAMP unset now isolate by tenant (previously shared default).
  • An invalid value for that variable is now a startup error, not a silent Off.
  • A multi-tenant token with no selector is refused on reads.

Gates: cargo test --workspace --no-fail-fast --locked 8339 passed / 0 failed · clippy --workspace -D warnings clean · fmt clean · unwrap-ratchet 389/389.

🤖 Generated with Claude Code

CueCrux-Myles and others added 3 commits August 22, 2026 12:34
Default wired HTTP fact-backed surfaces to verified JWT tenant stamping while retaining explicit off and shadow migration modes. Freeze the posture at startup, fail closed on ambiguous authority, and tenant-bind paired reads, caches, receipts, overlays, candidates, replay, and result-envelope flows.

Co-Authored-By: OpenAI Codex <codex@openai.com>
The replay of 426ea8a applies to current main with only two conflicts, both in
generated files, but it does not compile: main has grown call sites and tests
against the weaker API the commit replaces. Migrated deliberately rather than
reverted, one note per site.

Production:
- escrow.rs get_wrapped_dek — tenant_hash_for_read_context is now fail-closed
  and returns Result. This route landed after the branch was written, so it was
  never updated; it now propagates the problem response like every other
  fact-backed route.
- replay.rs store_answer_capsule — takes an explicit tenant_hash instead of
  hardcoding "default". Five test call sites pass "default", which is exactly
  the value the function used to bake in, so their behaviour is unchanged.

Tests that asserted the old contract, each migrated to assert the new one:
- tenant_stamp_mode_from_env_reads_the_real_env -> ..._from_env_for_auth_defaults_on_for_jwt_and_rejects_junk.
  Two changes are the point of the commit and are now pinned: under JWT an unset
  variable resolves to On rather than Off, and an unrecognised value is an error
  rather than a silent fall to Off. A typo in a deployment used to disable
  tenant isolation and say nothing.
- scope_context_write_and_read_tenant_follow_the_env_posture ->
  ..._tenant_resolution_uses_the_frozen_posture_not_the_env. The posture is now
  frozen into Authz at startup, so the old name described the behaviour this
  commit removes. The test now pins that a late set_var cannot move an already
  authenticated caller between tenants, and keeps the assertions that still
  carry weight (On resolves the token's tenant, an unowned selector is 403,
  Shadow neither stamps nor rejects — built directly, since the posture no
  longer comes from the environment).
- multi_tenant_token_needs_a_selector_when_stamping_is_on — the read side used
  to fall back to reading `default` when a multi-tenant token named no tenant.
  That ambiguity is what the commit closes, so the read is now refused in
  lockstep with the write.

CHANGELOG resolution kept only 426ea8a's own entry; the merge pulled in
adjacent text belonging to c093c00 (S5, not landed), which would have claimed
coordination and punchcard work had shipped. llms-full.txt regenerated with
scripts/build-llms-full.sh rather than hand-merged.

Status: cargo check --workspace --all-targets --locked clean; 3167 of 3168
corecruxd tests pass. ONE test fails and is NOT addressed here because it is a
policy question, not a merge error — see the next commit message / plan note.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s not

Resolves the one disagreement between this slice and main. 426ea8a routes
fact-backed reads through fact_visible_to_agent, which hides born-private
reserved namespaces from a caller with no passport. Main's b36a72b guarantees
the opposite for the addressed path: internal namespaces are excluded from
UNDIRECTED recall but stay reachable when named, so nothing becomes
unreachable. Both rules are right; they met on one code path.

On an auth-off daemon passport_id is None and the operator is nonetheless the
owner, so applying the first rule everywhere made the daemon's own seeded
manual unreadable through /v1/context even by name — precisely the outcome
b36a72b exists to prevent.

Operator decision, taken 2026-08-22: exempt seeded documentation only. The
distinction is content, not prefix shape. __bootstrap__:: is documentation the
daemon seeded about itself and is safe to hand to anyone who can already reach
the port; __agent:: and __ops:: are private state whose disclosure would be a
real leak — wider than the problem being solved. The exemption is therefore one
namespace wide and addressed-only; undirected recall still excludes every
internal namespace.

Adds fact_privacy::is_addressable_without_agent_identity so the rule has one
home and a name, rather than a prefix literal at the filter.

Both directions are tested, because only testing the permissive one would let
the exemption widen silently:
- addressing_an_internal_entity_still_returns_it (main's, now passing again)
- addressing_private_daemon_state_without_an_agent_returns_nothing (new).
  Positive-controlled: widening the helper to is_internal_namespace fails it
  with __agent::alice::secret in the response.

Gates: cargo test --workspace --no-fail-fast --locked 8339 passed / 0 failed;
clippy --workspace -D warnings clean; fmt clean; unwrap-ratchet OK (389/389).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rustdoc gate caught a real interaction between this replay and newer main
code. `426ea8a0` makes `promote_for_tenant` / `reject_for_tenant` the real API
and demotes `promote` / `reject` to `#[cfg(test)]` wrappers, but the module
doc — added on main by `bcd38b00`, after this branch was written — links to
the bare names. Outside a test build those items no longer exist, so
`cargo doc -D warnings` fails with 'unresolved link to promote'.

Repoint the links at the functions that actually exist in a non-test build.

Verified with the gate CI runs: RUSTDOCFLAGS='-D warnings' cargo doc --locked
--workspace --no-deps, exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@CueCrux-Myles
CueCrux-Myles added this pull request to the merge queue Aug 22, 2026
Merged via the queue into main with commit f47320a Aug 22, 2026
24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant