mirror of
https://github.com/hanzoai/agent.git
synced 2026-09-11 21:08:51 +00:00
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.
56 lines
2.0 KiB
SQL
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
|