Skip to content

feat(dashboard): identity→tier grants for IdPs that mint no tier claim (JEF-501)#268

Merged
thejefflarson merged 2 commits into
mainfrom
thejefflarson/jef-501-identity-tier-grants
Jul 24, 2026
Merged

feat(dashboard): identity→tier grants for IdPs that mint no tier claim (JEF-501)#268
thejefflarson merged 2 commits into
mainfrom
thejefflarson/jef-501-identity-tier-grants

Conversation

@thejefflarson

Copy link
Copy Markdown
Owner

Summary

Cloudflare Access over GitHub (this cluster's setup) relays no tier claim — GitHub emits none — so every operator floors to redacted and the forensic/raw debugging surface is unreachable (verified live). Adds an operator-configured identity→tier grant that resolves the ceiling from the VERIFIED token identity (sub/email) when no tier claim is present.

  • New config: PROTECTOR_DASHBOARD_OIDC_TIER_GRANTS, format tier=id1,id2;tier=id3, e.g. raw=alice@example.com;forensic=bob@example.com. Parsed strictly (mirrors the MIN_TIER parser, JEF-487) — an unknown tier name or malformed entry is a loud ConfigError so build_dashboard_auth/build_mcp_verifier refuse to serve, never silently drop the grant or over-grant.
  • Precedence: (1) an explicit, recognized tier claim always wins — the IdP's own statement is authoritative, even over a grant; (2) else the highest tier granted to the verified identity (sub exact match, email case-insensitive); (3) else the unchanged Redacted floor. An unrecognized (garbage) claim value falls through to the grant lookup rather than shadowing it as an "explicit redacted" statement.
  • Threads an optional email claim through Claims/Identity (post-signature-verified only — never a header/arg/tool param).
  • Does not touch EffectiveTier::clamp, the .capped_at(Forensic) bulk cap, or the audit path — the resolved tier is still just the ceiling those already clamp against.
  • Chart gains engine.dashboard.oidc.tierGrants (empty by default) → PROTECTOR_DASHBOARD_OIDC_TIER_GRANTS. Left empty; the actual grant for this cluster is wired in the separate cluster repo (../cluster), not here.

Precedence example (from the acceptance criteria)

A token with an explicit tier: forensic claim and a raw grant for the same identity resolves to forensic — the claim wins.

Test plan

  • cargo fmt --all -- --check
  • cargo clippy --all-targets -- -D warnings (clean)
  • cargo test — full suite green (912 passed, 0 failed, 2 ignored [network-only])
  • New unit tests (claims.rs/tests.rs): raw/forensic/unlisted grant resolution by email or sub; explicit claim precedence over a grant; unrecognized claim falls through to a grant (not the floor); case-insensitive email match / exact sub match; missing-email + sub-based grant
  • New end-to-end tests: real signed RS256 tokens (JEF-485 in-test keypair scaffolding, zero network) minted with/without email/tier claims, driven through the full Verifier::verify() path
  • New config-parsing tests: multi-tier TIER_GRANTS parses and resolves correctly; unknown tier name / malformed syntax / empty identifier list / stray ; all fail loud with ConfigError
  • New run_loop test: build_dashboard_auth/build_mcp_verifier both refuse to serve on a misconfigured TIER_GRANTS, both serve on a valid one (mirrors the existing MIN_TIER fail-loud test)
  • helm lint + helm template — chart renders correctly with/without tierGrants set, no stray TIER_GRANTS env var when unset
  • No source file exceeds the 1,000-line cap (file_size_guard test passes; largest touched file is 845 lines)

Closes JEF-501

🤖 Generated with Claude Code

https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP

thejefflarson and others added 2 commits July 23, 2026 23:59
…m (JEF-501)

Cloudflare Access over GitHub relays no `tier` claim (GitHub emits none), so
every operator floors to `redacted` and the forensic/raw debugging surface is
unreachable. Adds an operator-configured identity→tier grant
(PROTECTOR_DASHBOARD_OIDC_TIER_GRANTS, format `tier=id1,id2;tier=id3`) that
resolves the ceiling from the VERIFIED sub/email when no tier claim is
present.

Precedence: (1) an explicit, recognized `tier` claim always wins — the IdP's
own statement is authoritative, even over a grant; (2) else the highest tier
granted to the verified identity (sub exact match, email case-insensitive);
(3) else the unchanged Redacted floor. Parsing is strict (mirrors the
MIN_TIER parser): an unknown tier name or malformed entry is a loud
ConfigError so build_dashboard_auth/build_mcp_verifier refuse to serve rather
than silently drop or silently over-grant.

Threads an optional `email` claim through Claims/Identity for the match. The
chart gains a `tierGrants` value (empty by default — actual grants are wired
in the separate cluster repo).

Closes JEF-501

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP
…501)

Security review of PR #268 found two HIGH over-grant paths in the
identity->tier grant matching:

1. An email-typed grant matched claims.email with no ownership check. A
   valid signature only proves the IdP minted the token, not that the
   subject owns the email it self-asserts (social login, a self-service
   directory) -- an attacker could set their account email to a granted
   operator's address and get elevated. Fixed by adding
   Claims.email_verified (bool, #[serde(default)] so absent means
   unverified, the safe default) and requiring it be true before an
   email-typed grant can match.

2. matches() compared every identifier against BOTH sub (exact) and email
   (case-insensitive) with a single OR, so an email-shaped grant could
   also match an opaque sub that happened to equal the same string, and
   vice versa -- silently widening the granted set. Fixed by typing each
   grant identifier at parse time (GrantId::Email vs GrantId::Sub, split
   on '@' presence, which is unambiguous for a sub) so it only ever
   matches its own field.

Added tests for both: an unverified email equal to a grant resolves to
Redacted while the same verified email resolves to the granted tier; a
sub equal to a granted email string (and vice versa) does not match.
Split the growing JEF-501 test surface into a new tier_grants_tests.rs to
keep both files well under the repo's 1,000-line cap.

Re-verified: cargo fmt, cargo clippy --all-targets -D warnings (clean),
full cargo test green (907 passed).

Addresses JEF-501 security review findings on PR #268.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP
@thejefflarson

Copy link
Copy Markdown
Owner Author

Addressed both HIGH findings from the security review:

  1. Unverified email over-grant: added Claims.email_verified (#[serde(default)] → absent = false, the safe default). An email-typed grant now only matches when the token asserts email_verified: true; sub-based grants are unaffected.
  2. Cross-field sub/email matching: grant identifiers are now typed at parse time (GrantId::Email vs GrantId::Sub, split on @ presence — unambiguous for a sub in practice) so an identifier only ever matches its own field.

New tests cover both: unverified vs. verified email against the same raw grant (Redacted vs. Raw), and a sub/email that collides with the other field's grant string (no match either direction). Also split the growing JEF-501 test surface into tier_grants_tests.rs to keep files well under the 1,000-line cap.

Gates re-run: cargo fmt, cargo clippy --all-targets -- -D warnings (clean), full cargo test green (907 passed, 0 failed).

Not merging — leaving for architect review.

@thejefflarson
thejefflarson enabled auto-merge (squash) July 24, 2026 07:18
@thejefflarson
thejefflarson merged commit bd3a340 into main Jul 24, 2026
6 checks passed
@thejefflarson
thejefflarson deleted the thejefflarson/jef-501-identity-tier-grants branch July 24, 2026 07:20
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