Files
hanzoai_agent/control-plane/migrations/022_api_keys_table.sql
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

56 lines
2.0 KiB
SQL

-- 022_api_keys_table.sql
--
-- Bound API keys: the key value itself carries the org_id. APIKeyAuth
-- loads the key, reads OrgID, and rejects (403) when the request's
-- X-Org-Id mismatches. Closes Red 2026-04-27 P0-2: a valid X-API-Key
-- combined with attacker-chosen X-Org-Id no longer crosses tenants.
--
-- Hash column stores bcrypt of the raw key (golang.org/x/crypto/bcrypt,
-- DefaultCost). Prefix column is the first 11 bytes of the raw key
-- ("hk-" + 8 hex) — enough entropy to make prefix-only enumeration
-- pointless and short enough for an indexed lookup.
--
-- Lookup path: SELECT WHERE prefix = $1 AND revoked_at IS NULL
-- AND (expires_at IS NULL OR expires_at > now())
-- → bcrypt.CompareHashAndPassword(hash, raw) (constant-time at the
-- crypto layer, length-blinded by bcrypt itself).
--
-- org_id NOT NULL is the schema-level guarantee that no row can land
-- in the legacy unscoped bucket. revoked_at gets a partial index for
-- the live-key fast path; expires_at is read on every Validate call
-- so it stays unindexed (cheap comparison).
-- +goose Up
-- +goose StatementBegin
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
);
CREATE INDEX IF NOT EXISTS idx_api_keys_prefix_live
ON api_keys(prefix) WHERE revoked_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_api_keys_org
ON api_keys(org_id);
CREATE INDEX IF NOT EXISTS idx_api_keys_user
ON api_keys(user_id);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP INDEX IF EXISTS idx_api_keys_user;
DROP INDEX IF EXISTS idx_api_keys_org;
DROP INDEX IF EXISTS idx_api_keys_prefix_live;
DROP TABLE IF EXISTS api_keys;
-- +goose StatementEnd