diff --git a/.progress/002_20260709_arn_167_credential_bound_ingress.md b/.progress/002_20260709_arn_167_credential_bound_ingress.md new file mode 100644 index 000000000..e43261428 --- /dev/null +++ b/.progress/002_20260709_arn_167_credential_bound_ingress.md @@ -0,0 +1,28 @@ +# ARN-167: Credential-bound TemperPaw ingress identity + +## Objective + +Reject every client-asserted identity at the TemperPaw HTTP edge, including +loopback callers, while preserving internal transport and startup behavior +through credential-derived identity. + +## Plan + +1. Revise ADR-0066 so network location is defense in depth, never proof of + identity. +2. Add red tests proving loopback self-assertion is rejected and loopback API + clients use bearer credentials without principal headers. +3. Remove the loopback pre-authentication branch and its connection-info + plumbing; strip the entire client-assertable identity family on every + request before resolving a session or bearer credential. +4. Remove obsolete client-side loopback identity synthesis. +5. Run focused tests, full affected-crate tests, a live local HTTP flow, Clippy, + formatting, and independent review. + +## Acceptance criteria + +- Loopback plus raw admin headers and no credential returns 401. +- Remote and loopback requests cannot preserve forged identity attributes. +- A valid session is injected server-side after stripping. +- Internal `PawApiClient` calls use the configured bearer token on loopback. +- No IP/hostname allowlist or duplicate authentication implementation remains. diff --git a/.proofs/arn-167-credential-bound-ingress.md b/.proofs/arn-167-credential-bound-ingress.md new file mode 100644 index 000000000..7f6346bb5 --- /dev/null +++ b/.proofs/arn-167-credential-bound-ingress.md @@ -0,0 +1,88 @@ +# Proof Report: ARN-167 — Credential-bound ingress identity + +## Date + +2026-07-09 + +## Branch / Commit + +`codex/pr452-review-fixes` based on PR #452 head +`f4149275b76174f2a29c521911dfe8690c74e593`. + +## What Was Done + +- Removed loopback-address authentication and the corresponding connection-info + server plumbing. +- Stripped all client-assertable identity and tenant headers for every peer. +- Kept session identity injection server-side and bearer identity resolution in + the kernel. +- Changed `PawApiClient` to use its configured bearer token on loopback and to + synthesize no principal headers. +- Removed obsolete self-asserted admin headers from setup and startup clients. + +## Verification Flow + +1. Wrote rejection and credential-binding tests against the vulnerable PR. +2. Confirmed both loopback auth tests failed: self-assertion returned 200 and all + nine forged identity-family headers survived. +3. Confirmed both pure transport request tests failed because the client emitted + raw admin headers and omitted bearer auth. +4. Implemented the credential-only boundary. +5. Ran the complete auth test module, the transport header/trace suite, the full + TemperPaw crate test suite, formatting, diff-check, and Clippy with warnings + denied. + +## Verification Results + +| Step | Expected | Actual | Status | +|------|----------|--------|--------| +| Red: loopback self-assertion | New tests fail on old behavior | 200 vs 401; 9 forged headers survived | Pass | +| Red: transport identity | New pure tests fail on old behavior | Raw admin headers present; bearer absent | Pass | +| Auth module | Credential-bound flows pass | 11 passed, 0 failed | Pass | +| Combined auth chain | Global bearer replaces forged identity | `admin/api-key-holder/default` | Pass | +| Transport request construction | No principal headers; bearer on loopback | 3 passed, 0 failed | Pass | +| Full TemperPaw crate suite | All tests pass | 77 unit tests plus all integration contract tests passed | Pass | +| Formatting / diff hygiene | No drift | `cargo fmt --check` and `git diff --check` passed | Pass | +| Clippy | No warnings | `-D warnings` passed for both crates/all targets | Pass | + +## What Worked + +- The full in-process Axum chain exercises TemperPaw ingress middleware followed + by the real kernel bearer middleware, without a mock authentication decision. +- Session-cookie identity overrides forged client headers after stripping. +- The remediation deletes more code than it adds and leaves one credential path. + +## Limitations + +No deployed Railway/Datadog verification was performed because the PR must remain +open for human review. + +## What Still Doesn't Work + +- Push/update PR #452 and request Greptile as `nerdsane`. +- Run deployed verification after human review/merge. +- Deployed verification is intentionally pending human merge. + +## Artifacts + +- `cargo test --locked -p temperpaw auth::tests -- --nocapture` +- `cargo test --locked -p paw-transport tests::paw_api_client_ -- --nocapture` +- `cargo test --locked -p temperpaw` +- `cargo fmt --check` +- `git diff --check` +- `cargo clippy --locked -p temperpaw -p paw-transport --all-targets -- -D warnings` + +## Architecture Diagram + +```text +network peer (remote or loopback) + | + v +strip client identity + tenant headers + | + +-- valid session --> inject authenticated dashboard principal + | + +-- bearer token --> kernel resolves registered agent or API-key admin + | + `-- neither ------> 401 on protected routes +``` diff --git a/crates/paw-transport/src/lib.rs b/crates/paw-transport/src/lib.rs index 300d572aa..fe13aff9b 100644 --- a/crates/paw-transport/src/lib.rs +++ b/crates/paw-transport/src/lib.rs @@ -463,34 +463,20 @@ impl PawApiClient { fn build_request(&self, method: reqwest::Method, url: &str) -> reqwest::RequestBuilder { let mut req = self.http.request(method, url); req = req.header("x-tenant-id", &self.config.tenant); - if self.uses_internal_loopback(url) { - req = req.header("x-temper-principal-kind", "admin"); - req = req.header("x-temper-principal-id", "temperpaw-transport"); - } else if let Some(ref key) = self.config.api_key { + if let Some(ref key) = self.config.api_key { req = req.header("authorization", format!("Bearer {key}")); - } else { - req = req.header("x-temper-principal-kind", "admin"); - req = req.header("x-temper-principal-id", "temperpaw-transport"); } if let Some(traceparent) = current_traceparent_header() { req = req.header("traceparent", traceparent); } req } - - fn uses_internal_loopback(&self, url: &str) -> bool { - reqwest::Url::parse(url) - .ok() - .and_then(|parsed| parsed.host_str().map(|host| host.to_ascii_lowercase())) - .map(|host| host == "127.0.0.1" || host == "::1" || host == "localhost") - .unwrap_or(false) - } } #[cfg(test)] mod tests { use axum::extract::State; - use axum::http::{HeaderMap, StatusCode, Uri}; + use axum::http::{StatusCode, Uri}; use axum::routing::{get, post}; use axum::{Json, Router}; use opentelemetry::trace::{TraceContextExt, TracerProvider as _}; @@ -503,15 +489,6 @@ mod tests { use super::{ApprovalScope, PawApiClient, PawApiConfig, approval_body_for_scope}; - #[derive(Clone, Default)] - struct HeaderProbe { - last_kind: Arc>>, - last_id: Arc>>, - last_tenant: Arc>>, - last_auth: Arc>>, - last_traceparent: Arc>>, - } - #[derive(Clone, Default)] struct QueryProbe { last_uri: Arc>>, @@ -526,136 +503,65 @@ mod tests { format!("http://{}", addr) } - #[tokio::test] - async fn paw_api_client_without_api_key_includes_internal_admin_identity() { - let probe = HeaderProbe::default(); - let app = Router::new() - .route( - "/tdata/Channels", - post( - |State(probe): State, headers: HeaderMap| async move { - *probe.last_kind.lock().unwrap() = headers - .get("x-temper-principal-kind") - .and_then(|v| v.to_str().ok()) - .map(|v| v.to_string()); - *probe.last_id.lock().unwrap() = headers - .get("x-temper-principal-id") - .and_then(|v| v.to_str().ok()) - .map(|v| v.to_string()); - *probe.last_tenant.lock().unwrap() = headers - .get("x-tenant-id") - .and_then(|v| v.to_str().ok()) - .map(|v| v.to_string()); - *probe.last_auth.lock().unwrap() = headers - .get("authorization") - .and_then(|v| v.to_str().ok()) - .map(|v| v.to_string()); - - ( - StatusCode::CREATED, - Json(json!({"entity_id":"ch_123","ChannelType":"discord"})), - ) - }, - ), - ) - .with_state(probe.clone()); - - let base_url = spawn_test_server(app).await; + #[test] + fn paw_api_client_without_api_key_does_not_assert_identity() { let client = PawApiClient::new(PawApiConfig { - base_url, + base_url: "http://127.0.0.1:3497".to_string(), tenant: "default".to_string(), api_key: None, }); - let created = client - .create_entity("Channels", json!({"ChannelType":"discord"})) - .await - .unwrap(); + let request = client + .build_request( + reqwest::Method::POST, + "http://127.0.0.1:3497/tdata/Channels", + ) + .build() + .expect("build request"); assert_eq!( - created.get("entity_id").and_then(|v| v.as_str()), - Some("ch_123") - ); - assert_eq!( - probe.last_kind.lock().unwrap().as_deref(), - Some("admin"), - "internal loopback calls should advertise admin principal kind", + request + .headers() + .get("x-tenant-id") + .and_then(|value| value.to_str().ok()), + Some("default") ); - assert_eq!( - probe.last_id.lock().unwrap().as_deref(), - Some("temperpaw-transport"), - "internal loopback calls must include a principal id so auth middleware treats them as pre-authenticated", - ); - assert_eq!( - probe.last_tenant.lock().unwrap().as_deref(), - Some("default"), - ); - assert_eq!(probe.last_auth.lock().unwrap().as_deref(), None); + assert!(!request.headers().contains_key("x-temper-principal-kind")); + assert!(!request.headers().contains_key("x-temper-principal-id")); + assert!(!request.headers().contains_key("authorization")); } - #[tokio::test] - async fn paw_api_client_with_api_key_still_uses_internal_admin_identity_for_loopback() { - let probe = HeaderProbe::default(); - let app = Router::new() - .route( - "/tdata/Channels", - post( - |State(probe): State, headers: HeaderMap| async move { - *probe.last_kind.lock().unwrap() = headers - .get("x-temper-principal-kind") - .and_then(|v| v.to_str().ok()) - .map(|v| v.to_string()); - *probe.last_id.lock().unwrap() = headers - .get("x-temper-principal-id") - .and_then(|v| v.to_str().ok()) - .map(|v| v.to_string()); - *probe.last_tenant.lock().unwrap() = headers - .get("x-tenant-id") - .and_then(|v| v.to_str().ok()) - .map(|v| v.to_string()); - *probe.last_auth.lock().unwrap() = headers - .get("authorization") - .and_then(|v| v.to_str().ok()) - .map(|v| v.to_string()); - - ( - StatusCode::CREATED, - Json(json!({"entity_id":"ch_456","ChannelType":"discord"})), - ) - }, - ), - ) - .with_state(probe.clone()); - - let base_url = spawn_test_server(app).await; + #[test] + fn paw_api_client_with_api_key_uses_bearer_on_loopback() { let client = PawApiClient::new(PawApiConfig { - base_url, + base_url: "http://127.0.0.1:3497".to_string(), tenant: "default".to_string(), api_key: Some("test-token".to_string()), }); - let created = client - .create_entity("Channels", json!({"ChannelType":"discord"})) - .await - .unwrap(); + let request = client + .build_request( + reqwest::Method::POST, + "http://127.0.0.1:3497/tdata/Channels", + ) + .build() + .expect("build request"); assert_eq!( - created.get("entity_id").and_then(|v| v.as_str()), - Some("ch_456") - ); - assert_eq!(probe.last_kind.lock().unwrap().as_deref(), Some("admin")); - assert_eq!( - probe.last_id.lock().unwrap().as_deref(), - Some("temperpaw-transport") + request + .headers() + .get("x-tenant-id") + .and_then(|value| value.to_str().ok()), + Some("default") ); + assert!(!request.headers().contains_key("x-temper-principal-kind")); + assert!(!request.headers().contains_key("x-temper-principal-id")); assert_eq!( - probe.last_tenant.lock().unwrap().as_deref(), - Some("default"), - ); - assert_eq!( - probe.last_auth.lock().unwrap().as_deref(), - None, - "loopback requests should bypass bearer auth and use internal admin headers", + request + .headers() + .get("authorization") + .and_then(|value| value.to_str().ok()), + Some("Bearer test-token") ); } @@ -849,27 +755,10 @@ mod tests { ); } - #[tokio::test] - async fn paw_api_client_includes_traceparent_from_active_span() { - let probe = HeaderProbe::default(); - let app = Router::new() - .route( - "/tdata/Channels('ch_trace')/Paw.Channel.ReceiveMessage", - post( - |State(probe): State, headers: HeaderMap| async move { - *probe.last_traceparent.lock().unwrap() = headers - .get("traceparent") - .and_then(|value| value.to_str().ok()) - .map(|value| value.to_string()); - (StatusCode::OK, Json(json!({"status":"ok"}))) - }, - ), - ) - .with_state(probe.clone()); - - let base_url = spawn_test_server(app).await; + #[test] + fn paw_api_client_includes_traceparent_from_active_span() { let client = PawApiClient::new(PawApiConfig { - base_url, + base_url: "http://127.0.0.1:3497".to_string(), tenant: "default".to_string(), api_key: None, }); @@ -882,37 +771,33 @@ mod tests { let _subscriber_guard = tracing::subscriber::set_default(subscriber); let span = tracing::info_span!("discord.receive"); - let expected_traceparent = { + let (expected_traceparent, request) = { let _span_guard = span.enter(); let span_context = tracing::Span::current() .context() .span() .span_context() .clone(); - let traceparent = format!( + let expected = format!( "00-{}-{}-01", span_context.trace_id(), span_context.span_id() ); - client - .dispatch_action( - "Channels", - "ch_trace", - "Paw.Channel.ReceiveMessage", - json!({ - "message_id": "msg_123", - "author_id": "user_456", - "thread_id": "thread_789", - "content": "hello", - }), + let request = client + .build_request( + reqwest::Method::POST, + "http://127.0.0.1:3497/tdata/Channels('ch_trace')/Paw.Channel.ReceiveMessage", ) - .await - .expect("dispatch should succeed"); - traceparent + .build() + .expect("build request"); + (expected, request) }; assert_eq!( - probe.last_traceparent.lock().unwrap().as_deref(), + request + .headers() + .get("traceparent") + .and_then(|value| value.to_str().ok()), Some(expected_traceparent.as_str()), "expected PawApiClient to propagate the active tracing span via traceparent", ); diff --git a/crates/temperpaw/src/auth.rs b/crates/temperpaw/src/auth.rs index ee8921c83..9ad193b4d 100644 --- a/crates/temperpaw/src/auth.rs +++ b/crates/temperpaw/src/auth.rs @@ -123,6 +123,21 @@ pub fn router(state: AuthState) -> Router { .with_state(state) } +/// Prefix covering the full family of headers the kernel's Cedar principal +/// builder (`SecurityContext::from_headers`) trusts to establish identity, +/// delegation, scope, and context: principal id/kind, `agent-role`, +/// `agent-type`, `acting-for` (delegation), `principal-scopes`, `action-context`, +/// and arbitrary `x-temper-attr-*` / `x-temper-ctx-*` / `x-temper-span-attr-*` +/// attributes. A client must never be able to set any of these; they are honored +/// only when injected after credential resolution. TemperPaw is the sole network +/// edge in front of the kernel, which does not itself strip these, so the whole +/// prefix is removed at ingress for every request. +const CLIENT_ASSERTABLE_IDENTITY_HEADER_PREFIX: &str = "x-temper-"; + +/// Additional exact header names outside the `x-temper-*` prefix that also carry +/// client-assertable identity/tenant selection. +const CLIENT_ASSERTABLE_IDENTITY_HEADERS: [&str; 2] = ["x-agent-id", "x-tenant-id"]; + pub async fn middleware( State(state): State, mut request: Request, @@ -131,6 +146,11 @@ pub async fn middleware( let path = request.uri().path().to_string(); let method = request.method().as_str().to_string(); + // Ingress edge: no network peer may carry client-asserted identity headers. + // Strip them before Cedar or the kernel bearer check can trust them. Identity + // is re-derived below only from a session or bearer credential. + strip_client_identity_headers(request.headers_mut()); + if is_safe_setup_path_public_during_bootstrap(&state, &method, &path).await || is_public_path(request.method().as_str(), &path) { @@ -148,17 +168,6 @@ pub async fn middleware( return next.run(request).await; } - // Internal WASM agent calls carry principal headers but no Bearer token - // or session cookie. Mark as pre-authenticated so Temper's bearer_auth_check - // passes through. - if request.headers().contains_key("x-temper-principal-kind") - && request.headers().contains_key("x-temper-principal-id") - { - ensure_tenant_header(request.headers_mut(), state.tenant()); - request.extensions_mut().insert(PreAuthenticatedRequest); - return next.run(request).await; - } - if path.starts_with("/dashboard") && !is_dashboard_public_path(&path) { return Redirect::temporary("/dashboard/login").into_response(); } @@ -166,6 +175,24 @@ pub async fn middleware( StatusCode::UNAUTHORIZED.into_response() } +/// Remove any client-asserted identity/tenant headers so downstream layers +/// (Cedar, the kernel bearer check) cannot mistake them for a server-resolved +/// principal. Strips the entire `x-temper-*` family plus the extra exact names. +fn strip_client_identity_headers(headers: &mut HeaderMap) { + let forged: Vec = headers + .keys() + .filter(|name| { + let name = name.as_str(); + name.starts_with(CLIENT_ASSERTABLE_IDENTITY_HEADER_PREFIX) + || CLIENT_ASSERTABLE_IDENTITY_HEADERS.contains(&name) + }) + .cloned() + .collect(); + for name in forged { + headers.remove(&name); + } +} + async fn is_safe_setup_path_public_during_bootstrap( state: &AuthState, method: &str, @@ -618,7 +645,10 @@ enum AuthError { #[cfg(test)] mod tests { + use std::net::SocketAddr; + use axum::body::{Body, to_bytes}; + use axum::extract::ConnectInfo; use axum::http::HeaderMap; use axum::http::{Request, StatusCode}; use axum::middleware::from_fn_with_state; @@ -809,6 +839,9 @@ mod tests { .method("GET") .uri("/tdata/Agents") .header("cookie", cookie) + .header("x-temper-principal-kind", "agent") + .header("x-temper-principal-id", "attacker") + .header("x-tenant-id", "other-tenant") .body(Body::empty()) .unwrap(); let response = app.oneshot(request).await.unwrap(); @@ -931,4 +964,171 @@ mod tests { let authenticated_response = app.oneshot(authenticated_request).await.unwrap(); assert_eq!(authenticated_response.status(), StatusCode::OK); } + + #[tokio::test] + async fn external_self_asserted_principal_headers_are_rejected() { + async fn tdata_handler() -> impl IntoResponse { + StatusCode::OK + } + + let tempdir = tempfile::tempdir().unwrap(); + let state = AuthState::for_tests(tempdir.path()).await; + let app = Router::new() + .route("/tdata/Agents", get(tdata_handler)) + .merge(router(state.clone())) + .layer(from_fn_with_state(state.clone(), middleware)); + + // A remote client self-asserting an admin principal, with the real TCP + // peer being a public (non-loopback) address and no session cookie. + let mut request = Request::builder() + .method("GET") + .uri("/tdata/Agents") + .header("x-temper-principal-kind", "admin") + .header("x-temper-principal-id", "attacker") + .header("x-tenant-id", "default") + .body(Body::empty()) + .unwrap(); + request + .extensions_mut() + .insert(ConnectInfo(SocketAddr::from(([203, 0, 113, 7], 44321)))); + + let response = app.oneshot(request).await.unwrap(); + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "external self-asserted principal headers must not authenticate as admin" + ); + } + + #[tokio::test] + async fn loopback_forged_identity_family_is_stripped_before_downstream() { + // A public path passes through the middleware to the handler, so this + // proves the full x-temper-* family (not just principal id/kind) is + // removed for external callers before Cedar or a public handler sees it. + async fn probe_handler(headers: HeaderMap) -> impl IntoResponse { + let surviving: Vec = headers + .keys() + .map(|key| key.as_str().to_string()) + .filter(|key| { + key.starts_with("x-temper-") || key == "x-agent-id" || key == "x-tenant-id" + }) + .collect(); + (StatusCode::OK, Json(json!({ "surviving": surviving }))) + } + + let tempdir = tempfile::tempdir().unwrap(); + let state = AuthState::for_tests(tempdir.path()).await; + let app = Router::new() + .route("/triggers/webhook/test", get(probe_handler)) + .merge(router(state.clone())) + .layer(from_fn_with_state(state.clone(), middleware)); + + let mut request = Request::builder() + .method("GET") + .uri("/triggers/webhook/test") + .header("x-temper-principal-kind", "admin") + .header("x-temper-principal-id", "attacker") + .header("x-temper-agent-role", "admin") + .header("x-temper-acting-for", "victim") + .header("x-temper-principal-scopes", "*") + .header("x-temper-attr-clearance", "top-secret") + .header("x-temper-ctx-agentTypeVerified", "true") + .header("x-agent-id", "attacker") + .header("x-tenant-id", "other-tenant") + .body(Body::empty()) + .unwrap(); + request + .extensions_mut() + .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 5555)))); + + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let payload: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!( + payload["surviving"].as_array().map(|values| values.len()), + Some(0), + "no client-asserted identity headers may survive to a downstream handler: {payload}" + ); + } + + #[tokio::test] + async fn loopback_self_asserted_principal_headers_are_rejected() { + async fn tdata_handler() -> impl IntoResponse { + StatusCode::OK + } + + let tempdir = tempfile::tempdir().unwrap(); + let state = AuthState::for_tests(tempdir.path()).await; + let app = Router::new() + .route("/tdata/Agents", get(tdata_handler)) + .merge(router(state.clone())) + .layer(from_fn_with_state(state.clone(), middleware)); + + // Loopback is a routing property, not an identity credential. + let mut request = Request::builder() + .method("GET") + .uri("/tdata/Agents") + .header("x-temper-principal-kind", "admin") + .header("x-temper-principal-id", "temperpaw-transport") + .body(Body::empty()) + .unwrap(); + request + .extensions_mut() + .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 44321)))); + + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn loopback_bearer_credential_binds_kernel_identity() { + async fn tdata_handler(headers: HeaderMap) -> impl IntoResponse { + let value = |name: &str| { + headers + .get(name) + .and_then(|header| header.to_str().ok()) + .unwrap_or_default() + .to_string() + }; + Json(json!({ + "kind": value("x-temper-principal-kind"), + "id": value("x-temper-principal-id"), + "tenant": value("x-tenant-id"), + })) + } + + let tempdir = tempfile::tempdir().unwrap(); + let auth_state = AuthState::for_tests(tempdir.path()).await; + let mut platform_state = temper_platform::PlatformState::new(None); + platform_state.api_token = Some("platform-secret".to_string()); + let app = Router::new() + .route("/tdata/Agents", get(tdata_handler)) + .layer(from_fn_with_state( + platform_state, + temper_platform::bearer_auth::bearer_auth_check, + )) + .layer(from_fn_with_state(auth_state.clone(), middleware)); + + let mut request = Request::builder() + .method("GET") + .uri("/tdata/Agents") + .header("authorization", "Bearer platform-secret") + .header("x-temper-principal-kind", "agent") + .header("x-temper-principal-id", "attacker") + .header("x-tenant-id", "other-tenant") + .body(Body::empty()) + .unwrap(); + request + .extensions_mut() + .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 44321)))); + + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let payload: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(payload["kind"], "admin"); + assert_eq!(payload["id"], "api-key-holder"); + assert_eq!(payload["tenant"], "default"); + } } diff --git a/crates/temperpaw/src/setup.rs b/crates/temperpaw/src/setup.rs index 37ac75dd4..6147dc471 100644 --- a/crates/temperpaw/src/setup.rs +++ b/crates/temperpaw/src/setup.rs @@ -358,7 +358,6 @@ async fn resolve_paw_soul_entity( let agent_response: serde_json::Value = auth .apply(client.get(&agent_url)) .header("x-tenant-id", tenant) - .header("x-temper-principal-kind", "admin") .send() .await? .json() @@ -373,7 +372,6 @@ async fn resolve_paw_soul_entity( let soul_response: serde_json::Value = auth .apply(client.get(&soul_url)) .header("x-tenant-id", tenant) - .header("x-temper-principal-kind", "admin") .send() .await? .json() @@ -389,7 +387,6 @@ async fn resolve_paw_soul_entity( let soul_response: serde_json::Value = auth .apply(client.get(&soul_url)) .header("x-tenant-id", tenant) - .header("x-temper-principal-kind", "admin") .send() .await? .json() @@ -419,7 +416,6 @@ pub(crate) async fn load_paw_soul_content( let content = auth .apply(client.get(format!("{base}/tdata/Files('{file_id}')/$value"))) .header("x-tenant-id", tenant) - .header("x-temper-principal-kind", "admin") .send() .await? .text() @@ -489,7 +485,6 @@ pub(crate) async fn save_soul_to_temper( let resp = auth .apply(client.put(&upload_url)) .header("x-tenant-id", tenant) - .header("x-temper-principal-kind", "admin") .header("content-type", "text/markdown") .body(full_content) .send() diff --git a/crates/temperpaw/src/startup.rs b/crates/temperpaw/src/startup.rs index a0bd74f1c..d4423ad91 100644 --- a/crates/temperpaw/src/startup.rs +++ b/crates/temperpaw/src/startup.rs @@ -3634,17 +3634,14 @@ fn normalize_legacy_workdir(current_workdir: &str) -> Option { None } -/// OData GET helper with tenant + admin auth headers. +/// OData GET helper with deployment tenant and bearer authentication. async fn odata_get( client: &reqwest::Client, url: &str, tenant: &str, api_key: &Option, ) -> Result { - let mut req = client - .get(url) - .header("x-tenant-id", tenant) - .header("x-temper-principal-kind", "admin"); + let mut req = client.get(url).header("x-tenant-id", tenant); if let Some(key) = api_key { req = req.header("authorization", format!("Bearer {key}")); } @@ -3657,7 +3654,7 @@ async fn odata_get( serde_json::from_str(&body).context("Failed to parse JSON response") } -/// OData POST helper with tenant + admin auth headers. +/// OData POST helper with deployment tenant and bearer authentication. async fn odata_post( client: &reqwest::Client, url: &str, @@ -3668,7 +3665,6 @@ async fn odata_post( let mut req = client .post(url) .header("x-tenant-id", tenant) - .header("x-temper-principal-kind", "admin") .header("content-type", "application/json") .json(&body); if let Some(key) = api_key { @@ -3694,7 +3690,6 @@ async fn odata_put_bytes( let mut req = client .put(url) .header("x-tenant-id", tenant) - .header("x-temper-principal-kind", "admin") .header("content-type", content_type) .body(body); if let Some(key) = api_key { @@ -3716,10 +3711,7 @@ async fn odata_get_text( tenant: &str, api_key: &Option, ) -> Result { - let mut req = client - .get(url) - .header("x-tenant-id", tenant) - .header("x-temper-principal-kind", "admin"); + let mut req = client.get(url).header("x-tenant-id", tenant); if let Some(key) = api_key { req = req.header("authorization", format!("Bearer {key}")); } diff --git a/docs/adrs/0066-reject-self-asserted-principal-headers.md b/docs/adrs/0066-reject-self-asserted-principal-headers.md new file mode 100644 index 000000000..ad9c56a37 --- /dev/null +++ b/docs/adrs/0066-reject-self-asserted-principal-headers.md @@ -0,0 +1,73 @@ +# ADR-0066: Reject self-asserted principal headers at the ingress edge + +## Status + +Accepted. + +## Context + +The embedded dashboard/API auth middleware treated any request carrying both +`x-temper-principal-kind` and `x-temper-principal-id` as a +`PreAuthenticatedRequest`. No cookie or bearer token was required, so an +Internet caller could self-assert an admin principal and supply the tenant and +Cedar attributes that the kernel later trusted. + +The first proposed repair stripped those headers only for non-loopback TCP +peers and retained header-only pre-authentication for loopback callers. That +does not establish identity. TemperPaw supports separate same-host processes, +including `paw-codex-worker`, and those processes run lower-trust tasks. Local +reverse proxies and server-side request paths can also terminate on loopback. +Any such caller could still select `admin` and bypass the bearer layer. + +TemperPaw already creates a platform API key during startup. Internal startup +helpers and transports can use that existing bearer credential; agent workers +use registry-issued credentials. A second internal secret or a network-location +identity mechanism is unnecessary. + +This is the TemperPaw instance of the systemic Class A self-asserted-identity +bypass tracked by ARN-167 under ARN-165. The kernel-side credential binding is +tracked separately by ARN-170. + +## Decision + +Identity at the TemperPaw HTTP edge is credential-derived for every network +peer, including loopback: + +1. The outer middleware removes the complete client-assertable identity family + from every request before public-path handling or authentication: + `x-temper-*`, `x-agent-id`, and `x-tenant-id`. +2. A valid TemperPaw session cookie may inject the authenticated dashboard + principal server-side and mark the request pre-authenticated. +3. A bearer credential passes to the kernel bearer middleware, which resolves + either a registered agent identity or the platform API-key administrator. + TemperPaw injects only its configured deployment tenant. +4. Requests with neither credential are rejected on protected paths regardless + of their source address. +5. Internal `PawApiClient` traffic uses its configured bearer token on loopback + exactly as it does remotely. It never synthesizes principal headers. + +Connection metadata may still support logging or rate controls, but it grants no +authentication or authorization capability. + +## Alternatives considered + +- **Trust loopback principal headers.** Rejected because host/network placement + is not an identity boundary and same-host lower-privilege executors are a + supported topology. +- **Add a separate internal shared secret.** This would duplicate the existing + bearer credential path, add distribution and rotation state, and create a + second authentication implementation. +- **Use a Unix-domain socket for internal calls.** This can reduce exposure but + still requires peer/credential authorization and would add platform-specific + transport complexity. It remains optional defense in depth. + +## Consequences + +- Raw identity, delegation, scope, context, and tenant headers are untrusted at + the edge even when the TCP peer is loopback. +- Internal callers must possess the already-generated API key or a registered + scoped credential. No-key header-only compatibility is intentionally removed. +- Session and bearer flows remain the only authentication implementations. +- The runtime server no longer needs `ConnectInfo` solely for auth. +- A compromised local worker cannot become admin by changing request headers; + its effective identity is the one bound to its credential.