Skip to content

feat(audit): stamp org_id / workspace_id on every event from env + headers (closes #157)#158

Merged
initializ-mk merged 1 commit into
mainfrom
feat/issue-157-tenancy-stamp
Jun 14, 2026
Merged

feat(audit): stamp org_id / workspace_id on every event from env + headers (closes #157)#158
initializ-mk merged 1 commit into
mainfrom
feat/issue-157-tenancy-stamp

Conversation

@initializ-mk

Copy link
Copy Markdown
Contributor

Summary

Two-layer tenancy stamp so SIEM consumers can filter the Forge audit stream by tenancy without joining against the auth_verify row. Same posture as the existing FWS-2 workflow correlation system — vendor-neutral headers + deployment env vars.

Layers

Layer Source Wins when
1 — Explicit on event AuditEvent.OrgID / AuditEvent.WorkspaceID Always — caller-owned event takes precedence
2 — Per-request override X-Forge-Org-ID / X-Forge-Workspace-ID headers Inside the request scope when present
3 — Deployment-time stamp FORGE_ORG_ID / FORGE_WORKSPACE_ID env vars Whenever the higher layers carry no value

Each field is resolved independently. A request that overrides only X-Forge-Org-ID still lets the env stamp fill in workspace_id.

Deployment example

# Kubernetes manifest — static tenancy
env:
  - name: FORGE_ORG_ID
    value: \"org_abc123\"
  - name: FORGE_WORKSPACE_ID
    value: \"ws_xyz789\"

Every event — startup banners (agent_card_published, policy_loaded, audit_export_status) AND per-invocation events (session_start, llm_call, guardrail_check, invocation_complete) — gets \"org_id\":\"org_abc123\",\"workspace_id\":\"ws_xyz789\" at the top level.

SIEM filter: `org_id = "org_abc123" AND workspace_id = "ws_xyz789"`.

Multi-tenant routing example

curl -X POST https://agent.example.com/ \\
  -H 'X-Forge-Org-ID: org_def456' \\
  -H 'X-Forge-Workspace-ID: ws_pqr012' \\
  -d '...'

The orchestrator picks per request; the override headers shadow the env stamp inside the request scope.

Distinct from auth_verify.fields.org_id

The auth provider chain resolves Identity.OrgID from the bearer token (user's federated identity) and continues to stamp it on `auth_verify.fields.org_id` for back-compat. The new top-level `org_id` is the operator's declared deployment tenancy — trusted because the deployment / orchestrator set it. Both can coexist on the same `auth_verify` event when they're different identifiers.

Files

File Change
`forge-core/runtime/tenancy.go` (new) `TenancyContext`, header constants, `WithTenancyContext`, `TenancyContextFromHTTPHeaders`, `ApplyToHTTPHeaders`. Mirrors `workflow.go` exactly.
`forge-core/runtime/audit.go` Top-level `OrgID`/`WorkspaceID` on `AuditEvent` (omitempty). `WithTenancy(orgID, workspaceID)` method on `AuditLogger`. `EmitFromContext` merges ctx-first / logger-fallback. `Emit` also stamps from the static logger for startup banners.
`forge-cli/runtime/runner.go` Read `FORGE_ORG_ID` / `FORGE_WORKSPACE_ID` at startup; call `auditLogger.WithTenancy(...)`. Pick up override headers in REST tasks/send + sendSubscribe + auth callback.
`forge-cli/server/a2a_server.go` Pick up override headers at the JSON-RPC dispatch boundary, right after the existing workflow-context extraction.
`docs/security/tenancy.md` (new) Full reference: layers, precedence, static/multi-tenant examples, agent-to-agent propagation helper, back-compat, distinction from auth-derived org_id.
`docs/security/audit-logging.md` New Tenancy stamping section linking to the reference.
`forge-core/runtime/tenancy_test.go` (new) Unit tests — see below.

Test plan

Unit tests in `forge-core/runtime/tenancy_test.go`:

  • `TestTenancyContextFromHTTPHeaders` — both / neither / only-org header
  • `TestTenancyContext_IsZero` — zero detection
  • `TestAuditLogger_StaticTenancyStampsPlainEmit` — env stamp lands on startup-banner-style `Emit` (no ctx)
  • `TestAuditLogger_NoTenancyStamp_OmitsFields` — back-compat: no stamp + no header → no keys in emitted JSON
  • `TestEmitFromContext_HeaderOverridesStaticStamp` — header beats env
  • `TestEmitFromContext_PartialHeaderUsesStaticForOther` — independent-field fallback
  • `TestEmitFromContext_ExplicitEventValueWins` — explicit on event beats both
  • `TestTenancyContext_ApplyToHTTPHeaders` — outbound propagation helper

End-to-end (manual):

  • Run with `FORGE_ORG_ID=org_x FORGE_WORKSPACE_ID=ws_y`, watch audit socket, confirm every row (banners + invocation events) carries the stamp.
  • Send a request with `X-Forge-Org-ID: org_z`; confirm that invocation's events carry `org_id=org_z` while `workspace_id` stays `ws_y`.

Local checks:

  • `gofmt -w` applied
  • `golangci-lint run ./...` → 0 issues in forge-core and forge-cli
  • `go test ./runtime/` clean in both modules

Schema impact

Additive only — both keys use `omitempty`. No `AuditSchemaVersion` bump. Existing consumers that ignore unknown keys keep working unchanged.

Out of scope

  • Auto-deriving the top-level `org_id` from `Identity.OrgID`. The auth-derived value stays inside `auth_verify.fields` for back-compat.
  • Auto-propagating tenancy on outbound HTTP via the egress proxy. The helper exists (`ApplyToHTTPHeaders`) but tools opt in explicitly when they know the target is a tenancy-aware Forge peer.

Closes #157

…aders (closes #157)

Two-layer tenancy stamp so SIEM consumers can filter the audit
stream by tenancy without joining against the auth_verify row.
Same posture as the existing FWS-2 workflow correlation system.

Layer 1 (deployment-time):
  FORGE_ORG_ID / FORGE_WORKSPACE_ID env vars → AuditLogger.WithTenancy.
  Read once at runner startup. Stamps every emit, including
  startup banners (agent_card_published, policy_loaded,
  audit_export_status) and per-invocation events.

Layer 2 (per-request override):
  X-Forge-Org-ID / X-Forge-Workspace-ID headers → TenancyContext
  in ctx via TenancyContextFromHTTPHeaders. Per-request override
  for multi-tenant routing — one Forge agent serves many
  workspaces, the orchestrator picks per request. Inside the
  request scope, ctx beats the static stamp.

Precedence at emit (highest first):
  1. Explicit OrgID/WorkspaceID set on the AuditEvent.
  2. TenancyContext from ctx (header override).
  3. AuditLogger's static stamp (env).

Both fields independent: setting only one header lets the
static stamp fill in the other.

Files:
- forge-core/runtime/tenancy.go (new) — TenancyContext, header
  helpers, context plumbing. Mirrors workflow.go.
- forge-core/runtime/audit.go — top-level OrgID/WorkspaceID
  with omitempty; WithTenancy method; ctx-first / logger-fallback
  in EmitFromContext; deployment-stamp pass in plain Emit so
  startup banners participate.
- forge-cli/runtime/runner.go — read env at startup and call
  WithTenancy; pick up override headers in REST handlers and
  auth callback.
- forge-cli/server/a2a_server.go — pick up override headers at
  the JSON-RPC dispatch boundary alongside the workflow context.
- docs/security/tenancy.md (new) — full reference: layers,
  precedence, examples, backwards compat, distinction from
  auth-derived org_id.
- docs/security/audit-logging.md — new Tenancy stamping section.
- forge-core/runtime/tenancy_test.go (new) — covers header
  parsing, IsZero, static stamp on plain Emit, header beats
  static, partial header uses static for other field, explicit
  event value beats all fallbacks, ApplyToHTTPHeaders for
  outbound propagation, omitempty back-compat.

No schema version bump — additive optional fields are schema-
compatible per the documented policy.
@initializ-mk initializ-mk merged commit 90f4f69 into main Jun 14, 2026
10 checks passed
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.

Tenancy stamping: stamp org_id / workspace_id on every audit event from env + headers

1 participant