From 76eefdd1170bece34689f7b4ffced00a45050659 Mon Sep 17 00:00:00 2001 From: Mateusz Date: Thu, 2 Jul 2026 00:26:19 +0200 Subject: [PATCH 1/3] feat: add control plane principal scope --- .../control-plane-principal-scope/tasks.md | 349 ++++++++-------- docs/dogfood-local.md | 60 +-- internal/archtest/scope_boundary_test.go | 89 ++++ internal/core/auth/errors.go | 11 + internal/core/auth/local_apikey.go | 56 ++- internal/core/auth/local_apikey_record.go | 75 +++- internal/core/auth/local_apikey_scope_test.go | 130 ++++++ internal/core/auth/local_noop.go | 18 +- internal/core/auth/local_noop_scope_test.go | 66 +++ internal/core/auth/scope.go | 166 ++++++++ .../scope_phase6_attribution_only_test.go | 77 ++++ internal/core/auth/scope_test.go | 253 ++++++++++++ internal/core/auxreq/client.go | 12 + internal/core/auxreq/scope_test.go | 84 ++++ .../access_auth_local_attribution_test.go | 167 ++++++++ internal/core/config/access_auth_model.go | 20 + internal/core/config/access_auth_validate.go | 17 + internal/core/execctx/views.go | 15 +- internal/core/execctx/views_scope_test.go | 109 +++++ internal/core/runtime/attempt_stream.go | 21 +- .../core/runtime/attempt_stream_scope_test.go | 337 +++++++++++++++ .../core/runtime/executor_open_attempt.go | 13 +- .../core/runtime/executor_prepare_secure.go | 18 +- internal/core/runtime/executor_scope_test.go | 243 +++++++++++ .../runtime/executor_secure_session_test.go | 22 + .../scope_phase6_compatibility_test.go | 227 ++++++++++ .../scope_phase6_secret_safety_test.go | 177 ++++++++ internal/core/runtime/scope_resolver.go | 70 ++++ internal/core/runtime/scope_resolver_test.go | 138 +++++++ internal/core/runtime/secure_session.go | 9 + internal/stdhttp/auth/adapter.go | 95 ++++- internal/stdhttp/auth/adapter_test.go | 2 +- internal/stdhttp/auth/middleware.go | 35 +- internal/stdhttp/auth/middleware_test.go | 41 +- internal/stdhttp/auth/scope_bridge_test.go | 390 ++++++++++++++++++ pkg/lipsdk/auth/decision.go | 9 +- pkg/lipsdk/auth/decision_scope_test.go | 48 +++ pkg/lipsdk/auth/events.go | 10 +- pkg/lipsdk/auth/events_scope_test.go | 66 +++ pkg/lipsdk/execview/views.go | 3 + pkg/lipsdk/scope/context.go | 32 ++ pkg/lipsdk/scope/context_test.go | 71 ++++ pkg/lipsdk/scope/doc.go | 13 + pkg/lipsdk/scope/value.go | 34 ++ pkg/lipsdk/scope/value_test.go | 87 ++++ pkg/lipsdk/scope/view.go | 75 ++++ pkg/lipsdk/scope/view_test.go | 227 ++++++++++ pkg/lipsdk/traffic/emit.go | 3 +- pkg/lipsdk/traffic/observe.go | 15 +- pkg/lipsdk/traffic/observe_scope_test.go | 134 ++++++ pkg/lipsdk/transport/httpauth/context.go | 12 + .../transport/httpauth/context_scope_test.go | 34 ++ pkg/lipsdk/transport/httpauth/result.go | 6 + .../transport/httpauth/result_scope_test.go | 44 ++ pkg/lipsdk/usage/observe.go | 7 + pkg/lipsdk/usage/observe_scope_test.go | 61 +++ 56 files changed, 4328 insertions(+), 275 deletions(-) create mode 100644 internal/archtest/scope_boundary_test.go create mode 100644 internal/core/auth/local_apikey_scope_test.go create mode 100644 internal/core/auth/local_noop_scope_test.go create mode 100644 internal/core/auth/scope.go create mode 100644 internal/core/auth/scope_phase6_attribution_only_test.go create mode 100644 internal/core/auth/scope_test.go create mode 100644 internal/core/auxreq/scope_test.go create mode 100644 internal/core/config/access_auth_local_attribution_test.go create mode 100644 internal/core/execctx/views_scope_test.go create mode 100644 internal/core/runtime/attempt_stream_scope_test.go create mode 100644 internal/core/runtime/executor_scope_test.go create mode 100644 internal/core/runtime/scope_phase6_compatibility_test.go create mode 100644 internal/core/runtime/scope_phase6_secret_safety_test.go create mode 100644 internal/core/runtime/scope_resolver.go create mode 100644 internal/core/runtime/scope_resolver_test.go create mode 100644 internal/stdhttp/auth/scope_bridge_test.go create mode 100644 pkg/lipsdk/auth/decision_scope_test.go create mode 100644 pkg/lipsdk/auth/events_scope_test.go create mode 100644 pkg/lipsdk/scope/context.go create mode 100644 pkg/lipsdk/scope/context_test.go create mode 100644 pkg/lipsdk/scope/doc.go create mode 100644 pkg/lipsdk/scope/value.go create mode 100644 pkg/lipsdk/scope/value_test.go create mode 100644 pkg/lipsdk/scope/view.go create mode 100644 pkg/lipsdk/scope/view_test.go create mode 100644 pkg/lipsdk/traffic/observe_scope_test.go create mode 100644 pkg/lipsdk/transport/httpauth/context_scope_test.go create mode 100644 pkg/lipsdk/transport/httpauth/result_scope_test.go create mode 100644 pkg/lipsdk/usage/observe_scope_test.go diff --git a/.kiro/specs/control-plane-principal-scope/tasks.md b/.kiro/specs/control-plane-principal-scope/tasks.md index b3881689..2e4f5431 100644 --- a/.kiro/specs/control-plane-principal-scope/tasks.md +++ b/.kiro/specs/control-plane-principal-scope/tasks.md @@ -1,171 +1,180 @@ -# Implementation Plan - -- [x] 1. Establish the public principal/scope contract -- [x] 1.1 Add presence-aware safe attribution values and the authoritative scope snapshot - - Represent unknown, known-empty, and known-populated attribution without overloading plain empty strings. - - Include safe subject kind, principal identity, display label, auth method, credential identifier, roles, safe claims, tenant, organization, workspace, project, department, cost center, policy labels, and origin attribution. - - Clone/copy behavior prevents callers from mutating roles, claims, labels, or nested attribution after receiving a view. - - Done when package-local tests prove value semantics, clone isolation, field safety boundaries, and no raw credential/header fields exist in the public snapshot. - - _Requirements: 1.2, 1.3, 2.5, 2.6, 3.1, 3.2, 3.3, 3.4, 3.6, 5.1, 5.2, 5.3, 5.5_ - - _Boundary: SDK/public contract_ - - _Validation: go test ./pkg/lipsdk/scope_ - -- [x] 1.2 Add the legacy principal projection from the authoritative scope - - Derive the existing principal identity view from the authoritative scope instead of duplicating identity mapping rules. - - Preserve existing principal identity, display label, roles, and claim compatibility for current feature consumers. - - Done when projection tests show scope wins over any separate principal-only input and existing principal consumers receive the same compatible fields. - - _Requirements: 1.1, 1.5, 4.6, 7.3_ - - _Boundary: SDK/public contract_ - - _Validation: go test ./pkg/lipsdk/scope ./pkg/lipsdk/execview_ - -- [x] 2. Normalize trusted authentication into request scope -- [x] 2.1 Add trusted scope carriage to authentication results and audit evidence - - Allow authentication decisions and transport-auth principal results to carry an optional safe scope supplied by trusted auth code. - - Keep raw bearer tokens, API keys, OAuth tokens, resume tokens, and transport headers outside the auth result and event scope data. - - Done when auth contract tests compile with both legacy principal-only decisions and richer scope decisions, and auth evidence includes only safe attribution. - - _Requirements: 2.1, 2.5, 2.6, 5.2, 6.1, 7.1_ - - _Boundary: SDK/public contract_ - - _Depends: 1.1, 1.2_ - - _Validation: go test ./pkg/lipsdk/auth ./pkg/lipsdk/transport/httpauth_ - -- [x] 2.2 Build precedence and denial tests for scope normalization - - Cover precedence in this order: trusted scope, principal projection from trusted scope, legacy principal fallback, then allowed local synthetic fallback. - - Cover denied or challenged decisions so they produce safe decision evidence without creating a successful request lifecycle snapshot. - - Cover missing optional tenant, project, department, and cost-center values so they remain unknown and do not change allow/deny outcomes. - - Done when the focused tests fail against current principal-only behavior and describe the intended normalizer behavior. - - _Requirements: 1.1, 1.4, 1.6, 2.1, 2.2, 3.2, 3.5, 7.2, 8.5_ - - _Boundary: core/auth_ - - _Depends: 2.1_ - - _Validation: go test -run TestScope ./internal/core/auth_ - -- [x] 2.3 Implement trusted scope normalization and safety filtering - - Normalize accepted auth decisions into one authoritative scope plus the derived legacy principal projection. - - Preserve non-secret credential identifiers while omitting or rejecting unsafe attribution before execution begins. - - Keep client-provided session, resume, or scope hints from elevating authority over trusted auth results. - - Done when normalization tests pass and every returned principal view is derived from the returned scope snapshot. - - _Requirements: 1.1, 1.4, 1.5, 2.1, 2.2, 2.5, 2.6, 3.2, 3.5, 5.4, 8.5_ - - _Boundary: core/auth_ - - _Depends: 2.2_ - - _Validation: go test ./internal/core/auth_ - -- [x] 2.4 Add operator-controlled local attribution configuration - - Add optional local API key attribution for display label, auth method, credential id, tenant, organization, workspace, project, department, cost center, roles, safe claims, and policy labels. - - Validate configured known values, roles, safe claim keys, and policy label keys at startup without changing raw key handling. - - Done when config tests prove safe attribution is accepted, unsafe configured attribution is rejected, and missing optional fields remain unknown. - - _Requirements: 2.5, 3.1, 3.2, 3.5, 5.4_ - - _Boundary: core/config_ - - _Depends: 2.3_ - - _Validation: go test ./internal/core/config_ - -- [x] 2.5 Map local API key and local no-auth requests to scope - - Populate scope from validated local API key attribution while preserving non-secret credential identifiers and existing raw key behavior. - - Mark allowed local no-auth requests as local single-user scope without inventing tenant, project, department, or cost-center values. - - Done when local-auth tests prove configured API key attribution and local synthetic identity both produce safe scope snapshots. - - _Requirements: 1.4, 2.4, 2.5, 3.1, 3.2, 3.5_ - - _Boundary: core/auth_ - - _Depends: 2.4_ - - _Validation: go test ./internal/core/auth_ - -- [x] 3. Attach scope at the HTTP trust boundary -- [x] 3.1 Bridge accepted HTTP auth decisions into request context - - Attach both authoritative scope and the derived legacy principal projection for accepted requests before proxy execution begins. - - Preserve current denial, challenge, and frontend response shapes for rejected requests. - - Done when middleware tests prove accepted requests carry matching scope/principal views and rejected requests do not carry a successful lifecycle scope. - - _Requirements: 1.1, 1.5, 1.6, 2.1, 2.3, 4.1, 6.1, 7.1, 7.3_ - - _Boundary: stdhttp/auth_ - - _Depends: 2.3_ - - _Validation: go test ./internal/stdhttp/auth_ - -- [x] 3.2 Emit audit-safe authentication evidence with scope attribution - - Include trace correlation, outcome, reason, and safe principal/scope attribution in auth decision evidence where available. - - Keep raw credentials, raw headers, unvetted claim values, and resume authority out of emitted evidence. - - Done when auth adapter tests prove success and failure evidence contain safe scope identifiers only and preserve existing event compatibility fields. - - _Requirements: 2.6, 5.2, 5.3, 6.1, 6.5, 7.1_ - - _Boundary: stdhttp/auth_ - - _Depends: 3.1_ - - _Validation: go test ./internal/stdhttp/auth_ - -- [x] 4. Carry immutable scope through execution -- [x] 4.1 Add scope to execution views with immutable copy semantics - - Make the authoritative scope available alongside principal, session, attempt, workspace, and annotation views. - - Keep lifecycle annotations separate from trusted attribution and copy maps/slices on insert and read. - - Done when execution-view tests prove scope mutation after attach cannot affect stored views and annotations do not modify attribution. - - _Requirements: 4.2, 4.3, 4.6, 5.1, 5.5_ - - _Boundary: core/execctx_ - - _Depends: 1.1, 1.2_ - - _Validation: go test ./internal/core/execctx_ - -- [x] 4.2 Resolve one scope before secure-session and backend execution - - Read scope from trusted context when present, derive from legacy principal only when no scope exists, and create local synthetic scope only under existing local-mode conditions. - - Pass only the principal and workspace fields secure-session needs, without making secure-session own the richer attribution model. - - Done when runtime tests prove scope is present before backend work and secure-session receives a principal reference derived from the same scope. - - _Requirements: 1.1, 1.4, 2.2, 2.4, 4.1, 4.6, 6.2, 7.2, 7.5_ - - _Boundary: core/runtime_ - - _Depends: 2.5, 3.1, 4.1_ - - _Validation: go test -run Test.*Scope ./internal/core/runtime ./internal/core/securesession/... - -- [x] 4.3 Preserve scope across auxiliary requests and backend attempts - - Preserve parent principal/scope attribution for internally derived requests and mark derived origin separately from trusted attribution. - - Keep all backend attempts for one logical request associated with the same authoritative request scope. - - Done when runtime lineage tests prove parent scope correlation survives internal requests, retries before output, and multi-attempt execution without changing recovery semantics. - - _Requirements: 4.4, 4.5, 6.3, 6.5, 7.5_ - - _Boundary: core/runtime_ - - _Depends: 4.2_ - - _Validation: go test -run Test.*Scope ./internal/core/runtime_ - -- [x] 5. Propagate scope through observers -- [x] 5.1 Add safe scope attribution to usage and traffic observer contracts - - Add scope as optional event metadata while preserving existing principal identifier fields for compatibility. - - Keep observer implementations from needing to understand scope to keep working. - - Done when usage and traffic contract tests compile for existing observer fixtures and prove principal identifiers match the authoritative scope. - - _Requirements: 6.3, 6.4, 6.5, 7.3, 7.6_ - - _Boundary: SDK/public contract_ - - _Depends: 1.1, 1.2_ - - _Validation: go test ./pkg/lipsdk/usage ./pkg/lipsdk/traffic_ - -- [x] 5.2 Emit scope on runtime usage and traffic evidence - - Include safe scope in usage and traffic observations emitted from runtime attempts without changing observer ordering or delivery. - - Keep scope out of backend provider payloads, client-facing protocol responses, and high-cardinality metric labels. - - Done when observer integration tests prove usage and traffic evidence contain safe scope, preserve legacy principal id, and leave backend calls unchanged. - - _Requirements: 5.1, 5.2, 6.3, 6.4, 6.5, 7.4, 7.6_ - - _Boundary: core/runtime_ - - _Depends: 4.2, 5.1_ - - _Validation: go test -run Test.*Scope ./internal/core/runtime_ - -- [x] 6. Prove compatibility and explicit boundaries -- [x] 6.1 Verify client protocol compatibility and routing/session neutrality - - Preserve current frontend request and response shapes while principal/scope attribution stays internal to the proxy. - - Prove missing optional tenant, project, department, and cost-center attribution does not alter routing, secure-session eligibility, backend attempt selection, or non-streaming collection. - - Done when focused compatibility tests pass for frontend shapes, missing optional scope, and streaming versus non-streaming scope consistency. - - _Requirements: 7.1, 7.2, 7.4, 7.5, 7.6, 8.5_ - - _Boundary: tests/integration_ - - _Depends: 4.3, 5.2_ - - _Validation: go test ./internal/stdhttp/... ./internal/plugins/frontends/... ./internal/core/runtime/... - -- [x] 6.2 Verify secret-safety across auth, session, usage, and traffic evidence - - Prove bearer tokens, API keys, OAuth tokens, resume tokens, raw transport headers, and unsafe claim values never appear in safe scope, auth evidence, session evidence, usage events, or traffic observations. - - Prove roles, safe claims, and policy labels are copied before exposure and cannot mutate authoritative request scope. - - Done when security-focused tests pass across the edge, runtime, observer, and execution-view paths. - - _Requirements: 2.6, 5.1, 5.2, 5.3, 5.4, 5.5, 6.1, 6.2, 6.4_ - - _Boundary: tests/security_ - - _Depends: 3.2, 4.1, 5.2_ - - _Validation: go test ./internal/stdhttp/auth ./internal/core/runtime ./internal/core/execctx ./pkg/lipsdk/scope - -- [x] 6.3 Verify this remains attribution-only foundation work - - Prove the feature does not add OAuth/SAML provisioning, billing, budgeting, rate limiting, allowance management, spend enforcement, redaction engines, dangerous-tool policy, policy decision engines, admin GUI flows, reporting charts, or cross-session search. - - Prove principal/scope availability by itself does not change allow/deny outcomes when later enforcement features are absent. - - Done when boundary tests and existing architecture checks pass without new provider-facing scope forwarding or new enforcement/admin surfaces. - - _Requirements: 8.1, 8.2, 8.3, 8.4, 8.5_ - - _Boundary: tests/architecture_ - - _Depends: 6.1, 6.2_ - - _Validation: go test ./internal/archtest/... ./internal/qa/... ./internal/core/runtime/... - -- [x] 7. Run final focused verification for the completed task graph -- [x] 7.1 Run the scope feature's focused verification commands - - Run the package tests that cover scope contracts, auth normalization, HTTP auth bridging, execution views, runtime propagation, observers, and compatibility checks. - - Repair only failures caused by this feature, preserving unrelated user changes. - - Done when the focused command set passes and provides direct evidence for implementation readiness. - - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 5.1, 5.2, 5.3, 5.4, 5.5, 6.1, 6.2, 6.3, 6.4, 6.5, 7.1, 7.2, 7.3, 7.4, 7.5, 7.6, 8.1, 8.2, 8.3, 8.4, 8.5_ - - _Boundary: tests/verification_ - - _Depends: 6.3_ +# Implementation Plan + +- [x] 1. Establish the public principal/scope contract +- [x] 1.1 Add presence-aware safe attribution values and the authoritative scope snapshot + - Represent unknown, known-empty, and known-populated attribution without overloading plain empty strings. + - Include safe subject kind, principal identity, display label, auth method, credential identifier, roles, safe claims, tenant, organization, workspace, project, department, cost center, policy labels, and origin attribution. + - Clone/copy behavior prevents callers from mutating roles, claims, labels, or nested attribution after receiving a view. + - Done when package-local tests prove value semantics, clone isolation, field safety boundaries, and no raw credential/header fields exist in the public snapshot. + - _Requirements: 1.2, 1.3, 2.5, 2.6, 3.1, 3.2, 3.3, 3.4, 3.6, 5.1, 5.2, 5.3, 5.5_ + - _Boundary: SDK/public contract_ + - _Validation: go test ./pkg/lipsdk/scope_ + +- [x] 1.2 Add the legacy principal projection from the authoritative scope + - Derive the existing principal identity view from the authoritative scope instead of duplicating identity mapping rules. + - Preserve existing principal identity, display label, roles, and claim compatibility for current feature consumers. + - Done when projection tests show scope wins over any separate principal-only input and existing principal consumers receive the same compatible fields. + - _Requirements: 1.1, 1.5, 4.6, 7.3_ + - _Boundary: SDK/public contract_ + - _Validation: go test ./pkg/lipsdk/scope ./pkg/lipsdk/execview_ + +- [x] 2. Normalize trusted authentication into request scope +- [x] 2.1 Add trusted scope carriage to authentication results and audit evidence + - Allow authentication decisions and transport-auth principal results to carry an optional safe scope supplied by trusted auth code. + - Keep raw bearer tokens, API keys, OAuth tokens, resume tokens, and transport headers outside the auth result and event scope data. + - Done when auth contract tests compile with both legacy principal-only decisions and richer scope decisions, and auth evidence includes only safe attribution. + - _Requirements: 2.1, 2.5, 2.6, 5.2, 6.1, 7.1_ + - _Boundary: SDK/public contract_ + - _Depends: 1.1, 1.2_ + - _Validation: go test ./pkg/lipsdk/auth ./pkg/lipsdk/transport/httpauth_ + +- [x] 2.2 Build precedence and denial tests for scope normalization + - Cover precedence in this order: trusted scope, principal projection from trusted scope, legacy principal fallback, then allowed local synthetic fallback. + - Cover denied or challenged decisions so they produce safe decision evidence without creating a successful request lifecycle snapshot. + - Cover missing optional tenant, project, department, and cost-center values so they remain unknown and do not change allow/deny outcomes. + - Done when the focused tests fail against current principal-only behavior and describe the intended normalizer behavior. + - _Requirements: 1.1, 1.4, 1.6, 2.1, 2.2, 3.2, 3.5, 7.2, 8.5_ + - _Boundary: core/auth_ + - _Depends: 2.1_ + - _Validation: go test -run TestScope ./internal/core/auth_ + +- [x] 2.3 Implement trusted scope normalization and safety filtering + - Normalize accepted auth decisions into one authoritative scope plus the derived legacy principal projection. + - Preserve non-secret credential identifiers while omitting or rejecting unsafe attribution before execution begins. + - Keep client-provided session, resume, or scope hints from elevating authority over trusted auth results. + - Done when normalization tests pass and every returned principal view is derived from the returned scope snapshot. + - _Requirements: 1.1, 1.4, 1.5, 2.1, 2.2, 2.5, 2.6, 3.2, 3.5, 5.4, 8.5_ + - _Boundary: core/auth_ + - _Depends: 2.2_ + - _Validation: go test ./internal/core/auth_ + +- [x] 2.4 Add operator-controlled local attribution configuration + - Add optional local API key attribution for display label, auth method, credential id, tenant, organization, workspace, project, department, cost center, roles, safe claims, and policy labels. + - Validate configured known values, roles, safe claim keys, and policy label keys at startup without changing raw key handling. + - Done when config tests prove safe attribution is accepted, unsafe configured attribution is rejected, and missing optional fields remain unknown. + - _Requirements: 2.5, 3.1, 3.2, 3.5, 5.4_ + - _Boundary: core/config_ + - _Depends: 2.3_ + - _Validation: go test ./internal/core/config_ + +- [x] 2.5 Map local API key and local no-auth requests to scope + - Populate scope from validated local API key attribution while preserving non-secret credential identifiers and existing raw key behavior. + - Mark allowed local no-auth requests as local single-user scope without inventing tenant, project, department, or cost-center values. + - Done when local-auth tests prove configured API key attribution and local synthetic identity both produce safe scope snapshots. + - _Requirements: 1.4, 2.4, 2.5, 3.1, 3.2, 3.5_ + - _Boundary: core/auth_ + - _Depends: 2.4_ + - _Validation: go test ./internal/core/auth_ + +- [x] 3. Attach scope at the HTTP trust boundary +- [x] 3.1 Bridge accepted HTTP auth decisions into request context + - Attach both authoritative scope and the derived legacy principal projection for accepted requests before proxy execution begins. + - Preserve current denial, challenge, and frontend response shapes for rejected requests. + - Done when middleware tests prove accepted requests carry matching scope/principal views and rejected requests do not carry a successful lifecycle scope. + - _Requirements: 1.1, 1.5, 1.6, 2.1, 2.3, 4.1, 6.1, 7.1, 7.3_ + - _Boundary: stdhttp/auth_ + - _Depends: 2.3_ + - _Validation: go test ./internal/stdhttp/auth_ + +- [x] 3.2 Emit audit-safe authentication evidence with scope attribution + - Include trace correlation, outcome, reason, and safe principal/scope attribution in auth decision evidence where available. + - Keep raw credentials, raw headers, unvetted claim values, and resume authority out of emitted evidence. + - Done when auth adapter tests prove success and failure evidence contain safe scope identifiers only and preserve existing event compatibility fields. + - _Requirements: 2.6, 5.2, 5.3, 6.1, 6.5, 7.1_ + - _Boundary: stdhttp/auth_ + - _Depends: 3.1_ + - _Validation: go test ./internal/stdhttp/auth_ + +- [x] 4. Carry immutable scope through execution +- [x] 4.1 Add scope to execution views with immutable copy semantics + - Make the authoritative scope available alongside principal, session, attempt, workspace, and annotation views. + - Keep lifecycle annotations separate from trusted attribution and copy maps/slices on insert and read. + - Done when execution-view tests prove scope mutation after attach cannot affect stored views and annotations do not modify attribution. + - _Requirements: 4.2, 4.3, 4.6, 5.1, 5.5_ + - _Boundary: core/execctx_ + - _Depends: 1.1, 1.2_ + - _Validation: go test ./internal/core/execctx_ + +- [x] 4.2 Resolve one scope before secure-session and backend execution + - Read scope from trusted context when present, derive from legacy principal only when no scope exists, and create local synthetic scope only under existing local-mode conditions. + - Pass only the principal and workspace fields secure-session needs, without making secure-session own the richer attribution model. + - Done when runtime tests prove scope is present before backend work and secure-session receives a principal reference derived from the same scope. + - _Requirements: 1.1, 1.4, 2.2, 2.4, 4.1, 4.6, 6.2, 7.2, 7.5_ + - _Boundary: core/runtime_ + - _Depends: 2.5, 3.1, 4.1_ + - _Validation: go test -run Test.*Scope ./internal/core/runtime ./internal/core/securesession/... + +- [x] 4.3 Preserve scope across auxiliary requests and backend attempts + - Preserve parent principal/scope attribution for internally derived requests and mark derived origin separately from trusted attribution. + - Keep all backend attempts for one logical request associated with the same authoritative request scope. + - Done when runtime lineage tests prove parent scope correlation survives internal requests, retries before output, and multi-attempt execution without changing recovery semantics. + - _Requirements: 4.4, 4.5, 6.3, 6.5, 7.5_ + - _Boundary: core/runtime_ + - _Depends: 4.2_ + - _Validation: go test -run Test.*Scope ./internal/core/runtime_ + +- [x] 5. Propagate scope through observers +- [x] 5.1 Add safe scope attribution to usage and traffic observer contracts + - Add scope as optional event metadata while preserving existing principal identifier fields for compatibility. + - Keep observer implementations from needing to understand scope to keep working. + - Done when usage and traffic contract tests compile for existing observer fixtures and prove principal identifiers match the authoritative scope. + - _Requirements: 6.3, 6.4, 6.5, 7.3, 7.6_ + - _Boundary: SDK/public contract_ + - _Depends: 1.1, 1.2_ + - _Validation: go test ./pkg/lipsdk/usage ./pkg/lipsdk/traffic_ + +- [x] 5.2 Emit scope on runtime usage and traffic evidence + - Include safe scope in usage and traffic observations emitted from runtime attempts without changing observer ordering or delivery. + - Keep scope out of backend provider payloads, client-facing protocol responses, and high-cardinality metric labels. + - Done when observer integration tests prove usage and traffic evidence contain safe scope, preserve legacy principal id, and leave backend calls unchanged. + - _Requirements: 5.1, 5.2, 6.3, 6.4, 6.5, 7.4, 7.6_ + - _Boundary: core/runtime_ + - _Depends: 4.2, 5.1_ + - _Validation: go test -run Test.*Scope ./internal/core/runtime_ + +- [x] 6. Prove compatibility and explicit boundaries +- [x] 6.1 Verify client protocol compatibility and routing/session neutrality + - Preserve current frontend request and response shapes while principal/scope attribution stays internal to the proxy. + - Prove missing optional tenant, project, department, and cost-center attribution does not alter routing, secure-session eligibility, backend attempt selection, or non-streaming collection. + - Done when focused compatibility tests pass for frontend shapes, missing optional scope, and streaming versus non-streaming scope consistency. + - _Requirements: 7.1, 7.2, 7.4, 7.5, 7.6, 8.5_ + - _Boundary: tests/integration_ + - _Depends: 4.3, 5.2_ + - _Validation: go test ./internal/stdhttp/... ./internal/plugins/frontends/... ./internal/core/runtime/... + +- [x] 6.2 Verify secret-safety across auth, session, usage, and traffic evidence + - Prove bearer tokens, API keys, OAuth tokens, resume tokens, raw transport headers, and unsafe claim values never appear in safe scope, auth evidence, session evidence, usage events, or traffic observations. + - Prove roles, safe claims, and policy labels are copied before exposure and cannot mutate authoritative request scope. + - Done when security-focused tests pass across the edge, runtime, observer, and execution-view paths. + - _Requirements: 2.6, 5.1, 5.2, 5.3, 5.4, 5.5, 6.1, 6.2, 6.4_ + - _Boundary: tests/security_ + - _Depends: 3.2, 4.1, 5.2_ + - _Validation: go test ./internal/stdhttp/auth ./internal/core/runtime ./internal/core/execctx ./pkg/lipsdk/scope + +- [x] 6.3 Verify this remains attribution-only foundation work + - Prove the feature does not add OAuth/SAML provisioning, billing, budgeting, rate limiting, allowance management, spend enforcement, redaction engines, dangerous-tool policy, policy decision engines, admin GUI flows, reporting charts, or cross-session search. + - Prove principal/scope availability by itself does not change allow/deny outcomes when later enforcement features are absent. + - Done when boundary tests and existing architecture checks pass without new provider-facing scope forwarding or new enforcement/admin surfaces. + - _Requirements: 8.1, 8.2, 8.3, 8.4, 8.5_ + - _Boundary: tests/architecture_ + - _Depends: 6.1, 6.2_ + - _Validation: go test ./internal/archtest/... ./internal/qa/... ./internal/core/runtime/... + +- [x] 7. Run final focused verification for the completed task graph +- [x] 7.1 Run the scope feature's focused verification commands + - Run the package tests that cover scope contracts, auth normalization, HTTP auth bridging, execution views, runtime propagation, observers, and compatibility checks. + - Repair only failures caused by this feature, preserving unrelated user changes. + - Done when the focused command set passes and provides direct evidence for implementation readiness. + - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 5.1, 5.2, 5.3, 5.4, 5.5, 6.1, 6.2, 6.3, 6.4, 6.5, 7.1, 7.2, 7.3, 7.4, 7.5, 7.6, 8.1, 8.2, 8.3, 8.4, 8.5_ + - _Boundary: tests/verification_ + - _Depends: 6.3_ - _Validation: go test ./pkg/lipsdk/scope ./pkg/lipsdk/auth ./pkg/lipsdk/transport/httpauth ./pkg/lipsdk/usage ./pkg/lipsdk/traffic ./internal/core/auth ./internal/core/config ./internal/stdhttp/auth ./internal/core/execctx ./internal/core/runtime ./internal/archtest/... + +## Verification Evidence + +Independent verification ran against the working tree on the maintained branch to record completion evidence for the boundary and final-verification task IDs that list multi-package command sets or external guardrails. + +- Task **6.1** (compatibility, frontend/routing/session neutrality): covered by the overlap of task 7.1's selection with the broader `make test-unit` gate (`internal/stdhttp/...`, `internal/plugins/frontends/...`, `internal/core/runtime/...` all green). +- Task **6.2** (secret-safety across auth, session, usage, traffic): covered by task 7.1's selection — `internal/stdhttp/auth`, `internal/core/runtime`, `internal/core/execctx`, and `pkg/lipsdk/scope` all green; the 11 `_scope_test.go` files plus the in-package scope tests collectively assert no raw credential/header/resume values reach safe scope or evidence. +- Task **6.3** (boundary / attribution-only foundation): `internal/archtest` exited 0; guardrail tests enforce dependency direction and forbid provider-SDK, policy-engine, billing, or admin-GUI code on the affected paths. +- Task **7.1** (focused verification command set): the exact 11-package command set in this task's `_Validation` field passed (exit 0 on each package). The broader `make test-unit` gate (full-repo `go test -parallel=8 -timeout=10m ./...`) also returned 0 across all packages. diff --git a/docs/dogfood-local.md b/docs/dogfood-local.md index 5cf531ca..712d9c8b 100644 --- a/docs/dogfood-local.md +++ b/docs/dogfood-local.md @@ -87,38 +87,38 @@ Hosted provider setups belong in [`config/config.yaml`](../config/config.yaml) a ### vLLM CPU smoke (WSL, no GPU keys) -Proven maintainer path for exercising the **vLLM** OpenAI-compatible backend without hosted API keys: run a tiny CPU model in **WSL**, then drive [`scripts/vllm-text-smoke.ps1`](../scripts/vllm-text-smoke.ps1) from Windows PowerShell. The script defaults to `http://localhost:8000/v1`; pass **`-VllmBaseUrl`** when vLLM listens elsewhere. The script requires a non-empty proxy response by default; use `-ExpectedResponsePattern` only with models that reliably follow the exact smoke prompt. +Proven maintainer path for exercising the **vLLM** OpenAI-compatible backend without hosted API keys: run a tiny CPU model in **WSL**, then drive [`scripts/vllm-text-smoke.ps1`](../scripts/vllm-text-smoke.ps1) from Windows PowerShell. The script defaults to `http://localhost:8000/v1`; pass **`-VllmBaseUrl`** when vLLM listens elsewhere. The script requires a non-empty proxy response by default; use `-ExpectedResponsePattern` only with models that reliably follow the exact smoke prompt. -**1. Start vLLM in WSL** (example venv `~/venvs/vllm-cpu`; model `facebook/opt-125m`; port **18000**): +**1. Start vLLM in WSL** (example venv `~/venvs/vllm-cpu`; model `facebook/opt-125m`; port **18000**): ```bash -python3 -m venv ~/venvs/vllm-cpu -source ~/venvs/vllm-cpu/bin/activate -pip install --upgrade pip -pip install uv -uv pip install vllm \ - --extra-index-url https://wheels.vllm.ai/nightly/cpu \ - --index-strategy first-index \ - --torch-backend cpu - -cat > /tmp/vllm-chat-template.jinja <<'EOF' -{% for message in messages %}{% if message['role'] == 'user' %}User: {{ message['content'] }} -{% elif message['role'] == 'assistant' %}Assistant: {{ message['content'] }} -{% elif message['role'] == 'system' %}System: {{ message['content'] }} -{% endif %}{% endfor %}Assistant: -EOF - -vllm serve facebook/opt-125m \ - --host 0.0.0.0 \ - --port 18000 \ - --api-key vllm \ - --dtype float \ - --max-model-len 128 \ - --served-model-name opt-125m \ - --chat-template /tmp/vllm-chat-template.jinja -``` - -The explicit chat template is required for **`/v1/chat/completions`** smoke (including the script's direct preflight and proxy path). Without it, chat requests against base models such as `opt-125m` typically fail with a missing chat-template error. +python3 -m venv ~/venvs/vllm-cpu +source ~/venvs/vllm-cpu/bin/activate +pip install --upgrade pip +pip install uv +uv pip install vllm \ + --extra-index-url https://wheels.vllm.ai/nightly/cpu \ + --index-strategy first-index \ + --torch-backend cpu + +cat > /tmp/vllm-chat-template.jinja <<'EOF' +{% for message in messages %}{% if message['role'] == 'user' %}User: {{ message['content'] }} +{% elif message['role'] == 'assistant' %}Assistant: {{ message['content'] }} +{% elif message['role'] == 'system' %}System: {{ message['content'] }} +{% endif %}{% endfor %}Assistant: +EOF + +vllm serve facebook/opt-125m \ + --host 0.0.0.0 \ + --port 18000 \ + --api-key vllm \ + --dtype float \ + --max-model-len 128 \ + --served-model-name opt-125m \ + --chat-template /tmp/vllm-chat-template.jinja +``` + +The explicit chat template is required for **`/v1/chat/completions`** smoke (including the script's direct preflight and proxy path). Without it, chat requests against base models such as `opt-125m` typically fail with a missing chat-template error. **2. Run the smoke script from the repository root (Windows PowerShell):** @@ -133,7 +133,7 @@ WSL2 forwards `127.0.0.1:18000` on Windows to the listener in WSL. On success th ## Maintainer integration gate (spec task 5.2) -After doc or wiring changes, run repository quality checks and the stage-focused test list from `.kiro/specs/go-stage-five-dogfood-alpha-extension-proof/tasks.md` task **5.2**: +After doc or wiring changes, run repository quality checks and the stage-focused test list from `.kiro/specs/archive/go-stage-five-dogfood-alpha-extension-proof/tasks.md` task **5.2**: ```bash make quality-checks diff --git a/internal/archtest/scope_boundary_test.go b/internal/archtest/scope_boundary_test.go new file mode 100644 index 00000000..bba6cbf8 --- /dev/null +++ b/internal/archtest/scope_boundary_test.go @@ -0,0 +1,89 @@ +package archtest + +import ( + "bytes" + "encoding/json" + "os/exec" + "strings" + "testing" +) + +// TestPhase6_scopePackageImportsStayMinimalAndSafe proves the public scope contract package does +// not depend on enforcement, billing, rate-limiting, redaction, policy-decision, admin, auth, +// runtime, or provider/backend packages: the feature is attribution-only foundation work +// (requirements 8.1, 8.2, 8.3, 8.4, 7.4). Scope may only depend on stdlib and execview. +func TestPhase6_scopePackageImportsStayMinimalAndSafe(t *testing.T) { + t.Parallel() + forbidden := []string{ + "/internal/plugins/backends", + "/internal/plugins/frontends", + "/internal/plugins/features", + "/internal/core/securesession", + "/internal/core/runtime", + "/internal/core/auth", + "/internal/core/config", + "/internal/core/execctx", + "/pkg/lipsdk/usage", + "/pkg/lipsdk/traffic", + "/pkg/lipsdk/auth", + "/pkg/lipsdk/transport", + "billing", + "ratelimit", + "redact", + "oauth", + "saml", + } + for _, imp := range listDirectImports(t, "./pkg/lipsdk/scope") { + low := strings.ToLower(imp) + for _, bad := range forbidden { + if strings.Contains(low, bad) { + t.Fatalf("pkg/lipsdk/scope imports forbidden dependency %q (attribution-only boundary): %s", bad, imp) + } + } + } +} + +// TestPhase6_backendsDoNotDirectlyImportScope proves backend provider adapters do not directly +// import the control-plane scope contract, so attribution is not forwarded to backend providers by +// default (requirement 7.4). Transitively reachable via shared SDK types is acceptable; a direct +// import would indicate a new provider-facing forwarding surface. +func TestPhase6_backendsDoNotDirectlyImportScope(t *testing.T) { + t.Parallel() + for _, pkg := range listPackages(t, "./internal/plugins/backends/...") { + for _, imp := range pkg.Imports { + if strings.HasSuffix(imp, "/pkg/lipsdk/scope") { + t.Fatalf("backend adapter %s directly imports %s (no provider scope forwarding)", pkg.ImportPath, imp) + } + } + } +} + +// listDirectImports returns the direct (non-transitive) imports of a single package pattern. +func listDirectImports(t *testing.T, pattern string) []string { + t.Helper() + pkgs := listPackages(t, pattern) + if len(pkgs) == 0 { + t.Fatalf("no packages matched %s", pattern) + } + return pkgs[0].Imports +} + +func listPackages(t *testing.T, pattern string) []goListPackage { + t.Helper() + cmd := exec.Command("go", "list", "-test=false", "-json", pattern) + cmd.Dir = repoRoot(t) + out, err := cmd.Output() + if err != nil { + t.Fatalf("go list %s: %v", pattern, err) + } + var pkgs []goListPackage + dec := json.NewDecoder(bytes.NewReader(out)) + for dec.More() { + var pkg goListPackage + if err := dec.Decode(&pkg); err != nil { + t.Fatalf("decode: %v", err) + } + pkgs = append(pkgs, pkg) + } + return pkgs +} diff --git a/internal/core/auth/errors.go b/internal/core/auth/errors.go index edd77a43..047a3b80 100644 --- a/internal/core/auth/errors.go +++ b/internal/core/auth/errors.go @@ -7,4 +7,15 @@ var ( ErrDuplicateLocalAPIKeyID = errors.New("auth.local_api_keys: duplicate key_id") ErrDuplicateLocalAPIKeyMaterial = errors.New("auth.local_api_keys: duplicate key material") ErrLocalAPIKeyEmpty = errors.New("auth.local_api_keys: key is required") + ErrInvalidLocalAttribution = errors.New("auth.local_api_keys: invalid attribution") + + // ErrDeniedNoScope is returned by [BuildScope] when the auth decision is not an allow, + // so denied or challenged requests do not create a successful lifecycle scope. + ErrDeniedNoScope = errors.New("auth: denied or challenged decision has no lifecycle scope") + // ErrNoIdentity is returned by [BuildScope] when an allow decision carries no trusted + // scope, no legacy principal, and no local fallback is permitted. + ErrNoIdentity = errors.New("auth: no trusted identity or local fallback for scope") + // ErrUnsafeScope is returned by [BuildScope] when a trusted scope value looks like + // credential material and is rejected before entering request lifecycle evidence. + ErrUnsafeScope = errors.New("auth: scope value rejected as unsafe") ) diff --git a/internal/core/auth/local_apikey.go b/internal/core/auth/local_apikey.go index e98331bb..b73ebf5c 100644 --- a/internal/core/auth/local_apikey.go +++ b/internal/core/auth/local_apikey.go @@ -6,10 +6,13 @@ import ( "crypto/sha256" "crypto/subtle" "encoding/hex" + "maps" + "slices" "strings" sdkauth "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/auth" "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/execview" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/scope" ) // LocalAPIKeyAuthenticator validates bearer API keys against operator-configured records. @@ -23,6 +26,7 @@ type localAPIKeyEntry struct { principalID string secretDigest [sha256.Size]byte fingerprint string + attribution LocalAttribution } // NewLocalAPIKeyAuthenticator builds an authenticator from validated key records. @@ -40,6 +44,7 @@ func NewLocalAPIKeyAuthenticator(records []LocalAPIKeyRecord) (*LocalAPIKeyAuthe principalID: pid, secretDigest: sha256.Sum256([]byte(sec)), fingerprint: redactedAPIKeyFingerprint(kid, sec), + attribution: r.Attribution, }) } return &LocalAPIKeyAuthenticator{records: out}, nil @@ -73,22 +78,55 @@ func (a *LocalAPIKeyAuthenticator) Authenticate(ctx context.Context, req sdkauth } if matched >= 0 { r := a.records[matched] + principal := execview.PrincipalView{ID: strings.TrimSpace(r.principalID)} return sdkauth.Decision{ - Outcome: sdkauth.OutcomeAllow, - Principal: execview.PrincipalView{ - ID: strings.TrimSpace(r.principalID), - }, - Device: sdkauth.DeviceIdentity{ - ID: r.principalID + ":" + r.keyID, - KeyID: r.keyID, - Fingerprint: r.fingerprint, - }, + Outcome: sdkauth.OutcomeAllow, + Principal: principal, + Device: sdkauth.DeviceIdentity{ID: r.principalID + ":" + r.keyID, KeyID: r.keyID, Fingerprint: r.fingerprint}, SatisfiedLevel: sdkauth.LevelAPIKey, + Scope: scopeFromLocalAPIKey(r), }, nil } return sdkauth.Decision{Outcome: sdkauth.OutcomeDeny, ReasonCode: "unknown_api_key"}, nil } +// scopeFromLocalAPIKey builds the authoritative scope from a matched record's non-secret +// attribution. AuthMethod defaults to "local_api_key" (the authenticator knows its method). +// Missing optional fields remain unknown (requirement 3.5). Raw key material never enters. +func scopeFromLocalAPIKey(r localAPIKeyEntry) *scope.PrincipalScopeView { + return &scope.PrincipalScopeView{ + SubjectKind: scope.SubjectService, + Origin: scope.OriginClient, + PrincipalID: scope.Known(r.principalID), + CredentialID: scope.Known(r.keyID), + AuthMethod: knownOrDefault(r.attribution.AuthMethod, "local_api_key"), + DisplayName: knownOrUnknown(r.attribution.DisplayName), + TenantID: knownOrUnknown(r.attribution.TenantID), + OrganizationID: knownOrUnknown(r.attribution.OrganizationID), + WorkspaceID: knownOrUnknown(r.attribution.WorkspaceID), + ProjectID: knownOrUnknown(r.attribution.ProjectID), + DepartmentID: knownOrUnknown(r.attribution.DepartmentID), + CostCenterID: knownOrUnknown(r.attribution.CostCenterID), + Roles: slices.Clone(r.attribution.Roles), + SafeClaims: maps.Clone(r.attribution.SafeClaims), + PolicyLabels: maps.Clone(r.attribution.PolicyLabels), + } +} + +func knownOrUnknown(configured string) scope.Value { + if v := strings.TrimSpace(configured); v != "" { + return scope.Known(v) + } + return scope.Unknown() +} + +func knownOrDefault(configured, def string) scope.Value { + if v := strings.TrimSpace(configured); v != "" { + return scope.Known(v) + } + return scope.Known(def) +} + func stripBearer(s string) string { s = strings.TrimSpace(s) if len(s) >= 7 && strings.EqualFold(s[:7], "bearer ") { diff --git a/internal/core/auth/local_apikey_record.go b/internal/core/auth/local_apikey_record.go index fcfb5052..33393c7c 100644 --- a/internal/core/auth/local_apikey_record.go +++ b/internal/core/auth/local_apikey_record.go @@ -10,15 +10,34 @@ import ( // Shorter keys are rejected at validation to reduce trivial online guessing when the listener is exposed. const MinLocalAPIKeyRunes = 16 +// LocalAttribution carries optional operator-controlled safe attribution for a local API key +// record. Zero values mean "not configured" and map to unknown scope fields (no inference). +// Raw key material, bearer tokens, and transport headers must never be placed here. +type LocalAttribution struct { + DisplayName string + AuthMethod string + TenantID string + OrganizationID string + WorkspaceID string + ProjectID string + DepartmentID string + CostCenterID string + Roles []string + SafeClaims map[string]string + PolicyLabels map[string]string +} + // LocalAPIKeyRecord is one operator-configured API key for [LocalAPIKeyAuthenticator]. // It mirrors config-layer YAML records without importing internal/core/config. type LocalAPIKeyRecord struct { KeyID string PrincipalID string Key string + Attribution LocalAttribution } -// ValidateLocalAPIKeyRecords checks records for duplicates and required fields. +// ValidateLocalAPIKeyRecords checks records for duplicates, required fields, min key length, +// and safe attribution (non-empty roles/claim/label keys, no credential-like values). func ValidateLocalAPIKeyRecords(records []LocalAPIKeyRecord) error { seen := make(map[string]struct{}, len(records)) seenSecrets := make(map[string]struct{}, len(records)) @@ -52,6 +71,60 @@ func ValidateLocalAPIKeyRecords(records []LocalAPIKeyRecord) error { return fmt.Errorf("%w: key material reused for a different key_id is not allowed", ErrDuplicateLocalAPIKeyMaterial) } seenSecrets[key] = struct{}{} + if err := validateLocalAttribution(r.Attribution); err != nil { + return fmt.Errorf("auth.local_api_keys[%d] key_id %q: %w", i, kid, err) + } + } + return nil +} + +func validateLocalAttribution(a LocalAttribution) error { + stringFields := []struct { + name, v string + }{ + {"display_name", a.DisplayName}, + {"auth_method", a.AuthMethod}, + {"tenant_id", a.TenantID}, + {"organization_id", a.OrganizationID}, + {"workspace_id", a.WorkspaceID}, + {"project_id", a.ProjectID}, + {"department_id", a.DepartmentID}, + {"cost_center_id", a.CostCenterID}, + } + for _, f := range stringFields { + val := strings.TrimSpace(f.v) + if val == "" { + continue + } + if looksCredentialLike(val) { + return fmt.Errorf("%w: %s contains credential-like material", ErrInvalidLocalAttribution, f.name) + } + } + for i, role := range a.Roles { + if strings.TrimSpace(role) == "" { + return fmt.Errorf("%w: roles[%d] is empty", ErrInvalidLocalAttribution, i) + } + if looksCredentialLike(role) { + return fmt.Errorf("%w: roles[%d] contains credential-like material", ErrInvalidLocalAttribution, i) + } + } + if err := validateStringMapKeys(a.SafeClaims, "safe_claims"); err != nil { + return err + } + if err := validateStringMapKeys(a.PolicyLabels, "policy_labels"); err != nil { + return err + } + return nil +} + +func validateStringMapKeys(m map[string]string, field string) error { + for k, v := range m { + if strings.TrimSpace(k) == "" { + return fmt.Errorf("%w: %s has empty key", ErrInvalidLocalAttribution, field) + } + if looksCredentialLike(k) || looksCredentialLike(v) { + return fmt.Errorf("%w: %s contains credential-like material", ErrInvalidLocalAttribution, field) + } } return nil } diff --git a/internal/core/auth/local_apikey_scope_test.go b/internal/core/auth/local_apikey_scope_test.go new file mode 100644 index 00000000..a6f3bb70 --- /dev/null +++ b/internal/core/auth/local_apikey_scope_test.go @@ -0,0 +1,130 @@ +package auth + +import ( + "context" + "strings" + "testing" + + sdkauth "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/auth" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/scope" +) + +// TestLocalAPIKeyAuthenticator_validBearerBuildsScope proves a matched local API key +// populates the authoritative scope from validated attribution (requirement 1.4, 2.5, 3.1). +func TestLocalAPIKeyAuthenticator_validBearerBuildsScope(t *testing.T) { + t.Parallel() + secret := "my-api-key-value-16" + a, err := NewLocalAPIKeyAuthenticator([]LocalAPIKeyRecord{ + { + KeyID: "app1", + PrincipalID: "svc-1", + Key: secret, + Attribution: LocalAttribution{ + DisplayName: "Service One", + TenantID: "t1", + Roles: []string{"reader"}, + SafeClaims: map[string]string{"team": "core"}, + PolicyLabels: map[string]string{"env": "prod"}, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + d, err := a.Authenticate(context.Background(), sdkauth.InboundCallMeta{ + AuthorizationBearer: "Bearer " + secret, + }) + if err != nil { + t.Fatal(err) + } + if d.Outcome != sdkauth.OutcomeAllow { + t.Fatalf("Outcome: got %v", d.Outcome) + } + if d.Scope == nil { + t.Fatal("expected scope on local api key allow decision") + } + if d.Scope.SubjectKind != scope.SubjectService { + t.Fatalf("SubjectKind: got %v want service", d.Scope.SubjectKind) + } + if !d.Scope.PrincipalID.Equal(scope.Known("svc-1")) { + t.Fatalf("PrincipalID: %+v", d.Scope.PrincipalID) + } + if !d.Scope.CredentialID.Equal(scope.Known("app1")) { + t.Fatalf("CredentialID: %+v", d.Scope.CredentialID) + } + if d.Scope.AuthMethod.String() != "local_api_key" { + t.Fatalf("AuthMethod: got %q", d.Scope.AuthMethod) + } + if d.Scope.DisplayName.String() != "Service One" { + t.Fatalf("DisplayName: got %q", d.Scope.DisplayName) + } + if !d.Scope.TenantID.Equal(scope.Known("t1")) { + t.Fatalf("TenantID: %+v", d.Scope.TenantID) + } + if len(d.Scope.Roles) != 1 || d.Scope.Roles[0] != "reader" { + t.Fatalf("Roles: %+v", d.Scope.Roles) + } + if d.Scope.SafeClaims["team"] != "core" { + t.Fatalf("SafeClaims: %+v", d.Scope.SafeClaims) + } + if d.Scope.PolicyLabels["env"] != "prod" { + t.Fatalf("PolicyLabels: %+v", d.Scope.PolicyLabels) + } + // Missing optional org fields remain unknown (requirement 3.5). + if !d.Scope.ProjectID.IsUnknown() || !d.Scope.DepartmentID.IsUnknown() || !d.Scope.CostCenterID.IsUnknown() { + t.Fatal("optional org fields must remain unknown when not configured") + } +} + +// TestLocalAPIKeyAuthenticator_scopeOmitsRawKey proves raw key material never enters the +// scope snapshot (requirement 2.6, 5.2). +func TestLocalAPIKeyAuthenticator_scopeOmitsRawKey(t *testing.T) { + t.Parallel() + secret := "my-api-key-value-16" + a, err := NewLocalAPIKeyAuthenticator([]LocalAPIKeyRecord{ + {KeyID: "app1", PrincipalID: "svc-1", Key: secret}, + }) + if err != nil { + t.Fatal(err) + } + d, err := a.Authenticate(context.Background(), sdkauth.InboundCallMeta{ + AuthorizationBearer: "Bearer " + secret, + }) + if err != nil { + t.Fatal(err) + } + if d.Scope == nil { + t.Fatal("expected scope") + } + blob := strings.Join([]string{ + d.Scope.PrincipalID.String(), d.Scope.CredentialID.String(), + d.Scope.AuthMethod.String(), d.Scope.DisplayName.String(), + }, " ") + if strings.Contains(blob, secret) { + t.Fatalf("scope leaked raw key material: %q", blob) + } +} + +// TestLocalAPIKeyAuthenticator_deniedHasNoScope proves a denied local api key attempt never +// carries a successful lifecycle scope (requirement 1.6). +func TestLocalAPIKeyAuthenticator_deniedHasNoScope(t *testing.T) { + t.Parallel() + a, err := NewLocalAPIKeyAuthenticator([]LocalAPIKeyRecord{ + {KeyID: "app1", PrincipalID: "svc-1", Key: "my-api-key-value-16"}, + }) + if err != nil { + t.Fatal(err) + } + d, err := a.Authenticate(context.Background(), sdkauth.InboundCallMeta{ + AuthorizationBearer: "Bearer wrong", + }) + if err != nil { + t.Fatal(err) + } + if d.Outcome != sdkauth.OutcomeDeny { + t.Fatalf("Outcome: got %v", d.Outcome) + } + if d.Scope != nil { + t.Fatalf("denied decision must not carry scope, got %+v", d.Scope) + } +} diff --git a/internal/core/auth/local_noop.go b/internal/core/auth/local_noop.go index f27d6617..2e7bf387 100644 --- a/internal/core/auth/local_noop.go +++ b/internal/core/auth/local_noop.go @@ -6,6 +6,7 @@ import ( sdkauth "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/auth" "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/execview" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/scope" ) // LocalNoOpAuthenticator grants credential-free access with an explicit non-anonymous @@ -42,9 +43,24 @@ func (a LocalNoOpAuthenticator) Authenticate(ctx context.Context, req sdkauth.In id = LocalUnknownOSPrincipalID snap.FallbackUsed = true } + displayName := strings.TrimSpace(snap.DisplayName) + principal := execview.PrincipalView{ID: id, DisplayName: displayName} return sdkauth.Decision{ Outcome: sdkauth.OutcomeAllow, - Principal: execview.PrincipalView{ID: id, DisplayName: strings.TrimSpace(snap.DisplayName)}, + Principal: principal, SatisfiedLevel: sdkauth.LevelNone, + Scope: scopeFromLocalNoOp(id, displayName), }, nil } + +// scopeFromLocalNoOp marks allowed local no-auth requests as local single-user scope without +// inventing tenant, project, department, or cost-center values (requirements 1.4, 2.4, 3.5). +func scopeFromLocalNoOp(principalID, displayName string) *scope.PrincipalScopeView { + return &scope.PrincipalScopeView{ + SubjectKind: scope.SubjectLocal, + Origin: scope.OriginClient, + PrincipalID: scope.Known(principalID), + AuthMethod: scope.Known("local_noop"), + DisplayName: knownOrUnknown(displayName), + } +} diff --git a/internal/core/auth/local_noop_scope_test.go b/internal/core/auth/local_noop_scope_test.go new file mode 100644 index 00000000..344de4ac --- /dev/null +++ b/internal/core/auth/local_noop_scope_test.go @@ -0,0 +1,66 @@ +package auth + +import ( + "context" + "testing" + + sdkauth "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/auth" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/scope" +) + +// TestLocalNoOpAuthenticator_buildsLocalScope proves local no-auth requests are marked as +// local single-user scope without inventing org attribution (requirement 1.4, 2.4, 3.5). +func TestLocalNoOpAuthenticator_buildsLocalScope(t *testing.T) { + t.Parallel() + a := LocalNoOpAuthenticator{ + OS: fakeOSIdentity{snap: OSIdentitySnapshot{ + PrincipalID: "alice", + DisplayName: "Alice Example", + }}, + } + d, err := a.Authenticate(context.Background(), sdkauth.InboundCallMeta{}) + if err != nil { + t.Fatalf("Authenticate: %v", err) + } + if d.Outcome != sdkauth.OutcomeAllow { + t.Fatalf("Outcome: got %v", d.Outcome) + } + if d.Scope == nil { + t.Fatal("expected scope on local no-op allow decision") + } + if d.Scope.SubjectKind != scope.SubjectLocal { + t.Fatalf("SubjectKind: got %v want local", d.Scope.SubjectKind) + } + if !d.Scope.PrincipalID.Equal(scope.Known("alice")) { + t.Fatalf("PrincipalID: %+v", d.Scope.PrincipalID) + } + if d.Scope.AuthMethod.String() != "local_noop" { + t.Fatalf("AuthMethod: got %q", d.Scope.AuthMethod) + } + if !d.Scope.TenantID.IsUnknown() { + t.Fatalf("TenantID must remain unknown, got %+v", d.Scope.TenantID) + } + if !d.Scope.ProjectID.IsUnknown() || !d.Scope.DepartmentID.IsUnknown() || !d.Scope.CostCenterID.IsUnknown() { + t.Fatal("local no-op must not invent org attribution") + } +} + +// TestLocalNoOpAuthenticator_fallbackBuildsLocalScope proves the fallback unknown principal +// still produces a local synthetic scope (requirement 1.4). +func TestLocalNoOpAuthenticator_fallbackBuildsLocalScope(t *testing.T) { + t.Parallel() + a := LocalNoOpAuthenticator{OS: fakeOSIdentity{err: context.Canceled}} + d, err := a.Authenticate(context.Background(), sdkauth.InboundCallMeta{}) + if err != nil { + t.Fatalf("Authenticate: %v", err) + } + if d.Scope == nil { + t.Fatal("expected scope") + } + if d.Scope.SubjectKind != scope.SubjectLocal { + t.Fatalf("SubjectKind: got %v want local", d.Scope.SubjectKind) + } + if d.Scope.PrincipalID.String() != LocalUnknownOSPrincipalID { + t.Fatalf("PrincipalID: got %q", d.Scope.PrincipalID) + } +} diff --git a/internal/core/auth/scope.go b/internal/core/auth/scope.go new file mode 100644 index 00000000..5b92daa5 --- /dev/null +++ b/internal/core/auth/scope.go @@ -0,0 +1,166 @@ +package auth + +import ( + "fmt" + "maps" + "slices" + "strings" + + sdkauth "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/auth" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/execview" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/scope" +) + +// ScopeBuildInput is the input to [BuildScope]: a trusted auth decision. +type ScopeBuildInput struct { + Decision sdkauth.Decision +} + +// ScopeBuildResult is the output of [BuildScope]: one authoritative scope snapshot and the +// derived legacy principal projection. The principal is always derived from the scope. +type ScopeBuildResult struct { + Scope scope.PrincipalScopeView + Principal execview.PrincipalView +} + +// BuildScope normalizes a trusted auth decision into one authoritative principal/scope +// snapshot plus the derived legacy principal projection. +// +// Precedence (highest first): +// 1. Trusted scope on the decision (Decision.Scope) wins; the principal projection is +// derived from it and any legacy Decision.Principal is ignored for identity. +// 2. Legacy principal fallback: when no scope is supplied but the decision carries a +// non-empty principal id, a scope is derived from it. Unknown optional fields remain +// unknown (no inference). AuthMethod is derived from SatisfiedLevel and CredentialID +// from Device.KeyID. +// +// Denied or challenged decisions never produce a successful lifecycle scope. Unsafe +// credential-like material in trusted scope values is rejected before lifecycle evidence. +func BuildScope(input ScopeBuildInput) (ScopeBuildResult, error) { + d := input.Decision + if d.Outcome != sdkauth.OutcomeAllow { + return ScopeBuildResult{}, ErrDeniedNoScope + } + if d.Scope != nil { + s := d.Scope.Clone() + if err := SanitizeScope(s); err != nil { + return ScopeBuildResult{}, err + } + return ScopeBuildResult{Scope: s, Principal: s.Principal()}, nil + } + if pid := strings.TrimSpace(d.Principal.ID); pid != "" { + s := ScopeFromLegacyPrincipal(d.Principal) + s.AuthMethod = authMethodFromLevel(d.SatisfiedLevel) + if kid := strings.TrimSpace(d.Device.KeyID); kid != "" { + s.CredentialID = scope.Known(kid) + } + if err := SanitizeScope(s); err != nil { + return ScopeBuildResult{}, err + } + return ScopeBuildResult{Scope: s, Principal: s.Principal()}, nil + } + return ScopeBuildResult{}, ErrNoIdentity +} + +// ScopeFromLegacyPrincipal derives an authoritative scope from a legacy principal view +// without inferring optional org/tenant fields (requirement 3.5). SubjectKind is unknown +// because the legacy view does not carry subject classification. AuthMethod and +// CredentialID remain unknown here; callers that have them (auth BuildScope) set them on +// the returned view. Shared by auth BuildScope and runtime request-scope resolution. +func ScopeFromLegacyPrincipal(p execview.PrincipalView) scope.PrincipalScopeView { + s := scope.PrincipalScopeView{ + Origin: scope.OriginClient, + SubjectKind: scope.SubjectUnknown, + PrincipalID: scope.Known(strings.TrimSpace(p.ID)), + } + if dn := strings.TrimSpace(p.DisplayName); dn != "" { + s.DisplayName = scope.Known(dn) + } + s.Roles = slices.Clone(p.Roles) + s.SafeClaims = maps.Clone(p.Claims) + return s +} + +func authMethodFromLevel(l sdkauth.RequiredLevel) scope.Value { + switch l { + case sdkauth.LevelAPIKey: + return scope.Known("api_key") + case sdkauth.LevelAPIKeySSO: + return scope.Known("api_key_sso") + case sdkauth.LevelNone: + return scope.Known("none") + default: + return scope.Unknown() + } +} + +// SanitizeScope rejects credential-like material in any scope string field or map value before +// the snapshot enters request lifecycle or audit evidence (requirements 2.6, 5.4). It is the +// shared safety gate called by [BuildScope] for accepted decisions and by the HTTP auth bridge +// for denied/challenged attribution evidence. +func SanitizeScope(s scope.PrincipalScopeView) error { + values := []namedValue{ + {"principal_id", s.PrincipalID}, + {"display_name", s.DisplayName}, + {"auth_method", s.AuthMethod}, + {"credential_id", s.CredentialID}, + {"tenant_id", s.TenantID}, + {"organization_id", s.OrganizationID}, + {"workspace_id", s.WorkspaceID}, + {"project_id", s.ProjectID}, + {"department_id", s.DepartmentID}, + {"cost_center_id", s.CostCenterID}, + {"parent_trace_id", s.ParentTraceID}, + } + for _, v := range values { + if v.Value.IsUnknown() { + continue + } + if looksCredentialLike(v.Value.String()) { + return fmt.Errorf("%w: %s", ErrUnsafeScope, v.Name) + } + } + for _, r := range s.Roles { + if looksCredentialLike(r) { + return fmt.Errorf("%w: roles", ErrUnsafeScope) + } + } + for _, m := range []struct { + name string + v map[string]string + }{{"safe_claims", s.SafeClaims}, {"policy_labels", s.PolicyLabels}} { + for k, val := range m.v { + if looksCredentialLike(k) || looksCredentialLike(val) { + return fmt.Errorf("%w: %s", ErrUnsafeScope, m.name) + } + } + } + return nil +} + +type namedValue struct { + Name string + Value scope.Value +} + +var credentialLikePatterns = []string{ + "bearer ", + "access_token", + "refresh_token", + "id_token", + "client_secret", + "password=", + "api_key=", + "authorization:", + "apikey:", +} + +func looksCredentialLike(s string) bool { + low := strings.ToLower(s) + for _, p := range credentialLikePatterns { + if strings.Contains(low, p) { + return true + } + } + return false +} diff --git a/internal/core/auth/scope_phase6_attribution_only_test.go b/internal/core/auth/scope_phase6_attribution_only_test.go new file mode 100644 index 00000000..c7c6c1fc --- /dev/null +++ b/internal/core/auth/scope_phase6_attribution_only_test.go @@ -0,0 +1,77 @@ +package auth + +import ( + "errors" + "testing" + + sdkauth "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/auth" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/scope" +) + +// TestPhase6_attributionRichnessDoesNotChangeAllowOutcome proves the presence or absence of +// optional tenant/project/department/cost-center attribution does not change the allow/deny +// outcome of BuildScope: a rich scope and a minimal scope on an allowed decision both succeed, +// and attribution by itself never turns a denied decision into an allowed one (requirement 8.5, +// 7.2). BuildScope is a normalizer, not a policy engine. +func TestPhase6_attributionRichnessDoesNotChangeAllowOutcome(t *testing.T) { + t.Parallel() + rich := trustedScope() + min := scope.PrincipalScopeView{ + SubjectKind: scope.SubjectHuman, + PrincipalID: scope.Known("user-1"), + } + richRes, richErr := BuildScope(ScopeBuildInput{ + Decision: sdkauth.Decision{Outcome: sdkauth.OutcomeAllow, Scope: &rich}, + }) + minRes, minErr := BuildScope(ScopeBuildInput{ + Decision: sdkauth.Decision{Outcome: sdkauth.OutcomeAllow, Scope: &min}, + }) + if richErr != nil || minErr != nil { + t.Fatalf("attribution richness changed allow outcome: richErr=%v minErr=%v", richErr, minErr) + } + if !richRes.Scope.PrincipalID.Equal(minRes.Scope.PrincipalID) { + t.Fatalf("principal id drifted with optional attribution: rich=%+v min=%+v", richRes.Scope.PrincipalID, minRes.Scope.PrincipalID) + } + // Optional fields are present on the rich scope and remain unknown on the minimal scope; + // neither outcome was denied for the absence or presence of optional attribution. + if !richRes.Scope.TenantID.IsKnown() { + t.Fatal("rich scope must preserve known optional TenantID") + } + if minRes.Scope.TenantID.IsKnown() { + t.Fatal("minimal scope must leave optional TenantID unknown, not inferred") + } +} + +// TestPhase6_attributionDoesNotAllowDeniedDecision proves a denied decision never yields a +// successful lifecycle scope regardless of how rich the attached attribution is (requirements 8.5, +// 1.6). Attribution cannot override the trusted auth outcome. +func TestPhase6_attributionDoesNotAllowDeniedDecision(t *testing.T) { + t.Parallel() + rich := trustedScope() + _, err := BuildScope(ScopeBuildInput{ + Decision: sdkauth.Decision{Outcome: sdkauth.OutcomeDeny, Scope: &rich, ReasonCode: "missing_api_key"}, + }) + if err == nil { + t.Fatal("rich attribution must not turn a denied decision into an allowed lifecycle scope") + } + if !isDeniedNoScope(err) { + t.Fatalf("denied decision must return ErrDeniedNoScope, got %v", err) + } +} + +// TestPhase6_noIdentityNoAttributionDoesNotAllow proves that without any trusted identity or +// attribution and without local-mode fallback, BuildScope does not synthesize authority +// (requirement 8.5, 1.4). Attribution alone is never a basis for allow. +func TestPhase6_noIdentityNoAttributionDoesNotAllow(t *testing.T) { + t.Parallel() + _, err := BuildScope(ScopeBuildInput{ + Decision: sdkauth.Decision{Outcome: sdkauth.OutcomeAllow}, + }) + if err == nil { + t.Fatal("allow decision with no identity and no local fallback must not produce a scope") + } +} + +func isDeniedNoScope(err error) bool { + return errors.Is(err, ErrDeniedNoScope) +} diff --git a/internal/core/auth/scope_test.go b/internal/core/auth/scope_test.go new file mode 100644 index 00000000..8bd35624 --- /dev/null +++ b/internal/core/auth/scope_test.go @@ -0,0 +1,253 @@ +package auth + +import ( + "errors" + "strings" + "testing" + + sdkauth "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/auth" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/execview" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/scope" +) + +func trustedScope() scope.PrincipalScopeView { + return scope.PrincipalScopeView{ + SubjectKind: scope.SubjectHuman, + PrincipalID: scope.Known("user-1"), + DisplayName: scope.Known("Alice"), + AuthMethod: scope.Known("oidc"), + CredentialID: scope.Known("key-1"), + Roles: []string{"admin"}, + SafeClaims: map[string]string{"team": "core"}, + TenantID: scope.Known("t1"), + OrganizationID: scope.Known("org-1"), + WorkspaceID: scope.Known("ws-1"), + ProjectID: scope.Unknown(), + DepartmentID: scope.Unknown(), + CostCenterID: scope.Unknown(), + PolicyLabels: map[string]string{"env": "prod"}, + Origin: scope.OriginClient, + } +} + +// TestBuildScope_trustedScopeWins proves a trusted auth-provided scope is returned +// authoritatively and the legacy principal projection is derived from it (precedence rung 1). +func TestBuildScope_trustedScopeWins(t *testing.T) { + t.Parallel() + trusted := trustedScope() + res, err := BuildScope(ScopeBuildInput{ + Decision: sdkauth.Decision{Outcome: sdkauth.OutcomeAllow, Scope: &trusted}, + }) + if err != nil { + t.Fatalf("BuildScope: %v", err) + } + if !res.Scope.PrincipalID.Equal(scope.Known("user-1")) { + t.Fatalf("scope PrincipalID: %+v", res.Scope.PrincipalID) + } + if res.Scope.AuthMethod.String() != "oidc" { + t.Fatalf("AuthMethod: got %q", res.Scope.AuthMethod) + } + if res.Principal.ID != "user-1" { + t.Fatalf("principal projection ID: got %q", res.Principal.ID) + } +} + +// TestBuildScope_principalDerivedFromTrustedScope proves the returned principal view is the +// projection of the returned scope, not the legacy principal field (requirements 1.5, 4.6). +func TestBuildScope_principalDerivedFromTrustedScope(t *testing.T) { + t.Parallel() + trusted := trustedScope() + trusted.PrincipalID = scope.Known("scope-wins") + res, err := BuildScope(ScopeBuildInput{ + Decision: sdkauth.Decision{ + Outcome: sdkauth.OutcomeAllow, + Principal: execview.PrincipalView{ID: "legacy-loses"}, + Scope: &trusted, + }, + }) + if err != nil { + t.Fatalf("BuildScope: %v", err) + } + if res.Principal.ID != "scope-wins" { + t.Fatalf("expected scope-derived principal %q, got %q", "scope-wins", res.Principal.ID) + } + projection := res.Scope.Principal() + if projection.ID != res.Principal.ID { + t.Fatalf("principal must equal scope projection: %+v vs %+v", res.Principal, projection) + } +} + +// TestBuildScope_legacyPrincipalFallback proves a principal-only decision (no scope) still +// produces an authoritative scope with unknown optional fields preserved as unknown. +func TestBuildScope_legacyPrincipalFallback(t *testing.T) { + t.Parallel() + res, err := BuildScope(ScopeBuildInput{ + Decision: sdkauth.Decision{ + Outcome: sdkauth.OutcomeAllow, + Principal: execview.PrincipalView{ID: "legacy-user", DisplayName: "Legacy", Roles: []string{"ops"}, Claims: map[string]string{"t": "a"}}, + Device: sdkauth.DeviceIdentity{KeyID: "kid-1"}, + SatisfiedLevel: sdkauth.LevelAPIKey, + }, + }) + if err != nil { + t.Fatalf("BuildScope: %v", err) + } + if !res.Scope.PrincipalID.Equal(scope.Known("legacy-user")) { + t.Fatalf("PrincipalID: %+v", res.Scope.PrincipalID) + } + if res.Scope.SubjectKind != scope.SubjectUnknown { + t.Fatalf("SubjectKind: got %v want unknown (no inference)", res.Scope.SubjectKind) + } + if !res.Scope.CredentialID.Equal(scope.Known("kid-1")) { + t.Fatalf("CredentialID: %+v", res.Scope.CredentialID) + } + if res.Scope.AuthMethod.String() != "api_key" { + t.Fatalf("AuthMethod: got %q want api_key (from SatisfiedLevel)", res.Scope.AuthMethod) + } + if !res.Scope.TenantID.IsUnknown() { + t.Fatalf("TenantID must remain unknown, got %+v", res.Scope.TenantID) + } + if !res.Scope.ProjectID.IsUnknown() || !res.Scope.DepartmentID.IsUnknown() || !res.Scope.CostCenterID.IsUnknown() { + t.Fatal("optional org fields must remain unknown (requirement 3.5)") + } + if res.Principal.ID != "legacy-user" { + t.Fatalf("principal projection ID: got %q", res.Principal.ID) + } +} + +// TestBuildScope_deniedDecisionReturnsNoScope proves denied decisions do not create a +// successful lifecycle scope (requirement 1.6, 8.5). +func TestBuildScope_deniedDecisionReturnsNoScope(t *testing.T) { + t.Parallel() + _, err := BuildScope(ScopeBuildInput{ + Decision: sdkauth.Decision{Outcome: sdkauth.OutcomeDeny, ReasonCode: "no_key"}, + }) + if err == nil { + t.Fatal("expected error for denied decision") + } + if !errors.Is(err, ErrDeniedNoScope) { + t.Fatalf("expected ErrDeniedNoScope, got %v", err) + } +} + +// TestBuildScope_challengeDecisionReturnsNoScope proves challenged decisions do not create +// a successful lifecycle scope (requirement 1.6). +func TestBuildScope_challengeDecisionReturnsNoScope(t *testing.T) { + t.Parallel() + trusted := trustedScope() + _, err := BuildScope(ScopeBuildInput{ + Decision: sdkauth.Decision{Outcome: sdkauth.OutcomeChallenge, Scope: &trusted}, + }) + if err == nil { + t.Fatal("expected error for challenged decision") + } + if !errors.Is(err, ErrDeniedNoScope) { + t.Fatalf("expected ErrDeniedNoScope, got %v", err) + } +} + +// TestBuildScope_noIdentityReturnsError proves a non-local allow without any identity or +// scope does not silently produce an anonymous snapshot (requirement 1.4, 2.2). +func TestBuildScope_noIdentityReturnsError(t *testing.T) { + t.Parallel() + _, err := BuildScope(ScopeBuildInput{ + Decision: sdkauth.Decision{Outcome: sdkauth.OutcomeAllow}, + }) + if err == nil { + t.Fatal("expected error for allow without identity") + } + if !errors.Is(err, ErrNoIdentity) { + t.Fatalf("expected ErrNoIdentity, got %v", err) + } +} + +// TestBuildScope_rejectsUnsafeScopeValue proves the normalizer rejects credential-like +// material before it enters request lifecycle evidence (requirement 2.6, 5.4). +func TestBuildScope_rejectsUnsafeScopeValue(t *testing.T) { + t.Parallel() + unsafe := trustedScope() + unsafe.PrincipalID = scope.Known("bearer abcdef0123456789") + if _, err := BuildScope(ScopeBuildInput{ + Decision: sdkauth.Decision{Outcome: sdkauth.OutcomeAllow, Scope: &unsafe}, + }); err == nil { + t.Fatal("expected error for credential-like scope value") + } +} + +// TestBuildScope_clientHintsDoNotElevate proves a client-supplied legacy principal cannot +// override a trusted scope (requirement 2.2). +func TestBuildScope_clientHintsDoNotElevate(t *testing.T) { + t.Parallel() + trusted := trustedScope() + trusted.PrincipalID = scope.Known("trusted-id") + res, err := BuildScope(ScopeBuildInput{ + Decision: sdkauth.Decision{ + Outcome: sdkauth.OutcomeAllow, + Principal: execview.PrincipalView{ID: "attacker-id"}, + Scope: &trusted, + }, + }) + if err != nil { + t.Fatalf("BuildScope: %v", err) + } + if res.Scope.PrincipalID.String() != "trusted-id" { + t.Fatalf("client hint elevated authority: got %q", res.Scope.PrincipalID) + } +} + +// TestBuildScope_trustedScopeIsCloned proves the returned scope is a copy so callers cannot +// mutate the trusted source (requirement 5.5). +func TestBuildScope_trustedScopeIsCloned(t *testing.T) { + t.Parallel() + trusted := trustedScope() + res, err := BuildScope(ScopeBuildInput{ + Decision: sdkauth.Decision{Outcome: sdkauth.OutcomeAllow, Scope: &trusted}, + }) + if err != nil { + t.Fatalf("BuildScope: %v", err) + } + res.Scope.Roles[0] = "mutated" + if trusted.Roles[0] == "mutated" { + t.Fatal("mutating returned scope affected trusted source") + } +} + +// TestBuildScope_doesNotInferMissingOptionalFields is a focused regression for requirement 3.5. +func TestBuildScope_doesNotInferMissingOptionalFields(t *testing.T) { + t.Parallel() + res, err := BuildScope(ScopeBuildInput{ + Decision: sdkauth.Decision{ + Outcome: sdkauth.OutcomeAllow, + Principal: execview.PrincipalView{ID: "u"}, + SatisfiedLevel: sdkauth.LevelAPIKey, + }, + }) + if err != nil { + t.Fatalf("BuildScope: %v", err) + } + for _, v := range []scope.Value{res.Scope.TenantID, res.Scope.OrganizationID, res.Scope.WorkspaceID, res.Scope.ProjectID, res.Scope.DepartmentID, res.Scope.CostCenterID} { + if !v.IsUnknown() { + t.Fatalf("optional field must remain unknown, got %+v", v) + } + } +} + +// TestBuildScope_preservesNonSecretCredentialID proves non-secret credential identifiers are +// preserved through normalization (requirement 2.5). +func TestBuildScope_preservesNonSecretCredentialID(t *testing.T) { + t.Parallel() + trusted := trustedScope() + trusted.CredentialID = scope.Known("app1:key-1") + res, err := BuildScope(ScopeBuildInput{ + Decision: sdkauth.Decision{Outcome: sdkauth.OutcomeAllow, Scope: &trusted}, + }) + if err != nil { + t.Fatalf("BuildScope: %v", err) + } + if res.Scope.CredentialID.String() != "app1:key-1" { + t.Fatalf("CredentialID: got %q", res.Scope.CredentialID) + } + if strings.Contains(res.Scope.CredentialID.String(), "secret") { + t.Fatal("credential id must not be secret material") + } +} diff --git a/internal/core/auxreq/client.go b/internal/core/auxreq/client.go index 5fc9fd33..89258736 100644 --- a/internal/core/auxreq/client.go +++ b/internal/core/auxreq/client.go @@ -10,6 +10,7 @@ import ( "github.com/matdev83/go-llm-interactive-proxy/internal/core/execctx" "github.com/matdev83/go-llm-interactive-proxy/pkg/lipapi" "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/auxiliary" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/scope" ) // ExecutorRunner is satisfied by [*runtime.Executor] for auxiliary delegation. @@ -49,6 +50,17 @@ func (c Client) Stream(ctx context.Context, req auxiliary.Request) (lipapi.Event if len(req.DisablePlugins) > 0 { childCtx = execctx.WithSuppressedPluginIDs(childCtx, req.DisablePlugins) } + // Preserve parent principal/scope attribution and mark the derived origin separately so + // auxiliary/internal requests stay correlated to the authoritative parent snapshot without + // inheriting client origin authority (requirement 4.4). ScopeFromContext returns an owned + // clone, so we mutate it directly; WithScope clones again on store. + if derived, ok := scope.ScopeFromContext(ctx); ok { + derived.Origin = scope.OriginInternal + if req.ParentTraceID != "" { + derived.ParentTraceID = scope.Known(req.ParentTraceID) + } + childCtx = scope.WithScope(childCtx, derived) + } work := lipapi.CloneCall(*req.Call) work.ID = childAuxTraceID(req.ParentTraceID) if work.Extensions == nil { diff --git a/internal/core/auxreq/scope_test.go b/internal/core/auxreq/scope_test.go new file mode 100644 index 00000000..414ca311 --- /dev/null +++ b/internal/core/auxreq/scope_test.go @@ -0,0 +1,84 @@ +package auxreq_test + +import ( + "context" + "testing" + + "github.com/matdev83/go-llm-interactive-proxy/internal/core/auxreq" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipapi" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/auxiliary" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/scope" +) + +type captureRunner struct { + got context.Context +} + +func (c *captureRunner) Execute(ctx context.Context, call *lipapi.Call) (lipapi.EventStream, error) { + c.got = ctx + return lipapi.NewFixedEventStream([]lipapi.Event{{Kind: lipapi.EventResponseFinished}}), nil +} + +// TestClient_Stream_preservesParentScopeAndMarksInternalOrigin proves auxiliary requests +// preserve the parent principal/scope attribution and mark the derived origin separately +// (requirement 4.4). +func TestClient_Stream_preservesParentScopeAndMarksInternalOrigin(t *testing.T) { + t.Parallel() + parent := scope.PrincipalScopeView{ + SubjectKind: scope.SubjectHuman, + PrincipalID: scope.Known("parent-user"), + TenantID: scope.Known("t-parent"), + Origin: scope.OriginClient, + } + ctx := scope.WithScope(context.Background(), parent) + + r := &captureRunner{} + c := auxreq.NewClient(func() auxreq.ExecutorRunner { return r }).(auxreq.Client) + call := &lipapi.Call{ + Route: lipapi.RouteIntent{Selector: "openai:gpt-4"}, + Messages: []lipapi.Message{{Role: lipapi.RoleUser, Parts: []lipapi.Part{lipapi.TextPart("hi")}}}, + } + if _, err := c.Stream(ctx, auxiliary.Request{ + ParentTraceID: "trace-parent", + Call: call, + }); err != nil { + t.Fatalf("Stream: %v", err) + } + got, ok := scope.ScopeFromContext(r.got) + if !ok { + t.Fatal("expected scope preserved on auxiliary context") + } + if !got.PrincipalID.Equal(scope.Known("parent-user")) { + t.Fatalf("aux scope PrincipalID: %+v want parent-user", got.PrincipalID) + } + if !got.TenantID.Equal(scope.Known("t-parent")) { + t.Fatalf("aux scope TenantID: %+v want t-parent", got.TenantID) + } + if got.Origin != scope.OriginInternal { + t.Fatalf("aux origin: got %q want internal", got.Origin) + } + if got.ParentTraceID.String() != "trace-parent" { + t.Fatalf("aux ParentTraceID: got %q want trace-parent", got.ParentTraceID) + } +} + +// TestClient_Stream_noParentScopeNoDerivedScope proves auxiliary requests without a parent +// scope do not synthesize one (no scope authority is invented). +func TestClient_Stream_noParentScopeNoDerivedScope(t *testing.T) { + t.Parallel() + r := &captureRunner{} + c := auxreq.NewClient(func() auxreq.ExecutorRunner { return r }).(auxreq.Client) + call := &lipapi.Call{ + Route: lipapi.RouteIntent{Selector: "openai:gpt-4"}, + Messages: []lipapi.Message{{Role: lipapi.RoleUser, Parts: []lipapi.Part{lipapi.TextPart("hi")}}}, + } + if _, err := c.Stream(context.Background(), auxiliary.Request{ + ParentTraceID: "trace-parent", + Call: call, + }); err != nil { + t.Fatalf("Stream: %v", err) + } + if _, ok := scope.ScopeFromContext(r.got); ok { + t.Fatal("expected no scope on auxiliary context when parent had none") + } +} diff --git a/internal/core/config/access_auth_local_attribution_test.go b/internal/core/config/access_auth_local_attribution_test.go new file mode 100644 index 00000000..5db2c962 --- /dev/null +++ b/internal/core/config/access_auth_local_attribution_test.go @@ -0,0 +1,167 @@ +package config_test + +import ( + "errors" + "strings" + "testing" + + coreauth "github.com/matdev83/go-llm-interactive-proxy/internal/core/auth" + "github.com/matdev83/go-llm-interactive-proxy/internal/core/config" +) + +func localAPIKeyAttributionRecord() config.AuthLocalAPIKeyRecord { + return config.AuthLocalAPIKeyRecord{ + KeyID: "k1", + PrincipalID: "svc-1", + Key: "test-local-api-key-16", + Attribution: config.AuthLocalAttribution{ + DisplayName: "Service One", + AuthMethod: "local_api_key", + TenantID: "t1", + OrganizationID: "org-1", + WorkspaceID: "ws-1", + ProjectID: "proj-1", + DepartmentID: "dept-1", + CostCenterID: "cc-1", + Roles: []string{"reader", "writer"}, + SafeClaims: map[string]string{"team": "core"}, + PolicyLabels: map[string]string{"env": "prod"}, + }, + } +} + +// TestValidate_auth_localAPIKeyAttributionAccepted proves operator-controlled safe +// attribution is accepted at startup (requirement 3.1, 2.5). +func TestValidate_auth_localAPIKeyAttributionAccepted(t *testing.T) { + t.Parallel() + cfg := &config.Config{ + Access: config.AccessConfig{Mode: "multi_user"}, + Server: config.ServerConfig{Address: "127.0.0.1:8080", AuthMode: config.AuthModeExternal}, + Auth: config.AuthConfig{ + Handler: "local_api_key", + RequiredLevel: "api_key", + LocalAPIKeys: []config.AuthLocalAPIKeyRecord{localAPIKeyAttributionRecord()}, + }, + Continuity: config.ContinuityConfig{InMemory: true}, + Plugins: minimalPlugins(), + } + if err := config.Validate(cfg); err != nil { + t.Fatalf("Validate: %v", err) + } +} + +// TestValidate_auth_localAPIKeyAttributionMissingOptionalStaysUnknown proves a record with +// no attribution validates (missing optional fields remain unknown, requirement 3.5). +func TestValidate_auth_localAPIKeyAttributionMissingOptionalStaysUnknown(t *testing.T) { + t.Parallel() + cfg := &config.Config{ + Access: config.AccessConfig{Mode: "single_user"}, + Server: config.ServerConfig{Address: "127.0.0.1:8080"}, + Auth: config.AuthConfig{ + Handler: "local_api_key", + RequiredLevel: "api_key", + LocalAPIKeys: []config.AuthLocalAPIKeyRecord{ + {KeyID: "k1", PrincipalID: "p1", Key: "test-local-api-key-16"}, + }, + }, + Continuity: config.ContinuityConfig{InMemory: true}, + Plugins: minimalPlugins(), + } + if err := config.Validate(cfg); err != nil { + t.Fatalf("Validate: %v", err) + } +} + +// TestValidate_auth_localAPIKeyAttributionRejectsEmptyRole proves roles entries must be +// non-empty when provided (requirement 3.2, 5.4). +func TestValidate_auth_localAPIKeyAttributionRejectsEmptyRole(t *testing.T) { + t.Parallel() + rec := localAPIKeyAttributionRecord() + rec.Attribution.Roles = []string{"reader", " "} + cfg := &config.Config{ + Access: config.AccessConfig{Mode: "single_user"}, + Server: config.ServerConfig{Address: "127.0.0.1:8080"}, + Auth: config.AuthConfig{ + Handler: "local_api_key", + RequiredLevel: "api_key", + LocalAPIKeys: []config.AuthLocalAPIKeyRecord{rec}, + }, + Continuity: config.ContinuityConfig{InMemory: true}, + Plugins: minimalPlugins(), + } + err := config.Validate(cfg) + if err == nil || !errors.Is(err, coreauth.ErrInvalidLocalAttribution) { + t.Fatalf("want ErrInvalidLocalAttribution, got %v", err) + } +} + +// TestValidate_auth_localAPIKeyAttributionRejectsEmptySafeClaimKey proves safe claim keys +// must be non-empty trimmed (requirement 3.2). +func TestValidate_auth_localAPIKeyAttributionRejectsEmptySafeClaimKey(t *testing.T) { + t.Parallel() + rec := localAPIKeyAttributionRecord() + rec.Attribution.SafeClaims = map[string]string{"": "v"} + cfg := &config.Config{ + Access: config.AccessConfig{Mode: "single_user"}, + Server: config.ServerConfig{Address: "127.0.0.1:8080"}, + Auth: config.AuthConfig{ + Handler: "local_api_key", + RequiredLevel: "api_key", + LocalAPIKeys: []config.AuthLocalAPIKeyRecord{rec}, + }, + Continuity: config.ContinuityConfig{InMemory: true}, + Plugins: minimalPlugins(), + } + err := config.Validate(cfg) + if err == nil || !errors.Is(err, coreauth.ErrInvalidLocalAttribution) { + t.Fatalf("want ErrInvalidLocalAttribution, got %v", err) + } +} + +// TestValidate_auth_localAPIKeyAttributionRejectsCredentialLikeValue proves unsafe +// attribution values are rejected at startup (requirement 2.6, 5.4). +func TestValidate_auth_localAPIKeyAttributionRejectsCredentialLikeValue(t *testing.T) { + t.Parallel() + rec := localAPIKeyAttributionRecord() + rec.Attribution.DisplayName = "Bearer abcdef0123456789" + cfg := &config.Config{ + Access: config.AccessConfig{Mode: "single_user"}, + Server: config.ServerConfig{Address: "127.0.0.1:8080"}, + Auth: config.AuthConfig{ + Handler: "local_api_key", + RequiredLevel: "api_key", + LocalAPIKeys: []config.AuthLocalAPIKeyRecord{rec}, + }, + Continuity: config.ContinuityConfig{InMemory: true}, + Plugins: minimalPlugins(), + } + err := config.Validate(cfg) + if err == nil || !errors.Is(err, coreauth.ErrInvalidLocalAttribution) { + t.Fatalf("want ErrInvalidLocalAttribution, got %v", err) + } + if !strings.Contains(err.Error(), "display_name") { + t.Fatalf("error should name the offending field: %v", err) + } +} + +// TestValidateAuthLocalAPIKeyRecords_attributionConverted proves the config validator +// forwards attribution into core auth records. +func TestValidateAuthLocalAPIKeyRecords_attributionConverted(t *testing.T) { + t.Parallel() + rec := localAPIKeyAttributionRecord() + // Round-trip through core auth validator by constructing core records directly. + coreRec := coreauth.LocalAPIKeyRecord{ + KeyID: rec.KeyID, + PrincipalID: rec.PrincipalID, + Key: rec.Key, + Attribution: coreauth.LocalAttribution{ + DisplayName: rec.Attribution.DisplayName, + TenantID: rec.Attribution.TenantID, + Roles: rec.Attribution.Roles, + SafeClaims: rec.Attribution.SafeClaims, + }, + } + if err := coreauth.ValidateLocalAPIKeyRecords([]coreauth.LocalAPIKeyRecord{coreRec}); err != nil { + t.Fatalf("core validate: %v", err) + } +} diff --git a/internal/core/config/access_auth_model.go b/internal/core/config/access_auth_model.go index ca6acb5e..c1a91b04 100644 --- a/internal/core/config/access_auth_model.go +++ b/internal/core/config/access_auth_model.go @@ -13,6 +13,26 @@ type AuthLocalAPIKeyRecord struct { KeyID string `yaml:"key_id"` PrincipalID string `yaml:"principal_id"` Key string `yaml:"key"` + // Attribution carries optional operator-controlled safe attribution for this key. + // Missing optional fields remain unknown (no inference). Raw secrets and transport + // headers must never be placed here. + Attribution AuthLocalAttribution `yaml:"attribution"` +} + +// AuthLocalAttribution mirrors [coreauth.LocalAttribution] for YAML decoding. Zero values +// mean "not configured". +type AuthLocalAttribution struct { + DisplayName string `yaml:"display_name"` + AuthMethod string `yaml:"auth_method"` + TenantID string `yaml:"tenant_id"` + OrganizationID string `yaml:"organization_id"` + WorkspaceID string `yaml:"workspace_id"` + ProjectID string `yaml:"project_id"` + DepartmentID string `yaml:"department_id"` + CostCenterID string `yaml:"cost_center_id"` + Roles []string `yaml:"roles"` + SafeClaims map[string]string `yaml:"safe_claims"` + PolicyLabels map[string]string `yaml:"policy_labels"` } // AuthRemoteConfig holds opaque placeholders for future remote auth wiring. diff --git a/internal/core/config/access_auth_validate.go b/internal/core/config/access_auth_validate.go index 8fcd7a0d..3907f299 100644 --- a/internal/core/config/access_auth_validate.go +++ b/internal/core/config/access_auth_validate.go @@ -84,11 +84,28 @@ func ValidateAuthLocalAPIKeyRecords(records []AuthLocalAPIKeyRecord) error { KeyID: r.KeyID, PrincipalID: r.PrincipalID, Key: r.Key, + Attribution: r.Attribution.toCore(), }) } return coreauth.ValidateLocalAPIKeyRecords(conv) } +func (a AuthLocalAttribution) toCore() coreauth.LocalAttribution { + return coreauth.LocalAttribution{ + DisplayName: a.DisplayName, + AuthMethod: a.AuthMethod, + TenantID: a.TenantID, + OrganizationID: a.OrganizationID, + WorkspaceID: a.WorkspaceID, + ProjectID: a.ProjectID, + DepartmentID: a.DepartmentID, + CostCenterID: a.CostCenterID, + Roles: a.Roles, + SafeClaims: a.SafeClaims, + PolicyLabels: a.PolicyLabels, + } +} + // effectiveAuthPolicy maps legacy server.auth_mode and new auth.* into a single view for posture checks. // Empty auth.handler with no_auth (or omitted) behaves like explicit local_noop + none for validation purposes. func effectiveAuthPolicy(cfg *Config) effectiveAuthPolicyResult { diff --git a/internal/core/execctx/views.go b/internal/core/execctx/views.go index 4ffca5df..ac08b35f 100644 --- a/internal/core/execctx/views.go +++ b/internal/core/execctx/views.go @@ -5,6 +5,7 @@ import ( "maps" "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/execview" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/scope" "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/session" "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/workspace" ) @@ -14,8 +15,12 @@ type ctxKey int const keyViews ctxKey = iota + 6200 // offset avoids collision with diag keys // Views bundles typed snapshots for one request (design §2, R3). +// Scope is the authoritative principal/scope snapshot; Principal is the legacy compatibility +// projection derived from it. Annotations carry lifecycle-only notes kept separate from +// trusted attribution (requirements 4.2, 4.3, 4.6, 5.1). type Views struct { Principal execview.PrincipalView + Scope scope.PrincipalScopeView Session session.SessionView Attempt execview.AttemptView Workspace workspace.WorkspaceView @@ -33,7 +38,9 @@ func WithViews(ctx context.Context, v Views) context.Context { return context.WithValue(ctx, keyViews, v) } -// FromContext returns the views attached with [WithViews], if any. +// FromContext returns the views attached with [WithViews], if any. The returned Views is a +// deep copy of the stored snapshot so callers cannot mutate the stored scope, principal, +// session, workspace, or annotation slices/maps through the returned value (requirement 5.5). func FromContext(ctx context.Context) (Views, bool) { if ctx == nil { return Views{}, false @@ -43,7 +50,10 @@ func FromContext(ctx context.Context) (Views, bool) { return Views{}, false } v, ok := raw.(Views) - return v, ok + if !ok { + return Views{}, false + } + return copyViews(v), true } func copyViews(v Views) Views { @@ -53,6 +63,7 @@ func copyViews(v Views) Views { if len(v.Principal.Roles) > 0 { v.Principal.Roles = append([]string(nil), v.Principal.Roles...) } + v.Scope = v.Scope.Clone() if len(v.Session.Labels) > 0 { v.Session.Labels = maps.Clone(v.Session.Labels) } diff --git a/internal/core/execctx/views_scope_test.go b/internal/core/execctx/views_scope_test.go new file mode 100644 index 00000000..70bcbfcf --- /dev/null +++ b/internal/core/execctx/views_scope_test.go @@ -0,0 +1,109 @@ +package execctx_test + +import ( + "context" + "testing" + + "github.com/matdev83/go-llm-interactive-proxy/internal/core/execctx" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/scope" +) + +// TestWithViews_carriesScope proves Views carries the authoritative scope alongside the +// existing principal/session/attempt/workspace/annotations fields (requirement 4.6, 5.1). +func TestWithViews_carriesScope(t *testing.T) { + t.Parallel() + want := scope.PrincipalScopeView{ + SubjectKind: scope.SubjectHuman, + PrincipalID: scope.Known("u1"), + TenantID: scope.Known("t1"), + Roles: []string{"admin"}, + SafeClaims: map[string]string{"team": "core"}, + PolicyLabels: map[string]string{"env": "prod"}, + Origin: scope.OriginClient, + } + ctx := execctx.WithViews(context.Background(), execctx.Views{Scope: want}) + got, ok := execctx.FromContext(ctx) + if !ok { + t.Fatal("expected views") + } + if !got.Scope.PrincipalID.Equal(want.PrincipalID) { + t.Fatalf("Scope.PrincipalID: %+v", got.Scope.PrincipalID) + } + if got.Scope.SubjectKind != want.SubjectKind { + t.Fatalf("SubjectKind: got %v want %v", got.Scope.SubjectKind, want.SubjectKind) + } + if !got.Scope.TenantID.Equal(want.TenantID) { + t.Fatalf("TenantID: %+v", got.Scope.TenantID) + } +} + +// TestWithViews_scopeMapSliceIsolation proves the scope snapshot is deep-copied on attach +// so callers cannot mutate the stored scope through their original slices/maps (req 5.5, 4.2). +func TestWithViews_scopeMapSliceIsolation(t *testing.T) { + t.Parallel() + in := scope.PrincipalScopeView{ + PrincipalID: scope.Known("u1"), + Roles: []string{"admin"}, + SafeClaims: map[string]string{"k": "v"}, + PolicyLabels: map[string]string{"env": "prod"}, + } + ctx := execctx.WithViews(context.Background(), execctx.Views{Scope: in}) + in.Roles[0] = "mutated" + in.SafeClaims["k"] = "mutated" + in.PolicyLabels["env"] = "mutated" + got, _ := execctx.FromContext(ctx) + if got.Scope.Roles[0] == "mutated" { + t.Fatal("mutating input Roles affected stored scope") + } + if got.Scope.SafeClaims["k"] == "mutated" { + t.Fatal("mutating input SafeClaims affected stored scope") + } + if got.Scope.PolicyLabels["env"] == "mutated" { + t.Fatal("mutating input PolicyLabels affected stored scope") + } +} + +// TestWithViews_scopeReadIsCopy proves FromContext returns a copy of the stored scope so +// callers cannot mutate the stored snapshot through the retrieved view (requirement 5.5). +func TestWithViews_scopeReadIsCopy(t *testing.T) { + t.Parallel() + ctx := execctx.WithViews(context.Background(), execctx.Views{Scope: scope.PrincipalScopeView{ + PrincipalID: scope.Known("u1"), + Roles: []string{"admin"}, + SafeClaims: map[string]string{"k": "v"}, + }}) + got, _ := execctx.FromContext(ctx) + got.Scope.Roles[0] = "mutated" + got.Scope.SafeClaims["k"] = "mutated" + got2, _ := execctx.FromContext(ctx) + if got2.Scope.Roles[0] == "mutated" { + t.Fatal("mutating retrieved Roles affected stored scope") + } + if got2.Scope.SafeClaims["k"] == "mutated" { + t.Fatal("mutating retrieved SafeClaims affected stored scope") + } +} + +// TestWithViews_annotationsSeparateFromScope proves lifecycle annotations remain separate +// from trusted attribution and do not modify scope fields (requirement 4.3, 4.2). +func TestWithViews_annotationsSeparateFromScope(t *testing.T) { + t.Parallel() + sc := scope.PrincipalScopeView{ + PrincipalID: scope.Known("u1"), + PolicyLabels: map[string]string{"env": "prod"}, + } + ctx := execctx.WithViews(context.Background(), execctx.Views{ + Scope: sc, + Annotations: map[string]string{"env": "annotated", "note": "x"}, + }) + got, _ := execctx.FromContext(ctx) + if got.Annotations["env"] != "annotated" { + t.Fatalf("annotations env: %q", got.Annotations["env"]) + } + if got.Scope.PolicyLabels["env"] != "prod" { + t.Fatalf("scope policy label env must remain %q, got %q", "prod", got.Scope.PolicyLabels["env"]) + } + if got.Annotations["note"] != "x" { + t.Fatalf("annotations note: %q", got.Annotations["note"]) + } +} diff --git a/internal/core/runtime/attempt_stream.go b/internal/core/runtime/attempt_stream.go index 00adfc44..09143a94 100644 --- a/internal/core/runtime/attempt_stream.go +++ b/internal/core/runtime/attempt_stream.go @@ -300,6 +300,7 @@ func (s *retryRecvStream) Recv(ctx context.Context) (lipapi.Event, error) { if s.isFinished() { return lipapi.Event{}, io.EOF } + ctx = s.recvExecContext(ctx) if len(s.recoverDrain) > 0 { ev := s.recoverDrain[0] s.recoverDrain = s.recoverDrain[1:] @@ -318,7 +319,6 @@ func (s *retryRecvStream) Recv(ctx context.Context) (lipapi.Event, error) { } return ev, nil } - ctx = s.recvExecContext(ctx) for { if ev, ok := s.popGateDrainHead(); ok { ev = s.emitGateDrained(ctx, ev) @@ -558,12 +558,15 @@ func (s *retryRecvStream) emitTraffic(ctx context.Context, leg sdktraffic.Leg, e } return } + sc := scopeFromCtx(ctx) meta := sdktraffic.CaptureMeta{ - TraceID: pm.TraceID, - ALegID: pm.ALegID, - BLegID: pm.BLegID, - AttemptSeq: pm.AttemptSeq, - BackendID: strings.TrimSpace(s.cand.Primary.Backend), + TraceID: pm.TraceID, + ALegID: pm.ALegID, + BLegID: pm.BLegID, + AttemptSeq: pm.AttemptSeq, + BackendID: strings.TrimSpace(s.cand.Primary.Backend), + PrincipalID: strings.TrimSpace(sc.PrincipalID.String()), + Scope: sc, } bundle.Emit( ctx, @@ -763,8 +766,9 @@ func (s *retryRecvStream) emitUsage(ctx context.Context, ev lipapi.Event) { return } principalID := "" - if v, ok := execctx.FromContext(ctx); ok { - principalID = v.Principal.ID + scopeView := scopeFromCtx(ctx) + if scopeView.PrincipalID.IsKnown() { + principalID = strings.TrimSpace(scopeView.PrincipalID.String()) } model := "" if s.cand.Primary.Model != "" { @@ -779,6 +783,7 @@ func (s *retryRecvStream) emitUsage(ctx context.Context, ev lipapi.Event) { AttemptSeq: int(s.bleg.Seq), BackendID: strings.TrimSpace(s.cand.Primary.Backend), Model: strings.TrimSpace(model), + Scope: scopeView.Clone(), InputTokens: ev.InputTokens, OutputTokens: ev.OutputTokens, CacheReadTokens: ev.CacheReadTokens, diff --git a/internal/core/runtime/attempt_stream_scope_test.go b/internal/core/runtime/attempt_stream_scope_test.go new file mode 100644 index 00000000..9db1d71f --- /dev/null +++ b/internal/core/runtime/attempt_stream_scope_test.go @@ -0,0 +1,337 @@ +package runtime_test + +import ( + "bytes" + "context" + "sync" + "testing" + + "github.com/matdev83/go-llm-interactive-proxy/internal/core/b2bua" + "github.com/matdev83/go-llm-interactive-proxy/internal/core/execbackend" + "github.com/matdev83/go-llm-interactive-proxy/internal/core/extensions" + "github.com/matdev83/go-llm-interactive-proxy/internal/core/hooks" + "github.com/matdev83/go-llm-interactive-proxy/internal/core/routing" + "github.com/matdev83/go-llm-interactive-proxy/internal/core/runtime" + "github.com/matdev83/go-llm-interactive-proxy/internal/core/streamrecovery" + accountingapp "github.com/matdev83/go-llm-interactive-proxy/internal/core/tokenaccounting/app" + accountingstream "github.com/matdev83/go-llm-interactive-proxy/internal/core/tokenaccounting/streamusage" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipapi" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/scope" + sdktraffic "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/traffic" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/usage" +) + +type scopeCaptureUsage struct { + mu sync.Mutex + events []usage.Event +} + +func (c *scopeCaptureUsage) OnUsage(_ context.Context, ev usage.Event) error { + c.mu.Lock() + c.events = append(c.events, ev) + c.mu.Unlock() + return nil +} + +type scopeCaptureTraffic struct { + mu sync.Mutex + obs []sdktraffic.Observation +} + +func (c *scopeCaptureTraffic) OnObservation(_ context.Context, ev sdktraffic.Observation) error { + c.mu.Lock() + c.obs = append(c.obs, ev) + c.mu.Unlock() + return nil +} + +func scopeEmissionExecutor(t *testing.T, uobs usage.Observer, tobs sdktraffic.Observer) *runtime.Executor { + t.Helper() + st, err := b2bua.NewMemoryStore(b2bua.MemoryStoreOptions{}) + if err != nil { + t.Fatal(err) + } + bus := hooks.New(hooks.Config{}) + snap := extensions.NewRequestRuntimeSnapshot(bus, extensions.SnapshotOptions{ + UsageObserver: uobs, + TrafficObserver: tobs, + }) + return &runtime.Executor{ + Store: st, + Bus: bus, + RuntimeSnapshot: snap, + Backends: map[string]execbackend.Backend{ + "openai": { + Caps: lipapi.NewBackendCaps(lipapi.CapabilityStreaming), + Open: func(context.Context, lipapi.Call, routing.AttemptCandidate) (lipapi.ManagedEventStream, error) { + return lipapi.NewFixedEventStream([]lipapi.Event{ + { + Kind: lipapi.EventUsageDelta, + InputTokens: 3, + RawUsageJSON: `{"usage":true}`, + }, + {Kind: lipapi.EventResponseFinished}, + }), nil + }, + }, + }, + Rand: routing.NewSeededRng(1), + } +} + +// TestRuntime_usageEvidence_carriesScope proves the usage observer receives the authoritative +// scope from execctx views and the legacy PrincipalID matches the scope principal id when scope +// is present (requirements 6.4, 6.5, 7.3). +func TestRuntime_usageEvidence_carriesScope(t *testing.T) { + t.Parallel() + uobs := &scopeCaptureUsage{} + ex := scopeEmissionExecutor(t, uobs, nil) + trusted := scope.PrincipalScopeView{ + SubjectKind: scope.SubjectHuman, + PrincipalID: scope.Known("scope-user"), + TenantID: scope.Known("t1"), + Roles: []string{"admin"}, + } + ctx := scope.WithScope(context.Background(), trusted) + stream, err := ex.Execute(ctx, &lipapi.Call{ + Route: lipapi.RouteIntent{Selector: "openai:gpt-4"}, + Messages: []lipapi.Message{{Role: lipapi.RoleUser, Parts: []lipapi.Part{lipapi.TextPart("hi")}}}, + }) + if err != nil { + t.Fatal(err) + } + _, _ = lipapi.Collect(context.Background(), stream) + + uobs.mu.Lock() + defer uobs.mu.Unlock() + if len(uobs.events) == 0 { + t.Fatal("expected usage events") + } + ev := uobs.events[0] + if !ev.Scope.PrincipalID.Equal(scope.Known("scope-user")) { + t.Fatalf("usage Scope.PrincipalID: %+v", ev.Scope.PrincipalID) + } + if !ev.Scope.TenantID.Equal(scope.Known("t1")) { + t.Fatalf("usage Scope.TenantID: %+v", ev.Scope.TenantID) + } + if ev.PrincipalID != "scope-user" { + t.Fatalf("legacy PrincipalID %q must match scope principal id", ev.PrincipalID) + } +} + +// TestRuntime_trafficEvidence_carriesScope proves traffic observations carry the authoritative +// scope and legacy PrincipalID matches the scope principal id on the CTP leg where PrincipalID +// is populated (requirements 6.3, 6.4, 6.5, 7.3). +func TestRuntime_trafficEvidence_carriesScope(t *testing.T) { + t.Parallel() + tobs := &scopeCaptureTraffic{} + ex := scopeEmissionExecutor(t, nil, tobs) + trusted := scope.PrincipalScopeView{ + SubjectKind: scope.SubjectHuman, + PrincipalID: scope.Known("scope-user-leak-marker"), + TenantID: scope.Known("tenant-leak-marker"), + } + ctx := scope.WithScope(context.Background(), trusted) + stream, err := ex.Execute(ctx, &lipapi.Call{ + Route: lipapi.RouteIntent{Selector: "openai:gpt-4"}, + Messages: []lipapi.Message{{Role: lipapi.RoleUser, Parts: []lipapi.Part{lipapi.TextPart("hi")}}}, + }) + if err != nil { + t.Fatal(err) + } + _, _ = lipapi.Collect(context.Background(), stream) + + tobs.mu.Lock() + defer tobs.mu.Unlock() + if len(tobs.obs) == 0 { + t.Fatal("expected traffic observations") + } + var ctp *sdktraffic.Observation + for i := range tobs.obs { + if tobs.obs[i].Leg == sdktraffic.LegCTP { + ctp = &tobs.obs[i] + break + } + } + if ctp == nil { + t.Fatalf("no CTP observation among %d obs", len(tobs.obs)) + } + if !ctp.Scope.PrincipalID.Equal(scope.Known("scope-user-leak-marker")) { + t.Fatalf("CTP Scope.PrincipalID: %+v", ctp.Scope.PrincipalID) + } + if ctp.PrincipalID != "scope-user-leak-marker" { + t.Fatalf("CTP legacy PrincipalID %q must match scope principal id", ctp.PrincipalID) + } +} + +// TestRuntime_trafficEvidence_backendPayloadDoesNotLeakScope proves scope is not forwarded into +// backend provider payloads: the PTB observation body (the marshaled backend call) must not +// contain scope principal or tenant values (requirements 7.4, 7.1). +func TestRuntime_trafficEvidence_backendPayloadDoesNotLeakScope(t *testing.T) { + t.Parallel() + tobs := &scopeCaptureTraffic{} + ex := scopeEmissionExecutor(t, nil, tobs) + trusted := scope.PrincipalScopeView{ + SubjectKind: scope.SubjectHuman, + PrincipalID: scope.Known("leak-principal"), + TenantID: scope.Known("leak-tenant"), + } + ctx := scope.WithScope(context.Background(), trusted) + stream, err := ex.Execute(ctx, &lipapi.Call{ + Route: lipapi.RouteIntent{Selector: "openai:gpt-4"}, + Messages: []lipapi.Message{{Role: lipapi.RoleUser, Parts: []lipapi.Part{lipapi.TextPart("hi")}}}, + }) + if err != nil { + t.Fatal(err) + } + _, _ = lipapi.Collect(context.Background(), stream) + + tobs.mu.Lock() + defer tobs.mu.Unlock() + for _, o := range tobs.obs { + if o.Leg != sdktraffic.LegPTB { + continue + } + if bytes.Contains(o.Body, []byte("leak-principal")) || bytes.Contains(o.Body, []byte("leak-tenant")) { + t.Fatalf("scope values leaked into backend payload (leg %s): %s", o.Leg, string(o.Body)) + } + if !o.Scope.PrincipalID.Equal(scope.Known("leak-principal")) { + t.Fatalf("PTB Scope.PrincipalID: %+v", o.Scope.PrincipalID) + } + } +} + +// TestRuntime_trafficEvidence_allLegsPrincipalIDMatchesScope proves that on every traffic leg +// carrying a known scope (PTB, BTP, PTC), the legacy PrincipalID is populated from the scope +// principal id and matches Scope.PrincipalID.String() (requirements 6.3, 6.5, 7.3). +func TestRuntime_trafficEvidence_allLegsPrincipalIDMatchesScope(t *testing.T) { + t.Parallel() + tobs := &scopeCaptureTraffic{} + ex := scopeEmissionExecutor(t, nil, tobs) + trusted := scope.PrincipalScopeView{ + SubjectKind: scope.SubjectHuman, + PrincipalID: scope.Known("scope-user"), + TenantID: scope.Known("t1"), + } + ctx := scope.WithScope(context.Background(), trusted) + stream, err := ex.Execute(ctx, &lipapi.Call{ + Route: lipapi.RouteIntent{Selector: "openai:gpt-4"}, + Messages: []lipapi.Message{{Role: lipapi.RoleUser, Parts: []lipapi.Part{lipapi.TextPart("hi")}}}, + }) + if err != nil { + t.Fatal(err) + } + _, _ = lipapi.Collect(context.Background(), stream) + + tobs.mu.Lock() + defer tobs.mu.Unlock() + if len(tobs.obs) == 0 { + t.Fatal("expected traffic observations") + } + checked := 0 + for _, o := range tobs.obs { + if !o.Scope.PrincipalID.IsKnown() { + continue + } + checked++ + if o.PrincipalID != o.Scope.PrincipalID.String() { + t.Fatalf("leg %s legacy PrincipalID %q must match scope principal id %q", o.Leg, o.PrincipalID, o.Scope.PrincipalID.String()) + } + if bytes.Contains(o.Body, []byte("scope-user-leak-marker")) || bytes.Contains(o.Body, []byte("tenant-leak-marker")) { + t.Fatalf("scope values leaked into payload (leg %s): %s", o.Leg, string(o.Body)) + } + } + if checked == 0 { + t.Fatalf("no observations carried a known scope among %d", len(tobs.obs)) + } +} + +// TestRuntime_usageEvidence_syntheticLocalScopeMatchesPrincipal proves that when no trusted +// scope is attached and the executor's existing local-mode synthetic principal is active, the +// usage event carries the synthetic local scope and the legacy PrincipalID matches the scope +// principal id (requirements 1.4, 6.4, 7.3). +func TestRuntime_usageEvidence_syntheticLocalScopeMatchesPrincipal(t *testing.T) { + t.Parallel() + uobs := &scopeCaptureUsage{} + ex := scopeEmissionExecutor(t, uobs, nil) + stream, err := ex.Execute(context.Background(), &lipapi.Call{ + Route: lipapi.RouteIntent{Selector: "openai:gpt-4"}, + Messages: []lipapi.Message{{Role: lipapi.RoleUser, Parts: []lipapi.Part{lipapi.TextPart("hi")}}}, + }) + if err != nil { + t.Fatal(err) + } + _, _ = lipapi.Collect(context.Background(), stream) + + uobs.mu.Lock() + defer uobs.mu.Unlock() + if len(uobs.events) == 0 { + t.Fatal("expected usage events") + } + ev := uobs.events[0] + if !ev.Scope.PrincipalID.IsKnown() { + t.Fatalf("synthetic local scope must be present: %+v", ev.Scope.PrincipalID) + } + if ev.Scope.SubjectKind != scope.SubjectLocal { + t.Fatalf("SubjectKind: got %v want local", ev.Scope.SubjectKind) + } + if ev.PrincipalID != ev.Scope.PrincipalID.String() { + t.Fatalf("legacy PrincipalID %q must match scope principal id %q", ev.PrincipalID, ev.Scope.PrincipalID.String()) + } +} + +// TestRuntime_usageEvidence_recoveryDrainCarriesScope proves stream-recovery synthesized +// response_finished events use the same scoped recv context as the normal stream path. +func TestRuntime_usageEvidence_recoveryDrainCarriesScope(t *testing.T) { + t.Parallel() + uobs := &scopeCaptureUsage{} + ex := scopeEmissionExecutor(t, uobs, nil) + ex.StreamRecovery = streamrecovery.Config{Enabled: true, EmitWarning: true} + ex.StreamUsage = accountingstream.New(scopeFixedCounter{}, accountingstream.Config{}) + ex.Backends["openai"] = execbackend.Backend{ + Caps: lipapi.NewBackendCaps(lipapi.CapabilityStreaming), + Open: func(context.Context, lipapi.Call, routing.AttemptCandidate) (lipapi.ManagedEventStream, error) { + return lipapi.NewFixedEventStream([]lipapi.Event{ + {Kind: lipapi.EventResponseStarted}, + {Kind: lipapi.EventMessageStarted}, + {Kind: lipapi.EventTextDelta, Delta: "partial"}, + }), nil + }, + } + trusted := scope.PrincipalScopeView{ + SubjectKind: scope.SubjectHuman, + PrincipalID: scope.Known("recovery-scope-user"), + TenantID: scope.Known("t-recovery"), + } + stream, err := ex.Execute(scope.WithScope(context.Background(), trusted), &lipapi.Call{ + Route: lipapi.RouteIntent{Selector: "openai:gpt-4"}, + Messages: []lipapi.Message{{Role: lipapi.RoleUser, Parts: []lipapi.Part{lipapi.TextPart("hi")}}}, + }) + if err != nil { + t.Fatal(err) + } + _, _ = lipapi.Collect(context.Background(), stream) + + uobs.mu.Lock() + defer uobs.mu.Unlock() + if len(uobs.events) == 0 { + t.Fatal("expected usage event from recovery drain") + } + ev := uobs.events[len(uobs.events)-1] + if !ev.Scope.PrincipalID.Equal(scope.Known("recovery-scope-user")) { + t.Fatalf("usage Scope.PrincipalID: %+v", ev.Scope.PrincipalID) + } + if ev.PrincipalID != "recovery-scope-user" { + t.Fatalf("legacy PrincipalID %q must match recovery scope principal", ev.PrincipalID) + } +} + +type scopeFixedCounter struct{} + +func (scopeFixedCounter) CountCall(context.Context, accountingapp.CountCallInput) (accountingapp.CountResult, error) { + return accountingapp.CountResult{InputTokens: 3}, nil +} + +func (scopeFixedCounter) CountOutput(context.Context, accountingapp.CountOutputInput) (accountingapp.CountResult, error) { + return accountingapp.CountResult{OutputTokens: 4}, nil +} diff --git a/internal/core/runtime/executor_open_attempt.go b/internal/core/runtime/executor_open_attempt.go index 92a6f8e3..5d53afe7 100644 --- a/internal/core/runtime/executor_open_attempt.go +++ b/internal/core/runtime/executor_open_attempt.go @@ -366,12 +366,15 @@ func (e *Executor) openPlannedCandidate( } if e.RuntimeSnapshot != nil { if rawPayload, jerr := json.Marshal(openCall); jerr == nil { + sc := scopeFromCtx(p.ctx) meta := sdktraffic.CaptureMeta{ - TraceID: p.traceID, - ALegID: p.aLegID, - BLegID: bleg.BLegID, - AttemptSeq: bleg.Seq, - BackendID: strings.TrimSpace(c.Primary.Backend), + TraceID: p.traceID, + ALegID: p.aLegID, + BLegID: bleg.BLegID, + AttemptSeq: bleg.Seq, + BackendID: strings.TrimSpace(c.Primary.Backend), + PrincipalID: strings.TrimSpace(sc.PrincipalID.String()), + Scope: sc, } coretraffic.PortBundleFromSnapshot(e.RuntimeSnapshot).Emit( p.ctx, diff --git a/internal/core/runtime/executor_prepare_secure.go b/internal/core/runtime/executor_prepare_secure.go index 0a078a91..70f26edb 100644 --- a/internal/core/runtime/executor_prepare_secure.go +++ b/internal/core/runtime/executor_prepare_secure.go @@ -24,6 +24,7 @@ import ( "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/prerequest" "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/request" "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/routehint" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/scope" "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/session" "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/toolcatalog" sdktraffic "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/traffic" @@ -62,18 +63,13 @@ func (e *Executor) prepareSubmitAndALegSecure( outCtx = ctx var principal execview.PrincipalView hasPrincipal := false - if p, ok := execview.PrincipalFromContext(ctx); ok { + var reqScope scope.PrincipalScopeView + if s, p, ok := e.resolveRequestScope(ctx); ok { + reqScope = s principal = p hasPrincipal = true outCtx = execview.WithPrincipal(outCtx, p) - } - if !hasPrincipal && e != nil && e.SyntheticLocalPrincipal { - principal = execview.PrincipalView{ - ID: syntheticLocalPrincipalID, - Claims: map[string]string{"issuer": syntheticLocalPrincipalIssuer}, - } - hasPrincipal = true - outCtx = execview.WithPrincipal(outCtx, principal) + outCtx = scope.WithScope(outCtx, s) } outCtx = diag.WithCallDiag(outCtx, traceID, "") @@ -158,7 +154,7 @@ func (e *Executor) prepareSubmitAndALegSecure( Now: e.now(), TraceID: traceID, Session: secureSessionWireFromLipAPI(work.Session), - Principal: principalRefFromView(principal), + Principal: principalRefFromScope(principal, reqScope), Workspace: domain.WorkspaceRef{ID: strings.TrimSpace(wsView.ID)}, GlobalPolicy: app.DefaultGlobalPolicy(), ClientHints: domain.ClientHints{ClientSessionID: strings.TrimSpace(work.Session.ClientSessionID)}, @@ -242,6 +238,7 @@ func (e *Executor) prepareSubmitAndALegSecure( ALegID: strings.TrimSpace(aLeg.ALegID), SessionID: ctpCall.Session.CorrelationID(), PrincipalID: strings.TrimSpace(principal.ID), + Scope: reqScope.Clone(), } coretraffic.PortBundleFromSnapshot(e.RuntimeSnapshot).Emit( outCtx, @@ -360,6 +357,7 @@ func (e *Executor) prepareSubmitAndALegSecure( }) if hasPrincipal { views.Principal = principal + views.Scope = reqScope } views.Workspace = wsView views.Session.WorkspaceID = strings.TrimSpace(wsView.ID) diff --git a/internal/core/runtime/executor_scope_test.go b/internal/core/runtime/executor_scope_test.go new file mode 100644 index 00000000..8c001dac --- /dev/null +++ b/internal/core/runtime/executor_scope_test.go @@ -0,0 +1,243 @@ +package runtime_test + +import ( + "context" + "fmt" + "sync/atomic" + "testing" + + "github.com/matdev83/go-llm-interactive-proxy/internal/core/b2bua" + "github.com/matdev83/go-llm-interactive-proxy/internal/core/execbackend" + "github.com/matdev83/go-llm-interactive-proxy/internal/core/execctx" + "github.com/matdev83/go-llm-interactive-proxy/internal/core/hooks" + "github.com/matdev83/go-llm-interactive-proxy/internal/core/routing" + "github.com/matdev83/go-llm-interactive-proxy/internal/core/runtime" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipapi" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/execview" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/scope" +) + +func scopeTestBackendOpenCapture(openCtx *context.Context, opens *atomic.Int32) execbackend.Backend { + return execbackend.Backend{ + Caps: lipapi.NewBackendCaps(lipapi.CapabilityStreaming), + Open: func(ctx context.Context, call lipapi.Call, cand routing.AttemptCandidate) (lipapi.ManagedEventStream, error) { + opens.Add(1) + *openCtx = ctx + return lipapi.NewFixedEventStream([]lipapi.Event{ + {Kind: lipapi.EventResponseFinished}, + }), nil + }, + } +} + +// TestExecutor_OpenContext_carriesTrustedScopeInViews proves the authoritative scope from +// the trusted context is available on the backend open context before backend work starts +// and the principal view is derived from it (requirements 4.1, 4.6, 2.2). +func TestExecutor_OpenContext_carriesTrustedScopeInViews(t *testing.T) { + t.Parallel() + st, err := b2bua.NewMemoryStore(b2bua.MemoryStoreOptions{}) + if err != nil { + t.Fatal(err) + } + var openCtx context.Context + var opens atomic.Int32 + ex := &runtime.Executor{ + Store: st, + Bus: hooks.New(hooks.Config{}), + Backends: map[string]execbackend.Backend{ + "openai": scopeTestBackendOpenCapture(&openCtx, &opens), + }, + Rand: routing.NewSeededRng(3), + } + trusted := scope.PrincipalScopeView{ + SubjectKind: scope.SubjectHuman, + PrincipalID: scope.Known("scope-user"), + TenantID: scope.Known("t1"), + Roles: []string{"admin"}, + } + ctx := scope.WithScope(context.Background(), trusted) + // Legacy principal in context must NOT override the trusted scope (req 2.2). + ctx = execview.WithPrincipal(ctx, execview.PrincipalView{ID: "legacy-loses"}) + stream, err := ex.Execute(ctx, &lipapi.Call{ + Route: lipapi.RouteIntent{Selector: "openai:gpt-4"}, + Messages: []lipapi.Message{{Role: lipapi.RoleUser, Parts: []lipapi.Part{lipapi.TextPart("hi")}}}, + }) + if err != nil { + t.Fatal(err) + } + _, _ = lipapi.Collect(context.Background(), stream) + + v, ok := execctx.FromContext(openCtx) + if !ok { + t.Fatal("expected execctx views on backend open context") + } + if !v.Scope.PrincipalID.Equal(scope.Known("scope-user")) { + t.Fatalf("scope PrincipalID: %+v", v.Scope.PrincipalID) + } + if v.Principal.ID != "scope-user" { + t.Fatalf("principal must derive from scope, got %q", v.Principal.ID) + } + if !v.Scope.TenantID.Equal(scope.Known("t1")) { + t.Fatalf("scope TenantID: %+v", v.Scope.TenantID) + } +} + +// TestExecutor_OpenContext_legacyPrincipalDerivesScope proves a legacy principal-only +// context (no scope) still yields a scope on the backend open context (requirement 4.2). +func TestExecutor_OpenContext_legacyPrincipalDerivesScope(t *testing.T) { + t.Parallel() + st, err := b2bua.NewMemoryStore(b2bua.MemoryStoreOptions{}) + if err != nil { + t.Fatal(err) + } + var openCtx context.Context + var opens atomic.Int32 + ex := &runtime.Executor{ + Store: st, + Bus: hooks.New(hooks.Config{}), + Backends: map[string]execbackend.Backend{ + "openai": scopeTestBackendOpenCapture(&openCtx, &opens), + }, + Rand: routing.NewSeededRng(3), + } + ctx := execview.WithPrincipal(context.Background(), execview.PrincipalView{ID: "transport-user"}) + stream, err := ex.Execute(ctx, &lipapi.Call{ + Route: lipapi.RouteIntent{Selector: "openai:gpt-4"}, + Messages: []lipapi.Message{{Role: lipapi.RoleUser, Parts: []lipapi.Part{lipapi.TextPart("hi")}}}, + }) + if err != nil { + t.Fatal(err) + } + _, _ = lipapi.Collect(context.Background(), stream) + + v, ok := execctx.FromContext(openCtx) + if !ok { + t.Fatal("expected execctx views") + } + if !v.Scope.PrincipalID.Equal(scope.Known("transport-user")) { + t.Fatalf("scope PrincipalID: %+v", v.Scope.PrincipalID) + } + if v.Principal.ID != "transport-user" { + t.Fatalf("principal id: want transport-user got %q", v.Principal.ID) + } +} + +// TestExecutor_MultiAttempt_sharesRequestScope proves multiple backend attempts for one +// logical request share the same authoritative request scope without changing recovery +// semantics (requirement 4.5). The first backend returns a recoverable pre-output error so +// the executor opens a second attempt; both open contexts must carry the same scope. +func TestExecutor_MultiAttempt_sharesRequestScope(t *testing.T) { + t.Parallel() + st, err := b2bua.NewMemoryStore(b2bua.MemoryStoreOptions{}) + if err != nil { + t.Fatal(err) + } + var firstOpenCtx, secondOpenCtx context.Context + var opens atomic.Int32 + ex := &runtime.Executor{ + Store: st, + Bus: hooks.New(hooks.Config{}), + Backends: map[string]execbackend.Backend{ + "fail": { + Caps: lipapi.NewBackendCaps(lipapi.CapabilityStreaming), + Open: func(ctx context.Context, call lipapi.Call, cand routing.AttemptCandidate) (lipapi.ManagedEventStream, error) { + opens.Add(1) + firstOpenCtx = ctx + return nil, fmt.Errorf("boom: %w", lipapi.ErrRecoverablePreOutput) + }, + }, + "ok": { + Caps: lipapi.NewBackendCaps(lipapi.CapabilityStreaming), + Open: func(ctx context.Context, call lipapi.Call, cand routing.AttemptCandidate) (lipapi.ManagedEventStream, error) { + opens.Add(1) + secondOpenCtx = ctx + return lipapi.NewFixedEventStream([]lipapi.Event{ + {Kind: lipapi.EventResponseStarted}, + {Kind: lipapi.EventResponseFinished}, + }), nil + }, + }, + }, + Rand: routing.NewSeededRng(2), + } + trusted := scope.PrincipalScopeView{ + SubjectKind: scope.SubjectHuman, + PrincipalID: scope.Known("shared-user"), + TenantID: scope.Known("t-shared"), + } + ctx := scope.WithScope(context.Background(), trusted) + stream, err := ex.Execute(ctx, &lipapi.Call{ + Route: lipapi.RouteIntent{Selector: "fail:gpt-4|ok:gpt-4"}, + Messages: []lipapi.Message{{Role: lipapi.RoleUser, Parts: []lipapi.Part{lipapi.TextPart("hi")}}}, + }) + if err != nil { + t.Fatal(err) + } + if _, err := lipapi.Collect(context.Background(), stream); err != nil { + t.Fatalf("Collect: %v", err) + } + if opens.Load() < 2 { + t.Fatalf("expected at least 2 opens, got %d", opens.Load()) + } + v1, ok1 := execctx.FromContext(firstOpenCtx) + v2, ok2 := execctx.FromContext(secondOpenCtx) + if !ok1 || !ok2 { + t.Fatalf("expected views on both attempts: ok1=%v ok2=%v", ok1, ok2) + } + if !v1.Scope.PrincipalID.Equal(v2.Scope.PrincipalID) { + t.Fatalf("attempt scopes differ: %+v vs %+v", v1.Scope.PrincipalID, v2.Scope.PrincipalID) + } + if !v1.Scope.TenantID.Equal(v2.Scope.TenantID) { + t.Fatalf("attempt tenant scopes differ: %+v vs %+v", v1.Scope.TenantID, v2.Scope.TenantID) + } + if !v1.Scope.PrincipalID.Equal(scope.Known("shared-user")) { + t.Fatalf("shared scope PrincipalID: %+v", v1.Scope.PrincipalID) + } +} + +// TestExecutor_MultiAttempt_recoversOnPreOutputError confirms recovery semantics are +// preserved: a recoverable pre-output failure on the first attempt still yields a finished +// stream from the second attempt (requirement 4.5, 7.5). +func TestExecutor_MultiAttempt_recoversOnPreOutputError(t *testing.T) { + t.Parallel() + st, err := b2bua.NewMemoryStore(b2bua.MemoryStoreOptions{}) + if err != nil { + t.Fatal(err) + } + ex := &runtime.Executor{ + Store: st, + Bus: hooks.New(hooks.Config{}), + Backends: map[string]execbackend.Backend{ + "fail": { + Caps: lipapi.NewBackendCaps(lipapi.CapabilityStreaming), + Open: func(context.Context, lipapi.Call, routing.AttemptCandidate) (lipapi.ManagedEventStream, error) { + return nil, fmt.Errorf("boom: %w", lipapi.ErrRecoverablePreOutput) + }, + }, + "ok": { + Caps: lipapi.NewBackendCaps(lipapi.CapabilityStreaming), + Open: func(context.Context, lipapi.Call, routing.AttemptCandidate) (lipapi.ManagedEventStream, error) { + return lipapi.NewFixedEventStream([]lipapi.Event{ + {Kind: lipapi.EventResponseStarted}, + {Kind: lipapi.EventResponseFinished}, + }), nil + }, + }, + }, + Rand: routing.NewSeededRng(2), + } + stream, err := ex.Execute(context.Background(), &lipapi.Call{ + Route: lipapi.RouteIntent{Selector: "fail:gpt-4|ok:gpt-4"}, + Messages: []lipapi.Message{{Role: lipapi.RoleUser, Parts: []lipapi.Part{lipapi.TextPart("hi")}}}, + }) + if err != nil { + t.Fatal(err) + } + collected, err := lipapi.Collect(context.Background(), stream) + if err != nil { + t.Fatalf("Collect: %v", err) + } + if !collected.FinishReceived { + t.Fatal("expected finished stream from second attempt") + } +} diff --git a/internal/core/runtime/executor_secure_session_test.go b/internal/core/runtime/executor_secure_session_test.go index ba1bbc59..eec084e2 100644 --- a/internal/core/runtime/executor_secure_session_test.go +++ b/internal/core/runtime/executor_secure_session_test.go @@ -22,6 +22,7 @@ import ( "github.com/matdev83/go-llm-interactive-proxy/pkg/lipapi" "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/execview" sdkhooks "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/hooks" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/scope" lipworkspace "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/workspace" ) @@ -77,6 +78,27 @@ func (c *countSubmitHook) Handle(context.Context, *lipapi.Call, *sdkhooks.Submit return sdkhooks.SubmitDecision{}, nil } +func TestPrincipalRefFromScope_usesScopeTenant(t *testing.T) { + t.Parallel() + + ref := principalRefFromScope( + execview.PrincipalView{ + ID: "user-1", + Claims: map[string]string{"issuer": "issuer-1", "tenant": "legacy-tenant"}, + }, + scope.PrincipalScopeView{TenantID: scope.Known("scope-tenant")}, + ) + if ref.ID != "user-1" { + t.Fatalf("ID: got %q want user-1", ref.ID) + } + if ref.Issuer != "issuer-1" { + t.Fatalf("Issuer: got %q want issuer-1", ref.Issuer) + } + if ref.Tenant != "scope-tenant" { + t.Fatalf("Tenant: got %q want scope-tenant", ref.Tenant) + } +} + func TestExecutor_prepareSubmitAndALeg_secure_newSession_replacesForgedALeg(t *testing.T) { t.Parallel() b2, err := b2bua.NewMemoryStore(b2bua.MemoryStoreOptions{}) diff --git a/internal/core/runtime/scope_phase6_compatibility_test.go b/internal/core/runtime/scope_phase6_compatibility_test.go new file mode 100644 index 00000000..da9d6f0c --- /dev/null +++ b/internal/core/runtime/scope_phase6_compatibility_test.go @@ -0,0 +1,227 @@ +package runtime_test + +import ( + "bytes" + "context" + "encoding/json" + "sync/atomic" + "testing" + + "github.com/matdev83/go-llm-interactive-proxy/internal/core/b2bua" + "github.com/matdev83/go-llm-interactive-proxy/internal/core/execbackend" + "github.com/matdev83/go-llm-interactive-proxy/internal/core/execctx" + "github.com/matdev83/go-llm-interactive-proxy/internal/core/extensions" + "github.com/matdev83/go-llm-interactive-proxy/internal/core/hooks" + "github.com/matdev83/go-llm-interactive-proxy/internal/core/routing" + "github.com/matdev83/go-llm-interactive-proxy/internal/core/runtime" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipapi" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/scope" + sdktraffic "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/traffic" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/usage" +) + +// phase6BackendOpenCapture captures the backend open context (the streaming entry point) so the +// authoritative scope visible to the backend can be compared against observer evidence collected +// from the non-streaming collection path (lipapi.Collect). +func phase6BackendOpenCapture(openCtx *context.Context, opens *atomic.Int32) execbackend.Backend { + return execbackend.Backend{ + Caps: lipapi.NewBackendCaps(lipapi.CapabilityStreaming), + Open: func(ctx context.Context, call lipapi.Call, cand routing.AttemptCandidate) (lipapi.ManagedEventStream, error) { + opens.Add(1) + *openCtx = ctx + return lipapi.NewFixedEventStream([]lipapi.Event{ + {Kind: lipapi.EventResponseStarted}, + {Kind: lipapi.EventUsageDelta, InputTokens: 3, RawUsageJSON: `{"usage":true}`}, + {Kind: lipapi.EventResponseFinished}, + }), nil + }, + } +} + +// phase6ExecutorWithObservers builds an executor wired to the supplied usage and traffic observers +// and a single backend, so evidence emission is observable without a full composition root. +func phase6ExecutorWithObservers(t *testing.T, uobs usage.Observer, tobs sdktraffic.Observer, openCtx *context.Context, opens *atomic.Int32) *runtime.Executor { + t.Helper() + st, err := b2bua.NewMemoryStore(b2bua.MemoryStoreOptions{}) + if err != nil { + t.Fatal(err) + } + snap := extensions.NewRequestRuntimeSnapshot(hooks.New(hooks.Config{}), extensions.SnapshotOptions{ + UsageObserver: uobs, + TrafficObserver: tobs, + }) + return &runtime.Executor{ + Store: st, + Bus: hooks.New(hooks.Config{}), + RuntimeSnapshot: snap, + Backends: map[string]execbackend.Backend{ + "openai": phase6BackendOpenCapture(openCtx, opens), + }, + Rand: routing.NewSeededRng(1), + } +} + +// TestPhase6_streamingAndNonStreamingCarrySameScope proves the authoritative scope visible at the +// streaming backend-open entry point is identical to the scope carried on usage evidence collected +// via the non-streaming canonical collection path (requirement 7.6, 6.5). +func TestPhase6_streamingAndNonStreamingCarrySameScope(t *testing.T) { + t.Parallel() + uobs := &scopeCaptureUsage{} + var openCtx context.Context + var opens atomic.Int32 + ex := phase6ExecutorWithObservers(t, uobs, nil, &openCtx, &opens) + trusted := scope.PrincipalScopeView{ + SubjectKind: scope.SubjectHuman, + PrincipalID: scope.Known("scope-user-7-6"), + TenantID: scope.Known("t-7-6"), + Roles: []string{"admin"}, + } + ctx := scope.WithScope(context.Background(), trusted) + stream, err := ex.Execute(ctx, &lipapi.Call{ + Route: lipapi.RouteIntent{Selector: "openai:gpt-4"}, + Messages: []lipapi.Message{{Role: lipapi.RoleUser, Parts: []lipapi.Part{lipapi.TextPart("hi")}}}, + }) + if err != nil { + t.Fatal(err) + } + if _, err := lipapi.Collect(context.Background(), stream); err != nil { + t.Fatalf("Collect: %v", err) + } + + openViews, ok := execctx.FromContext(openCtx) + if !ok { + t.Fatal("expected execctx views on backend open context") + } + if !openViews.Scope.PrincipalID.Equal(trusted.PrincipalID) { + t.Fatalf("streaming open scope PrincipalID: %+v want %q", openViews.Scope.PrincipalID, trusted.PrincipalID) + } + uobs.mu.Lock() + defer uobs.mu.Unlock() + if len(uobs.events) == 0 { + t.Fatal("expected usage events on collection path") + } + uev := uobs.events[0] + if !uev.Scope.PrincipalID.Equal(openViews.Scope.PrincipalID) { + t.Fatalf("usage scope PrincipalID %+v must equal streaming scope %+v", uev.Scope.PrincipalID, openViews.Scope.PrincipalID) + } + if !uev.Scope.TenantID.Equal(openViews.Scope.TenantID) { + t.Fatalf("usage scope TenantID %+v must equal streaming scope %+v", uev.Scope.TenantID, openViews.Scope.TenantID) + } +} + +// TestPhase6_missingOptionalScopeDoesNotChangeRoutingOrAttempts proves the presence or absence of +// optional tenant/project/department/cost-center attribution does not alter backend attempt +// selection or attempt count (requirements 7.2, 7.5, 8.5). +func TestPhase6_missingOptionalScopeDoesNotChangeRoutingOrAttempts(t *testing.T) { + t.Parallel() + run := func(t *testing.T, sc scope.PrincipalScopeView) (string, int32) { + t.Helper() + var openCtx context.Context + var opens atomic.Int32 + ex := phase6ExecutorWithObservers(t, nil, nil, &openCtx, &opens) + ctx := scope.WithScope(context.Background(), sc) + stream, err := ex.Execute(ctx, &lipapi.Call{ + Route: lipapi.RouteIntent{Selector: "openai:gpt-4"}, + Messages: []lipapi.Message{{Role: lipapi.RoleUser, Parts: []lipapi.Part{lipapi.TextPart("hi")}}}, + }) + if err != nil { + t.Fatal(err) + } + _, _ = lipapi.Collect(context.Background(), stream) + v, ok := execctx.FromContext(openCtx) + if !ok { + t.Fatal("expected open context views") + } + return v.Scope.PrincipalID.String(), opens.Load() + } + + rich := scope.PrincipalScopeView{ + SubjectKind: scope.SubjectHuman, + PrincipalID: scope.Known("user-r"), + TenantID: scope.Known("t-r"), + ProjectID: scope.Known("p-r"), + DepartmentID: scope.Known("d-r"), + CostCenterID: scope.Known("c-r"), + OrganizationID: scope.Known("o-r"), + } + min := scope.PrincipalScopeView{ + SubjectKind: scope.SubjectHuman, + PrincipalID: scope.Known("user-r"), + } + _, richOpens := run(t, rich) + minID, minOpens := run(t, min) + if richOpens != minOpens { + t.Fatalf("optional scope changed attempt count: rich=%d min=%d", richOpens, minOpens) + } + if minID != "user-r" { + t.Fatalf("principal id drifted without optional scope: %q", minID) + } +} + +// TestPhase6_canonicalRequestShapeUnchangedByScope proves the client-to-proxy canonical request +// payload does not carry scope attribution and that scope attachment does not alter the structural +// request shape (messages, route, tools, options). Generated session/ALeg ids are non-deterministic +// per run and are excluded from the comparison. (requirements 7.1, 7.4) +func TestPhase6_canonicalRequestShapeUnchangedByScope(t *testing.T) { + t.Parallel() + ctpCall := func(t *testing.T, withScope bool) lipapi.Call { + t.Helper() + tobs := &scopeCaptureTraffic{} + var openCtx context.Context + var opens atomic.Int32 + ex := phase6ExecutorWithObservers(t, nil, tobs, &openCtx, &opens) + ctx := context.Background() + if withScope { + ctx = scope.WithScope(ctx, scope.PrincipalScopeView{ + SubjectKind: scope.SubjectHuman, + PrincipalID: scope.Known("shape-user"), + TenantID: scope.Known("shape-tenant"), + Roles: []string{"admin"}, + SafeClaims: map[string]string{"team": "core"}, + }) + } + stream, err := ex.Execute(ctx, &lipapi.Call{ + Route: lipapi.RouteIntent{Selector: "openai:gpt-4"}, + Messages: []lipapi.Message{{Role: lipapi.RoleUser, Parts: []lipapi.Part{lipapi.TextPart("hi")}}}, + }) + if err != nil { + t.Fatal(err) + } + _, _ = lipapi.Collect(context.Background(), stream) + tobs.mu.Lock() + defer tobs.mu.Unlock() + for _, o := range tobs.obs { + if o.Leg == sdktraffic.LegCTP { + if bytes.Contains(o.Body, []byte("shape-user")) || bytes.Contains(o.Body, []byte("shape-tenant")) { + t.Fatalf("scope attribution leaked into canonical client request payload: %s", o.Body) + } + if bytes.Contains(o.Body, []byte(`"Roles"`)) || bytes.Contains(o.Body, []byte(`"SafeClaims"`)) || bytes.Contains(o.Body, []byte(`"PrincipalID"`)) { + t.Fatalf("scope contract fields leaked into canonical client request payload: %s", o.Body) + } + var c lipapi.Call + if err := json.Unmarshal(o.Body, &c); err != nil { + t.Fatalf("unmarshal CTP call: %v", err) + } + return c + } + } + t.Fatal("no CTP observation captured") + return lipapi.Call{} + } + + withoutScope := ctpCall(t, false) + withScope := ctpCall(t, true) + // Structural request shape must be identical; only generated session/ALeg ids may differ. + if withoutScope.Route.Selector != withScope.Route.Selector { + t.Fatalf("Route.Selector changed by scope: %q vs %q", withoutScope.Route.Selector, withScope.Route.Selector) + } + if len(withoutScope.Messages) != len(withScope.Messages) { + t.Fatalf("Messages length changed by scope: %d vs %d", len(withoutScope.Messages), len(withScope.Messages)) + } + if withoutScope.Messages[0].Parts[0].Text != withScope.Messages[0].Parts[0].Text { + t.Fatalf("message text changed by scope: %q vs %q", withoutScope.Messages[0].Parts[0].Text, withScope.Messages[0].Parts[0].Text) + } + if withoutScope.Tools != nil || withScope.Tools != nil { + t.Fatalf("Tools changed by scope: without=%v with=%v", withoutScope.Tools, withScope.Tools) + } +} diff --git a/internal/core/runtime/scope_phase6_secret_safety_test.go b/internal/core/runtime/scope_phase6_secret_safety_test.go new file mode 100644 index 00000000..43195e49 --- /dev/null +++ b/internal/core/runtime/scope_phase6_secret_safety_test.go @@ -0,0 +1,177 @@ +package runtime_test + +import ( + "context" + "strings" + "sync" + "testing" + "time" + + coreauth "github.com/matdev83/go-llm-interactive-proxy/internal/core/auth" + "github.com/matdev83/go-llm-interactive-proxy/internal/core/b2bua" + "github.com/matdev83/go-llm-interactive-proxy/internal/core/execbackend" + "github.com/matdev83/go-llm-interactive-proxy/internal/core/extensions" + "github.com/matdev83/go-llm-interactive-proxy/internal/core/hooks" + "github.com/matdev83/go-llm-interactive-proxy/internal/core/routing" + "github.com/matdev83/go-llm-interactive-proxy/internal/core/runtime" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipapi" + sdkauth "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/auth" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/execview" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/scope" +) + +// phase6SessionSink captures session-start audit events for secret-safety verification. +type phase6SessionSink struct { + mu sync.Mutex + evs []sdkauth.SessionStartEvent +} + +func (s *phase6SessionSink) OnAuthDecision(context.Context, sdkauth.AuthDecisionEvent) error { + return nil +} + +func (s *phase6SessionSink) OnSessionStart(_ context.Context, ev sdkauth.SessionStartEvent) error { + s.mu.Lock() + defer s.mu.Unlock() + s.evs = append(s.evs, ev) + return nil +} + +func (s *phase6SessionSink) snapshot() []sdkauth.SessionStartEvent { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]sdkauth.SessionStartEvent, len(s.evs)) + copy(out, s.evs) + return out +} + +// phase6SecureExecutor builds an executor with an auth-event dispatcher wired to sink. SecureSession +// is intentionally left nil so Execute auto-wires the test secure-session manager (export_test.go), +// keeping the test free of internal helpers. +func phase6SecureExecutor(t *testing.T, sink *phase6SessionSink) *runtime.Executor { + t.Helper() + st, err := b2bua.NewMemoryStore(b2bua.MemoryStoreOptions{}) + if err != nil { + t.Fatal(err) + } + disp := coreauth.NewEventDispatcher(sink, coreauth.EventFailureBestEffort) + snap := extensions.NewRequestRuntimeSnapshot(hooks.New(hooks.Config{}), extensions.SnapshotOptions{}) + return &runtime.Executor{ + Store: st, + Bus: hooks.New(hooks.Config{}), + RuntimeSnapshot: snap, + Now: func() time.Time { return time.Unix(3000, 0).UTC() }, + AuthEvents: disp, + SessionAuditPolicy: coreauth.SessionAuditPolicy{}, + Backends: map[string]execbackend.Backend{ + "openai": { + Caps: lipapi.NewBackendCaps(lipapi.CapabilityStreaming), + Open: func(context.Context, lipapi.Call, routing.AttemptCandidate) (lipapi.ManagedEventStream, error) { + return lipapi.NewFixedEventStream([]lipapi.Event{{Kind: lipapi.EventResponseFinished}}), nil + }, + }, + }, + Rand: routing.NewSeededRng(1), + } +} + +// TestPhase6_sessionStartEvidenceDerivedFromScopeAndSecretFree proves that when an authoritative +// scope is attached, the session-start audit event's principal fields are derived from the scope +// projection and the event carries no raw secret material (requirements 6.2, 5.2, 4.6). +func TestPhase6_sessionStartEvidenceDerivedFromScopeAndSecretFree(t *testing.T) { + t.Parallel() + sink := &phase6SessionSink{} + ex := phase6SecureExecutor(t, sink) + trusted := scope.PrincipalScopeView{ + SubjectKind: scope.SubjectHuman, + PrincipalID: scope.Known("scope-session-user"), + DisplayName: scope.Known("Scope Alice"), + AuthMethod: scope.Known("oidc"), + CredentialID: scope.Known("key-session"), + TenantID: scope.Known("t-session"), + Roles: []string{"ops"}, + SafeClaims: map[string]string{"team": "core"}, + } + ctx := scope.WithScope(context.Background(), trusted) + ctx = execview.WithFrontendID(ctx, "anthropic") + stream, err := ex.Execute(ctx, &lipapi.Call{ + Route: lipapi.RouteIntent{Selector: "openai:gpt-4"}, + Session: lipapi.SessionRef{ClientSessionID: "client-hint-phase6"}, + Messages: []lipapi.Message{{Role: lipapi.RoleUser, Parts: []lipapi.Part{lipapi.TextPart("hi")}}}, + }) + if err != nil { + t.Fatal(err) + } + _, _ = lipapi.Collect(context.Background(), stream) + + evs := sink.snapshot() + if len(evs) != 1 { + t.Fatalf("session-start events: want 1 got %d", len(evs)) + } + ev := evs[0] + if ev.PrincipalID != "scope-session-user" || ev.PrincipalDisplayName != "Scope Alice" { + t.Fatalf("session evidence principal must derive from scope: id=%q name=%q", ev.PrincipalID, ev.PrincipalDisplayName) + } + joined := strings.Join([]string{ev.PrincipalID, ev.PrincipalDisplayName, ev.SessionID, ev.ClientSessionRef, ev.ALegID}, " ") + for _, bad := range []string{"bearer ", "key-session", "secret", "access_token", "authorization:"} { + if strings.Contains(strings.ToLower(joined), bad) { + t.Fatalf("session evidence leaked secret-like material %q: %s", bad, joined) + } + } +} + +// TestPhase6_observerEventScopeIsCopy proves usage and traffic observer events receive independent +// copies of the authoritative scope: mutating one emitted event's scope slices/maps does not affect +// another event's scope from the same request (requirements 5.5, 5.3). +func TestPhase6_observerEventScopeIsCopy(t *testing.T) { + t.Parallel() + uobs := &scopeCaptureUsage{} + tobs := &scopeCaptureTraffic{} + ex := scopeEmissionExecutor(t, uobs, tobs) + trusted := scope.PrincipalScopeView{ + SubjectKind: scope.SubjectHuman, + PrincipalID: scope.Known("copy-user"), + Roles: []string{"admin", "ops"}, + SafeClaims: map[string]string{"team": "core"}, + PolicyLabels: map[string]string{"env": "prod"}, + } + ctx := scope.WithScope(context.Background(), trusted) + stream, err := ex.Execute(ctx, &lipapi.Call{ + Route: lipapi.RouteIntent{Selector: "openai:gpt-4"}, + Messages: []lipapi.Message{{Role: lipapi.RoleUser, Parts: []lipapi.Part{lipapi.TextPart("hi")}}}, + }) + if err != nil { + t.Fatal(err) + } + _, _ = lipapi.Collect(context.Background(), stream) + + uobs.mu.Lock() + if len(uobs.events) == 0 { + t.Fatal("expected usage events") + } + uev := uobs.events[0] + if uev.Scope.Roles[0] != "admin" { + t.Fatalf("usage scope Roles: %+v", uev.Scope.Roles) + } + uev.Scope.Roles[0] = "mutated" + uev.Scope.SafeClaims["team"] = "mutated" + uev.Scope.PolicyLabels["env"] = "mutated" + uobs.mu.Unlock() + + tobs.mu.Lock() + defer tobs.mu.Unlock() + for _, o := range tobs.obs { + if !o.Scope.PrincipalID.IsKnown() { + continue + } + if o.Scope.Roles[0] == "mutated" { + t.Fatal("traffic scope Roles mutated via usage event copy (no isolation)") + } + if o.Scope.SafeClaims["team"] == "mutated" { + t.Fatal("traffic scope SafeClaims mutated via usage event copy (no isolation)") + } + if o.Scope.PolicyLabels["env"] == "mutated" { + t.Fatal("traffic scope PolicyLabels mutated via usage event copy (no isolation)") + } + } +} diff --git a/internal/core/runtime/scope_resolver.go b/internal/core/runtime/scope_resolver.go new file mode 100644 index 00000000..1edf7a46 --- /dev/null +++ b/internal/core/runtime/scope_resolver.go @@ -0,0 +1,70 @@ +package runtime + +import ( + "context" + "strings" + + coreauth "github.com/matdev83/go-llm-interactive-proxy/internal/core/auth" + "github.com/matdev83/go-llm-interactive-proxy/internal/core/execctx" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/execview" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/scope" +) + +// resolveRequestScope produces one authoritative principal/scope snapshot for the request +// before secure-session and backend execution (design Runtime Scope Resolver, req 4.1). +// +// Precedence: +// 1. Trusted scope already attached to the context (by the HTTP auth bridge) wins; the +// principal projection is derived from it and any legacy principal in context is ignored +// for identity (requirements 2.2, 4.6). +// 2. Legacy principal fallback: when no scope is present but a non-empty principal is +// attached, a scope is derived via the shared [coreauth.ScopeFromLegacyPrincipal] helper. +// Unknown optional fields remain unknown (req 3.5). +// 3. Local synthetic fallback: only under the existing local-mode condition +// (Executor.SyntheticLocalPrincipal), an explicit local single-user scope is produced. +// +// Returns ok=false when no identity is available and local synthesis is disabled; callers +// preserve prior behavior (empty principal, no scope) in that case. +func (e *Executor) resolveRequestScope(ctx context.Context) (scope.PrincipalScopeView, execview.PrincipalView, bool) { + if s, ok := scope.ScopeFromContext(ctx); ok { + return s, s.Principal(), true + } + if p, ok := execview.PrincipalFromContext(ctx); ok { + if id := strings.TrimSpace(p.ID); id != "" { + s := coreauth.ScopeFromLegacyPrincipal(p) + return s, s.Principal(), true + } + } + if e != nil && e.SyntheticLocalPrincipal { + s := localSyntheticScopeForRuntime() + return s, s.Principal(), true + } + return scope.PrincipalScopeView{}, execview.PrincipalView{}, false +} + +// localSyntheticScopeForRuntime builds the explicit local single-user scope used when the +// executor is operating under the existing local-mode condition (SyntheticLocalPrincipal). +// The principal id and issuer claim match the legacy local-dev synthetic principal so +// secure-session binding and existing tests remain unchanged (requirements 1.4, 2.4, 4.2). +func localSyntheticScopeForRuntime() scope.PrincipalScopeView { + return scope.PrincipalScopeView{ + Origin: scope.OriginClient, + SubjectKind: scope.SubjectLocal, + PrincipalID: scope.Known(syntheticLocalPrincipalID), + AuthMethod: scope.Known("local_noop"), + SafeClaims: map[string]string{"issuer": syntheticLocalPrincipalIssuer}, + } +} + +// scopeFromCtx returns the authoritative scope attached to the request context, or the zero +// view when none is present. Used on usage and traffic emission paths so observer evidence +// carries safe attribution from execctx.Views.Scope (requirement 6.4). +func scopeFromCtx(ctx context.Context) scope.PrincipalScopeView { + if s, ok := scope.ScopeFromContext(ctx); ok { + return s + } + if v, ok := execctx.FromContext(ctx); ok { + return v.Scope + } + return scope.PrincipalScopeView{} +} diff --git a/internal/core/runtime/scope_resolver_test.go b/internal/core/runtime/scope_resolver_test.go new file mode 100644 index 00000000..9f441429 --- /dev/null +++ b/internal/core/runtime/scope_resolver_test.go @@ -0,0 +1,138 @@ +package runtime + +import ( + "context" + "testing" + + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/execview" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/scope" +) + +// TestResolveRequestScope_trustedScopeWins proves a scope already attached to context is +// returned authoritatively and the principal projection is derived from it, ignoring any +// legacy principal in context (requirements 2.2, 4.6). +func TestResolveRequestScope_trustedScopeWins(t *testing.T) { + t.Parallel() + trusted := scope.PrincipalScopeView{ + SubjectKind: scope.SubjectHuman, + PrincipalID: scope.Known("scope-user"), + Roles: []string{"admin"}, + } + ctx := scope.WithScope(context.Background(), trusted) + ctx = execview.WithPrincipal(ctx, execview.PrincipalView{ID: "legacy-loses"}) + ex := &Executor{} + s, p, ok := ex.resolveRequestScope(ctx) + if !ok { + t.Fatal("expected scope resolved") + } + if s.PrincipalID.String() != "scope-user" { + t.Fatalf("PrincipalID: got %q want scope-user", s.PrincipalID) + } + if p.ID != "scope-user" { + t.Fatalf("principal projection must derive from scope, got %q", p.ID) + } +} + +// TestResolveRequestScope_legacyPrincipalFallback proves a legacy principal-only context +// (no scope) derives a scope with unknown optional fields preserved as unknown (req 4.2, 3.5). +func TestResolveRequestScope_legacyPrincipalFallback(t *testing.T) { + t.Parallel() + ctx := execview.WithPrincipal(context.Background(), execview.PrincipalView{ + ID: "legacy-user", + DisplayName: "Legacy", + Roles: []string{"ops"}, + Claims: map[string]string{"tenant": "a"}, + }) + ex := &Executor{} + s, p, ok := ex.resolveRequestScope(ctx) + if !ok { + t.Fatal("expected scope resolved from legacy principal") + } + if s.PrincipalID.String() != "legacy-user" { + t.Fatalf("PrincipalID: got %q", s.PrincipalID) + } + if s.SubjectKind != scope.SubjectUnknown { + t.Fatalf("SubjectKind: got %v want unknown (no inference)", s.SubjectKind) + } + if s.DisplayName.String() != "Legacy" { + t.Fatalf("DisplayName: got %q want Legacy (shared helper copies display)", s.DisplayName) + } + if len(s.Roles) != 1 || s.Roles[0] != "ops" { + t.Fatalf("Roles: got %+v want [ops] (shared helper copies roles)", s.Roles) + } + if !s.AuthMethod.IsUnknown() { + t.Fatalf("AuthMethod must remain unknown: runtime has no auth method, got %+v", s.AuthMethod) + } + if !s.TenantID.IsUnknown() || !s.ProjectID.IsUnknown() { + t.Fatalf("optional fields must remain unknown: %+v %+v", s.TenantID, s.ProjectID) + } + if p.ID != "legacy-user" { + t.Fatalf("principal projection ID: got %q", p.ID) + } + if p.Claims["tenant"] != "a" { + t.Fatalf("principal projection claims must copy legacy claims: %v", p.Claims) + } +} + +// TestResolveRequestScope_localSyntheticFallback proves a local-mode executor with no +// identity produces an explicit local single-user scope (requirement 1.4, 2.4, 4.2). +func TestResolveRequestScope_localSyntheticFallback(t *testing.T) { + t.Parallel() + ex := &Executor{SyntheticLocalPrincipal: true} + s, p, ok := ex.resolveRequestScope(context.Background()) + if !ok { + t.Fatal("expected synthetic local scope") + } + if s.SubjectKind != scope.SubjectLocal { + t.Fatalf("SubjectKind: got %v want local", s.SubjectKind) + } + if s.PrincipalID.String() != syntheticLocalPrincipalID { + t.Fatalf("PrincipalID: got %q want %q", s.PrincipalID, syntheticLocalPrincipalID) + } + if s.AuthMethod.String() != "local_noop" { + t.Fatalf("AuthMethod: got %q", s.AuthMethod) + } + if !s.TenantID.IsUnknown() { + t.Fatal("local synthetic must not invent tenant") + } + if p.Claims["issuer"] != syntheticLocalPrincipalIssuer { + t.Fatalf("issuer claim: got %q want %q", p.Claims["issuer"], syntheticLocalPrincipalIssuer) + } +} + +// TestResolveRequestScope_noIdentityNoSynthetic proves no scope is produced when no +// identity is present and local synthesis is disabled (preserves prior behavior). +func TestResolveRequestScope_noIdentityNoSynthetic(t *testing.T) { + t.Parallel() + ex := &Executor{SyntheticLocalPrincipal: false} + _, _, ok := ex.resolveRequestScope(context.Background()) + if ok { + t.Fatal("expected no scope when no identity and no synthetic fallback") + } +} + +// TestResolveRequestScope_emptyLegacyPrincipalFallsThrough proves a zero-ID legacy principal +// does not produce a scope; it falls through to the synthetic path when enabled. +func TestResolveRequestScope_emptyLegacyPrincipalFallsThrough(t *testing.T) { + t.Parallel() + ctx := execview.WithPrincipal(context.Background(), execview.PrincipalView{ID: " "}) + ex := &Executor{SyntheticLocalPrincipal: true} + s, _, ok := ex.resolveRequestScope(ctx) + if !ok { + t.Fatal("expected synthetic fallback") + } + if s.PrincipalID.String() != syntheticLocalPrincipalID { + t.Fatalf("PrincipalID: got %q want synthetic %q", s.PrincipalID, syntheticLocalPrincipalID) + } +} + +func TestScopeFromCtx_prefersScopeContext(t *testing.T) { + t.Parallel() + trusted := scope.PrincipalScopeView{PrincipalID: scope.Known("scope-only")} + ctx := scope.WithScope(context.Background(), trusted) + + s := scopeFromCtx(ctx) + if !s.PrincipalID.Equal(scope.Known("scope-only")) { + t.Fatalf("PrincipalID: got %+v want scope-only", s.PrincipalID) + } +} diff --git a/internal/core/runtime/secure_session.go b/internal/core/runtime/secure_session.go index 7c9a5d1d..437b21b7 100644 --- a/internal/core/runtime/secure_session.go +++ b/internal/core/runtime/secure_session.go @@ -6,6 +6,7 @@ import ( "github.com/matdev83/go-llm-interactive-proxy/internal/core/securesession/app" "github.com/matdev83/go-llm-interactive-proxy/internal/core/securesession/domain" "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/execview" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/scope" ) // SecureSessionRecorder is an alias for the secure-session gate recording port implemented by [app.Recorder]. @@ -25,6 +26,14 @@ func principalRefFromView(p execview.PrincipalView) domain.PrincipalRef { return r } +func principalRefFromScope(p execview.PrincipalView, s scope.PrincipalScopeView) domain.PrincipalRef { + r := principalRefFromView(p) + if s.TenantID.IsKnown() { + r.Tenant = strings.TrimSpace(s.TenantID.String()) + } + return r +} + func (e *Executor) secureSessionForAttempt() *app.Manager { if e == nil || e.SecureSession == nil { return nil diff --git a/internal/stdhttp/auth/adapter.go b/internal/stdhttp/auth/adapter.go index db03b280..89cd4151 100644 --- a/internal/stdhttp/auth/adapter.go +++ b/internal/stdhttp/auth/adapter.go @@ -3,6 +3,7 @@ package auth import ( "context" + "errors" "fmt" "net/http" "slices" @@ -12,6 +13,7 @@ import ( coreauth "github.com/matdev83/go-llm-interactive-proxy/internal/core/auth" "github.com/matdev83/go-llm-interactive-proxy/internal/core/diag" "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/auth" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/scope" "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/transport/httpauth" ) @@ -91,13 +93,27 @@ func (p *PolicyProvider) Authenticate(ctx context.Context, w http.ResponseWriter if traceID == "" { traceID = meta.TraceID } - ev := authDecisionEvent(now, traceID, p.Policy, meta, d) + + // Normalize an accepted decision into one authoritative safe scope plus the derived + // principal projection before any evidence is emitted or proxy execution begins + // (requirements 1.1, 1.5, 2.1, 4.1). Denied/challenged decisions never produce a + // successful lifecycle scope (requirement 1.6). + bridged := bridgeScope(d) + if bridged.err != nil { + // Credential-like scope material is rejected before execution and evidence. + d.Outcome = auth.OutcomeDeny + if d.ReasonCode == "" { + d.ReasonCode = "unsafe_scope" + } + } + + ev := authDecisionEvent(now, traceID, p.Policy, meta, d, bridged.evidence) if p.Events != nil { if e2 := p.Events.DispatchAuthDecision(ctx, ev); e2 != nil { synth := d synth.Outcome = auth.OutcomeDeny synth.ReasonCode = "event_delivery_failed" - ev2 := authDecisionEvent(now, traceID, p.Policy, meta, synth) + ev2 := authDecisionEvent(now, traceID, p.Policy, meta, synth, nil) rend := p.callRenderer(ctx, frontendID, &meta, synth, ev2, http.StatusServiceUnavailable) return resultFromRender(rend, auth.OutcomeDeny), nil } @@ -105,6 +121,12 @@ func (p *PolicyProvider) Authenticate(ctx context.Context, w http.ResponseWriter switch d.Outcome { case auth.OutcomeAllow: + if bridged.lifecycle != nil { + s := bridged.lifecycle.Scope + return httpauth.AuthenticationResult{Type: httpauth.TypePrincipal, Principal: bridged.lifecycle.Principal, Scope: &s}, nil + } + // Allow without a trusted scope or identity (legacy pass-through): attach the + // legacy principal only; the runtime derives synthetic scope under local mode. return httpauth.AuthenticationResult{Type: httpauth.TypePrincipal, Principal: d.Principal}, nil case auth.OutcomeChallenge, auth.OutcomeDeny: st := defaultTerminalHTTPStatus(&d) @@ -116,12 +138,52 @@ func (p *PolicyProvider) Authenticate(ctx context.Context, w http.ResponseWriter if d2.ReasonCode == "" { d2.ReasonCode = "unusable_outcome" } - ev2 := authDecisionEvent(now, traceID, p.Policy, meta, d2) + ev2 := authDecisionEvent(now, traceID, p.Policy, meta, d2, nil) rend := p.callRenderer(ctx, frontendID, &meta, d2, ev2, http.StatusUnauthorized) return resultFromRender(rend, auth.OutcomeDeny), nil } } +type scopeBridgeResult struct { + lifecycle *coreauth.ScopeBuildResult + evidence *scope.PrincipalScopeView + err error +} + +// bridgeScope normalizes an accepted auth decision into one authoritative safe scope and the +// derived legacy principal projection. It returns the built scope/principal for accepted +// decisions, an evidence-safe scope pointer (set for both accepted and rejected decisions when +// identity attribution is available), and a non-nil error only when a trusted scope value looks +// like credential material and must be rejected before execution (requirement 2.6, 5.4). +func bridgeScope(d auth.Decision) scopeBridgeResult { + if d.Outcome == auth.OutcomeAllow { + res, bErr := coreauth.BuildScope(coreauth.ScopeBuildInput{Decision: d}) + switch { + case bErr == nil: + s := res.Scope + return scopeBridgeResult{lifecycle: &res, evidence: &s} + case errors.Is(bErr, coreauth.ErrNoIdentity): + // Legacy allow with no trusted identity; runtime derives scope when permitted. + return scopeBridgeResult{} + default: + // Unsafe scope material or any other normalization failure rejects before execution. + return scopeBridgeResult{err: bErr} + } + } + // Denied/challenged decisions: emit safe attribution from a trusted scope when the + // authenticator supplied one, without creating a lifecycle scope (requirement 6.1, 1.6). + // The scope is run through the Phase 2 safety filter so credential-like material in a + // rejected decision's scope is omitted from evidence rather than emitted (requirement 2.6, 5.4). + if d.Scope != nil { + s := d.Scope.Clone() + if err := coreauth.SanitizeScope(s); err != nil { + return scopeBridgeResult{} + } + return scopeBridgeResult{evidence: &s} + } + return scopeBridgeResult{} +} + func (p *PolicyProvider) frontendID(r *http.Request) string { if p.FrontendID != nil { return p.FrontendID(r) @@ -259,14 +321,22 @@ func authDecisionEvent( pol PolicySnapshot, meta auth.InboundCallMeta, d auth.Decision, + evidenceScope *scope.PrincipalScopeView, ) auth.AuthDecisionEvent { - roles := slices.Clone(d.Principal.Roles) + // Prefer the authoritative scope projection for compatibility fields so legacy event + // consumers see the same identity as the request lifecycle (requirements 1.5, 4.6, 7.3); + // fall back to the legacy principal when no scope is available. + src := d.Principal + if evidenceScope != nil { + src = evidenceScope.Principal() + } + roles := slices.Clone(src.Roles) // PrincipalSafeClaims must not carry claim values on the audit path: only key names are // emitted so misconfigured or hostile deciders cannot seed OAuth/access tokens into events. var claims map[string]string - if len(d.Principal.Claims) > 0 { - claims = make(map[string]string, len(d.Principal.Claims)) - for k := range d.Principal.Claims { + if len(src.Claims) > 0 { + claims = make(map[string]string, len(src.Claims)) + for k := range src.Claims { k = strings.TrimSpace(k) if k == "" { continue @@ -277,7 +347,7 @@ func authDecisionEvent( claims = nil } } - return auth.AuthDecisionEvent{ + ev := auth.AuthDecisionEvent{ Time: now, TraceID: traceID, AccessMode: pol.AccessMode, @@ -286,8 +356,8 @@ func authDecisionEvent( Frontend: meta.Frontend, Outcome: d.Outcome, ReasonCode: d.ReasonCode, - PrincipalID: strings.TrimSpace(d.Principal.ID), - PrincipalDisplayName: strings.TrimSpace(d.Principal.DisplayName), + PrincipalID: strings.TrimSpace(src.ID), + PrincipalDisplayName: strings.TrimSpace(src.DisplayName), PrincipalRoles: roles, PrincipalSafeClaims: claims, DeviceID: strings.TrimSpace(d.Device.ID), @@ -296,6 +366,11 @@ func authDecisionEvent( ChallengeKind: d.Challenge.Kind, ChallengeSummary: d.Challenge.Summary, } + if evidenceScope != nil { + s := evidenceScope.Clone() + ev.Scope = &s + } + return ev } var _ httpauth.Provider = (*PolicyProvider)(nil) diff --git a/internal/stdhttp/auth/adapter_test.go b/internal/stdhttp/auth/adapter_test.go index e65f4e28..880c2cb3 100644 --- a/internal/stdhttp/auth/adapter_test.go +++ b/internal/stdhttp/auth/adapter_test.go @@ -365,7 +365,7 @@ func TestAuthDecisionEventMapping(t *testing.T) { Device: auth.DeviceIdentity{ID: "d1", KeyID: "k1", Fingerprint: "fp1"}, } pol := PolicySnapshot{HandlerKind: auth.HandlerLocalNoop, RequiredLevel: auth.LevelNone, AccessMode: auth.AccessSingleUser} - ev := authDecisionEvent(now, "t1", pol, auth.InboundCallMeta{Frontend: "fe1"}, d) + ev := authDecisionEvent(now, "t1", pol, auth.InboundCallMeta{Frontend: "fe1"}, d, nil) if ev.PrincipalID != "a" || ev.DeviceID != "d1" { t.Fatalf("ev: %+v", ev) } diff --git a/internal/stdhttp/auth/middleware.go b/internal/stdhttp/auth/middleware.go index 0a553421..7562f3ac 100644 --- a/internal/stdhttp/auth/middleware.go +++ b/internal/stdhttp/auth/middleware.go @@ -99,6 +99,9 @@ func Middleware(log *slog.Logger, providers []httpauth.Provider, next http.Handl continue case httpauth.TypePrincipal: ctx = httpauth.WithPrincipal(ctx, res.Principal) + if res.Scope != nil { + ctx = httpauth.WithScope(ctx, *res.Scope) + } case httpauth.TypeAnnotate: mergeAnnotateResponseHeaders(ctx, log, w.Header(), res.ResponseHeaders) case httpauth.TypeReject, httpauth.TypeChallenge: @@ -190,31 +193,33 @@ func writeTermination(ctx context.Context, log *slog.Logger, w http.ResponseWrit } } -// EnsureContextPrincipal copies a transport principal from parent into child if child has none. +// EnsureContextIdentity copies transport identity from parent into child if child has none. // Used when a sub-context loses values (tests or isolated decode paths). // A nil child is reserved for tests and isolated decode helpers; production request paths must pass // a non-nil request-derived child so cancellation and context values behave normally. // If child is nil, it returns a non-nil context: when parent is non-nil, context.WithoutCancel(parent) -// with the parent principal attached (preserves request-scoped values such as trace IDs without +// with the parent identity attached (preserves request-scoped values such as trace IDs without // inheriting parent cancellation); otherwise context.Background. -func EnsureContextPrincipal(parent, child context.Context) context.Context { +func EnsureContextIdentity(parent, child context.Context) context.Context { if child == nil { - if p, ok := httpauth.PrincipalFromContext(parent); ok { - if parent != nil { - return httpauth.WithPrincipal(context.WithoutCancel(parent), p) - } - return httpauth.WithPrincipal(context.Background(), p) - } if parent != nil { - return context.WithoutCancel(parent) + child = context.WithoutCancel(parent) + } else { + child = context.Background() } - return context.Background() } - if _, ok := httpauth.PrincipalFromContext(child); ok { - return child + + if _, ok := httpauth.PrincipalFromContext(child); !ok { + if p, ok := httpauth.PrincipalFromContext(parent); ok { + child = httpauth.WithPrincipal(child, p) + } } - if p, ok := httpauth.PrincipalFromContext(parent); ok { - return httpauth.WithPrincipal(child, p) + + if _, ok := httpauth.ScopeFromContext(child); !ok { + if s, ok := httpauth.ScopeFromContext(parent); ok { + child = httpauth.WithScope(child, s) + } } + return child } diff --git a/internal/stdhttp/auth/middleware_test.go b/internal/stdhttp/auth/middleware_test.go index 883aba73..f448bb24 100644 --- a/internal/stdhttp/auth/middleware_test.go +++ b/internal/stdhttp/auth/middleware_test.go @@ -12,6 +12,7 @@ import ( "github.com/matdev83/go-llm-interactive-proxy/internal/core/diag" "github.com/matdev83/go-llm-interactive-proxy/internal/stdhttp/auth" "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/execview" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/scope" "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/transport/httpauth" ) @@ -332,21 +333,43 @@ func TestMiddleware_chain_ordering(t *testing.T) { type testCollisionKey struct{} -func TestEnsureContextPrincipal_copiesFromParent(t *testing.T) { +func TestEnsureContextIdentity_copiesFromParent(t *testing.T) { t.Parallel() parent := httpauth.WithPrincipal(context.Background(), execview.PrincipalView{ID: "p"}) + parent = httpauth.WithScope(parent, scope.PrincipalScopeView{PrincipalID: scope.Known("scope-p")}) child := context.WithValue(context.Background(), testCollisionKey{}, 1) - out := auth.EnsureContextPrincipal(parent, child) + out := auth.EnsureContextIdentity(parent, child) p, ok := httpauth.PrincipalFromContext(out) if !ok || p.ID != "p" { t.Fatalf("got %+v ok=%v", p, ok) } + s, ok := httpauth.ScopeFromContext(out) + if !ok || !s.PrincipalID.Equal(scope.Known("scope-p")) { + t.Fatalf("scope %+v ok=%v", s, ok) + } +} + +func TestEnsureContextIdentity_preservesChildScopeWhenPrincipalMissing(t *testing.T) { + t.Parallel() + parent := httpauth.WithPrincipal(context.Background(), execview.PrincipalView{ID: "parent-p"}) + parent = httpauth.WithScope(parent, scope.PrincipalScopeView{PrincipalID: scope.Known("parent-scope")}) + child := httpauth.WithScope(context.Background(), scope.PrincipalScopeView{PrincipalID: scope.Known("child-scope")}) + out := auth.EnsureContextIdentity(parent, child) + p, ok := httpauth.PrincipalFromContext(out) + if !ok || p.ID != "parent-p" { + t.Fatalf("principal %+v ok=%v", p, ok) + } + s, ok := httpauth.ScopeFromContext(out) + if !ok || !s.PrincipalID.Equal(scope.Known("child-scope")) { + t.Fatalf("scope %+v ok=%v", s, ok) + } } -func TestEnsureContextPrincipal_nilChild_nonNil(t *testing.T) { +func TestEnsureContextIdentity_nilChild_nonNil(t *testing.T) { t.Parallel() parent := httpauth.WithPrincipal(context.Background(), execview.PrincipalView{ID: "p"}) - out := auth.EnsureContextPrincipal(parent, nil) + parent = httpauth.WithScope(parent, scope.PrincipalScopeView{PrincipalID: scope.Known("scope-p")}) + out := auth.EnsureContextIdentity(parent, nil) if out == nil { t.Fatal("expected non-nil context") } @@ -354,7 +377,11 @@ func TestEnsureContextPrincipal_nilChild_nonNil(t *testing.T) { if !ok || p.ID != "p" { t.Fatalf("got %+v ok=%v", p, ok) } - out2 := auth.EnsureContextPrincipal(context.Background(), nil) + s, ok := httpauth.ScopeFromContext(out) + if !ok || !s.PrincipalID.Equal(scope.Known("scope-p")) { + t.Fatalf("scope %+v ok=%v", s, ok) + } + out2 := auth.EnsureContextIdentity(context.Background(), nil) if out2 == nil { t.Fatal("expected non-nil context") } @@ -363,11 +390,11 @@ func TestEnsureContextPrincipal_nilChild_nonNil(t *testing.T) { } } -func TestEnsureContextPrincipal_nilChild_preservesParentValuesWithoutCancellation(t *testing.T) { +func TestEnsureContextIdentity_nilChild_preservesParentValuesWithoutCancellation(t *testing.T) { t.Parallel() parent, cancel := context.WithCancel(diag.WithTraceID(context.Background(), "trace-nil-child")) cancel() - out := auth.EnsureContextPrincipal(parent, nil) + out := auth.EnsureContextIdentity(parent, nil) if got := diag.TraceID(out); got != "trace-nil-child" { t.Fatalf("trace id: got %q", got) } diff --git a/internal/stdhttp/auth/scope_bridge_test.go b/internal/stdhttp/auth/scope_bridge_test.go new file mode 100644 index 00000000..6a3d9548 --- /dev/null +++ b/internal/stdhttp/auth/scope_bridge_test.go @@ -0,0 +1,390 @@ +package auth + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + coreauth "github.com/matdev83/go-llm-interactive-proxy/internal/core/auth" + "github.com/matdev83/go-llm-interactive-proxy/internal/core/diag" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/auth" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/execview" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/scope" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/transport/httpauth" +) + +// allowScope is a trusted, safe-by-construction scope snapshot shared by scope-bridge tests. +func allowScope() scope.PrincipalScopeView { + return scope.PrincipalScopeView{ + SubjectKind: scope.SubjectHuman, + Origin: scope.OriginClient, + PrincipalID: scope.Known("user-7"), + DisplayName: scope.Known("Alice"), + AuthMethod: scope.Known("oidc"), + CredentialID: scope.Known("key-7"), + Roles: []string{"ops"}, + SafeClaims: map[string]string{"team": "core"}, + TenantID: scope.Known("t-7"), + } +} + +// TestPolicyProvider_allow_attachesScopeAndDerivedPrincipalToContext proves accepted requests +// carry matching authoritative scope and derived principal projection before proxy execution +// (requirements 1.1, 1.5, 2.1, 4.1, 7.3). +func TestPolicyProvider_allow_attachesScopeAndDerivedPrincipalToContext(t *testing.T) { + t.Parallel() + trusted := allowScope() + stub := &stubCoreAuthenticator{dec: auth.Decision{Outcome: auth.OutcomeAllow, Scope: &trusted}} + p := NewPolicyProvider(stub, nil, PolicySnapshot{ + AccessMode: auth.AccessMultiUser, HandlerKind: auth.HandlerRemote, RequiredLevel: auth.LevelAPIKey, + }, nil) + var innerCtx context.Context + rec := httptest.NewRecorder() + Middleware(nil, []httpauth.Provider{p}, http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + innerCtx = r.Context() + })).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/chat", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("code %d", rec.Code) + } + gotScope, ok := httpauth.ScopeFromContext(innerCtx) + if !ok { + t.Fatal("expected authoritative scope in request context") + } + if gotScope.PrincipalID.String() != "user-7" { + t.Fatalf("scope PrincipalID %q", gotScope.PrincipalID) + } + gotPrincipal, ok := httpauth.PrincipalFromContext(innerCtx) + if !ok || gotPrincipal.ID != "user-7" { + t.Fatalf("principal %+v ok=%v", gotPrincipal, ok) + } + if proj := gotScope.Principal(); proj.ID != gotPrincipal.ID { + t.Fatalf("principal %q must equal scope projection %q", gotPrincipal.ID, proj.ID) + } +} + +// TestPolicyProvider_allow_legacyPrincipal_attachesDerivedScope proves a principal-only allow +// still receives an authoritative derived scope at the bridge (precedence rung 2). +func TestPolicyProvider_allow_legacyPrincipal_attachesDerivedScope(t *testing.T) { + t.Parallel() + stub := &stubCoreAuthenticator{dec: auth.Decision{ + Outcome: auth.OutcomeAllow, + Principal: execview.PrincipalView{ID: "legacy-1", DisplayName: "Legacy"}, + SatisfiedLevel: auth.LevelAPIKey, + }} + p := NewPolicyProvider(stub, nil, PolicySnapshot{ + AccessMode: auth.AccessMultiUser, HandlerKind: auth.HandlerLocalAPIKey, RequiredLevel: auth.LevelAPIKey, + }, nil) + var innerCtx context.Context + Middleware(nil, []httpauth.Provider{p}, http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + innerCtx = r.Context() + })).ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/v1/", nil)) + gotScope, ok := httpauth.ScopeFromContext(innerCtx) + if !ok { + t.Fatal("expected derived scope for legacy principal allow") + } + if gotScope.PrincipalID.String() != "legacy-1" { + t.Fatalf("scope PrincipalID %q", gotScope.PrincipalID) + } + if gotScope.AuthMethod.String() != "api_key" { + t.Fatalf("AuthMethod %q", gotScope.AuthMethod) + } + gotPrincipal, _ := httpauth.PrincipalFromContext(innerCtx) + if gotPrincipal.ID != gotScope.Principal().ID { + t.Fatalf("principal %q must match scope projection %q", gotPrincipal.ID, gotScope.Principal().ID) + } +} + +// TestPolicyProvider_deny_resultHasNoLifecycleScope proves denied decisions preserve the +// rejection shape and do not attach a successful lifecycle scope (requirement 1.6). +func TestPolicyProvider_deny_resultHasNoLifecycleScope(t *testing.T) { + t.Parallel() + stub := &stubCoreAuthenticator{dec: auth.Decision{Outcome: auth.OutcomeDeny, ReasonCode: "missing_api_key"}} + p := NewPolicyProvider(stub, nil, PolicySnapshot{ + AccessMode: auth.AccessMultiUser, HandlerKind: auth.HandlerLocalAPIKey, RequiredLevel: auth.LevelAPIKey, + }, nil) + res, err := p.Authenticate(context.Background(), httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/v1/", nil)) + if err != nil { + t.Fatal(err) + } + if res.Type != httpauth.TypeReject { + t.Fatalf("type %q want reject", res.Type) + } + if res.Scope != nil { + t.Fatalf("denied result must not carry lifecycle scope, got %+v", res.Scope) + } +} + +// TestPolicyProvider_challenge_resultHasNoLifecycleScope_butEvidenceHasAttribution proves +// challenged decisions preserve the challenge shape without lifecycle scope while still +// emitting safe attribution evidence when identity is available (requirements 1.6, 6.1). +func TestPolicyProvider_challenge_resultHasNoLifecycleScope_butEvidenceHasAttribution(t *testing.T) { + t.Parallel() + trusted := allowScope() + stub := &stubCoreAuthenticator{dec: auth.Decision{ + Outcome: auth.OutcomeChallenge, + ReasonCode: "sso_required", + Scope: &trusted, + Challenge: auth.Challenge{Kind: auth.ChallengeSSORequired, Summary: "SSO required"}, + }} + sink := &captureSink{} + disp := coreauth.NewEventDispatcher(sink, coreauth.EventFailureBestEffort) + p := NewPolicyProvider(stub, disp, PolicySnapshot{ + AccessMode: auth.AccessMultiUser, HandlerKind: auth.HandlerRemote, RequiredLevel: auth.LevelAPIKey, + }, nil) + res, err := p.Authenticate(context.Background(), httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/v1/", nil)) + if err != nil { + t.Fatal(err) + } + if res.Type != httpauth.TypeChallenge { + t.Fatalf("type %q want challenge", res.Type) + } + if res.Scope != nil { + t.Fatal("challenged result must not carry lifecycle scope") + } + if len(sink.events) != 1 { + t.Fatalf("events %d", len(sink.events)) + } + ev := sink.events[0] + if ev.Outcome != auth.OutcomeChallenge { + t.Fatalf("event outcome %q", ev.Outcome) + } + if ev.Scope == nil { + t.Fatal("challenge evidence must carry safe scope attribution when available") + } + if ev.Scope.PrincipalID.String() != "user-7" { + t.Fatalf("evidence scope PrincipalID %q", ev.Scope.PrincipalID) + } +} + +// TestPolicyProvider_allow_evidenceCarriesSafeScopeAndCompatFields proves success evidence +// includes trace correlation, outcome, reason, safe scope attribution, and existing +// compatibility fields (requirements 6.1, 6.5, 7.1). +func TestPolicyProvider_allow_evidenceCarriesSafeScopeAndCompatFields(t *testing.T) { + t.Parallel() + trusted := allowScope() + stub := &stubCoreAuthenticator{dec: auth.Decision{Outcome: auth.OutcomeAllow, Scope: &trusted, ReasonCode: ""}} + sink := &captureSink{} + disp := coreauth.NewEventDispatcher(sink, coreauth.EventFailureBestEffort) + p := NewPolicyProvider(stub, disp, PolicySnapshot{ + AccessMode: auth.AccessMultiUser, HandlerKind: auth.HandlerRemote, RequiredLevel: auth.LevelAPIKey, + }, nil) + req := httptest.NewRequest(http.MethodGet, "/v1/", nil) + req = req.WithContext(diag.WithTraceID(req.Context(), "trace-evidence-1")) + if _, err := p.Authenticate(context.Background(), httptest.NewRecorder(), req); err != nil { + t.Fatal(err) + } + if len(sink.events) != 1 { + t.Fatalf("events %d", len(sink.events)) + } + ev := sink.events[0] + if ev.Outcome != auth.OutcomeAllow { + t.Fatalf("outcome %q", ev.Outcome) + } + if ev.TraceID != "trace-evidence-1" { + t.Fatalf("evidence must carry trace correlation, got %q", ev.TraceID) + } + if ev.Scope == nil { + t.Fatal("allow evidence must carry safe scope attribution") + } + if ev.Scope.PrincipalID.String() != "user-7" { + t.Fatalf("evidence scope PrincipalID %q", ev.Scope.PrincipalID) + } + if ev.PrincipalID != "user-7" { + t.Fatalf("compat PrincipalID %q want user-7", ev.PrincipalID) + } + if len(ev.PrincipalRoles) != 1 || ev.PrincipalRoles[0] != "ops" { + t.Fatalf("compat roles %v", ev.PrincipalRoles) + } + if ev.PrincipalSafeClaims == nil || ev.PrincipalSafeClaims["team"] != "" { + t.Fatalf("PrincipalSafeClaims must list keys with empty values, got %#v", ev.PrincipalSafeClaims) + } +} + +// TestPolicyProvider_evidence_excludesRawSecrets proves raw bearer material from the transport +// never reaches auth decision evidence, and safe scope attribution is emitted instead +// (requirements 2.6, 5.2). +func TestPolicyProvider_evidence_excludesRawSecrets(t *testing.T) { + t.Parallel() + trusted := allowScope() + stub := &stubCoreAuthenticator{dec: auth.Decision{Outcome: auth.OutcomeAllow, Scope: &trusted}} + sink := &captureSink{} + disp := coreauth.NewEventDispatcher(sink, coreauth.EventFailureBestEffort) + p := NewPolicyProvider(stub, disp, PolicySnapshot{ + AccessMode: auth.AccessMultiUser, HandlerKind: auth.HandlerRemote, RequiredLevel: auth.LevelAPIKey, + }, nil) + req := httptest.NewRequest(http.MethodGet, "/v1/", nil) + req.Header.Set("Authorization", "Bearer do-not-leak-this-secret-1234") + if _, err := p.Authenticate(context.Background(), httptest.NewRecorder(), req); err != nil { + t.Fatal(err) + } + if len(sink.events) != 1 { + t.Fatalf("events %d", len(sink.events)) + } + ev := sink.events[0] + if ev.Scope == nil { + t.Fatal("expected safe scope evidence") + } + if scopeContains(ev.Scope, "do-not-leak-this-secret-1234") { + t.Fatalf("raw secret leaked into evidence scope: %+v", ev.Scope) + } + if strings.Contains(ev.PrincipalID, "do-not-leak") || strings.Contains(strings.Join(ev.PrincipalRoles, ","), "do-not-leak") { + t.Fatalf("raw secret leaked into compatibility fields: %+v", ev) + } +} + +// TestPolicyProvider_unsafeScope_deniesAndOmitsFromEvidence proves credential-like scope +// material is rejected before execution and omitted from evidence (requirements 2.6, 5.4, 8.5). +func TestPolicyProvider_unsafeScope_deniesAndOmitsFromEvidence(t *testing.T) { + t.Parallel() + unsafe := allowScope() + unsafe.PrincipalID = scope.Known("bearer some-token-1234567890") + stub := &stubCoreAuthenticator{dec: auth.Decision{Outcome: auth.OutcomeAllow, Scope: &unsafe}} + sink := &captureSink{} + disp := coreauth.NewEventDispatcher(sink, coreauth.EventFailureBestEffort) + p := NewPolicyProvider(stub, disp, PolicySnapshot{ + AccessMode: auth.AccessMultiUser, HandlerKind: auth.HandlerRemote, RequiredLevel: auth.LevelAPIKey, + }, nil) + res, err := p.Authenticate(context.Background(), httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/v1/", nil)) + if err != nil { + t.Fatal(err) + } + if res.Type != httpauth.TypeReject { + t.Fatalf("unsafe scope must deny, got type %q", res.Type) + } + if res.Scope != nil { + t.Fatal("unsafe denied result must not carry scope") + } + if len(sink.events) != 1 { + t.Fatalf("events %d", len(sink.events)) + } + ev := sink.events[0] + if ev.Outcome != auth.OutcomeDeny { + t.Fatalf("evidence outcome %q want deny", ev.Outcome) + } + if ev.Scope != nil { + t.Fatalf("unsafe scope must not appear in evidence, got %+v", ev.Scope) + } + if strings.Contains(ev.PrincipalID, "bearer") { + t.Fatalf("unsafe principal id leaked into evidence: %q", ev.PrincipalID) + } +} + +// TestPolicyProvider_deny_unsafeScope_omittedFromEvidence proves a denied decision that carries +// credential-like scope material still renders the rejection shape but omits the unsafe scope +// from evidence entirely (requirements 1.6, 2.6, 5.4, 6.1). +func TestPolicyProvider_deny_unsafeScope_omittedFromEvidence(t *testing.T) { + t.Parallel() + unsafe := allowScope() + unsafe.PrincipalID = scope.Known("bearer deny-secret-1234567890") + stub := &stubCoreAuthenticator{dec: auth.Decision{ + Outcome: auth.OutcomeDeny, + ReasonCode: "missing_api_key", + Scope: &unsafe, + }} + sink := &captureSink{} + disp := coreauth.NewEventDispatcher(sink, coreauth.EventFailureBestEffort) + p := NewPolicyProvider(stub, disp, PolicySnapshot{ + AccessMode: auth.AccessMultiUser, HandlerKind: auth.HandlerLocalAPIKey, RequiredLevel: auth.LevelAPIKey, + }, nil) + res, err := p.Authenticate(context.Background(), httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/v1/", nil)) + if err != nil { + t.Fatal(err) + } + if res.Type != httpauth.TypeReject { + t.Fatalf("denied unsafe scope must keep reject shape, got type %q", res.Type) + } + if res.Scope != nil { + t.Fatal("denied result must not carry lifecycle scope") + } + if len(sink.events) != 1 { + t.Fatalf("events %d", len(sink.events)) + } + ev := sink.events[0] + if ev.Outcome != auth.OutcomeDeny { + t.Fatalf("evidence outcome %q want deny", ev.Outcome) + } + if ev.Scope != nil { + t.Fatalf("unsafe denied scope must be omitted from evidence, got %+v", ev.Scope) + } + if scopeContains(ev.Scope, "deny-secret-1234567890") { + t.Fatalf("unsafe material leaked into evidence scope") + } + if strings.Contains(ev.PrincipalID, "bearer") || strings.Contains(ev.PrincipalID, "deny-secret") { + t.Fatalf("unsafe principal id leaked into evidence: %q", ev.PrincipalID) + } +} + +// TestPolicyProvider_challenge_unsafeScope_omittedFromEvidence proves a challenged decision that +// carries credential-like scope material still renders the challenge shape but omits the unsafe +// scope from evidence entirely (requirements 1.6, 2.6, 5.4, 6.1). +func TestPolicyProvider_challenge_unsafeScope_omittedFromEvidence(t *testing.T) { + t.Parallel() + unsafe := allowScope() + unsafe.PrincipalID = scope.Known("bearer challenge-secret-1234567890") + stub := &stubCoreAuthenticator{dec: auth.Decision{ + Outcome: auth.OutcomeChallenge, + ReasonCode: "sso_required", + Scope: &unsafe, + Challenge: auth.Challenge{Kind: auth.ChallengeSSORequired, Summary: "SSO required"}, + }} + sink := &captureSink{} + disp := coreauth.NewEventDispatcher(sink, coreauth.EventFailureBestEffort) + p := NewPolicyProvider(stub, disp, PolicySnapshot{ + AccessMode: auth.AccessMultiUser, HandlerKind: auth.HandlerRemote, RequiredLevel: auth.LevelAPIKey, + }, nil) + res, err := p.Authenticate(context.Background(), httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/v1/", nil)) + if err != nil { + t.Fatal(err) + } + if res.Type != httpauth.TypeChallenge { + t.Fatalf("challenged unsafe scope must keep challenge shape, got type %q", res.Type) + } + if res.Scope != nil { + t.Fatal("challenged result must not carry lifecycle scope") + } + if len(sink.events) != 1 { + t.Fatalf("events %d", len(sink.events)) + } + ev := sink.events[0] + if ev.Outcome != auth.OutcomeChallenge { + t.Fatalf("evidence outcome %q want challenge", ev.Outcome) + } + if ev.Scope != nil { + t.Fatalf("unsafe challenged scope must be omitted from evidence, got %+v", ev.Scope) + } + if strings.Contains(ev.PrincipalID, "bearer") || strings.Contains(ev.PrincipalID, "challenge-secret") { + t.Fatalf("unsafe principal id leaked into evidence: %q", ev.PrincipalID) + } +} + +func scopeContains(v *scope.PrincipalScopeView, needle string) bool { + if v == nil { + return false + } + fields := []string{ + v.PrincipalID.String(), v.DisplayName.String(), v.AuthMethod.String(), + v.CredentialID.String(), v.TenantID.String(), v.OrganizationID.String(), + v.WorkspaceID.String(), v.ProjectID.String(), v.DepartmentID.String(), + v.CostCenterID.String(), v.ParentTraceID.String(), + } + for _, f := range fields { + if strings.Contains(f, needle) { + return true + } + } + for _, r := range v.Roles { + if strings.Contains(r, needle) { + return true + } + } + for _, m := range []map[string]string{v.SafeClaims, v.PolicyLabels} { + for k, val := range m { + if strings.Contains(k, needle) || strings.Contains(val, needle) { + return true + } + } + } + return false +} diff --git a/pkg/lipsdk/auth/decision.go b/pkg/lipsdk/auth/decision.go index fa2a7251..f3cb4722 100644 --- a/pkg/lipsdk/auth/decision.go +++ b/pkg/lipsdk/auth/decision.go @@ -1,6 +1,9 @@ package auth -import "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/execview" +import ( + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/execview" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/scope" +) // DeviceIdentity distinguishes app, device, or key material from a human principal. // Fingerprints and IDs must be non-secret (or redacted) when emitted to events. @@ -29,4 +32,8 @@ type Decision struct { Challenge Challenge // ReasonCode is a stable, machine-oriented reason (e.g. "invalid_api_key", "remote_denied"). ReasonCode string + // Scope carries an optional authoritative safe principal/scope snapshot supplied by + // trusted auth code. It is nil for legacy principal-only decisions. Raw bearer/API/OAuth/ + // resume tokens and transport headers must never be placed here (requirements 2.1, 2.5, 2.6). + Scope *scope.PrincipalScopeView } diff --git a/pkg/lipsdk/auth/decision_scope_test.go b/pkg/lipsdk/auth/decision_scope_test.go new file mode 100644 index 00000000..27c4c1d9 --- /dev/null +++ b/pkg/lipsdk/auth/decision_scope_test.go @@ -0,0 +1,48 @@ +package auth + +import ( + "testing" + + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/execview" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/scope" +) + +// TestDecision_carriesOptionalTrustedScope proves a trusted auth provider can attach an +// authoritative safe scope to an allow decision, while legacy principal-only decisions +// still compile and behave unchanged (requirements 2.1, 2.5, 2.6, 7.1). +func TestDecision_carriesOptionalTrustedScope(t *testing.T) { + t.Parallel() + trusted := scope.PrincipalScopeView{ + SubjectKind: scope.SubjectHuman, + PrincipalID: scope.Known("user-1"), + CredentialID: scope.Known("key-1"), + AuthMethod: scope.Known("oidc"), + } + d := Decision{ + Outcome: OutcomeAllow, + Principal: execview.PrincipalView{ID: "user-1"}, + Scope: &trusted, + } + if d.Scope == nil { + t.Fatal("expected scope to be carried on decision") + } + if !d.Scope.PrincipalID.Equal(scope.Known("user-1")) { + t.Fatalf("scope PrincipalID: %+v", d.Scope.PrincipalID) + } +} + +// TestDecision_legacyPrincipalOnlyStillCompiles proves existing principal-only decisions +// remain valid without supplying scope (requirement 7.1 compatibility). +func TestDecision_legacyPrincipalOnlyStillCompiles(t *testing.T) { + t.Parallel() + d := Decision{ + Outcome: OutcomeAllow, + Principal: execview.PrincipalView{ID: "legacy-user"}, + } + if d.Scope != nil { + t.Fatalf("expected nil scope on legacy decision, got %+v", d.Scope) + } + if d.Principal.ID != "legacy-user" { + t.Fatalf("Principal.ID: got %q", d.Principal.ID) + } +} diff --git a/pkg/lipsdk/auth/events.go b/pkg/lipsdk/auth/events.go index 9c99d881..8f583f27 100644 --- a/pkg/lipsdk/auth/events.go +++ b/pkg/lipsdk/auth/events.go @@ -1,6 +1,10 @@ package auth -import "time" +import ( + "time" + + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/scope" +) // AccessMode is deployment access posture at the time of an event (string for stable wire/logging). type AccessMode string @@ -53,6 +57,10 @@ type AuthDecisionEvent struct { // Challenge, when applicable, is non-secret metadata. ChallengeKind ChallengeKind ChallengeSummary string + // Scope is an optional safe principal/scope snapshot from a trusted auth decision. It is + // nil when no scope was supplied. Raw secrets, transport headers, and resume authority + // must never be placed here (requirements 6.1, 2.6, 5.2). + Scope *scope.PrincipalScopeView } // SessionStartEvent is a non-secret record of a new or uncertain proxy-recognized session. diff --git a/pkg/lipsdk/auth/events_scope_test.go b/pkg/lipsdk/auth/events_scope_test.go new file mode 100644 index 00000000..aa9308e0 --- /dev/null +++ b/pkg/lipsdk/auth/events_scope_test.go @@ -0,0 +1,66 @@ +package auth + +import ( + "reflect" + "strings" + "testing" + + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/scope" +) + +// TestAuthDecisionEvent_carriesOptionalSafeScope proves audit evidence can include a safe +// principal/scope snapshot from a trusted auth decision (requirements 6.1, 2.6, 5.2). +func TestAuthDecisionEvent_carriesOptionalSafeScope(t *testing.T) { + t.Parallel() + trusted := scope.PrincipalScopeView{ + SubjectKind: scope.SubjectService, + PrincipalID: scope.Known("svc-1"), + CredentialID: scope.Known("key-1"), + } + ev := AuthDecisionEvent{ + Outcome: OutcomeAllow, + Scope: &trusted, + } + if ev.Scope == nil { + t.Fatal("expected scope carried on auth decision event") + } + if !ev.Scope.PrincipalID.Equal(scope.Known("svc-1")) { + t.Fatalf("event scope PrincipalID: %+v", ev.Scope.PrincipalID) + } +} + +// TestAuthDecisionEvent_scopeFieldNotSecretBearing ensures the new Scope field name does +// not suggest secret storage (requirement 2.6, 5.2). +func TestAuthDecisionEvent_scopeFieldNotSecretBearing(t *testing.T) { + t.Parallel() + typ := reflect.TypeFor[AuthDecisionEvent]() + for field := range typ.Fields() { + if eventFieldNameForbidden(field.Name) { + t.Fatalf("field %q looks like a secret-bearing column name", field.Name) + } + if strings.Contains(field.Name, "Raw") { + t.Fatalf("field %q suggests raw material", field.Name) + } + } +} + +// TestAuthDecisionEvent_omitsRawSecretsFromScope proves scope values never carry bearer +// or api key material even when a trusted provider supplies attribution (requirement 2.6). +func TestAuthDecisionEvent_omitsRawSecretsFromScope(t *testing.T) { + t.Parallel() + trusted := scope.PrincipalScopeView{ + PrincipalID: scope.Known("svc-1"), + CredentialID: scope.Known("key-1"), + SafeClaims: map[string]string{"team": "core"}, + } + ev := AuthDecisionEvent{Scope: &trusted} + raw := strings.Join([]string{ + ev.Scope.PrincipalID.String(), ev.Scope.CredentialID.String(), + ev.Scope.SafeClaims["team"], + }, " ") + for _, bad := range []string{"bearer ", "api_key=", "authorization:", "secret"} { + if strings.Contains(strings.ToLower(raw), bad) { + t.Fatalf("scope evidence contains credential-like material %q: %q", bad, raw) + } + } +} diff --git a/pkg/lipsdk/execview/views.go b/pkg/lipsdk/execview/views.go index 252d58b9..54a8bd67 100644 --- a/pkg/lipsdk/execview/views.go +++ b/pkg/lipsdk/execview/views.go @@ -1,6 +1,9 @@ package execview // PrincipalView is a generic identity snapshot visible to plugins (no HTTP or auth-provider types). +// For richer request attribution (subject kind, credential id, tenant/org/project/department/cost +// center, policy labels, origin — see [scope.PrincipalScopeView] in pkg/lipsdk/scope), this type +// is the legacy compatibility projection derived from the authoritative scope view. type PrincipalView struct { ID string DisplayName string diff --git a/pkg/lipsdk/scope/context.go b/pkg/lipsdk/scope/context.go new file mode 100644 index 00000000..de07d009 --- /dev/null +++ b/pkg/lipsdk/scope/context.go @@ -0,0 +1,32 @@ +package scope + +import "context" + +type ctxKey int + +const keyScope ctxKey = iota + 18400 + +// WithScope returns a child context carrying the authoritative principal/scope snapshot for +// downstream handlers and the execution pipeline. The value is cloned so callers cannot +// mutate the stored scope through the returned view (requirement 5.5). A nil parent is +// treated as [context.TODO] so the result is always non-nil. +func WithScope(ctx context.Context, v PrincipalScopeView) context.Context { + if ctx == nil { + ctx = context.TODO() + } + return context.WithValue(ctx, keyScope, v.Clone()) +} + +// ScopeFromContext returns the authoritative scope attached with [WithScope], if any. +// The returned view is a copy of the stored snapshot. +func ScopeFromContext(ctx context.Context) (PrincipalScopeView, bool) { + if ctx == nil { + return PrincipalScopeView{}, false + } + raw := ctx.Value(keyScope) + v, ok := raw.(PrincipalScopeView) + if !ok { + return PrincipalScopeView{}, false + } + return v.Clone(), true +} diff --git a/pkg/lipsdk/scope/context_test.go b/pkg/lipsdk/scope/context_test.go new file mode 100644 index 00000000..bcc7dc2c --- /dev/null +++ b/pkg/lipsdk/scope/context_test.go @@ -0,0 +1,71 @@ +package scope + +import ( + "context" + "testing" +) + +func TestScopeContext_roundTrip(t *testing.T) { + t.Parallel() + want := PrincipalScopeView{ + SubjectKind: SubjectHuman, + PrincipalID: Known("u1"), + Roles: []string{"admin"}, + SafeClaims: map[string]string{"team": "core"}, + } + ctx := WithScope(context.Background(), want) + got, ok := ScopeFromContext(ctx) + if !ok { + t.Fatal("expected scope") + } + if !got.PrincipalID.Equal(want.PrincipalID) { + t.Fatalf("PrincipalID: %+v", got.PrincipalID) + } + if got.SubjectKind != want.SubjectKind { + t.Fatalf("SubjectKind: got %v", got.SubjectKind) + } +} + +func TestScopeFromContext_missing(t *testing.T) { + t.Parallel() + if _, ok := ScopeFromContext(context.Background()); ok { + t.Fatal("expected no scope on bare context") + } + if _, ok := ScopeFromContext(nil); ok { //nolint:staticcheck // SA1012: intentional nil context contract + t.Fatal("expected no scope on nil context") + } +} + +func TestWithScope_nilParent_usesTODO(t *testing.T) { + t.Parallel() + ctx := WithScope(nil, PrincipalScopeView{PrincipalID: Known("x")}) //nolint:staticcheck // SA1012: intentional nil parent + if ctx == nil { + t.Fatal("expected non-nil context") + } + got, ok := ScopeFromContext(ctx) + if !ok || !got.PrincipalID.Equal(Known("x")) { + t.Fatalf("scope %+v ok=%v", got, ok) + } +} + +// TestScopeContext_returnsCopy proves the value retrieved from context is a copy so callers +// cannot mutate the stored scope through the returned view (requirement 5.5). +func TestScopeContext_returnsCopy(t *testing.T) { + t.Parallel() + want := PrincipalScopeView{ + PrincipalID: Known("u1"), + Roles: []string{"admin"}, + SafeClaims: map[string]string{"k": "v"}, + } + ctx := WithScope(context.Background(), want) + got, _ := ScopeFromContext(ctx) + got.Roles[0] = "mutated" + got.SafeClaims["k"] = "mutated" + got2, _ := ScopeFromContext(ctx) + if got2.Roles[0] == "mutated" { + t.Fatal("mutating retrieved Roles affected stored scope") + } + if got2.SafeClaims["k"] == "mutated" { + t.Fatal("mutating retrieved SafeClaims affected stored scope") + } +} diff --git a/pkg/lipsdk/scope/doc.go b/pkg/lipsdk/scope/doc.go new file mode 100644 index 00000000..0cefc73e --- /dev/null +++ b/pkg/lipsdk/scope/doc.go @@ -0,0 +1,13 @@ +// Package scope holds the authoritative, protocol-neutral principal/scope +// attribution snapshot for an accepted LLM Interactive Proxy request. +// +// Values in this package are safe-by-construction: raw credentials, raw +// transport headers, bearer/API/OAuth/resume tokens, and unvetted claim +// values are never fields on [PrincipalScopeView]. Only non-secret +// identifiers, display labels, roles, operator-safe claims, and policy +// labels are carried. +// +// The snapshot is immutable request lifecycle evidence. Consumers receive +// copies produced by [PrincipalScopeView.Clone] so roles, claims, and labels +// cannot be mutated through returned views. +package scope diff --git a/pkg/lipsdk/scope/value.go b/pkg/lipsdk/scope/value.go new file mode 100644 index 00000000..050a22f6 --- /dev/null +++ b/pkg/lipsdk/scope/value.go @@ -0,0 +1,34 @@ +package scope + +import "fmt" + +var _ fmt.Stringer = Value{} + +// Value is a presence-aware string used for attribution fields where unknown +// must be distinguished from a known-but-empty value. The zero Value is unknown. +type Value struct { + Known bool `json:"known"` + Value string `json:"value"` +} + +// Unknown returns a Value representing an unknown attribution field. +func Unknown() Value { return Value{Known: false} } + +// Known returns a Value representing a known attribution field, including a +// known-empty string (""). +func Known(s string) Value { return Value{Known: true, Value: s} } + +// IsUnknown reports whether the value is unknown (not supplied). +func (v Value) IsUnknown() bool { return !v.Known } + +// IsKnown reports whether the value is known, including known-empty. +func (v Value) IsKnown() bool { return v.Known } + +// IsKnownEmpty reports whether the value is known and intentionally empty. +func (v Value) IsKnownEmpty() bool { return v.Known && v.Value == "" } + +// String returns the underlying string for known values and "" for unknown. +func (v Value) String() string { return v.Value } + +// Equal reports whether two Values share presence and string content. +func (v Value) Equal(o Value) bool { return v.Known == o.Known && v.Value == o.Value } diff --git a/pkg/lipsdk/scope/value_test.go b/pkg/lipsdk/scope/value_test.go new file mode 100644 index 00000000..40cff839 --- /dev/null +++ b/pkg/lipsdk/scope/value_test.go @@ -0,0 +1,87 @@ +package scope_test + +import ( + "testing" + + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/scope" +) + +func TestValue_UnknownByDefault(t *testing.T) { + t.Parallel() + var v scope.Value + if v.IsKnown() { + t.Fatal("zero Value must be unknown, not known-empty") + } + if !v.IsUnknown() { + t.Fatal("zero Value must report unknown") + } + if v.String() != "" { + t.Fatalf("unknown Value String must be empty, got %q", v.String()) + } +} + +func TestValue_KnownPopulated(t *testing.T) { + t.Parallel() + v := scope.Known("alice") + if !v.IsKnown() { + t.Fatal("expected known") + } + if v.IsUnknown() { + t.Fatal("expected not unknown") + } + if v.IsKnownEmpty() { + t.Fatal("expected not known-empty") + } + if v.String() != "alice" { + t.Fatalf("String: got %q want %q", v.String(), "alice") + } +} + +func TestValue_KnownEmptyDistinctFromUnknown(t *testing.T) { + t.Parallel() + v := scope.Known("") + if !v.IsKnown() { + t.Fatal("known-empty must report known") + } + if v.IsUnknown() { + t.Fatal("known-empty must not report unknown") + } + if !v.IsKnownEmpty() { + t.Fatal("expected known-empty") + } + if v.String() != "" { + t.Fatalf("String: got %q want empty", v.String()) + } + + u := scope.Unknown() + if u.IsKnown() || !u.IsUnknown() { + t.Fatal("Unknown() must be unknown") + } + if u.IsKnownEmpty() { + t.Fatal("unknown must not report known-empty") + } +} + +func TestValue_Equal(t *testing.T) { + t.Parallel() + cases := []struct { + name string + a, b scope.Value + want bool + }{ + {"both unknown", scope.Unknown(), scope.Value{}, true}, + {"unknown vs known-empty", scope.Unknown(), scope.Known(""), false}, + {"known-empty vs known-empty", scope.Known(""), scope.Known(""), true}, + {"same populated", scope.Known("a"), scope.Known("a"), true}, + {"different populated", scope.Known("a"), scope.Known("b"), false}, + {"populated vs unknown", scope.Known("a"), scope.Unknown(), false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + if c.a.Equal(c.b) != c.want { + t.Fatalf("Equal(%+v,%+v) want %v", c.a, c.b, c.want) + } + }) + } +} diff --git a/pkg/lipsdk/scope/view.go b/pkg/lipsdk/scope/view.go new file mode 100644 index 00000000..89c3edf9 --- /dev/null +++ b/pkg/lipsdk/scope/view.go @@ -0,0 +1,75 @@ +package scope + +import ( + "maps" + "slices" + + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/execview" +) + +// SubjectKind classifies the request caller category (requirement 1.2). +type SubjectKind string + +const ( + SubjectUnknown SubjectKind = "unknown" + SubjectHuman SubjectKind = "human" + SubjectService SubjectKind = "service" + SubjectLocal SubjectKind = "local" +) + +// Origin records whether the request originated from a client or an +// internal auxiliary derivation (requirement 4.4). +type Origin string + +const ( + OriginClient Origin = "client" + OriginInternal Origin = "internal" +) + +// PrincipalScopeView is the authoritative, protocol-neutral principal and +// scope attribution snapshot for one accepted request. It is safe-by-construction: +// raw credentials, raw transport headers, bearer/API/OAuth/resume tokens, and +// unvetted claim values are never fields here. +type PrincipalScopeView struct { + SubjectKind SubjectKind `json:"subject_kind"` + PrincipalID Value `json:"principal_id"` + DisplayName Value `json:"display_name"` + AuthMethod Value `json:"auth_method"` + CredentialID Value `json:"credential_id"` + Roles []string `json:"roles,omitempty"` + SafeClaims map[string]string `json:"safe_claims,omitempty"` + TenantID Value `json:"tenant_id"` + OrganizationID Value `json:"organization_id"` + WorkspaceID Value `json:"workspace_id"` + ProjectID Value `json:"project_id"` + DepartmentID Value `json:"department_id"` + CostCenterID Value `json:"cost_center_id"` + PolicyLabels map[string]string `json:"policy_labels,omitempty"` + Origin Origin `json:"origin"` + ParentTraceID Value `json:"parent_trace_id"` +} + +// Clone returns a deep copy of the view so roles, safe claims, and policy +// labels cannot be mutated through the returned view (requirements 5.5, 4.2). +// Nil slices and maps are preserved as nil. +func (v PrincipalScopeView) Clone() PrincipalScopeView { + out := v + out.Roles = slices.Clone(v.Roles) + out.SafeClaims = maps.Clone(v.SafeClaims) + out.PolicyLabels = maps.Clone(v.PolicyLabels) + return out +} + +// Principal projects the authoritative scope onto the legacy +// [execview.PrincipalView] compatibility shape, preserving identity, display +// label, roles, and claims (requirements 1.5, 4.6, 7.3). Unknown scope values +// project to empty strings; roles and safe claims are copied so callers cannot +// mutate the authoritative scope through the projection. +func (v PrincipalScopeView) Principal() execview.PrincipalView { + return execview.PrincipalView{ + ID: v.PrincipalID.String(), + DisplayName: v.DisplayName.String(), + Roles: slices.Clone(v.Roles), + Claims: maps.Clone(v.SafeClaims), + } +} diff --git a/pkg/lipsdk/scope/view_test.go b/pkg/lipsdk/scope/view_test.go new file mode 100644 index 00000000..d77d526c --- /dev/null +++ b/pkg/lipsdk/scope/view_test.go @@ -0,0 +1,227 @@ +package scope_test + +import ( + "reflect" + "strings" + "testing" + + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/execview" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/scope" +) + +func sampleView() scope.PrincipalScopeView { + return scope.PrincipalScopeView{ + SubjectKind: scope.SubjectHuman, + PrincipalID: scope.Known("user-1"), + DisplayName: scope.Known("Alice"), + AuthMethod: scope.Known("oidc"), + CredentialID: scope.Known("key-1"), + Roles: []string{"admin", "operator"}, + SafeClaims: map[string]string{"tenant": "t1", "team": "core"}, + TenantID: scope.Known("t1"), + OrganizationID: scope.Known("org-1"), + WorkspaceID: scope.Known("ws-1"), + ProjectID: scope.Unknown(), + DepartmentID: scope.Unknown(), + CostCenterID: scope.Unknown(), + PolicyLabels: map[string]string{"env": "prod", "tier": "0"}, + Origin: scope.OriginClient, + ParentTraceID: scope.Unknown(), + } +} + +func TestPrincipalScopeView_NoForbiddenSecretFields(t *testing.T) { + t.Parallel() + forbidden := []string{ + "Token", "Secret", "Bearer", "APIKey", "OAuth", + "Header", "Password", "Raw", + } + var v scope.PrincipalScopeView + rt := reflect.TypeOf(v) + for i := 0; i < rt.NumField(); i++ { + f := rt.Field(i) + for _, bad := range forbidden { + if strings.Contains(f.Name, bad) { + t.Fatalf("field %s contains forbidden substring %q (raw secret/transport must not be in scope)", f.Name, bad) + } + } + } +} + +func TestPrincipalScopeView_CloneIsolatesRoles(t *testing.T) { + t.Parallel() + orig := sampleView() + clone := orig.Clone() + clone.Roles[0] = "mutated" + if orig.Roles[0] == "mutated" { + t.Fatal("mutating clone Roles affected original") + } + clone.Roles = append(clone.Roles, "extra") + if len(orig.Roles) == len(clone.Roles) { + t.Fatal("appending to clone Roles affected original length") + } +} + +func TestPrincipalScopeView_CloneIsolatesSafeClaims(t *testing.T) { + t.Parallel() + orig := sampleView() + clone := orig.Clone() + clone.SafeClaims["tenant"] = "mutated" + if orig.SafeClaims["tenant"] == "mutated" { + t.Fatal("mutating clone SafeClaims affected original") + } + clone.SafeClaims["new"] = "v" + if _, ok := orig.SafeClaims["new"]; ok { + t.Fatal("adding to clone SafeClaims leaked into original") + } +} + +func TestPrincipalScopeView_CloneIsolatesPolicyLabels(t *testing.T) { + t.Parallel() + orig := sampleView() + clone := orig.Clone() + clone.PolicyLabels["env"] = "mutated" + if orig.PolicyLabels["env"] == "mutated" { + t.Fatal("mutating clone PolicyLabels affected original") + } + delete(clone.PolicyLabels, "tier") + if _, ok := orig.PolicyLabels["tier"]; !ok { + t.Fatal("deleting from clone PolicyLabels leaked into original") + } +} + +func TestPrincipalScopeView_ClonePreservesValues(t *testing.T) { + t.Parallel() + orig := sampleView() + clone := orig.Clone() + if !clone.PrincipalID.Equal(orig.PrincipalID) { + t.Fatal("clone PrincipalID mismatch") + } + if clone.SubjectKind != orig.SubjectKind { + t.Fatal("clone SubjectKind mismatch") + } + if clone.Origin != orig.Origin { + t.Fatal("clone Origin mismatch") + } + if len(clone.Roles) != len(orig.Roles) { + t.Fatal("clone Roles length mismatch") + } + if len(clone.SafeClaims) != len(orig.SafeClaims) { + t.Fatal("clone SafeClaims length mismatch") + } +} + +func TestPrincipalScopeView_CloneHandlesNilMapsAndSlices(t *testing.T) { + t.Parallel() + v := scope.PrincipalScopeView{PrincipalID: scope.Known("x")} + clone := v.Clone() + if clone.Roles != nil { + t.Fatalf("nil Roles should stay nil, got %v", clone.Roles) + } + if clone.SafeClaims != nil { + t.Fatalf("nil SafeClaims should stay nil, got %v", clone.SafeClaims) + } + if clone.PolicyLabels != nil { + t.Fatalf("nil PolicyLabels should stay nil, got %v", clone.PolicyLabels) + } +} + +func TestPrincipalScopeView_PrincipalProjection(t *testing.T) { + t.Parallel() + v := sampleView() + p := v.Principal() + if p.ID != "user-1" { + t.Fatalf("Principal ID: got %q want %q", p.ID, "user-1") + } + if p.DisplayName != "Alice" { + t.Fatalf("Principal DisplayName: got %q want %q", p.DisplayName, "Alice") + } + if len(p.Roles) != 2 || p.Roles[0] != "admin" || p.Roles[1] != "operator" { + t.Fatalf("Principal Roles: got %+v", p.Roles) + } + if p.Claims["tenant"] != "t1" || p.Claims["team"] != "core" { + t.Fatalf("Principal Claims: got %+v", p.Claims) + } +} + +func TestPrincipalScopeView_PrincipalProjectionFromUnknown(t *testing.T) { + t.Parallel() + v := scope.PrincipalScopeView{ + SubjectKind: scope.SubjectUnknown, + PrincipalID: scope.Unknown(), + DisplayName: scope.Unknown(), + } + p := v.Principal() + if p.ID != "" { + t.Fatalf("unknown PrincipalID must project to empty ID, got %q", p.ID) + } + if p.DisplayName != "" { + t.Fatalf("unknown DisplayName must project to empty, got %q", p.DisplayName) + } + if p.Roles != nil { + t.Fatalf("nil Roles must project to nil, got %+v", p.Roles) + } + if p.Claims != nil { + t.Fatalf("nil Claims must project to nil, got %+v", p.Claims) + } +} + +func TestPrincipalScopeView_PrincipalProjectionIsCopy(t *testing.T) { + t.Parallel() + v := sampleView() + p := v.Principal() + p.Roles[0] = "mutated" + p.Claims["tenant"] = "mutated" + if v.Roles[0] == "mutated" { + t.Fatal("mutating projected Roles affected scope") + } + if v.SafeClaims["tenant"] == "mutated" { + t.Fatal("mutating projected Claims affected scope SafeClaims") + } +} + +func TestPrincipalScopeView_PrincipalProjectionKnownEmpty(t *testing.T) { + t.Parallel() + v := scope.PrincipalScopeView{ + PrincipalID: scope.Known(""), + DisplayName: scope.Known(""), + } + p := v.Principal() + if p.ID != "" { + t.Fatalf("known-empty PrincipalID must project to empty, got %q", p.ID) + } + if p.DisplayName != "" { + t.Fatalf("known-empty DisplayName must project to empty, got %q", p.DisplayName) + } +} + +func TestSubjectKind_Constants(t *testing.T) { + t.Parallel() + if scope.SubjectUnknown != "unknown" { + t.Fatalf("SubjectUnknown = %q", scope.SubjectUnknown) + } + if scope.SubjectHuman != "human" { + t.Fatalf("SubjectHuman = %q", scope.SubjectHuman) + } + if scope.SubjectService != "service" { + t.Fatalf("SubjectService = %q", scope.SubjectService) + } + if scope.SubjectLocal != "local" { + t.Fatalf("SubjectLocal = %q", scope.SubjectLocal) + } +} + +func TestOrigin_Constants(t *testing.T) { + t.Parallel() + if scope.OriginClient != "client" { + t.Fatalf("OriginClient = %q", scope.OriginClient) + } + if scope.OriginInternal != "internal" { + t.Fatalf("OriginInternal = %q", scope.OriginInternal) + } +} + +func TestPrincipalScopeView_CompilesAsPrincipalViewSource(t *testing.T) { + t.Parallel() + var _ execview.PrincipalView = sampleView().Principal() +} diff --git a/pkg/lipsdk/traffic/emit.go b/pkg/lipsdk/traffic/emit.go index c6b82622..9d131e00 100644 --- a/pkg/lipsdk/traffic/emit.go +++ b/pkg/lipsdk/traffic/emit.go @@ -5,7 +5,7 @@ import ( "time" ) -// PortBundle is the raw/redactor/observer triple used at each traffic leg (design §10–§11). +// PortBundle is the raw/redactor/observer triple used at each traffic leg (design sections 10-11). // [Emit] maps [CaptureMeta] and the leg/protocol/content-type arguments into [Observation] only; // it does not attach transport handles or provider-specific values beyond those string fields. type PortBundle struct { @@ -69,6 +69,7 @@ func (p PortBundle) Emit(ctx context.Context, leg Leg, meta CaptureMeta, protoco Protocol: protocol, ContentType: contentType, Body: out, + Scope: meta.Scope.Clone(), RecordedAt: time.Now(), }) } diff --git a/pkg/lipsdk/traffic/observe.go b/pkg/lipsdk/traffic/observe.go index 687ff4e9..caa56060 100644 --- a/pkg/lipsdk/traffic/observe.go +++ b/pkg/lipsdk/traffic/observe.go @@ -3,9 +3,11 @@ package traffic import ( "context" "time" + + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/scope" ) -// Leg identifies one hop in the four-leg observation model (design §10). +// Leg identifies one hop in the four-leg observation model (design section 10). type Leg string const ( @@ -21,6 +23,10 @@ const ( // privileged payloads unless policy explicitly places them on the redacted path. Callers of // [Observer.OnObservation] run after [PortBundle.Emit] applies redactors; [Body] is the // post-redaction bytes passed to the observer. +// +// Scope is optional safe principal/scope attribution (requirement 6.4). It is additive metadata +// only; existing observers may ignore it. Scope must never be injected into [Body]; [Body] +// remains the wire payload for the leg (requirements 7.1, 7.4). type Observation struct { Leg Leg TraceID string @@ -34,10 +40,11 @@ type Observation struct { Protocol string ContentType string Body []byte + Scope scope.PrincipalScopeView RecordedAt time.Time } -// Observer receives non-mutating traffic observations (design §10). Implementations should treat +// Observer receives non-mutating traffic observations (design section 10). Implementations should treat // [Observation] as read-only data for logging, transcript, or metrics adapters; they must not // mutate the slice backing [Observation.Body] in place if they retain it beyond the call. type Observer interface { @@ -51,7 +58,8 @@ func (NoopObserver) OnObservation(context.Context, Observation) error { return n // CaptureMeta is correlation metadata for traffic legs: request trace, attempt lineage, principal // and session identifiers, and route-facing backend/frontend labels. It intentionally excludes -// transport and provider concrete types (hexagonal task 5.2). +// transport and provider concrete types (hexagonal task 5.2). Scope is optional safe attribution +// propagated to observers as metadata; it is never injected into payload bytes (req 6.4, 7.4). type CaptureMeta struct { TraceID string ALegID string @@ -61,6 +69,7 @@ type CaptureMeta struct { AttemptSeq int BackendID string FrontendID string + Scope scope.PrincipalScopeView } // RawCaptureSink receives verbatim bytes for privileged capture paths (design §10), using the diff --git a/pkg/lipsdk/traffic/observe_scope_test.go b/pkg/lipsdk/traffic/observe_scope_test.go new file mode 100644 index 00000000..d53f5d45 --- /dev/null +++ b/pkg/lipsdk/traffic/observe_scope_test.go @@ -0,0 +1,134 @@ +package traffic_test + +import ( + "bytes" + "context" + "testing" + + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/scope" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/traffic" +) + +// TestObservation_carriesOptionalScope proves the traffic observation contract carries an +// optional safe scope snapshot while preserving the existing PrincipalID field (req 6.4, 7.3). +func TestObservation_carriesOptionalScope(t *testing.T) { + t.Parallel() + o := traffic.Observation{ + PrincipalID: "scope-user", + Scope: scope.PrincipalScopeView{ + SubjectKind: scope.SubjectHuman, + PrincipalID: scope.Known("scope-user"), + TenantID: scope.Known("t1"), + }, + } + if o.Scope.PrincipalID.String() != "scope-user" { + t.Fatalf("Scope.PrincipalID: got %q", o.Scope.PrincipalID) + } + if o.PrincipalID != "scope-user" { + t.Fatalf("PrincipalID: got %q", o.PrincipalID) + } +} + +// TestCaptureMeta_carriesOptionalScope proves capture meta can carry scope for emission paths. +func TestCaptureMeta_carriesOptionalScope(t *testing.T) { + t.Parallel() + m := traffic.CaptureMeta{ + TraceID: "tr", + Scope: scope.PrincipalScopeView{ + SubjectKind: scope.SubjectService, + PrincipalID: scope.Known("svc"), + }, + } + if m.Scope.PrincipalID.String() != "svc" { + t.Fatalf("Scope.PrincipalID: got %q", m.Scope.PrincipalID) + } +} + +// TestPortBundle_Emit_propagatesMetaScope proves Emit copies CaptureMeta.Scope onto the +// observation so runtime emission can carry safe scope through to observers (req 6.4). +func TestPortBundle_Emit_propagatesMetaScope(t *testing.T) { + t.Parallel() + var got traffic.Observation + obs := captureObs{fn: func(ev traffic.Observation) { got = ev }} + meta := traffic.CaptureMeta{ + TraceID: "tr", + Scope: scope.PrincipalScopeView{ + SubjectKind: scope.SubjectHuman, + PrincipalID: scope.Known("scope-user"), + TenantID: scope.Known("t1"), + Roles: []string{"admin"}, + }, + } + b := traffic.PortBundle{Obs: obs} + b.Emit(context.Background(), traffic.LegBTP, meta, "p", "c", []byte("body")) + if !got.Scope.PrincipalID.Equal(scope.Known("scope-user")) { + t.Fatalf("observation Scope.PrincipalID: %+v", got.Scope.PrincipalID) + } + if !got.Scope.TenantID.Equal(scope.Known("t1")) { + t.Fatalf("observation Scope.TenantID: %+v", got.Scope.TenantID) + } +} + +// TestPortBundle_Emit_scopeCloneIsolation proves the scope delivered to the observer is a copy +// so callers cannot mutate the authoritative scope through the meta after emission (req 5.5). +func TestPortBundle_Emit_scopeCloneIsolation(t *testing.T) { + t.Parallel() + var got traffic.Observation + obs := captureObs{fn: func(ev traffic.Observation) { got = ev }} + roles := []string{"admin"} + meta := traffic.CaptureMeta{ + TraceID: "tr", + Scope: scope.PrincipalScopeView{ + PrincipalID: scope.Known("u"), + Roles: roles, + }, + } + b := traffic.PortBundle{Obs: obs} + b.Emit(context.Background(), traffic.LegBTP, meta, "p", "c", []byte("body")) + roles[0] = "mutated" + if got.Scope.Roles[0] == "mutated" { + t.Fatal("observer scope roles must be isolated from caller mutation") + } +} + +// TestPortBundle_Emit_payloadBytesUnchangedByScope proves adding scope to meta does not alter +// the payload bytes delivered to the observer (scope is metadata, not body content) — required +// for "scope must not be forwarded to backend/client payloads" (req 7.4, 7.1). +func TestPortBundle_Emit_payloadBytesUnchangedByScope(t *testing.T) { + t.Parallel() + var got traffic.Observation + obs := captureObs{fn: func(ev traffic.Observation) { got = ev }} + body := []byte(`{"hello":"world"}`) + meta := traffic.CaptureMeta{ + TraceID: "tr", + Scope: scope.PrincipalScopeView{ + PrincipalID: scope.Known("secret-principal"), + TenantID: scope.Known("secret-tenant"), + }, + } + b := traffic.PortBundle{Obs: obs} + b.Emit(context.Background(), traffic.LegPTB, meta, "p", "c", body) + if !bytes.Equal(got.Body, body) { + t.Fatalf("payload bytes changed: got %q want %q", got.Body, body) + } + if bytes.Contains(got.Body, []byte("secret-principal")) || bytes.Contains(got.Body, []byte("secret-tenant")) { + t.Fatalf("scope values leaked into payload: %q", got.Body) + } +} + +// legacyTrafficObserverIgnorer proves an observer implemented against the pre-scope contract +// still satisfies traffic.Observer after scope is added (req 6.4, design observer compatibility). +type legacyTrafficObserverIgnorer struct{} + +func (legacyTrafficObserverIgnorer) OnObservation(_ context.Context, ev traffic.Observation) error { + _ = ev.PrincipalID + return nil +} + +func TestLegacyTrafficObserver_stillSatisfiesInterface(t *testing.T) { + t.Parallel() + var obs traffic.Observer = legacyTrafficObserverIgnorer{} + if err := obs.OnObservation(context.Background(), traffic.Observation{}); err != nil { + t.Fatal(err) + } +} diff --git a/pkg/lipsdk/transport/httpauth/context.go b/pkg/lipsdk/transport/httpauth/context.go index 810fff23..1eb9e812 100644 --- a/pkg/lipsdk/transport/httpauth/context.go +++ b/pkg/lipsdk/transport/httpauth/context.go @@ -4,6 +4,7 @@ import ( "context" "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/execview" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/scope" ) // WithPrincipal is a transport-named alias for [execview.WithPrincipal] so the auth middleware @@ -16,3 +17,14 @@ func WithPrincipal(ctx context.Context, p execview.PrincipalView) context.Contex func PrincipalFromContext(ctx context.Context) (execview.PrincipalView, bool) { return execview.PrincipalFromContext(ctx) } + +// WithScope is a transport-named alias for [scope.WithScope] so transport auth and the core +// share one context key for the authoritative principal/scope snapshot. +func WithScope(ctx context.Context, v scope.PrincipalScopeView) context.Context { + return scope.WithScope(ctx, v) +} + +// ScopeFromContext is a transport-named alias for [scope.ScopeFromContext]. +func ScopeFromContext(ctx context.Context) (scope.PrincipalScopeView, bool) { + return scope.ScopeFromContext(ctx) +} diff --git a/pkg/lipsdk/transport/httpauth/context_scope_test.go b/pkg/lipsdk/transport/httpauth/context_scope_test.go new file mode 100644 index 00000000..0d3c8e39 --- /dev/null +++ b/pkg/lipsdk/transport/httpauth/context_scope_test.go @@ -0,0 +1,34 @@ +package httpauth_test + +import ( + "context" + "testing" + + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/scope" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/transport/httpauth" +) + +// TestScopeContext_httpauthAliasesScope proves the transport auth context aliases carry the +// authoritative scope at the edge (requirement 2.1, 4.1). +func TestScopeContext_httpauthAliasesScope(t *testing.T) { + t.Parallel() + want := scope.PrincipalScopeView{ + SubjectKind: scope.SubjectHuman, + PrincipalID: scope.Known("u1"), + } + ctx := httpauth.WithScope(context.Background(), want) + got, ok := httpauth.ScopeFromContext(ctx) + if !ok { + t.Fatal("expected scope") + } + if !got.PrincipalID.Equal(want.PrincipalID) { + t.Fatalf("PrincipalID: %+v", got.PrincipalID) + } +} + +func TestScopeContext_httpauthMissing(t *testing.T) { + t.Parallel() + if _, ok := httpauth.ScopeFromContext(context.Background()); ok { + t.Fatal("expected no scope on bare context") + } +} diff --git a/pkg/lipsdk/transport/httpauth/result.go b/pkg/lipsdk/transport/httpauth/result.go index 0c1d69e6..1150aa38 100644 --- a/pkg/lipsdk/transport/httpauth/result.go +++ b/pkg/lipsdk/transport/httpauth/result.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/execview" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/scope" ) // AuthenticationType classifies the outcome of one provider invocation. @@ -29,6 +30,11 @@ type AuthenticationResult struct { // Principal is used when Type is TypePrincipal. Principal execview.PrincipalView + // Scope is an optional authoritative safe principal/scope snapshot carried from trusted + // auth provider code into the middleware. It is nil for legacy principal-only results. + // Raw secrets and transport headers must never be placed here (requirements 2.1, 2.6). + Scope *scope.PrincipalScopeView + // HTTPStatus is used for TypeReject and TypeChallenge (default 401 if zero). HTTPStatus int diff --git a/pkg/lipsdk/transport/httpauth/result_scope_test.go b/pkg/lipsdk/transport/httpauth/result_scope_test.go new file mode 100644 index 00000000..30625fbe --- /dev/null +++ b/pkg/lipsdk/transport/httpauth/result_scope_test.go @@ -0,0 +1,44 @@ +package httpauth_test + +import ( + "testing" + + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/execview" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/scope" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/transport/httpauth" +) + +// TestAuthenticationResult_carriesOptionalScope proves transport auth results can carry a +// trusted safe scope from auth provider code into the middleware (requirement 2.1, 2.5). +func TestAuthenticationResult_carriesOptionalScope(t *testing.T) { + t.Parallel() + trusted := scope.PrincipalScopeView{ + SubjectKind: scope.SubjectHuman, + PrincipalID: scope.Known("user-1"), + CredentialID: scope.Known("key-1"), + } + r := httpauth.AuthenticationResult{ + Type: httpauth.TypePrincipal, + Principal: execview.PrincipalView{ID: "user-1"}, + Scope: &trusted, + } + if r.Scope == nil { + t.Fatal("expected scope carried on transport auth result") + } + if !r.Scope.PrincipalID.Equal(scope.Known("user-1")) { + t.Fatalf("result scope PrincipalID: %+v", r.Scope.PrincipalID) + } +} + +// TestAuthenticationResult_legacyPrincipalOnlyStillCompiles proves existing principal-only +// results remain valid without scope (requirement 7.1). +func TestAuthenticationResult_legacyPrincipalOnlyStillCompiles(t *testing.T) { + t.Parallel() + r := httpauth.AuthenticationResult{ + Type: httpauth.TypePrincipal, + Principal: execview.PrincipalView{ID: "legacy"}, + } + if r.Scope != nil { + t.Fatalf("expected nil scope on legacy result, got %+v", r.Scope) + } +} diff --git a/pkg/lipsdk/usage/observe.go b/pkg/lipsdk/usage/observe.go index 78337927..26c81488 100644 --- a/pkg/lipsdk/usage/observe.go +++ b/pkg/lipsdk/usage/observe.go @@ -3,6 +3,8 @@ package usage import ( "context" "time" + + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/scope" ) // Event is a provider-neutral usage observation emitted from canonical usage deltas. @@ -17,6 +19,11 @@ type Event struct { FrontendID string Model string + // Scope is the optional safe principal/scope attribution snapshot for this request. It is + // additive metadata; existing observers may ignore it and keep reading PrincipalID + // (requirements 6.4, 6.5, 7.3). When Scope.PrincipalID is known it must match PrincipalID. + Scope scope.PrincipalScopeView + InputTokens int OutputTokens int CacheReadTokens int diff --git a/pkg/lipsdk/usage/observe_scope_test.go b/pkg/lipsdk/usage/observe_scope_test.go new file mode 100644 index 00000000..da616776 --- /dev/null +++ b/pkg/lipsdk/usage/observe_scope_test.go @@ -0,0 +1,61 @@ +package usage_test + +import ( + "context" + "testing" + + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/scope" + "github.com/matdev83/go-llm-interactive-proxy/pkg/lipsdk/usage" +) + +// TestEvent_carriesOptionalScope proves the usage event contract carries an optional safe scope +// snapshot while preserving the existing PrincipalID compatibility field (requirements 6.4, 7.3). +func TestEvent_carriesOptionalScope(t *testing.T) { + t.Parallel() + ev := usage.Event{ + PrincipalID: "scope-user", + Scope: scope.PrincipalScopeView{ + SubjectKind: scope.SubjectHuman, + PrincipalID: scope.Known("scope-user"), + TenantID: scope.Known("t1"), + }, + } + if ev.Scope.PrincipalID.String() != "scope-user" { + t.Fatalf("Scope.PrincipalID: got %q", ev.Scope.PrincipalID) + } + if ev.PrincipalID != "scope-user" { + t.Fatalf("PrincipalID: got %q", ev.PrincipalID) + } +} + +// TestEvent_zeroScopeCompiles proves existing observer implementations keep working without +// inspecting scope: a zero-scope event is a valid value and legacy observers compile unchanged +// (requirement 6.4, design "do not require observer implementations to inspect Scope"). +func TestEvent_zeroScopeCompiles(t *testing.T) { + t.Parallel() + var ev usage.Event + if ev.Scope.PrincipalID.IsKnown() { + t.Fatal("zero event scope must be unknown") + } + var obs usage.Observer = usage.NoopObserver{} + if err := obs.OnUsage(context.Background(), ev); err != nil { + t.Fatal(err) + } +} + +// legacyUsageObserverIgnorer is a compile-time check that an observer implemented against the +// pre-scope contract (only reading PrincipalID) still satisfies usage.Observer. +type legacyUsageObserverIgnorer struct{} + +func (legacyUsageObserverIgnorer) OnUsage(_ context.Context, ev usage.Event) error { + _ = ev.PrincipalID + return nil +} + +func TestLegacyUsageObserver_stillSatisfiesInterface(t *testing.T) { + t.Parallel() + var obs usage.Observer = legacyUsageObserverIgnorer{} + if err := obs.OnUsage(context.Background(), usage.Event{}); err != nil { + t.Fatal(err) + } +} From d61e067b79ed6281a54c7e460b8ac7f609926595 Mon Sep 17 00:00:00 2001 From: Mateusz Date: Thu, 2 Jul 2026 00:42:56 +0200 Subject: [PATCH 2/3] fix(scope): resolve golangci-lint findings on principal scope Fix the 5 golangci-lint issues that failed the QA workflow: forcetypeassert on the auxreq.Client type assertions, modernize (slices.ContainsFunc for roles sanitization and reflect.Type.Fields iterator in the view test), and staticcheck QF1011 replaced with a compile-time guard. No behavior change. Co-authored-by: Cursor --- internal/core/auth/scope.go | 6 ++---- internal/core/auxreq/scope_test.go | 10 ++++++++-- pkg/lipsdk/scope/view_test.go | 9 ++++++--- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/internal/core/auth/scope.go b/internal/core/auth/scope.go index 5b92daa5..528b01d1 100644 --- a/internal/core/auth/scope.go +++ b/internal/core/auth/scope.go @@ -120,10 +120,8 @@ func SanitizeScope(s scope.PrincipalScopeView) error { return fmt.Errorf("%w: %s", ErrUnsafeScope, v.Name) } } - for _, r := range s.Roles { - if looksCredentialLike(r) { - return fmt.Errorf("%w: roles", ErrUnsafeScope) - } + if slices.ContainsFunc(s.Roles, looksCredentialLike) { + return fmt.Errorf("%w: roles", ErrUnsafeScope) } for _, m := range []struct { name string diff --git a/internal/core/auxreq/scope_test.go b/internal/core/auxreq/scope_test.go index 414ca311..eb2eec67 100644 --- a/internal/core/auxreq/scope_test.go +++ b/internal/core/auxreq/scope_test.go @@ -33,7 +33,10 @@ func TestClient_Stream_preservesParentScopeAndMarksInternalOrigin(t *testing.T) ctx := scope.WithScope(context.Background(), parent) r := &captureRunner{} - c := auxreq.NewClient(func() auxreq.ExecutorRunner { return r }).(auxreq.Client) + c, ok := auxreq.NewClient(func() auxreq.ExecutorRunner { return r }).(auxreq.Client) + if !ok { + t.Fatal("auxreq.NewClient must return auxreq.Client when an executor is provided") + } call := &lipapi.Call{ Route: lipapi.RouteIntent{Selector: "openai:gpt-4"}, Messages: []lipapi.Message{{Role: lipapi.RoleUser, Parts: []lipapi.Part{lipapi.TextPart("hi")}}}, @@ -67,7 +70,10 @@ func TestClient_Stream_preservesParentScopeAndMarksInternalOrigin(t *testing.T) func TestClient_Stream_noParentScopeNoDerivedScope(t *testing.T) { t.Parallel() r := &captureRunner{} - c := auxreq.NewClient(func() auxreq.ExecutorRunner { return r }).(auxreq.Client) + c, ok := auxreq.NewClient(func() auxreq.ExecutorRunner { return r }).(auxreq.Client) + if !ok { + t.Fatal("auxreq.NewClient must return auxreq.Client when an executor is provided") + } call := &lipapi.Call{ Route: lipapi.RouteIntent{Selector: "openai:gpt-4"}, Messages: []lipapi.Message{{Role: lipapi.RoleUser, Parts: []lipapi.Part{lipapi.TextPart("hi")}}}, diff --git a/pkg/lipsdk/scope/view_test.go b/pkg/lipsdk/scope/view_test.go index d77d526c..893b05e1 100644 --- a/pkg/lipsdk/scope/view_test.go +++ b/pkg/lipsdk/scope/view_test.go @@ -38,8 +38,7 @@ func TestPrincipalScopeView_NoForbiddenSecretFields(t *testing.T) { } var v scope.PrincipalScopeView rt := reflect.TypeOf(v) - for i := 0; i < rt.NumField(); i++ { - f := rt.Field(i) + for f := range rt.Fields() { for _, bad := range forbidden { if strings.Contains(f.Name, bad) { t.Fatalf("field %s contains forbidden substring %q (raw secret/transport must not be in scope)", f.Name, bad) @@ -223,5 +222,9 @@ func TestOrigin_Constants(t *testing.T) { func TestPrincipalScopeView_CompilesAsPrincipalViewSource(t *testing.T) { t.Parallel() - var _ execview.PrincipalView = sampleView().Principal() + requirePrincipalView(sampleView().Principal()) } + +// requirePrincipalView is a compile-time guard asserting the projected principal satisfies +// execview.PrincipalView; it performs no runtime check. +func requirePrincipalView(_ execview.PrincipalView) {} From 1bcb6895c53d3818a4f1c738876e04db7615cb98 Mon Sep 17 00:00:00 2001 From: Mateusz Date: Thu, 2 Jul 2026 00:56:22 +0200 Subject: [PATCH 3/3] fix(scope): address CodeRabbit review on principal scope - BuildScope: reject trusted scopes with no principal id (ErrNoIdentity), mirroring the legacy path - adapter: forced unsafe-scope deny always reports unsafe_scope, superseding stale allow-era reason codes - config: toCore deep-copies Roles/SafeClaims/PolicyLabels so core auth records don't alias config state - SanitizeScope doc: note the credential heuristic is best-effort/non-exhaustive - context key: document the iota offset convention - tests: cover the new branches; fix attributionConverted test to call config.ValidateAuthLocalAPIKeyRecords Co-authored-by: Cursor --- internal/core/auth/scope.go | 6 ++++ internal/core/auth/scope_test.go | 18 ++++++++++ .../access_auth_local_attribution_test.go | 16 ++------- internal/core/config/access_auth_validate.go | 8 +++-- .../access_auth_validate_internal_test.go | 27 ++++++++++++++ internal/stdhttp/auth/adapter.go | 7 ++-- internal/stdhttp/auth/scope_bridge_test.go | 35 +++++++++++++++++++ pkg/lipsdk/scope/context.go | 2 +- 8 files changed, 97 insertions(+), 22 deletions(-) create mode 100644 internal/core/config/access_auth_validate_internal_test.go diff --git a/internal/core/auth/scope.go b/internal/core/auth/scope.go index 528b01d1..0a44e403 100644 --- a/internal/core/auth/scope.go +++ b/internal/core/auth/scope.go @@ -46,6 +46,9 @@ func BuildScope(input ScopeBuildInput) (ScopeBuildResult, error) { if err := SanitizeScope(s); err != nil { return ScopeBuildResult{}, err } + if strings.TrimSpace(s.PrincipalID.String()) == "" { + return ScopeBuildResult{}, ErrNoIdentity + } return ScopeBuildResult{Scope: s, Principal: s.Principal()}, nil } if pid := strings.TrimSpace(d.Principal.ID); pid != "" { @@ -98,6 +101,9 @@ func authMethodFromLevel(l sdkauth.RequiredLevel) scope.Value { // the snapshot enters request lifecycle or audit evidence (requirements 2.6, 5.4). It is the // shared safety gate called by [BuildScope] for accepted decisions and by the HTTP auth bridge // for denied/challenged attribution evidence. +// +// The substring heuristic is best-effort defense-in-depth and is not exhaustive; trusted +// callers remain responsible for never placing raw secret material in scope fields. func SanitizeScope(s scope.PrincipalScopeView) error { values := []namedValue{ {"principal_id", s.PrincipalID}, diff --git a/internal/core/auth/scope_test.go b/internal/core/auth/scope_test.go index 8bd35624..070d9052 100644 --- a/internal/core/auth/scope_test.go +++ b/internal/core/auth/scope_test.go @@ -161,6 +161,24 @@ func TestBuildScope_noIdentityReturnsError(t *testing.T) { } } +// TestBuildScope_trustedScopeWithoutIdentityReturnsError proves a trusted scope carrying no +// principal id does not silently produce an anonymous snapshot, mirroring the legacy path +// (requirement 1.4, 2.2). +func TestBuildScope_trustedScopeWithoutIdentityReturnsError(t *testing.T) { + t.Parallel() + noID := trustedScope() + noID.PrincipalID = scope.Unknown() + _, err := BuildScope(ScopeBuildInput{ + Decision: sdkauth.Decision{Outcome: sdkauth.OutcomeAllow, Scope: &noID}, + }) + if err == nil { + t.Fatal("expected error for trusted scope without identity") + } + if !errors.Is(err, ErrNoIdentity) { + t.Fatalf("expected ErrNoIdentity, got %v", err) + } +} + // TestBuildScope_rejectsUnsafeScopeValue proves the normalizer rejects credential-like // material before it enters request lifecycle evidence (requirement 2.6, 5.4). func TestBuildScope_rejectsUnsafeScopeValue(t *testing.T) { diff --git a/internal/core/config/access_auth_local_attribution_test.go b/internal/core/config/access_auth_local_attribution_test.go index 5db2c962..483f262f 100644 --- a/internal/core/config/access_auth_local_attribution_test.go +++ b/internal/core/config/access_auth_local_attribution_test.go @@ -149,19 +149,7 @@ func TestValidate_auth_localAPIKeyAttributionRejectsCredentialLikeValue(t *testi func TestValidateAuthLocalAPIKeyRecords_attributionConverted(t *testing.T) { t.Parallel() rec := localAPIKeyAttributionRecord() - // Round-trip through core auth validator by constructing core records directly. - coreRec := coreauth.LocalAPIKeyRecord{ - KeyID: rec.KeyID, - PrincipalID: rec.PrincipalID, - Key: rec.Key, - Attribution: coreauth.LocalAttribution{ - DisplayName: rec.Attribution.DisplayName, - TenantID: rec.Attribution.TenantID, - Roles: rec.Attribution.Roles, - SafeClaims: rec.Attribution.SafeClaims, - }, - } - if err := coreauth.ValidateLocalAPIKeyRecords([]coreauth.LocalAPIKeyRecord{coreRec}); err != nil { - t.Fatalf("core validate: %v", err) + if err := config.ValidateAuthLocalAPIKeyRecords([]config.AuthLocalAPIKeyRecord{rec}); err != nil { + t.Fatalf("ValidateAuthLocalAPIKeyRecords: %v", err) } } diff --git a/internal/core/config/access_auth_validate.go b/internal/core/config/access_auth_validate.go index 3907f299..abeb61b4 100644 --- a/internal/core/config/access_auth_validate.go +++ b/internal/core/config/access_auth_validate.go @@ -2,6 +2,8 @@ package config import ( "fmt" + "maps" + "slices" "strings" "github.com/matdev83/go-llm-interactive-proxy/internal/core/accessmode" @@ -100,9 +102,9 @@ func (a AuthLocalAttribution) toCore() coreauth.LocalAttribution { ProjectID: a.ProjectID, DepartmentID: a.DepartmentID, CostCenterID: a.CostCenterID, - Roles: a.Roles, - SafeClaims: a.SafeClaims, - PolicyLabels: a.PolicyLabels, + Roles: slices.Clone(a.Roles), + SafeClaims: maps.Clone(a.SafeClaims), + PolicyLabels: maps.Clone(a.PolicyLabels), } } diff --git a/internal/core/config/access_auth_validate_internal_test.go b/internal/core/config/access_auth_validate_internal_test.go new file mode 100644 index 00000000..15ec410a --- /dev/null +++ b/internal/core/config/access_auth_validate_internal_test.go @@ -0,0 +1,27 @@ +package config + +import "testing" + +// TestAuthLocalAttribution_toCore_isolatesMutableFields proves toCore deep-copies the +// slice/map attribution fields so core auth records never alias config-owned mutable state. +func TestAuthLocalAttribution_toCore_isolatesMutableFields(t *testing.T) { + t.Parallel() + a := AuthLocalAttribution{ + Roles: []string{"r1"}, + SafeClaims: map[string]string{"k": "v"}, + PolicyLabels: map[string]string{"p": "q"}, + } + got := a.toCore() + got.Roles[0] = "mutated" + if a.Roles[0] == "mutated" { + t.Fatal("toCore must clone Roles, not alias config-owned slice") + } + got.SafeClaims["k"] = "mutated" + if a.SafeClaims["k"] == "mutated" { + t.Fatal("toCore must clone SafeClaims, not alias config-owned map") + } + got.PolicyLabels["p"] = "mutated" + if a.PolicyLabels["p"] == "mutated" { + t.Fatal("toCore must clone PolicyLabels, not alias config-owned map") + } +} diff --git a/internal/stdhttp/auth/adapter.go b/internal/stdhttp/auth/adapter.go index 89cd4151..bbbef46c 100644 --- a/internal/stdhttp/auth/adapter.go +++ b/internal/stdhttp/auth/adapter.go @@ -100,11 +100,10 @@ func (p *PolicyProvider) Authenticate(ctx context.Context, w http.ResponseWriter // successful lifecycle scope (requirement 1.6). bridged := bridgeScope(d) if bridged.err != nil { - // Credential-like scope material is rejected before execution and evidence. + // Credential-like scope material is rejected before execution and evidence; the + // unsafe-scope reason always supersedes any unrelated allow-era reason code. d.Outcome = auth.OutcomeDeny - if d.ReasonCode == "" { - d.ReasonCode = "unsafe_scope" - } + d.ReasonCode = "unsafe_scope" } ev := authDecisionEvent(now, traceID, p.Policy, meta, d, bridged.evidence) diff --git a/internal/stdhttp/auth/scope_bridge_test.go b/internal/stdhttp/auth/scope_bridge_test.go index 6a3d9548..6e521f1b 100644 --- a/internal/stdhttp/auth/scope_bridge_test.go +++ b/internal/stdhttp/auth/scope_bridge_test.go @@ -271,6 +271,41 @@ func TestPolicyProvider_unsafeScope_deniesAndOmitsFromEvidence(t *testing.T) { } } +// TestPolicyProvider_unsafeScope_overwritesStaleReasonCode proves a forced unsafe-scope deny +// always reports the unsafe_scope reason even when the original allow carried an unrelated +// informational reason code (requirement 2.6, 5.4). +func TestPolicyProvider_unsafeScope_overwritesStaleReasonCode(t *testing.T) { + t.Parallel() + unsafe := allowScope() + unsafe.PrincipalID = scope.Known("bearer stale-reason-secret-1234567890") + stub := &stubCoreAuthenticator{dec: auth.Decision{Outcome: auth.OutcomeAllow, Scope: &unsafe, ReasonCode: "degraded_allow"}} + sink := &captureSink{} + disp := coreauth.NewEventDispatcher(sink, coreauth.EventFailureBestEffort) + p := NewPolicyProvider(stub, disp, PolicySnapshot{ + AccessMode: auth.AccessMultiUser, HandlerKind: auth.HandlerRemote, RequiredLevel: auth.LevelAPIKey, + }, nil) + res, err := p.Authenticate(context.Background(), httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/v1/", nil)) + if err != nil { + t.Fatal(err) + } + if res.Type != httpauth.TypeReject { + t.Fatalf("unsafe scope must deny, got type %q", res.Type) + } + if len(sink.events) != 1 { + t.Fatalf("events %d", len(sink.events)) + } + ev := sink.events[0] + if ev.Outcome != auth.OutcomeDeny { + t.Fatalf("evidence outcome %q want deny", ev.Outcome) + } + if ev.ReasonCode != "unsafe_scope" { + t.Fatalf("stale allow reason survived unsafe-scope deny: got %q want unsafe_scope", ev.ReasonCode) + } + if ev.Scope != nil { + t.Fatalf("unsafe scope must not appear in evidence, got %+v", ev.Scope) + } +} + // TestPolicyProvider_deny_unsafeScope_omittedFromEvidence proves a denied decision that carries // credential-like scope material still renders the rejection shape but omits the unsafe scope // from evidence entirely (requirements 1.6, 2.6, 5.4, 6.1). diff --git a/pkg/lipsdk/scope/context.go b/pkg/lipsdk/scope/context.go index de07d009..95c3ef2f 100644 --- a/pkg/lipsdk/scope/context.go +++ b/pkg/lipsdk/scope/context.go @@ -4,7 +4,7 @@ import "context" type ctxKey int -const keyScope ctxKey = iota + 18400 +const keyScope ctxKey = iota + 18400 // offset avoids collision with other packages' context keys // WithScope returns a child context carrying the authoritative principal/scope snapshot for // downstream handlers and the execution pipeline. The value is cloned so callers cannot