Skip to content

fix(observe): authorize OTS trajectory endpoints (ARN-187)#347

Open
rita-aga wants to merge 1 commit into
mainfrom
claude/arn-187-ots-auth-gate
Open

fix(observe): authorize OTS trajectory endpoints (ARN-187)#347
rita-aga wants to merge 1 commit into
mainfrom
claude/arn-187-ots-auth-gate

Conversation

@rita-aga

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

Copy link
Copy Markdown
Collaborator

Closes ARN-187 (Urgent, security) under epic ARN-165. Draft for review — do not merge.

Hole

handle_get_ots_trajectories and handle_post_ots_trajectory (crates/temper-server/src/observe/evolution/trajectories.rs) read the raw X-Tenant-Id header and selected that tenant's store with no authorization — sibling handlers in the same file (handle_trajectories) gate, these two didn't. Any caller could read another tenant's full OTS agent-execution traces (GET) or forge trajectories into any tenant (POST) by setting a header.

Fix

require_observe_auth + observe_tenant_scope at the top of both handlers, matching the sibling pattern. None scope → "default" (preserves prior single-tenant behavior).

Red-green

Reverting the two gates reproduces RED (unauth GET/POST returned 200/202, cross-tenant read got 200); the fix turns them GREEN. Tests: anonymous GET/POST → 403; authorized admin GET/POST still succeed; tenant-A can neither read nor write tenant-B. cargo test -p temper-server --features observe observe::evolution::trajectories → 6 passed (full workspace suite green at push).

Notes

  • Same systemic class as ARN-170 (the auth edge); this per-handler gate is correct regardless, and the header-derived-admin path hardens once ARN-170 lands. The authorized-admin test uses header-only admin (valid pre-ARN-170); on rebase after ARN-170 it needs the trusted-marker migration (same as the observe/mod_test.rs migration in ARN-170's PR).

🤖 Generated with Claude Code

Greptile Summary

Closes a missing authorization hole in the OTS trajectory endpoints: handle_get_ots_trajectories and handle_post_ots_trajectory were reading X-Tenant-Id directly with no gate, letting any caller read another tenant's agent-execution traces or forge writes into any tenant. The fix inserts require_observe_auth + observe_tenant_scope at the top of both handlers, exactly matching the sibling handle_trajectories pattern.

  • trajectories.rs: Auth and tenant-scope checks now precede all business logic in both OTS handlers; None scope (admin/single-tenant compat) maps to \"default\", preserving pre-fix behavior.
  • trajectories_test.rs (new): Six regression tests covering anonymous deny (GET/POST), authorized admin allow (GET/POST), and cross-tenant isolation for both read and write paths.
  • Known pre-merge dependency: The authorized_admin_* tests rely on header-only admin identity, which will need the trusted-marker migration after ARN-170 lands (documented in PR notes).

Confidence Score: 4/5

The core auth fix is correct and complete — both handlers now gate identically to their sibling. Safe to review, but the PR is intentionally marked draft pending ARN-170 rebase.

The implementation is clean and the six regression tests cover the critical exploit paths. The only gap is a missing test for the admin-without-tenant branch (None → "default"), which leaves that mapping unprotected against accidental regression. The authorized-admin tests also use the pre-ARN-170 header-only identity pattern that will need updating before merge.

trajectories_test.rs: the None-scope (admin without X-Tenant-Id) path in both OTS handlers has no dedicated test.

Important Files Changed

Filename Overview
crates/temper-server/src/observe/evolution/trajectories.rs Adds require_observe_auth + observe_tenant_scope gates to handle_post_ots_trajectory and handle_get_ots_trajectories, closing the unauthenticated cross-tenant read/write path. Auth calls match the sibling handle_trajectories pattern exactly.
crates/temper-server/src/observe/evolution/trajectories_test.rs New security regression tests covering 6 cases: anonymous GET/POST → 403, authorized admin GET/POST → 200/201, and cross-tenant isolation (same-tenant pass, cross-tenant deny) for both read and write paths. The None-scope branch (admin without X-Tenant-Id → "default") has no dedicated test.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant C as Caller
    participant H as Handler (GET/POST OTS)
    participant RA as require_observe_auth
    participant TS as observe_tenant_scope
    participant Cedar as Cedar Engine
    participant Store as Metadata Store

    C->>H: Request (headers incl. X-Tenant-Id, X-Temper-Principal-Kind)
    H->>RA: require_observe_auth(action, resource_type)
    RA->>RA: "Build SecurityContext from X-Temper-* headers"
    alt Admin or System principal
        RA-->>H: Ok(()) [bypass Cedar]
    else Other principal
        RA->>Cedar: authorize_with_context(tenant from X-Tenant-Id)
        Cedar-->>RA: permit / deny
        alt Denied
            RA-->>H: Err(403 FORBIDDEN)
            H-->>C: 403 Unauthorized
        else Permitted
            RA-->>H: Ok(())
        end
    end
    H->>TS: observe_tenant_scope(headers)
    alt X-Tenant-Id present and non-empty
        TS-->>H: Ok(Some(tenant))
    else Admin/System, no header
        TS-->>H: Ok(None) → default
    else Non-admin, multi-tenant, no header
        TS-->>H: Err(403 FORBIDDEN)
        H-->>C: 403 Unauthorized
    end
    H->>Store: metadata_store_for_tenant(tenant)
    alt No store
        Store-->>H: None → empty/CREATED
    else Store found
        Store-->>H: result rows / enqueue write
    end
    H-->>C: 200 OK / 201 CREATED / 202 ACCEPTED
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 C as Caller
    participant H as Handler (GET/POST OTS)
    participant RA as require_observe_auth
    participant TS as observe_tenant_scope
    participant Cedar as Cedar Engine
    participant Store as Metadata Store

    C->>H: Request (headers incl. X-Tenant-Id, X-Temper-Principal-Kind)
    H->>RA: require_observe_auth(action, resource_type)
    RA->>RA: "Build SecurityContext from X-Temper-* headers"
    alt Admin or System principal
        RA-->>H: Ok(()) [bypass Cedar]
    else Other principal
        RA->>Cedar: authorize_with_context(tenant from X-Tenant-Id)
        Cedar-->>RA: permit / deny
        alt Denied
            RA-->>H: Err(403 FORBIDDEN)
            H-->>C: 403 Unauthorized
        else Permitted
            RA-->>H: Ok(())
        end
    end
    H->>TS: observe_tenant_scope(headers)
    alt X-Tenant-Id present and non-empty
        TS-->>H: Ok(Some(tenant))
    else Admin/System, no header
        TS-->>H: Ok(None) → default
    else Non-admin, multi-tenant, no header
        TS-->>H: Err(403 FORBIDDEN)
        H-->>C: 403 Unauthorized
    end
    H->>Store: metadata_store_for_tenant(tenant)
    alt No store
        Store-->>H: None → empty/CREATED
    else Store found
        Store-->>H: result rows / enqueue write
    end
    H-->>C: 200 OK / 201 CREATED / 202 ACCEPTED
Loading

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

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

---

### Issue 1 of 1
crates/temper-server/src/observe/evolution/trajectories_test.rs:107-122
**Untested `None`-scope branch (admin without `X-Tenant-Id``"default"`)**

All six new tests supply `X-Tenant-Id`, so the `observe_tenant_scope``None``"default"` code path in both handlers is never exercised. If the `unwrap_or("default")` mapping were accidentally changed (e.g. to a 400 error or an all-tenants query), none of these tests would catch it. An admin calling without `X-Tenant-Id` currently gets silently scoped to the `"default"` tenant — worth one explicit test to lock that contract in.

Reviews (2): Last reviewed commit: "fix(observe): authorize OTS trajectory e..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

handle_get_ots_trajectories and handle_post_ots_trajectory read the raw
X-Tenant-Id header and selected that tenant's store with no authorization,
so any caller could read another tenant's OTS agent traces or forge writes
by setting a header. Add require_observe_auth + observe_tenant_scope at the
top of both handlers, matching the sibling gated handlers (handle_trajectories).

Same systemic class as ARN-170 (the auth edge); this per-handler gate is
correct regardless and hardens further once ARN-170 lands.

Co-Authored-By: Claude Fable 5 <[email protected]>
@rita-aga rita-aga marked this pull request as ready for review July 7, 2026 08:35
@rita-aga

rita-aga commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

@greptile review

Comment on lines +341 to 350
require_observe_auth(&state, &headers, "read_trajectories", "Trajectory")?;
// Resolve the tenant through the shared scope helper (never the raw header),
// matching `handle_trajectories`. `None` (admin/system cross-tenant or
// single-tenant compat) maps to the default tenant, since OTS listing is
// per-tenant by construction (`list_ots_trajectories` filters on tenant).
let tenant_scope = observe_tenant_scope(&state, &headers)?;
let tenant = tenant_scope
.as_ref()
.map(|t| t.as_str())
.unwrap_or("default");

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 Admin cross-tenant OTS listing is silently limited to "default"

When an admin omits X-Tenant-Id, observe_tenant_scope returns None and the handler falls back to "default" — only the default tenant's OTS traces are returned. The sibling handle_trajectories takes the opposite approach for None: it calls state.collect_all_metadata_stores() and aggregates across all tenants. An admin trying to list OTS trajectories system-wide without knowing they must specify a tenant per request will silently receive an incomplete result. The comment "matching handle_trajectories" refers to the helper-based resolution pattern, not this None behavior — a future maintainer touching both handlers could easily overlook the divergence. A short clarification in the comment (e.g. "unlike handle_trajectories, None does not aggregate cross-tenant here") would make the design intent self-documenting.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/temper-server/src/observe/evolution/trajectories.rs
Line: 341-350

Comment:
**Admin cross-tenant OTS listing is silently limited to `"default"`**

When an admin omits `X-Tenant-Id`, `observe_tenant_scope` returns `None` and the handler falls back to `"default"` — only the default tenant's OTS traces are returned. The sibling `handle_trajectories` takes the opposite approach for `None`: it calls `state.collect_all_metadata_stores()` and aggregates across all tenants. An admin trying to list OTS trajectories system-wide without knowing they must specify a tenant per request will silently receive an incomplete result. The comment "matching `handle_trajectories`" refers to the helper-based resolution pattern, not this `None` behavior — a future maintainer touching both handlers could easily overlook the divergence. A short clarification in the comment (e.g. "unlike `handle_trajectories`, `None` does not aggregate cross-tenant here") would make the design intent self-documenting.

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

Comment on lines +107 to +122
Request::get("/api/ots/trajectories")
.header("X-Temper-Principal-Kind", "admin")
.header("X-Tenant-Id", "tenant-a")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"authorized admin read should still succeed"
);
}

/// A properly-authorized (admin) same-tenant POST still succeeds.

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 Untested None-scope branch (admin without X-Tenant-Id"default")

All six new tests supply X-Tenant-Id, so the observe_tenant_scopeNone"default" code path in both handlers is never exercised. If the unwrap_or("default") mapping were accidentally changed (e.g. to a 400 error or an all-tenants query), none of these tests would catch it. An admin calling without X-Tenant-Id currently gets silently scoped to the "default" tenant — worth one explicit test to lock that contract in.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/temper-server/src/observe/evolution/trajectories_test.rs
Line: 107-122

Comment:
**Untested `None`-scope branch (admin without `X-Tenant-Id``"default"`)**

All six new tests supply `X-Tenant-Id`, so the `observe_tenant_scope``None``"default"` code path in both handlers is never exercised. If the `unwrap_or("default")` mapping were accidentally changed (e.g. to a 400 error or an all-tenants query), none of these tests would catch it. An admin calling without `X-Tenant-Id` currently gets silently scoped to the `"default"` tenant — worth one explicit test to lock that contract in.

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

Fix in Claude Code Fix in Codex Fix in Cursor

@rita-aga

Copy link
Copy Markdown
Collaborator Author

ARN-165 principle audit — request changes

Adding an authentication check is necessary, but this branch still permits forged attribution and cross-tenant record corruption.

Blocking findings

  1. Durable identity is still body-controlled. agent_id, session_id, and outcome/trace fields are accepted from the request rather than derived from the verified principal/session.
  2. Trajectory IDs are globally keyed. A second tenant can reuse an ID and overwrite or move another tenant's record.
  3. Tests authenticate with self-asserted headers. They do not exercise a real credential-to-principal path, so they can pass while the production boundary remains forgeable.
  4. Admission is incomplete. A negative limit reaches Turso as LIMIT -1, and quota is enforced after durable enqueue.
  5. The producer is independently cross-tenant/unbounded. temper-mcp can combine multiple tenants' complete code/results into one trajectory (tracked as ARN-222); endpoint auth does not repair that data.

Required direction

Derive tenant, agent, session, and actor attribution from the credential/session extension; make IDs tenant-keyed; validate all byte/count/query budgets before enqueue; use stable idempotency; add credential-backed cross-tenant overwrite/read tests and producer-to-OTS E2E coverage.

The failing GitHub test job is runner “No space left on device,” not a source failure. That CI incident should be rerun, but it does not waive the security blockers 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