diff --git a/docs/security/audit-logging.md b/docs/security/audit-logging.md index 0f67273..d6a5669 100644 --- a/docs/security/audit-logging.md +++ b/docs/security/audit-logging.md @@ -46,6 +46,40 @@ The `source` field distinguishes in-process enforcer events from subprocess prox When the inbound A2A request carries the orchestrator's correlation headers (`X-Workflow-ID`, `X-Workflow-Stage-ID`, `X-Workflow-Step-ID`, `X-Invocation-Caller`), every audit event emitted during that invocation is tagged with the matching `workflow_id` / `stage_id` / `step_id` / `invocation_caller` fields. Header names are vendor-neutral so any A2A-compatible orchestrator can populate them. Direct A2A invocations (no orchestrator) omit the fields entirely — emitted JSON is byte-identical to the pre-correlation shape. See [Workflow correlation IDs](workflow-correlation.md) for the full reference, including outbound propagation for agent-to-agent flows. +### Tenancy stamping + +For deployments where one or more agents serve multiple orgs or workspaces, every audit event can be stamped with `org_id` and `workspace_id` top-level fields so downstream consumers can filter by tenancy without joining against `auth_verify`. Two layers, highest precedence first: + +| Layer | Source | When it wins | +|-------|--------|--------------| +| Per-request override | `X-Forge-Org-ID` / `X-Forge-Workspace-ID` request headers | Always — when present, override the static stamp | +| Deployment-time stamp | `FORGE_ORG_ID` / `FORGE_WORKSPACE_ID` env vars | When the request carries no override headers | + +The deployment-time stamp is read once at agent startup and applied via `AuditLogger.WithTenancy(...)`. It covers every emitted event — startup banners (`agent_card_published`, `policy_loaded`, `audit_export_status`) AND per-invocation events (`session_start`, `llm_call`, `guardrail_check`, `invocation_complete`, etc.). The per-request override only kicks in inside the request scope; startup banners always reflect the env stamp. + +```yaml +# Initializ platform deployment manifest — static-tenancy case +env: + - name: FORGE_ORG_ID + value: "org_abc123" + - name: FORGE_WORKSPACE_ID + value: "ws_xyz789" +``` + +```sh +# Multi-tenant routing case — the orchestrator picks per request +curl -X POST https://agent.example.com/ \ + -H 'X-Forge-Org-ID: org_def456' \ + -H 'X-Forge-Workspace-ID: ws_pqr012' \ + ... +``` + +Both fields use `omitempty`. Deployments that set neither env nor header keep emitting the pre-tenancy JSON shape verbatim — no schema version bump. + +The top-level `org_id` is distinct from `auth_verify.fields.org_id`, which carries whatever the inbound auth token claimed (provider-derived). The top-level value is the operator's declared tenancy, trusted because the deployment / orchestrator set it. Both can be present on the same `auth_verify` event when they're different identifiers (e.g., the token came from a federated identity but the agent is deployed into a specific workspace). + +See [Tenancy stamping reference](tenancy.md) for the precedence rules and the agent-to-agent propagation helper. + ### Token usage and execution duration Every `llm_call` audit event carries the normalized token counts the provider returned in its response metadata, plus the wall-clock time spent in the provider call. Field naming aligns with [OTel GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/) (`gen_ai.usage.input_tokens` / `gen_ai.usage.output_tokens`) so audit consumers can correlate Forge audit events with OTel traces without a translation table. diff --git a/docs/security/tenancy.md b/docs/security/tenancy.md new file mode 100644 index 0000000..8537f33 --- /dev/null +++ b/docs/security/tenancy.md @@ -0,0 +1,84 @@ +--- +title: "Tenancy Stamping" +description: "Stamping org_id and workspace_id on every audit event from env + headers." +order: 9 +--- + +## Tenancy Stamping + +For multi-tenant deployments, every Forge audit event can carry top-level `org_id` and `workspace_id` keys so SIEM / audit-warehouse consumers filter by tenancy without joining against `auth_verify` rows. See issue #157. + +## Two layers + +The same agent process supports both the static-deployment case (one +agent serves one workspace) and the multi-tenant routing case (one +agent serves many workspaces, the orchestrator picks per request). + +| Layer | Source | Wins when | +|-------|--------|-----------| +| 1 — Explicit on event | `AuditEvent.OrgID` / `AuditEvent.WorkspaceID` set before emit | Always — caller-owned event takes precedence over every fallback | +| 2 — Per-request override | `X-Forge-Org-ID` / `X-Forge-Workspace-ID` request headers | Inside the request scope when present; ctx falls through to layer 3 otherwise | +| 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`. + +## Static tenancy (one agent per workspace) + +The simplest case: deploy one Forge agent into one workspace, declare the tenancy via env, never set headers. Every emitted event — including startup banners — carries the stamp. + +```yaml +# Kubernetes deployment fragment +env: + - name: FORGE_ORG_ID + value: "org_abc123" + - name: FORGE_WORKSPACE_ID + value: "ws_xyz789" +``` + +The audit stream then looks like: + +```json +{"ts":"2026-06-14T10:00:00Z","event":"agent_card_published","schema_version":"1.0","org_id":"org_abc123","workspace_id":"ws_xyz789","fields":{...}} +{"ts":"2026-06-14T10:00:05Z","event":"session_start","schema_version":"1.0","seq":1,"correlation_id":"...","task_id":"...","org_id":"org_abc123","workspace_id":"ws_xyz789"} +{"ts":"2026-06-14T10:00:08Z","event":"llm_call","schema_version":"1.0","seq":2,"correlation_id":"...","task_id":"...","model":"...","provider":"...","org_id":"org_abc123","workspace_id":"ws_xyz789"} +``` + +SIEM filter: `org_id = "org_abc123" AND workspace_id = "ws_xyz789"`. + +## Per-request routing (one agent serves many workspaces) + +For deployments where one Forge agent fronts many workspaces and the orchestrator routes per request, set the env vars to a default tenancy (or leave them empty) and have the orchestrator send the override headers on every request: + +```sh +curl -X POST https://agent.example.com/ \ + -H 'X-Forge-Org-ID: org_def456' \ + -H 'X-Forge-Workspace-ID: ws_pqr012' \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":"1","method":"tasks/send","params":{...}}' +``` + +Every audit event emitted during that request carries `"org_id":"org_def456","workspace_id":"ws_pqr012"`. The next request from a different workspace gets its own stamp. + +Startup banners (`agent_card_published`, `policy_loaded`, `audit_export_status`) still reflect the env stamp because they have no request context. + +## Outbound propagation (agent-to-agent flows) + +When one Forge agent calls another (via the egress proxy with explicit propagation), the helper `coreruntime.TenancyContextFromContext(ctx).ApplyToHTTPHeaders(req.Header)` writes both headers onto the outbound request. The downstream agent picks them up at its A2A boundary the same way. + +Auto-propagation is NOT built into the egress proxy. The agent only propagates tenancy when it knows the target is a tenancy-aware Forge peer. This mirrors the workflow-header behavior: explicit only, to avoid leaking tenancy to unrelated third-party APIs. + +## Backwards compatibility + +Both `org_id` and `workspace_id` use `omitempty`. Deployments that set neither env nor header keep emitting the pre-tenancy JSON shape verbatim. Consumers that ignore unknown keys continue to work unchanged. The audit schema version is **not** bumped — additive optional fields are schema-compatible per the documented policy. + +## Distinct from auth_verify.fields.org_id + +The auth provider chain resolves an `Identity.OrgID` from the inbound bearer token (whatever the issuer claims) and stamps it on `auth_verify.fields.org_id` for back-compat. That value reflects the *user's* org from their identity token. + +The top-level `org_id` documented here is the **deployment's** declared tenancy — the operator's explicit assertion of where this agent runs. The two can differ legitimately (federated identity, cross-tenant invocation) and downstream consumers should treat them as independent signals. Both can be present on the same `auth_verify` event. + +## See also + +- [Audit Logging](audit-logging.md) — full event catalog +- [Workflow correlation IDs](workflow-correlation.md) — the sibling FWS-2 header system (`X-Workflow-*`) +- [Authentication](authentication.md) — where `Identity.OrgID` comes from diff --git a/forge-cli/runtime/runner.go b/forge-cli/runtime/runner.go index e15240f..9b3e84f 100644 --- a/forge-cli/runtime/runner.go +++ b/forge-cli/runtime/runner.go @@ -308,6 +308,14 @@ func (r *Runner) Run(ctx context.Context) error { // pre-FWS-7 compatible. auditLogger := coreruntime.NewAuditLoggerFromConfig(r.cfg.AuditExport) auditLogger.SetOpsLogger(r.logger) + // Deployment-time tenancy stamp (#157). FORGE_ORG_ID / + // FORGE_WORKSPACE_ID are read once here and stamped on every + // emitted event — startup banners (agent_card_published, + // policy_loaded) AND per-invocation events all get the stamp. + // Per-request X-Forge-Org-ID / X-Forge-Workspace-ID headers + // (picked up in the A2A handlers) override the static stamp. + // Empty env → empty stamp → fields omitted (backward compatible). + auditLogger.WithTenancy(os.Getenv("FORGE_ORG_ID"), os.Getenv("FORGE_WORKSPACE_ID")) // 4a. Build guardrail checker (DB mode → file mode → defaults) and // wire the audit logger so every mask/block/warn decision lands on @@ -1579,6 +1587,9 @@ func (r *Runner) registerRESTHandlers(srv *server.Server, executor coreruntime.A // WorkflowContext → fields omitted (backward compat). ctx := coreruntime.WithWorkflowContext(req.Context(), coreruntime.WorkflowContextFromHTTPHeaders(req.Header)) + // Same for tenancy override headers (#157). + ctx = coreruntime.WithTenancyContext(ctx, + coreruntime.TenancyContextFromHTTPHeaders(req.Header)) task, snap, err := r.executeTask(ctx, params, store, executor, guardrails, egressClient, auditLogger) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) @@ -1640,6 +1651,9 @@ func (r *Runner) registerRESTHandlers(srv *server.Server, executor coreruntime.A // tagging via EmitFromContext. ctx = coreruntime.WithWorkflowContext(ctx, coreruntime.WorkflowContextFromHTTPHeaders(req.Header)) + // Same for tenancy override headers (#157). + ctx = coreruntime.WithTenancyContext(ctx, + coreruntime.TenancyContextFromHTTPHeaders(req.Header)) // Per-invocation usage accumulator + invocation_complete on exit. // See issue #87 / FWS-3. restSSEAcc := coreruntime.NewLLMUsageAccumulator() @@ -2417,6 +2431,11 @@ func makeAuthAuditCallback(auditLogger *coreruntime.AuditLogger) func(*http.Requ // workflow tags. Empty when the orchestrator didn't send them // — fields then omit (backward compat). wc := coreruntime.WorkflowContextFromHTTPHeaders(req.Header) + // Same for the per-request tenancy override (#157). When + // absent, the AuditLogger's static deployment-time stamp still + // kicks in via plain Emit so auth events match the rest of + // the stream's org_id / workspace_id columns. + tc := coreruntime.TenancyContextFromHTTPHeaders(req.Header) if err == nil && id != nil { // Success → auth_verify. @@ -2437,6 +2456,8 @@ func makeAuthAuditCallback(auditLogger *coreruntime.AuditLogger) func(*http.Requ StageID: wc.StageID, StepID: wc.StepID, InvocationCaller: wc.InvocationCaller, + OrgID: tc.OrgID, + WorkspaceID: tc.WorkspaceID, Fields: fields, }) return @@ -2450,6 +2471,8 @@ func makeAuthAuditCallback(auditLogger *coreruntime.AuditLogger) func(*http.Requ StageID: wc.StageID, StepID: wc.StepID, InvocationCaller: wc.InvocationCaller, + OrgID: tc.OrgID, + WorkspaceID: tc.WorkspaceID, Fields: map[string]any{ "reason": authFailReason(err), "token_kind": tokenKind, diff --git a/forge-cli/server/a2a_server.go b/forge-cli/server/a2a_server.go index 12b8681..b951992 100644 --- a/forge-cli/server/a2a_server.go +++ b/forge-cli/server/a2a_server.go @@ -309,6 +309,14 @@ func (s *Server) handleJSONRPC(w http.ResponseWriter, r *http.Request) { ctx = coreruntime.WithWorkflowContext(ctx, coreruntime.WorkflowContextFromHTTPHeaders(r.Header)) + // Extract per-request tenancy override headers (#157) at the same + // boundary so EmitFromContext can prefer them over the static + // deployment-time stamp installed via AuditLogger.WithTenancy. + // Absent headers produce an IsZero TenancyContext — the static + // stamp wins, or fields omit when no stamp is installed either. + ctx = coreruntime.WithTenancyContext(ctx, + coreruntime.TenancyContextFromHTTPHeaders(r.Header)) + // Phase 3 (#104) — open the inbound dispatch span. Span name // mirrors the JSON-RPC method ("a2a.tasks/send", "a2a.tasks/get", // "a2a.tasks/cancel") so backend dashboards key by the same diff --git a/forge-core/runtime/audit.go b/forge-core/runtime/audit.go index 0fbf3ce..dfb1d06 100644 --- a/forge-core/runtime/audit.go +++ b/forge-core/runtime/audit.go @@ -185,6 +185,29 @@ type AuditEvent struct { // or upstream agent in an agent-to-agent flow). InvocationCaller string `json:"invocation_caller,omitempty"` + // OrgID + WorkspaceID stamp the tenancy this agent run belongs + // to. Sourced from one of three layers (highest precedence first): + // + // 1. Explicit value set on the event before emit. + // 2. Per-request override headers parsed at the A2A boundary + // (X-Forge-Org-ID / X-Forge-Workspace-ID) and stashed on the + // context via WithTenancyContext. + // 3. Deployment-time stamp installed on the AuditLogger via + // WithTenancy(orgID, workspaceID) — typically populated from + // FORGE_ORG_ID / FORGE_WORKSPACE_ID at agent startup. + // + // Both keys use omitempty so deployments that don't set tenancy + // keep emitting the pre-tenancy JSON shape verbatim. The + // AuditSchemaVersion is NOT bumped — additive optional fields are + // schema-compatible per the documented policy. See issue #157. + // + // Distinct from the auth-derived `auth_verify.fields.org_id`, + // which continues to carry whatever the inbound token claimed. + // The top-level OrgID here is the operator's declared tenancy, + // trusted because the deployment / orchestrator set it. + OrgID string `json:"org_id,omitempty"` + WorkspaceID string `json:"workspace_id,omitempty"` + // LLM call attribution (llm_call, llm_call_cancelled, invocation_complete). Model string `json:"model,omitempty"` Provider string `json:"provider,omitempty"` @@ -252,6 +275,46 @@ type AuditLogger struct { sinks []Sink logOnce map[string]bool // sink_name → first-error-already-logged for that sink opsLog Logger // optional structured logger for sink-error reporting; nil disables + + // Static tenancy stamp, installed once at agent startup via + // WithTenancy(). Populated from FORGE_ORG_ID / FORGE_WORKSPACE_ID + // in the CLI runner. EmitFromContext falls back to these whenever + // the request context carries no TenancyContext override. See + // issue #157. + tenantOrgID string + tenantWorkspaceID string +} + +// WithTenancy installs the deployment-time tenancy stamp on the +// AuditLogger. Both arguments are optional — passing "" disables +// the stamp for that field. Called once at runner startup after +// resolving FORGE_ORG_ID / FORGE_WORKSPACE_ID. Returns the receiver +// for fluent construction. +// +// Precedence at emit time (highest first): +// +// 1. Explicit OrgID/WorkspaceID set on the AuditEvent. +// 2. TenancyContext from the request context (per-request override +// header X-Forge-Org-ID / X-Forge-Workspace-ID). +// 3. The static stamp installed here. +// +// Setting tenancy on an already-running AuditLogger is allowed but +// not the common path; hot-reload is the typical caller. +func (a *AuditLogger) WithTenancy(orgID, workspaceID string) *AuditLogger { + a.mu.Lock() + a.tenantOrgID = orgID + a.tenantWorkspaceID = workspaceID + a.mu.Unlock() + return a +} + +// tenancyStamp returns the static tenancy under lock so concurrent +// emit callers don't race against a hot-reload that re-runs +// WithTenancy. Internal — emit paths use this. +func (a *AuditLogger) tenancyStamp() (orgID, workspaceID string) { + a.mu.Lock() + defer a.mu.Unlock() + return a.tenantOrgID, a.tenantWorkspaceID } // NewAuditLogger creates a single-sink AuditLogger wrapping the given @@ -346,6 +409,21 @@ func (a *AuditLogger) Emit(event AuditEvent) { if event.SchemaVersion == "" { event.SchemaVersion = AuditSchemaVersion } + // Deployment-time tenancy stamp (#157). Plain Emit has no request + // context, so the per-request header override path can't fire + // here — but startup banners (agent_card_published, policy_loaded, + // audit_export_status) are exactly the events that MUST carry the + // deployment tenancy so SIEM filters work on every row, not just + // per-invocation events. + if event.OrgID == "" || event.WorkspaceID == "" { + staticOrg, staticWS := a.tenancyStamp() + if event.OrgID == "" { + event.OrgID = staticOrg + } + if event.WorkspaceID == "" { + event.WorkspaceID = staticWS + } + } data, err := json.Marshal(event) if err != nil { return @@ -446,6 +524,31 @@ func (a *AuditLogger) EmitFromContext(ctx context.Context, event AuditEvent) { } } } + // Tenancy stamp (#157) — per-request header override beats the + // deployment-time stamp, which beats the omitempty default. Same + // "context is fallback, not override" rule as the workflow keys + // above, but we ALSO consult the AuditLogger's static stamp when + // the ctx carries no override. Both fields are independent: the + // caller can override one (e.g. WorkspaceID via header) and let + // the other fall back to the env stamp. + if event.OrgID == "" || event.WorkspaceID == "" { + tc := TenancyContextFromContext(ctx) + staticOrg, staticWS := a.tenancyStamp() + if event.OrgID == "" { + if tc.OrgID != "" { + event.OrgID = tc.OrgID + } else { + event.OrgID = staticOrg + } + } + if event.WorkspaceID == "" { + if tc.WorkspaceID != "" { + event.WorkspaceID = tc.WorkspaceID + } else { + event.WorkspaceID = staticWS + } + } + } a.Emit(event) } diff --git a/forge-core/runtime/tenancy.go b/forge-core/runtime/tenancy.go new file mode 100644 index 0000000..64ab6fd --- /dev/null +++ b/forge-core/runtime/tenancy.go @@ -0,0 +1,99 @@ +package runtime + +import ( + "context" + "net/http" +) + +// Tenancy header names (issue #157). The X-Forge- prefix is +// deliberate: these are Forge-defined override headers, distinct +// from the X-Org-ID / org_id headers the auth providers parse to +// resolve the user's identity. The auth-derived org_id continues +// to live in auth_verify.fields.org_id for back-compat; these +// headers populate the top-level audit fields that get stamped on +// EVERY event. +// +// Header semantics: +// +// - Absent: the AuditLogger's deployment-time stamp wins (env +// vars FORGE_ORG_ID / FORGE_WORKSPACE_ID resolved at startup). +// This is the static-tenancy case — agent deployed into one +// workspace, no per-request routing. +// - Present: the header value overrides the env stamp for that +// invocation. This is the multi-tenant case — one Forge agent +// serves many workspaces, the orchestrator routes per request. +// +// Both: header wins. Neither: top-level fields are omitted entirely +// and emitted JSON matches the pre-tenancy shape. +const ( + HeaderForgeOrgID = "X-Forge-Org-ID" + HeaderForgeWorkspaceID = "X-Forge-Workspace-ID" +) + +// TenancyContext carries the org / workspace identifiers a Forge +// agent extracts from inbound A2A request headers. Zero value is +// meaningful — it means "no per-request override; fall back to +// whatever the AuditLogger's static stamp says." +type TenancyContext struct { + OrgID string + WorkspaceID string +} + +// IsZero reports whether the TenancyContext carries no overrides. +// EmitFromContext checks this before reaching for the AuditLogger's +// static stamp. +func (t TenancyContext) IsZero() bool { + return t.OrgID == "" && t.WorkspaceID == "" +} + +// TenancyContextFromHTTPHeaders extracts X-Forge-Org-ID and +// X-Forge-Workspace-ID from an inbound HTTP request's headers. +// Missing headers map to empty fields; the returned TenancyContext +// is IsZero when neither is set. Mirrors WorkflowContextFromHTTPHeaders +// — same pattern, same precedence rules at the call site. +func TenancyContextFromHTTPHeaders(h http.Header) TenancyContext { + return TenancyContext{ + OrgID: h.Get(HeaderForgeOrgID), + WorkspaceID: h.Get(HeaderForgeWorkspaceID), + } +} + +// ApplyToHTTPHeaders writes any non-empty TenancyContext fields +// onto outbound request headers. Used by tools that explicitly +// propagate tenancy to downstream A2A calls in an agent-to-agent +// flow. Auto-propagation is NOT built into the egress proxy — same +// rationale as WorkflowContext: a tenancy header would leak if the +// agent called a non-Forge third party. Tools propagate explicitly +// when they know the target is a tenancy-aware peer. +func (t TenancyContext) ApplyToHTTPHeaders(h http.Header) { + if t.OrgID != "" { + h.Set(HeaderForgeOrgID, t.OrgID) + } + if t.WorkspaceID != "" { + h.Set(HeaderForgeWorkspaceID, t.WorkspaceID) + } +} + +// Context key for the TenancyContext. Unexported — callers go +// through WithTenancyContext / TenancyContextFromContext so the key +// type can never collide with another package's context key. +type tenancyContextKey struct{} + +// WithTenancyContext stores a TenancyContext in the request context. +// Called at the A2A request boundary right after the workflow +// context is installed, so per-invocation handlers and the +// downstream audit emitters see both. +func WithTenancyContext(ctx context.Context, t TenancyContext) context.Context { + return context.WithValue(ctx, tenancyContextKey{}, t) +} + +// TenancyContextFromContext retrieves the TenancyContext from the +// context. Returns the zero value (IsZero == true) when none was +// set, which is the signal EmitFromContext uses to fall back to +// the AuditLogger's static tenancy stamp. +func TenancyContextFromContext(ctx context.Context) TenancyContext { + if t, ok := ctx.Value(tenancyContextKey{}).(TenancyContext); ok { + return t + } + return TenancyContext{} +} diff --git a/forge-core/runtime/tenancy_test.go b/forge-core/runtime/tenancy_test.go new file mode 100644 index 0000000..702db3d --- /dev/null +++ b/forge-core/runtime/tenancy_test.go @@ -0,0 +1,207 @@ +package runtime + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "strings" + "testing" +) + +// TestTenancyContextFromHTTPHeaders covers the standard case (both +// headers present), the absent case (zero value), and the partial +// case (only one header present). +func TestTenancyContextFromHTTPHeaders(t *testing.T) { + mkBoth := func() http.Header { + h := http.Header{} + h.Set(HeaderForgeOrgID, "org_abc") + h.Set(HeaderForgeWorkspaceID, "ws_xyz") + return h + } + mkOrgOnly := func() http.Header { + h := http.Header{} + h.Set(HeaderForgeOrgID, "org_abc") + return h + } + tests := []struct { + name string + h http.Header + want TenancyContext + }{ + { + name: "both headers present", + h: mkBoth(), + want: TenancyContext{OrgID: "org_abc", WorkspaceID: "ws_xyz"}, + }, + { + name: "no headers → IsZero", + h: http.Header{}, + want: TenancyContext{}, + }, + { + name: "only org → workspace omitted", + h: mkOrgOnly(), + want: TenancyContext{OrgID: "org_abc"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := TenancyContextFromHTTPHeaders(tt.h) + if got != tt.want { + t.Errorf("TenancyContextFromHTTPHeaders = %+v, want %+v", got, tt.want) + } + }) + } +} + +// TestTenancyContext_IsZero verifies the zero-value detection that +// EmitFromContext relies on to know whether to fall back to the +// AuditLogger's static stamp. +func TestTenancyContext_IsZero(t *testing.T) { + if !(TenancyContext{}).IsZero() { + t.Error("empty TenancyContext should be IsZero") + } + if (TenancyContext{OrgID: "x"}).IsZero() { + t.Error("TenancyContext with OrgID set should not be IsZero") + } + if (TenancyContext{WorkspaceID: "y"}).IsZero() { + t.Error("TenancyContext with WorkspaceID set should not be IsZero") + } +} + +// TestAuditLogger_StaticTenancyStampsPlainEmit pins the deployment- +// time stamp behavior: WithTenancy installed once at startup, plain +// Emit (no ctx) lands org_id + workspace_id on the event. This is +// the startup-banner case (agent_card_published, policy_loaded). +func TestAuditLogger_StaticTenancyStampsPlainEmit(t *testing.T) { + var buf bytes.Buffer + al := NewAuditLogger(&buf).WithTenancy("org_static", "ws_static") + al.Emit(AuditEvent{Event: "test_banner"}) + + var got AuditEvent + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.OrgID != "org_static" { + t.Errorf("OrgID = %q, want org_static", got.OrgID) + } + if got.WorkspaceID != "ws_static" { + t.Errorf("WorkspaceID = %q, want ws_static", got.WorkspaceID) + } +} + +// TestAuditLogger_NoTenancyStamp_OmitsFields confirms back-compat: +// without WithTenancy and without ctx override, the emitted JSON +// carries no org_id / workspace_id keys at all. +func TestAuditLogger_NoTenancyStamp_OmitsFields(t *testing.T) { + var buf bytes.Buffer + al := NewAuditLogger(&buf) + al.Emit(AuditEvent{Event: "test_banner"}) + + out := buf.String() + if strings.Contains(out, `"org_id"`) { + t.Errorf("expected no org_id key, got: %s", out) + } + if strings.Contains(out, `"workspace_id"`) { + t.Errorf("expected no workspace_id key, got: %s", out) + } +} + +// TestEmitFromContext_HeaderOverridesStaticStamp asserts the +// precedence rule: ctx-carried TenancyContext (from a per-request +// header) beats the AuditLogger's static stamp. +func TestEmitFromContext_HeaderOverridesStaticStamp(t *testing.T) { + var buf bytes.Buffer + al := NewAuditLogger(&buf).WithTenancy("org_env", "ws_env") + ctx := WithTenancyContext(context.Background(), TenancyContext{ + OrgID: "org_header", + WorkspaceID: "ws_header", + }) + al.EmitFromContext(ctx, AuditEvent{Event: "test_invocation"}) + + var got AuditEvent + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.OrgID != "org_header" { + t.Errorf("OrgID = %q, want org_header (header should win)", got.OrgID) + } + if got.WorkspaceID != "ws_header" { + t.Errorf("WorkspaceID = %q, want ws_header (header should win)", got.WorkspaceID) + } +} + +// TestEmitFromContext_PartialHeaderUsesStaticForOther verifies the +// independent-field behavior: a header that sets only OrgID lets +// the static stamp fill in WorkspaceID. Each field is resolved on +// its own merits. +func TestEmitFromContext_PartialHeaderUsesStaticForOther(t *testing.T) { + var buf bytes.Buffer + al := NewAuditLogger(&buf).WithTenancy("org_env", "ws_env") + ctx := WithTenancyContext(context.Background(), TenancyContext{ + OrgID: "org_override", + }) + al.EmitFromContext(ctx, AuditEvent{Event: "test_invocation"}) + + var got AuditEvent + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.OrgID != "org_override" { + t.Errorf("OrgID = %q, want org_override", got.OrgID) + } + if got.WorkspaceID != "ws_env" { + t.Errorf("WorkspaceID = %q, want ws_env (static fallback)", got.WorkspaceID) + } +} + +// TestEmitFromContext_ExplicitEventValueWins protects the +// "explicit-on-event beats every fallback" rule. Mirrors the same +// invariant EmitFromContext upholds for correlation_id, workflow_id, +// trace_id, etc. +func TestEmitFromContext_ExplicitEventValueWins(t *testing.T) { + var buf bytes.Buffer + al := NewAuditLogger(&buf).WithTenancy("org_env", "ws_env") + ctx := WithTenancyContext(context.Background(), TenancyContext{ + OrgID: "org_header", + WorkspaceID: "ws_header", + }) + al.EmitFromContext(ctx, AuditEvent{ + Event: "test_invocation", + OrgID: "org_explicit", + WorkspaceID: "ws_explicit", + }) + + var got AuditEvent + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.OrgID != "org_explicit" { + t.Errorf("OrgID = %q, want org_explicit", got.OrgID) + } + if got.WorkspaceID != "ws_explicit" { + t.Errorf("WorkspaceID = %q, want ws_explicit", got.WorkspaceID) + } +} + +// TestTenancyContext_ApplyToHTTPHeaders verifies the outbound +// propagation helper for agent-to-agent A2A flows. +func TestTenancyContext_ApplyToHTTPHeaders(t *testing.T) { + h := http.Header{} + TenancyContext{OrgID: "org_x", WorkspaceID: "ws_y"}.ApplyToHTTPHeaders(h) + + if got := h.Get(HeaderForgeOrgID); got != "org_x" { + t.Errorf("OrgID header = %q, want org_x", got) + } + if got := h.Get(HeaderForgeWorkspaceID); got != "ws_y" { + t.Errorf("WorkspaceID header = %q, want ws_y", got) + } + + // Zero value writes nothing. + hEmpty := http.Header{} + TenancyContext{}.ApplyToHTTPHeaders(hEmpty) + if len(hEmpty) != 0 { + t.Errorf("expected zero-value TenancyContext to write no headers, got: %+v", hEmpty) + } +}