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).
- 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.
- 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.
- 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
Summary
Registering a newer version of the same Action Manifest (
action_idunchanged,versionbumped) does not override the previously registered version for data-plane decisions. After registeringerp.invoice.payv1 withauth.mode: obo_userand then v2 with plain auth, the data plane (POST :13000/writes) still enforces v1 — the write is denied with403 obo_user_missing_subjecteven 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_userto 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
ghcr.io/finogeeks/finguard:0.1.1(digestsha256:1f2eac0c06b3d015406e3ed23d6efa1fbc3e9656ae164d3139b22e5b67a68350)finguard serve+ pinned agentgatewayadmin_role=systemPOST /writescounter)Steps to Reproduce
Expected Behavior
action_idshould 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.Actual Behavior
/v1/catalog.403 obo_user_missing_subject), i.e. the governance update is a no-op.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).
auth.mode, risk, approval) — the change appears to succeed (201 + catalog updated) but is silently ignored by the decision engine.obo_user) is impossible without knowing the version-replacement semantics — an operator would believe the constraint was lifted while it still blocks writes.Possible Cause / Suggested Direction
action_idalone or the decision-path query does not order/select byversion(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).versionperaction_idis authoritative and replaces older ones in the catalog, or (b) duplicateaction_idregistration 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