Skip to content

fix(server,authz): ingress auth edge — never trust a client-asserted principal (ARN-170)#343

Open
rita-aga wants to merge 1 commit into
mainfrom
claude/arn-170-class-a-auth-edge
Open

fix(server,authz): ingress auth edge — never trust a client-asserted principal (ARN-170)#343
rita-aga wants to merge 1 commit into
mainfrom
claude/arn-170-class-a-auth-edge

Conversation

@rita-aga

@rita-aga rita-aga commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Closes ARN-170 (kernel Class A) under epic ARN-165 — the linchpin auth fix. Draft for review — do not merge.

Problem (systemic Class A)

Identity was derived from client-asserted x-temper-* headers. SecurityContext::from_headers minted Admin from x-temper-principal-kind: admin, and bearer_auth failed open when no TEMPER_API_KEY was set. So any caller could self-assert admin and bypass every Cedar gate (require_policy_auth, require_observe_auth, require_repl_auth). This is the root cause behind ARN-187 (OTS) and is the reason ARN-166's Layer-A gate is only fully effective once this lands.

Fix — one edge, not per-site (ADR-0157)

  • Strip every inbound x-temper-* identity header at the ingress edge (authz/edge.rs, outermost middleware), except a small observability allowlist. A client can no longer assert its own principal.
  • Derive the principal only from a resolved credential, carried as an unforgeable EdgeAuthenticatedPrincipal request extension (headers can't forge or survive-strip it), materialized into trusted headers after the strip.
  • from_headers now defaults to Customer; honors Admin only alongside the internal trusted marker; never mints System from headers.
  • bearer_auth fails closed when a key is configured but no credential is presented.

Verification

  • Exploit tests: spoofed admin header → Customer:anonymous; no-credential on a privileged surface → 401; smuggled trusted-marker → stripped; operator key → Admin:api-key-holder. (edge 5/5, authz context 16/16.)
  • Full workspace pre-push suite green. Caught during verification: two governance tests asserted admin-approval via the old header-only shortcut — correctly now 403; migrated them to the trusted post-edge context (real admins authenticate via credential → Admin with the marker, so the production path is unaffected; only external header-spoofing is denied).
  • Independent code review PASS (0 P0/P1; 4 P2 — two folded in, three tracked as ARN-202) + DST review PASS.

Notes

🤖 Generated with Claude Code

Greptile Summary

This PR closes a Class A privilege-escalation vulnerability (ARN-170): SecurityContext::from_headers previously minted Admin from a client-supplied x-temper-principal-kind: admin header, and bearer_auth_check failed open when no API key was configured, meaning any caller could self-assert admin and bypass every Cedar gate. The fix is a three-layer ingress edge applied at build_platform_router that enforces "identity is derived from a resolved credential, never from a client header."

  • Strip layer (strip_inbound_identity_headers, outermost): removes all x-temper-* authority headers before credential resolution; observability prefixes (x-temper-observe-*, x-temper-workflow-*) are preserved.
  • from_headers hardening: Admin is honored only when the internal TRUSTED_PRINCIPAL_HEADER marker is present (set exclusively by trusted in-process code after the edge); System remains non-derivable from headers; everything else defaults to Customer.
  • Materialize layer (materialize_authenticated_principal, innermost): converts a resolved EdgeAuthenticatedPrincipal extension (set by bearer_auth_check on a verified global-key match) into trusted headers with the marker; agent credentials flow through the existing resolved-identity extension path unchanged.

Confidence Score: 4/5

The core auth fix is correct and thoroughly tested; the three-layer edge ordering is verified against Axum middleware semantics; exploit tests cover all four attack scenarios. The only issues found are quality/style concerns.

The auth architecture is sound, layer ordering is correct, and exploit test coverage is comprehensive. The four findings are a stale doc comment, a theoretically unreachable TigerStyle-violating silent-drop, a missing explicit match arm, and a deferred file-split. None affect the security property of this PR.

crates/temper-server/src/authz/edge.rs (silent-drop path in materialize_authenticated_principal) and crates/temper-platform/src/bearer_auth.rs (stale resolution-order doc comment) deserve a second look before ARN-167 reuses this pattern.

Important Files Changed

Filename Overview
crates/temper-server/src/authz/edge.rs New file implementing the two-part ingress edge: strip_inbound_identity_headers removes all x-temper-* authority headers (preserving observability prefixes), and materialize_authenticated_principal converts a resolved EdgeAuthenticatedPrincipal extension into trusted post-strip headers. Core logic is correct; silent-drop-to-anonymous on invalid principal ID violates TigerStyle no-silent-failures.
crates/temper-authz/src/context.rs Adds TRUSTED_PRINCIPAL_HEADER constant and gates Admin derivation in from_headers on the trusted marker being present; System remains non-derivable from headers. The fix is correct. The explicit "customer" arm was silently dropped to _ (functionally equivalent but less auditable in security-sensitive code). File is now 527 lines, crossing the >500-line split rule.
crates/temper-platform/src/bearer_auth.rs Global API key is now checked first (sets EdgeAuthenticatedPrincipal::operator() extension) before attempting agent credential resolution, reversing the old precedence. The old guest-override impersonation path is removed. Module doc comment still lists the old ordering (agent → global key) and needs updating.
crates/temper-platform/src/router.rs Wires strip_inbound_identity_headers as the outermost layer and materialize_authenticated_principal as the innermost layer in build_platform_router. Layer ordering (last .layer() = outermost) is correct per Axum semantics.
crates/temper-server/src/state/dispatch/wasm/local_tdata_host.rs Unconditionally injects TRUSTED_PRINCIPAL_HEADER into in-process local-TData dispatch headers, enabling from_headers to honor an Admin kind inherited from the invoking SecurityContext. This is intentional: the invoking context is trusted in-process code, not an external request.
crates/temper-server/src/router.rs Separates inline tokio::spawn(async move { ... }) into named future variables before spawn, allowing // determinism-ok DST annotations to be placed on the spawn lines. No behavioral change.
docs/adrs/0157-class-a-auth-edge.md Comprehensive ADR documenting all three sub-decisions, rollout plan, consequences, risks (including direct-embed and tenant_access_check scope change), DST compliance, and alternatives considered. Well-scoped and thorough.
crates/temper-platform/tests/compile_first_e2e.rs All test requests that previously used X-Temper-Principal-Kind: admin as a header shortcut are migrated to Authorization: Bearer test-operator-key. The test state now sets api_token to enforce the real credential path.
crates/temper-authz/src/lib.rs Exports TRUSTED_PRINCIPAL_HEADER publicly so internal crates (local_tdata_host, tests) can set the trusted marker without encoding the string literal themselves.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Client
    participant Strip as strip_inbound_identity_headers
    participant Auth as bearer_auth_check
    participant Mat as materialize_authenticated_principal
    participant Handler

    Client->>Strip: Request + x-temper-principal-kind: admin
    Note over Strip: Removes ALL x-temper-* authority headers
    Strip->>Auth: Request (identity headers gone)

    alt No API key configured
        Auth->>Mat: passthrough, no extension
        Mat->>Handler: Customer:anonymous
    else Global API key match
        Auth->>Auth: Insert EdgeAuthenticatedPrincipal::operator()
        Auth->>Mat: Request + extension
        Note over Mat: Writes kind=admin, id=api-key-holder, trusted-marker=1
        Mat->>Handler: Admin:api-key-holder
    else Agent credential match
        Auth->>Auth: Insert ResolvedIdentity extension
        Auth->>Mat: Request + agent extension
        Mat->>Handler: Agent identity
    else No credential
        Auth-->>Client: 401 Unauthorized
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Client
    participant Strip as strip_inbound_identity_headers
    participant Auth as bearer_auth_check
    participant Mat as materialize_authenticated_principal
    participant Handler

    Client->>Strip: Request + x-temper-principal-kind: admin
    Note over Strip: Removes ALL x-temper-* authority headers
    Strip->>Auth: Request (identity headers gone)

    alt No API key configured
        Auth->>Mat: passthrough, no extension
        Mat->>Handler: Customer:anonymous
    else Global API key match
        Auth->>Auth: Insert EdgeAuthenticatedPrincipal::operator()
        Auth->>Mat: Request + extension
        Note over Mat: Writes kind=admin, id=api-key-holder, trusted-marker=1
        Mat->>Handler: Admin:api-key-holder
    else Agent credential match
        Auth->>Auth: Insert ResolvedIdentity extension
        Auth->>Mat: Request + agent extension
        Mat->>Handler: Agent identity
    else No credential
        Auth-->>Client: 401 Unauthorized
    end
Loading

Comments Outside Diff (5)

  1. docs/adrs/0157-class-a-auth-edge.md, line 1508-1512 (link)

    P2 ADR Negative consequence contradicts the implementation

    The ADR states: "A WASM TData host call that inherited an Admin principal now inherits it as Customer." However, the code does the opposite. local_tdata_host::header_map unconditionally inserts TRUSTED_PRINCIPAL_HEADER: "1", and security_context_headers maps PrincipalKind::Admin to "admin". from_headers therefore sees admin + the trusted marker and yields Admin — not Customer. The inline comment in local_tdata_host.rs and the updated local_tdata_headers_inherit_invoking_security_context test both confirm that Admin IS preserved in local TData dispatch. Future readers relying on the Negative consequences section for the security model will be misled.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: docs/adrs/0157-class-a-auth-edge.md
    Line: 1508-1512
    
    Comment:
    **ADR Negative consequence contradicts the implementation**
    
    The ADR states: *"A WASM TData host call that inherited an Admin principal now inherits it as Customer."* However, the code does the opposite. `local_tdata_host::header_map` unconditionally inserts `TRUSTED_PRINCIPAL_HEADER: "1"`, and `security_context_headers` maps `PrincipalKind::Admin` to `"admin"`. `from_headers` therefore sees `admin` + the trusted marker and yields `Admin` — not Customer. The inline comment in `local_tdata_host.rs` and the updated `local_tdata_headers_inherit_invoking_security_context` test both confirm that Admin IS preserved in local TData dispatch. Future readers relying on the Negative consequences section for the security model will be misled.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Claude Code Fix in Codex Fix in Cursor

  2. crates/temper-platform/src/bearer_auth.rs, line 57-62 (link)

    P2 PreAuthenticatedRequest bypass is unreachable in the production stack

    This check requires both the PreAuthenticatedRequest extension and the x-temper-principal-kind/x-temper-principal-id headers to be present. In the production layer order (strip_inbound_identity_headers outermost then bearer_auth_check), the strip layer removes those principal headers before this code runs, and no middleware in the current production stack sets PreAuthenticatedRequest between the two layers. The test pre_authenticated_request_bypasses_bearer_requirement exercises a stack without the strip layer, so it cannot verify the production path. The bypass is dead code under the full edge and would silently remain so for any future embedder that applies the strip.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: crates/temper-platform/src/bearer_auth.rs
    Line: 57-62
    
    Comment:
    **`PreAuthenticatedRequest` bypass is unreachable in the production stack**
    
    This check requires both the `PreAuthenticatedRequest` extension *and* the `x-temper-principal-kind`/`x-temper-principal-id` headers to be present. In the production layer order (`strip_inbound_identity_headers` outermost then `bearer_auth_check`), the strip layer removes those principal headers before this code runs, and no middleware in the current production stack sets `PreAuthenticatedRequest` between the two layers. The test `pre_authenticated_request_bypasses_bearer_requirement` exercises a stack without the strip layer, so it cannot verify the production path. The bypass is dead code under the full edge and would silently remain so for any future embedder that applies the strip.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Claude Code Fix in Codex Fix in Cursor

  3. crates/temper-authz/src/context.rs, line 1 (link)

    P2 File exceeds the 500-line split rule

    context.rs is now 527 lines, past the "Files > 500 lines must be split into directory modules" rule in CLAUDE.md/AGENTS.md. The PR description notes this is deferred to ARN-184, so flagging here to ensure it is tracked and not forgotten.

    Context Used: CLAUDE.md (source)

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: crates/temper-authz/src/context.rs
    Line: 1
    
    Comment:
    **File exceeds the 500-line split rule**
    
    `context.rs` is now 527 lines, past the "Files > 500 lines must be split into directory modules" rule in CLAUDE.md/AGENTS.md. The PR description notes this is deferred to ARN-184, so flagging here to ensure it is tracked and not forgotten.
    
    **Context Used:** CLAUDE.md ([source](https://app.greptile.com/arni-labs/github/nerdsane/temper/-/custom-context?memory=c3ad5080-2c13-46c8-b4c1-9e266e4df914))
    
    How can I resolve this? If you propose a fix, please make it concise.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

    Fix in Claude Code Fix in Codex Fix in Cursor

  4. crates/temper-platform/src/bearer_auth.rs, line 23-29 (link)

    P2 Stale resolution-order doc comment

    The module doc comment still lists the old resolution order — agent credentials (step 3) before global API key (step 4) — but the implementation now checks the global API key first (the new if matches_global_api_key block at line 88, labeled "Step 1", precedes the agent-credential check labeled "Step 2"). A reviewer or future maintainer reading the doc will expect the opposite order and may introduce a regression when modifying this flow.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: crates/temper-platform/src/bearer_auth.rs
    Line: 23-29
    
    Comment:
    **Stale resolution-order doc comment**
    
    The module doc comment still lists the old resolution order — agent credentials (step 3) before global API key (step 4) — but the implementation now checks the global API key first (the new `if matches_global_api_key` block at line 88, labeled "Step 1", precedes the agent-credential check labeled "Step 2"). A reviewer or future maintainer reading the doc will expect the opposite order and may introduce a regression when modifying this flow.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

    Fix in Claude Code Fix in Codex Fix in Cursor

  5. crates/temper-authz/src/context.rs, line 1 (link)

    P2 context.rs exceeds the 500-line module-split threshold

    The file is now 527 lines, which crosses the "Files > 500 lines must be split into directory modules" rule in CLAUDE.md. The PR description acknowledges this and defers the split to ARN-184, and the readability baseline has been bumped to reflect it. This is a tracked known violation, but it is worth calling out as a concrete follow-up gate before ARN-184 closes.

    Context Used: CLAUDE.md (source)

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: crates/temper-authz/src/context.rs
    Line: 1
    
    Comment:
    **`context.rs` exceeds the 500-line module-split threshold**
    
    The file is now 527 lines, which crosses the "Files > 500 lines must be split into directory modules" rule in CLAUDE.md. The PR description acknowledges this and defers the split to ARN-184, and the readability baseline has been bumped to reflect it. This is a tracked known violation, but it is worth calling out as a concrete follow-up gate before ARN-184 closes.
    
    **Context Used:** CLAUDE.md ([source](https://app.greptile.com/arni-labs/github/nerdsane/temper/-/custom-context?memory=c3ad5080-2c13-46c8-b4c1-9e266e4df914))
    
    How can I resolve this? If you propose a fix, please make it concise.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

    Fix in Claude Code Fix in Codex Fix in Cursor

Fix All in Claude Code Fix All in Codex Fix All in Cursor

Prompt To Fix All With AI
Fix the following 4 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 4
crates/temper-platform/src/bearer_auth.rs:23-29
**Stale resolution-order doc comment**

The module doc comment still lists the old resolution order — agent credentials (step 3) before global API key (step 4) — but the implementation now checks the global API key first (the new `if matches_global_api_key` block at line 88, labeled "Step 1", precedes the agent-credential check labeled "Step 2"). A reviewer or future maintainer reading the doc will expect the opposite order and may introduce a regression when modifying this flow.

### Issue 2 of 4
crates/temper-server/src/authz/edge.rs:127-134
**Silent identity drop on invalid principal ID violates TigerStyle "no silent failures"**

When `HeaderValue::from_str(&principal.id)` fails, the authenticated principal is silently dropped and the request continues as `Customer:anonymous` — the caller receives a `200` (or whatever the anonymous path returns) instead of an error. Today `operator()` always uses the ASCII-valid `"api-key-holder"`, so this branch is unreachable in production. But any future `EdgeAuthenticatedPrincipal` with a non-ASCII or control-character ID would silently downgrade a fully-authenticated request, which could be very difficult to diagnose. CLAUDE.md's TigerStyle explicitly requires "no silent failures." Returning an internal-server-error response here keeps the fail-closed property while also surfacing the problem to the caller.

```suggestion
        } else {
            tracing::error!(
                principal_id = %principal.id,
                "resolved principal id is not a valid header value; cannot materialize principal"
            );
            return axum::response::IntoResponse::into_response(
                axum::http::StatusCode::INTERNAL_SERVER_ERROR,
            );
        }
    }
    next.run(req).await
```

### Issue 3 of 4
crates/temper-authz/src/context.rs:91-101
**Removing the explicit `"customer"` arm makes the privilege-sensitive match harder to audit**

The previous code had `"customer" => PrincipalKind::Customer` as an explicit arm. Dropping it to the wildcard `_` is functionally equivalent today, but in security-sensitive code the convention of being explicit about every recognized value matters: a future maintainer adding a new kind (e.g. `"service"`) or renaming `"customer"` would not get a compile-time warning that the old string now silently falls to `Customer`. Keeping the explicit arm preserves the audit trail and makes the exhaustive-match intent clearer.

```suggestion
                    kind = match value.as_str() {
                        "customer" => PrincipalKind::Customer,
                        "agent" => PrincipalKind::Agent,
                        // "admin" is honored only alongside the trusted marker
                        // the edge strips from client requests. "system" is
                        // never accepted from headers — use
                        // SecurityContext::system() for trusted internal paths.
                        // Everything else (including untrusted "admin" and
                        // "system") falls back to the most restrictive kind.
                        "admin" if trusted_principal => PrincipalKind::Admin,
                        _ => PrincipalKind::Customer,
                    };
```

### Issue 4 of 4
crates/temper-authz/src/context.rs:1
**`context.rs` exceeds the 500-line module-split threshold**

The file is now 527 lines, which crosses the "Files > 500 lines must be split into directory modules" rule in CLAUDE.md. The PR description acknowledges this and defers the split to ARN-184, and the readability baseline has been bumped to reflect it. This is a tracked known violation, but it is worth calling out as a concrete follow-up gate before ARN-184 closes.

Reviews (2): Last reviewed commit: "fix(server,authz): never trust a client-..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

Context used:

  • Context used - CLAUDE.md (source)

…auth edge (ARN-170)

Systemic Class A fix. Strip all inbound x-temper-* identity headers at the
ingress edge (except an observability allowlist); derive the principal only
from a resolved credential, carried as an unforgeable EdgeAuthenticatedPrincipal
request extension and materialized into trusted headers after the strip.
from_headers now defaults to Customer, honors Admin only alongside the trusted
in-process marker, and never mints System from headers. bearer_auth fails
closed when a key is configured but no credential is presented.

Closes the header-spoofable admin bypass and fail-open auth (ARN-170); root-
causes ARN-187 (OTS) and is mirrored by ARN-167 (TemperPaw). Residual cleanup
tracked in ARN-202.

ADR-0157.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@rita-aga rita-aga marked this pull request as ready for review July 7, 2026 04:56
@rita-aga

rita-aga commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

@greptile review

Comment on lines +127 to +134
} else {
tracing::warn!(
principal_id = %principal.id,
"resolved principal id is not a valid header value; dropping to anonymous"
);
}
}
next.run(req).await

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Silent identity drop on invalid principal ID violates TigerStyle "no silent failures"

When HeaderValue::from_str(&principal.id) fails, the authenticated principal is silently dropped and the request continues as Customer:anonymous — the caller receives a 200 (or whatever the anonymous path returns) instead of an error. Today operator() always uses the ASCII-valid "api-key-holder", so this branch is unreachable in production. But any future EdgeAuthenticatedPrincipal with a non-ASCII or control-character ID would silently downgrade a fully-authenticated request, which could be very difficult to diagnose. CLAUDE.md's TigerStyle explicitly requires "no silent failures." Returning an internal-server-error response here keeps the fail-closed property while also surfacing the problem to the caller.

Suggested change
} else {
tracing::warn!(
principal_id = %principal.id,
"resolved principal id is not a valid header value; dropping to anonymous"
);
}
}
next.run(req).await
} else {
tracing::error!(
principal_id = %principal.id,
"resolved principal id is not a valid header value; cannot materialize principal"
);
return axum::response::IntoResponse::into_response(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
);
}
}
next.run(req).await
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/temper-server/src/authz/edge.rs
Line: 127-134

Comment:
**Silent identity drop on invalid principal ID violates TigerStyle "no silent failures"**

When `HeaderValue::from_str(&principal.id)` fails, the authenticated principal is silently dropped and the request continues as `Customer:anonymous` — the caller receives a `200` (or whatever the anonymous path returns) instead of an error. Today `operator()` always uses the ASCII-valid `"api-key-holder"`, so this branch is unreachable in production. But any future `EdgeAuthenticatedPrincipal` with a non-ASCII or control-character ID would silently downgrade a fully-authenticated request, which could be very difficult to diagnose. CLAUDE.md's TigerStyle explicitly requires "no silent failures." Returning an internal-server-error response here keeps the fail-closed property while also surfacing the problem to the caller.

```suggestion
        } else {
            tracing::error!(
                principal_id = %principal.id,
                "resolved principal id is not a valid header value; cannot materialize principal"
            );
            return axum::response::IntoResponse::into_response(
                axum::http::StatusCode::INTERNAL_SERVER_ERROR,
            );
        }
    }
    next.run(req).await
```

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex Fix in Cursor

Comment on lines 91 to 101
kind = match value.as_str() {
"customer" => PrincipalKind::Customer,
"agent" => PrincipalKind::Agent,
"admin" => PrincipalKind::Admin,
// "system" is NOT accepted from headers to prevent
// privilege escalation via header spoofing. Use
// "admin" is honored only alongside the trusted marker
// the edge strips from client requests. "system" is
// never accepted from headers — use
// SecurityContext::system() for trusted internal paths.
// Everything else (including untrusted "admin" and
// "system") falls back to the most restrictive kind.
"admin" if trusted_principal => PrincipalKind::Admin,
_ => PrincipalKind::Customer,
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Removing the explicit "customer" arm makes the privilege-sensitive match harder to audit

The previous code had "customer" => PrincipalKind::Customer as an explicit arm. Dropping it to the wildcard _ is functionally equivalent today, but in security-sensitive code the convention of being explicit about every recognized value matters: a future maintainer adding a new kind (e.g. "service") or renaming "customer" would not get a compile-time warning that the old string now silently falls to Customer. Keeping the explicit arm preserves the audit trail and makes the exhaustive-match intent clearer.

Suggested change
kind = match value.as_str() {
"customer" => PrincipalKind::Customer,
"agent" => PrincipalKind::Agent,
"admin" => PrincipalKind::Admin,
// "system" is NOT accepted from headers to prevent
// privilege escalation via header spoofing. Use
// "admin" is honored only alongside the trusted marker
// the edge strips from client requests. "system" is
// never accepted from headers — use
// SecurityContext::system() for trusted internal paths.
// Everything else (including untrusted "admin" and
// "system") falls back to the most restrictive kind.
"admin" if trusted_principal => PrincipalKind::Admin,
_ => PrincipalKind::Customer,
};
kind = match value.as_str() {
"customer" => PrincipalKind::Customer,
"agent" => PrincipalKind::Agent,
// "admin" is honored only alongside the trusted marker
// the edge strips from client requests. "system" is
// never accepted from headers — use
// SecurityContext::system() for trusted internal paths.
// Everything else (including untrusted "admin" and
// "system") falls back to the most restrictive kind.
"admin" if trusted_principal => PrincipalKind::Admin,
_ => PrincipalKind::Customer,
};
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/temper-authz/src/context.rs
Line: 91-101

Comment:
**Removing the explicit `"customer"` arm makes the privilege-sensitive match harder to audit**

The previous code had `"customer" => PrincipalKind::Customer` as an explicit arm. Dropping it to the wildcard `_` is functionally equivalent today, but in security-sensitive code the convention of being explicit about every recognized value matters: a future maintainer adding a new kind (e.g. `"service"`) or renaming `"customer"` would not get a compile-time warning that the old string now silently falls to `Customer`. Keeping the explicit arm preserves the audit trail and makes the exhaustive-match intent clearer.

```suggestion
                    kind = match value.as_str() {
                        "customer" => PrincipalKind::Customer,
                        "agent" => PrincipalKind::Agent,
                        // "admin" is honored only alongside the trusted marker
                        // the edge strips from client requests. "system" is
                        // never accepted from headers — use
                        // SecurityContext::system() for trusted internal paths.
                        // Everything else (including untrusted "admin" and
                        // "system") falls back to the most restrictive kind.
                        "admin" if trusted_principal => PrincipalKind::Admin,
                        _ => PrincipalKind::Customer,
                    };
```

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Codex Fix in Cursor

@rita-aga

Copy link
Copy Markdown
Collaborator Author

ARN-165 principle audit — reject this trust design and redesign

This branch removes one raw-header path but replaces it with an ambient global-admin credential and creates a confused deputy.

Blocking findings

  1. A valid platform key always materializes operator Admin. Credential possession is being conflated with an unrestricted principal instead of resolving the credential's scoped identity/capabilities.
  2. Internal WASM becomes a privilege-escalation path. The WASM host attaches the global platform key while preserving guest-selected principal headers. At the edge, a restricted guest call can therefore be promoted to Admin.
  3. Identity materialization is split. This middleware handles EdgeAuthenticatedPrincipal, while agent bearer authentication inserts a different resolved-identity form. Header-only management handlers can still see an anonymous or inconsistent principal.
  4. Stripping happens in only one router layer. Direct server-router callers/tests and alternative listeners still reach header-trusting handlers. A security boundary cannot depend on remembering which router was nested.
  5. A magic “trusted header” remains a bearer capability. Any forwarding mistake recreates the original vulnerability.

Required architecture

  • Resolve a typed principal/capability from the verified credential at the network listener.
  • Strip all external authority headers once, then pass the typed identity in request extensions.
  • Give internal services/modules scoped credentials or invocation-bound capabilities; never delegate the ambient operator key.
  • Make management handlers consume the typed principal and authorize resource-specific roles.
  • Fail startup or bind loopback-only when authentication is absent.
  • Test every credential type, direct router, internal WASM, low-priv bearer + forged admin header, and no-key mode.

The current branch is DIRTY/conflicting as well, but rebase is not the substantive blocker. ARN-170 needs the root design above.

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