Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
349 changes: 179 additions & 170 deletions .kiro/specs/control-plane-principal-scope/tasks.md

Large diffs are not rendered by default.

60 changes: 30 additions & 30 deletions docs/dogfood-local.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):**

Expand All @@ -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
Expand Down
89 changes: 89 additions & 0 deletions internal/archtest/scope_boundary_test.go
Original file line number Diff line number Diff line change
@@ -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
}
11 changes: 11 additions & 0 deletions internal/core/auth/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
)
56 changes: 47 additions & 9 deletions internal/core/auth/local_apikey.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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 ") {
Expand Down
75 changes: 74 additions & 1 deletion internal/core/auth/local_apikey_record.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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
}
Loading