Skip to content

[Bug] obo_user Action Manifest does not enforce act.sub on the data plane (write allowed without human accountability) #1

Description

@xulis

Summary

When an Action Manifest is registered with auth.mode: obo_user, the data plane (agentgateway :13000/writes) accepts a caller JWT that has no act.sub claim and forwards the write upstream (HTTP 200, write lands on the backend). Per docs/identity-iam.md §2, obo_user requires an RFC 8693 act claim (act.sub = the human): "Token sub alone is not enough". The enforcement appears to be missing on the greenfield data plane.

Related observation (same root area): the audit journal entry for the allowed write contains no caller identity fields at all (no sub / act.sub / caller), so the ledger cannot answer "which human is responsible for this write" even though the docs state obo_user writes must journal the human.

Environment

  • FinGuard image: ghcr.io/finogeeks/finguard:0.1.0 (signed digest sha256:63206295f5724a814892129ff8129f97a5ec26f4145e6450c80d5f14dce7f7a5)
  • Deployment: Compose lab (Docker Desktop, macOS arm64), finguard serve + pinned agentgateway
  • IdP: Keycloak 26.1 (start-dev), realm finguard, RS256 + JWKS
  • OIDC enabled on FinGuard: FINGUARD_OIDC_ISSUER / FINGUARD_OIDC_AUDIENCE=finguard / FINGUARD_OIDC_JWKS_URL set
  • Backend: mock ERP-compatible HTTP service (POST /writes counter)
  • FINGUARD_MCP_REPLAY default (gated); OIDC mode active

Steps to Reproduce

# 1. Obtain a caller JWT from the IdP (Keycloak password grant).
#    This token has sub + admin_role but NO act claim (verified below).
JWT=$(curl -s -X POST http://127.0.0.1:8080/realms/finguard/protocol/openid-connect/token \
  -H "content-type: application/x-www-form-urlencoded" \
  -d "grant_type=password&client_id=finguard&client_secret=finguard-secret&username=admin&password=admin" \
  | python3 -c "import json,sys; print(json.load(sys.stdin)['access_token'])")

# 1b. Confirm the token has no act claim (output below shows: has act claim: False)
echo "$JWT" | cut -d. -f2 | python3 -c "import sys,base64,json; p=sys.stdin.read(); p+='='*(-len(p)%4); c=json.loads(base64.urlsafe_b64decode(p)); print('has act claim:', 'act' in c)"

# 2. Register an obo_user Action Manifest for /writes
curl -s -w '\nHTTP:%{http_code}\n' -X POST http://127.0.0.1:19191/v1/action-manifests \
  -H "Authorization: Bearer $JWT" -H 'content-type: application/json' \
  -d '{
    "schema_version": 1,
    "service": "erp",
    "action_id": "erp.invoice.pay",
    "version": "1",
    "display_name": "Pay invoice",
    "protocol": {"kind": "rest", "method": "POST", "path": "/writes"},
    "risk": {"level": "high", "kind": "w"},
    "approval": {},
    "auth": {"mode": "obo_user"}
  }'
# -> 201 {"action_id":"erp.invoice.pay","version":"1"}

# 3. Call the data plane with the SAME token (no act.sub), as an agent would
curl -s -w '\nHTTP:%{http_code}\n' -X POST http://127.0.0.1:13000/writes \
  -H 'content-type: application/json' \
  -H "x-finguard-id-token: $JWT" \
  -H "Idempotency-Key: obo-repro-$(date +%s)" \
  -d '{"batch":"allow","run":"obo-repro"}'

# 4. Confirm the write actually landed on the backend
curl -s http://127.0.0.1:18081/writes   # -> {"writes":N,"attempts":N} increments

Expected Behavior

The call in step 3 should be rejected (401/403, or an explicit error such as act.sub required) because:

  • the manifest declares auth.mode: obo_user, and
  • the caller JWT carries no act.sub (no RFC 8693 act claim).
    docs/identity-iam.md: "act.sub — Required when the Action Manifest uses auth.mode: obo_user... Token sub alone is not enough."

Actual Behavior

  • Step 3 returns HTTP 200 with a created write result (write_number increments).
  • The backend counter confirms the write landed upstream.
  • The journal entry for this write is mode=allowed, detail=upstream_succeeded and contains no caller identity fields (full field list: event_id, tenant_id, key, digest, canonicalization_version, tier, mode, detail, data_classes, ts_unix_ms).
JWT payload used (no act):
{ "iss": "http://127.0.0.1:8080/realms/finguard",
  "sub": "604e6166-...",
  "aud": "finguard",
  "preferred_username": "admin",
  "admin_role": "system" }
has act claim: False

Step 3 response (HTTP 200):
{"id":"c972794f-...","write_number":4,"status":"created","recipient_count":1,
 "row_count":1,"target_ids":["allow"],
 "auth":{"authorization":null,"x_finguard_id_token":"eyJhbG...","x_erp_token":"...","x_api_key":null}}

Journal entry (no identity):
{"event_id":"5f9fe287-...","tenant_id":"default","key":"obo-issue-...","digest":"d56b9fef-...",
 "canonicalization_version":"v1","tier":"governed","mode":"allowed",
 "detail":"upstream_succeeded","data_classes":[],"ts_unix_ms":1787565416152}

Impact

High. The obo_user contract is the mechanism that requires a human to be responsible for high-risk writes (risk: high, kind: w). With this behavior:

  1. Any agent that holds a valid JWT (issuer/audience/expiry OK) — including unattended cron-style workloads — can execute writes declared as obo_user without proving any human accountability. The auth.mode declaration is effectively a no-op on the data plane.
  2. The audit ledger records what happened but not who (no sub, no act.sub), so accountability and compliance traceability ("which human authorized this write") is not attainable even when the caller did present a JWT.
  3. docs/identity-iam.md states spoofed x-finguard-acted-as is ignored when OIDC is on — but since act.sub itself is not required, the stronger on-behalf-of semantics documented in §2 are not enforced at all.

Possible Cause / Suggested Direction

  • The data-plane decision path (ext_authz/ext_proc on :13000) does not appear to consult the manifest's auth.mode when deciding an obo_user write, or the act.sub presence check is not wired for the greenfield gateway path (it may exist only on POST /v1/decide / POST /v1/actions/invoke).
  • Suggest: enforce act.sub presence on the data plane when the matched manifest declares auth.mode: obo_user, and persist the verified caller identity (sub, and act.sub when present) into the journal entry so the ledger is attributable.

Additional Context

  • Management-plane behavior for reference: with OIDC on, GET /v1/journal and POST /v1/action-manifests accept the caller JWT as Authorization: Bearer <jwt> (with admin_role) and reject the compose admin token (local-compose-token) with 401 oidc required but no bearer or x-finguard-id-token present. This may or may not be intended; flagging it as adjacent context.
  • Lab setup is stub-free (real OIDC + JWKS), so the finding is not an artifact of stub identity.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions