Skip to content

[Bug] Journal entries do not record caller identity (sub / act.sub) despite docs promising 'recorded as caller' / 'journal the human' #2

Description

@xulis

Summary

Audit journal entries contain no caller identity fields (no sub, no act.sub, no caller/actor/principal) even when the write request carried a valid caller JWT that FinGuard verified. The ledger records what happened (key, digest, mode, detail) but never who performed it.

docs/identity-iam.md promises identity recording in two places:

  • §2 JWT field table: sub"Workload / client subject; recorded as caller"
  • §7: "A valid JWT with act.sub must journal the human, not a service-account sub, when the manifest is obo_user."

Note on expectations: the docs do not specify the full journal entry schema. The exact field names/structure below (sub / act.sub / caller) are inferred from the documented semantics ("recorded as caller", "must journal the human"). The core expectation — that the journal records who the caller was — is a direct reading of the docs; the exact field shape is the inference part.

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, realm finguard, RS256 + JWKS; OIDC enabled on FinGuard (FINGUARD_OIDC_ISSUER/AUDIENCE/JWKS_URL)
  • Backend: mock ERP-compatible HTTP service (POST /writes counter)

Steps to Reproduce

# 1. Obtain a caller JWT (password grant). Note its sub (user UUID).
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'])")
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('sub:', c.get('sub'))"

# 2. Send a governed write with the JWT (FinGuard verifies it; ext_authz allow in logs)
curl -s -o /dev/null -w 'write: %{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: journal-issue-$(date +%s)" \
  -d '{"batch":"allow","run":"journal-issue"}'

# 3. Read the last journal entry and inspect its full field set
curl -s -H "Authorization: Bearer $JWT" http://127.0.0.1:19191/v1/journal | python3 -c "
import json,sys
e=json.load(sys.stdin)[-1]
print('journal entry fields:')
print(json.dumps(e, indent=1))
print('has identity fields:', any(k in e for k in ('sub','act_sub','caller','actor','principal')))
"

Expected Behavior

(Per docs §2 / §7 semantics — field names inferred, identity recording expected.)

The journal entry for the allowed write should record the caller identity — at minimum the verified sub (docs: "recorded as caller"), and act.sub when the manifest is obo_user (docs: "must journal the human") — so the ledger is attributable to a principal.

Actual Behavior

The journal entry contains only operational fields. Full field list observed:

{
  "event_id": "5f9fe287-...",
  "tenant_id": "default",
  "key": "journal-issue-...",
  "digest": "d56b9fef-...",
  "canonicalization_version": "v1",
  "tier": "governed",
  "mode": "allowed",
  "detail": "upstream_succeeded",
  "data_classes": [],
  "ts_unix_ms": 1787565416152
}

No sub, no act.sub, no caller/actor/principal field. The same JWT was verified by FinGuard for the write (log: ext_authz allow), so the identity was available at decision time but was not persisted.

Impact

High (for audit/compliance). The journal is the accountability ledger of the product's core governance promise. Without caller identity:

  1. The ledger cannot answer "which human/workload authorized this write" — the audit trail is not attributable, which is typically a hard requirement for enterprise/甲方 audit and compliance.
  2. Combined with issue [Bug] obo_user Action Manifest does not enforce act.sub on the data plane (write allowed without human accountability) #1 (obo_user not enforced on the data plane), there is no enforcement and no attribution — the two gaps compound: high-risk writes declared obo_user can be executed by any valid-JWT caller, and even the fact of who called is not recorded.
  3. The operator console's audit role has a Sessions view (/v1/sessions, per docs/operator-console.md) whose data would be empty/meaningless without identity capture.

Possible Cause / Suggested Direction

  • The journal persistence layer does not currently carry caller identity from the verified JWT (the ext_authz/ext_proc decision path drops it before journal insert), or the journal schema has no identity columns yet.
  • Suggest: persist verified caller identity (sub; act.sub when present) on journal entries for governed writes, and document the journal entry schema in docs/ so the contract is explicit.

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