Skip to content

[Bug] Newer Action Manifest version does not override older one for data-plane decisions (governance update is a no-op) #4

Description

@xulis

Summary

Registering a newer version of the same Action Manifest (action_id unchanged, version bumped) does not override the previously registered version for data-plane decisions. After registering erp.invoice.pay v1 with auth.mode: obo_user and then v2 with plain auth, the data plane (POST :13000/writes) still enforces v1 — the write is denied with 403 obo_user_missing_subject even though v2 (no such constraint) was registered successfully (HTTP 201) and both versions are listed in the catalog.

This means governance changes (e.g. relaxing an action from obo_user to plain, or tightening risk) do not take effect once an older version of the manifest exists — a silent trap for the "connect a legacy system / adjust governance" workflow.

Environment

  • FinGuard image: ghcr.io/finogeeks/finguard:0.1.1 (digest sha256:1f2eac0c06b3d015406e3ed23d6efa1fbc3e9656ae164d3139b22e5b67a68350)
  • Deployment: Compose lab (Docker Desktop, macOS arm64), finguard serve + pinned agentgateway
  • IdP: Keycloak 26.1 (RS256 + JWKS); OIDC enabled; management plane uses caller JWT with admin_role=system
  • Backend: mock HTTP service (POST /writes counter)

Steps to Reproduce

# 1. Obtain a caller JWT (no act.sub — admin user via password grant)
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'])")

# 2. Register v1 with obo_user
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

# 3. Write with the same JWT (no act.sub) -> v1 enforced
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: mv1-$(date +%s)" -d '{"batch":"allow","run":"manifest-v1"}'
# -> 403 {"error":"denied","reason":"obo_user_missing_subject"}

# 4. Register v2 (same action_id, plain auth — no obo_user)
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":"2",
       "display_name":"Pay invoice v2","protocol":{"kind":"rest","method":"POST","path":"/writes"},
       "risk":{"level":"high","kind":"w"},"approval":{},"auth":{}}'
# -> 201

# 5. Same write again — v2 should now govern (no obo constraint) and the write should pass
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: mv2-$(date +%s)" -d '{"batch":"allow","run":"manifest-v2"}'
# -> 403 {"error":"denied","reason":"obo_user_missing_subject"}   (v1 still enforced)

# 6. Confirm both versions coexist in the catalog
curl -s -H "Authorization: Bearer $JWT" http://127.0.0.1:19191/v1/catalog | python3 -c "
import json,sys
for s in json.load(sys.stdin).get('systems',[]):
    for m in s.get('manifests',[]):
        print(m.get('action_id'),'v'+str(m.get('version')),m.get('coverage'),m.get('method'),m.get('path'))"
# -> erp.invoice.pay v1 registered POST /writes
#    erp.invoice.pay v2 registered POST /writes

Expected Behavior

  • Registering v2 of the same action_id should either replace v1 for data-plane decisions (latest version wins), or be rejected as a conflict (409) — never silently coexist while the older version keeps governing.
  • After step 4, the step-5 write should be governed by v2 (plain auth) and pass (HTTP 200), since the caller JWT satisfies v2's requirements.

Actual Behavior

  • Both v1 and v2 are accepted (201) and both appear in /v1/catalog.
  • The data plane continues to enforce v1 (403 obo_user_missing_subject), i.e. the governance update is a no-op.
  • Control: a path that does not match either manifest (POST /writes/reset) passes (200), confirming the 403 is specifically the manifest-match path, not a general data-plane failure.

Impact

Medium-High (governance drift).

  1. Operators cannot update governance for an existing action (change auth.mode, risk, approval) — the change appears to succeed (201 + catalog updated) but is silently ignored by the decision engine.
  2. This is directly relevant to the "onboard a legacy system / adjust governance" workflow: tightening is survivable (old rule still applies), but relaxing (e.g. removing obo_user) is impossible without knowing the version-replacement semantics — an operator would believe the constraint was lifted while it still blocks writes.
  3. The catalog shows both versions without any indicator of which one is authoritative, so the drift is invisible from the operator console.

Possible Cause / Suggested Direction

  • The manifest store likely keys registrations by action_id alone or the decision-path query does not order/select by version (e.g. it fetches the first match, or upserts are not implemented and a new version is appended as a new row that the matcher never consults).
  • Suggest: define and document version semantics — either (a) latest version per action_id is authoritative and replaces older ones in the catalog, or (b) duplicate action_id registration is rejected with 409 unless explicitly versioned, and the matcher must select the authoritative version deterministically. Also surface the active version in /v1/catalog.

Additional Context

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