Files
hanzoai_agent/control-plane/pkg/auth/api_keys_test.go
zeekay a90bd685b1 fix(agent): close Red P0-2 — API key bound to org, 403 on mismatch
The 313be7c commit message claimed key→org binding but only added
crypto/subtle.ConstantTimeCompare. A valid X-API-Key combined with
an attacker-chosen X-Org-Id still passed through with the attacker's
org context — full cross-tenant access. Closed here.

pkg/auth/api_keys.go: APIKey, Validator interface, StaticValidator
(legacy single-key shape, no org binding) and Store (Postgres-backed,
bcrypt-hashed). Sentinel errors ErrKeyInvalid / ErrKeyRevoked /
ErrKeyExpired / ErrOrgMismatch. APIKey.CheckOrg compares org_id to
the request's X-Org-Id in constant time.

migrations/022_api_keys_table.sql: api_keys (id, prefix, hash,
org_id, user_id, scopes, created_at, expires_at, revoked_at).
Partial index on prefix WHERE revoked_at IS NULL for the live-key
fast path. CHECK (org_id <> '') so the schema rejects unscoped keys
even before the application layer reads them.

internal/server/middleware/auth.go: APIKeyAuth takes a Validator
(legacy AuthConfig.APIKey string still works — auto-wrapped as
StaticValidator). On Validator success with a bound OrgID, the
middleware enforces:
  - request X-Org-Id mismatched → 403 Forbidden
  - request X-Org-Id matched OR absent → 200, org pinned from key
The key wins on the org context: c.Set("iam_user_org", apikey.OrgID)
and auth.WithOrgContext(req.Context(), apikey.OrgID) override any
header value. Static (legacy) keys leave org untouched, deferring
to the gateway-trust identity middleware.

internal/server/middleware/grpc_auth.go: same shape via
WithValidator(...) functional option. PermissionDenied (gRPC code
for HTTP 403) on org mismatch, Unauthenticated on bad/revoked/
expired key.

Tests (+14):
- pkg/auth: TestStaticValidator_*, TestAPIKey_CheckOrg,
  TestHashKey_RoundTrip, TestPrefixOf, TestSplitScopes,
  TestStore_Validate (skipped without TEST_POSTGRES_DSN).
- middleware (HTTP): TestAPIKeyAuth_BoundKey_{MatchingOrg,
  MismatchedOrg_403, NoOrgHeader_KeyWins, InvalidHash_401,
  Revoked_401, Expired_401, OrgMismatchSentinel},
  TestAPIKeyAuth_StaticKey_DefersOrgToGateway.
- middleware (gRPC): TestAPIKeyUnaryInterceptor_BoundKey_*
  (parallel set), TestAPIKeyUnaryInterceptor_StaticKey_*.

go build ./... PASS. go test ./pkg/auth/... ./internal/server/middleware/...
PASS. Storage test failures (TestQueryRunSummariesParsesTextTimestamps
et al.) are pre-existing, unrelated to this change set.

Closes Red 2026-04-27 P0-2.
2026-04-28 13:55:35 -07:00

192 lines
5.0 KiB
Go

// Copyright © 2026 Hanzo AI. MIT License.
package auth
import (
"context"
"database/sql"
"errors"
"os"
"testing"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
)
func TestStaticValidator_NoConfiguredKey(t *testing.T) {
v := NewStaticValidator("")
if _, err := v.Validate(context.Background(), "anything"); !errors.Is(err, ErrKeyInvalid) {
t.Errorf("err: want ErrKeyInvalid, got %v", err)
}
}
func TestStaticValidator_Match(t *testing.T) {
v := NewStaticValidator("hk-static-key-1234567890abcdef")
k, err := v.Validate(context.Background(), "hk-static-key-1234567890abcdef")
if err != nil {
t.Fatalf("err: %v", err)
}
if k.OrgID != "" {
t.Errorf("static OrgID: want empty (defers to gateway), got %q", k.OrgID)
}
if k.ID != "static" {
t.Errorf("ID: want static, got %q", k.ID)
}
}
func TestStaticValidator_Mismatch(t *testing.T) {
v := NewStaticValidator("hk-static-key-1234567890abcdef")
if _, err := v.Validate(context.Background(), "hk-wrong"); !errors.Is(err, ErrKeyInvalid) {
t.Errorf("err: want ErrKeyInvalid, got %v", err)
}
}
func TestAPIKey_CheckOrg(t *testing.T) {
k := &APIKey{OrgID: "hanzo"}
if err := k.CheckOrg(""); err != nil {
t.Errorf("empty request org: want nil, got %v", err)
}
if err := k.CheckOrg("hanzo"); err != nil {
t.Errorf("matching org: want nil, got %v", err)
}
if err := k.CheckOrg("attacker"); !errors.Is(err, ErrOrgMismatch) {
t.Errorf("mismatched org: want ErrOrgMismatch, got %v", err)
}
}
func TestHashKey_RoundTrip(t *testing.T) {
raw := "hk-1234567890abcdef1234567890abcdef"
hash, err := HashKey(raw)
if err != nil {
t.Fatalf("HashKey: %v", err)
}
if hash == raw || len(hash) < 50 {
t.Errorf("hash looks unhashed: %q", hash)
}
}
func TestPrefixOf(t *testing.T) {
tests := []struct {
raw string
want string
}{
{"hk-12345678abcdef", "hk-12345678"},
{"hk-short", "hk-short"},
{"", ""},
}
for _, tt := range tests {
got := prefixOf(tt.raw)
if got != tt.want {
t.Errorf("prefixOf(%q): want %q, got %q", tt.raw, tt.want, got)
}
}
}
func TestSplitScopes(t *testing.T) {
tests := []struct {
in string
want int
}{
{"", 0},
{"a", 1},
{"a,b,c", 3},
{"a,,b", 2},
}
for _, tt := range tests {
got := splitScopes(tt.in)
if len(got) != tt.want {
t.Errorf("splitScopes(%q): want len %d, got %d (%v)", tt.in, tt.want, len(got), got)
}
}
}
// TestStore_Validate exercises the Postgres-backed path. Skipped
// unless TEST_POSTGRES_DSN is set so unit runs stay hermetic; the
// integration path is hit by CI's compose-up DB.
func TestStore_Validate(t *testing.T) {
dsn := os.Getenv("TEST_POSTGRES_DSN")
if dsn == "" {
t.Skip("TEST_POSTGRES_DSN not set; skipping integration test")
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open: %v", err)
}
defer db.Close()
ctx := context.Background()
if _, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
prefix TEXT NOT NULL,
hash TEXT NOT NULL,
org_id TEXT NOT NULL CHECK (org_id <> ''),
user_id TEXT NOT NULL CHECK (user_id <> ''),
scopes TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ
)`); err != nil {
t.Fatalf("create table: %v", err)
}
if _, err := db.ExecContext(ctx, `TRUNCATE api_keys`); err != nil {
t.Fatalf("truncate: %v", err)
}
raw := "hk-test1234abcd1234abcd1234abcd1234"
hash, err := HashKey(raw)
if err != nil {
t.Fatalf("hash: %v", err)
}
if _, err := db.ExecContext(ctx, `
INSERT INTO api_keys (id, prefix, hash, org_id, user_id, scopes)
VALUES ($1, $2, $3, $4, $5, $6)
`, "k1", raw[:KeyPrefixLen], hash, "hanzo", "[email protected]", "read,write"); err != nil {
t.Fatalf("insert: %v", err)
}
store := NewStore(db)
// Happy path
k, err := store.Validate(ctx, raw)
if err != nil {
t.Fatalf("validate: %v", err)
}
if k.OrgID != "hanzo" {
t.Errorf("OrgID: want hanzo, got %q", k.OrgID)
}
if len(k.Scopes) != 2 {
t.Errorf("scopes: want 2, got %v", k.Scopes)
}
// Invalid (different bytes, same prefix → bcrypt mismatch)
wrong := raw[:KeyPrefixLen] + "WRONGSUFFIXWRONGSUFFIXWRONGSUFFIX"
if _, err := store.Validate(ctx, wrong); !errors.Is(err, ErrKeyInvalid) {
t.Errorf("wrong key: want ErrKeyInvalid, got %v", err)
}
// Revoked
if _, err := db.ExecContext(ctx, `UPDATE api_keys SET revoked_at = now() WHERE id = 'k1'`); err != nil {
t.Fatalf("revoke: %v", err)
}
if _, err := store.Validate(ctx, raw); !errors.Is(err, ErrKeyRevoked) {
t.Errorf("revoked: want ErrKeyRevoked, got %v", err)
}
// Expired
if _, err := db.ExecContext(ctx, `
UPDATE api_keys SET revoked_at = NULL, expires_at = $1 WHERE id = 'k1'
`, time.Now().Add(-1*time.Hour)); err != nil {
t.Fatalf("expire: %v", err)
}
if _, err := store.Validate(ctx, raw); !errors.Is(err, ErrKeyExpired) {
t.Errorf("expired: want ErrKeyExpired, got %v", err)
}
// No row
if _, err := store.Validate(ctx, "hk-nosuch1234nosuch1234nosuch1234"); !errors.Is(err, ErrKeyInvalid) {
t.Errorf("no row: want ErrKeyInvalid, got %v", err)
}
}