feat: add ai_gateway_keys table and related RBAC (#25563)

Adds table to store keys that AI Gateway standalone replicas will use
to authenticate into Coderd.
Also adds RBAC and audit boilerplate.
This commit is contained in:
Paweł Banaszewski
2026-06-02 09:28:43 +02:00
committed by GitHub
parent 49c2142d2d
commit f22d4e2cbb
26 changed files with 264 additions and 38 deletions
+12
View File
@@ -15269,6 +15269,10 @@ const docTemplate = `{
"enum": [
"all",
"application_connect",
"ai_gateway_key:*",
"ai_gateway_key:create",
"ai_gateway_key:delete",
"ai_gateway_key:read",
"ai_model_price:*",
"ai_model_price:read",
"ai_model_price:update",
@@ -15499,6 +15503,10 @@ const docTemplate = `{
"x-enum-varnames": [
"APIKeyScopeAll",
"APIKeyScopeApplicationConnect",
"APIKeyScopeAiGatewayKeyAll",
"APIKeyScopeAiGatewayKeyCreate",
"APIKeyScopeAiGatewayKeyDelete",
"APIKeyScopeAiGatewayKeyRead",
"APIKeyScopeAiModelPriceAll",
"APIKeyScopeAiModelPriceRead",
"APIKeyScopeAiModelPriceUpdate",
@@ -22329,6 +22337,7 @@ const docTemplate = `{
"type": "string",
"enum": [
"*",
"ai_gateway_key",
"ai_model_price",
"ai_provider",
"ai_seat",
@@ -22380,6 +22389,7 @@ const docTemplate = `{
],
"x-enum-varnames": [
"ResourceWildcard",
"ResourceAIGatewayKey",
"ResourceAiModelPrice",
"ResourceAIProvider",
"ResourceAiSeat",
@@ -22641,6 +22651,7 @@ const docTemplate = `{
"ai_seat",
"ai_provider",
"ai_provider_key",
"ai_gateway_key",
"group_ai_budget",
"chat",
"user_secret",
@@ -22676,6 +22687,7 @@ const docTemplate = `{
"ResourceTypeAISeat",
"ResourceTypeAIProvider",
"ResourceTypeAIProviderKey",
"ResourceTypeAIGatewayKey",
"ResourceTypeGroupAIBudget",
"ResourceTypeChat",
"ResourceTypeUserSecret",
+12
View File
@@ -13653,6 +13653,10 @@
"enum": [
"all",
"application_connect",
"ai_gateway_key:*",
"ai_gateway_key:create",
"ai_gateway_key:delete",
"ai_gateway_key:read",
"ai_model_price:*",
"ai_model_price:read",
"ai_model_price:update",
@@ -13883,6 +13887,10 @@
"x-enum-varnames": [
"APIKeyScopeAll",
"APIKeyScopeApplicationConnect",
"APIKeyScopeAiGatewayKeyAll",
"APIKeyScopeAiGatewayKeyCreate",
"APIKeyScopeAiGatewayKeyDelete",
"APIKeyScopeAiGatewayKeyRead",
"APIKeyScopeAiModelPriceAll",
"APIKeyScopeAiModelPriceRead",
"APIKeyScopeAiModelPriceUpdate",
@@ -20460,6 +20468,7 @@
"type": "string",
"enum": [
"*",
"ai_gateway_key",
"ai_model_price",
"ai_provider",
"ai_seat",
@@ -20511,6 +20520,7 @@
],
"x-enum-varnames": [
"ResourceWildcard",
"ResourceAIGatewayKey",
"ResourceAiModelPrice",
"ResourceAIProvider",
"ResourceAiSeat",
@@ -20762,6 +20772,7 @@
"ai_seat",
"ai_provider",
"ai_provider_key",
"ai_gateway_key",
"group_ai_budget",
"chat",
"user_secret",
@@ -20797,6 +20808,7 @@
"ResourceTypeAISeat",
"ResourceTypeAIProvider",
"ResourceTypeAIProviderKey",
"ResourceTypeAIGatewayKey",
"ResourceTypeGroupAIBudget",
"ResourceTypeChat",
"ResourceTypeUserSecret",
+1
View File
@@ -36,6 +36,7 @@ type Auditable interface {
database.AiSeatState |
database.AIProvider |
database.AIProviderKey |
database.AIGatewayKey |
database.Chat |
database.AuditableGroupAiBudget |
database.UserSecret |
+9
View File
@@ -138,6 +138,8 @@ func ResourceTarget[T Auditable](tgt T) string {
return typed.Name
case database.AIProviderKey:
return typed.ID.String()
case database.AIGatewayKey:
return typed.Name
case database.AuditableGroupAiBudget:
return typed.GroupName
case database.Chat:
@@ -222,6 +224,8 @@ func ResourceID[T Auditable](tgt T) uuid.UUID {
return typed.ID
case database.AIProviderKey:
return typed.ID
case database.AIGatewayKey:
return typed.ID
case database.AuditableGroupAiBudget:
return typed.GroupID
case database.Chat:
@@ -291,6 +295,8 @@ func ResourceType[T Auditable](tgt T) database.ResourceType {
return database.ResourceTypeAIProvider
case database.AIProviderKey:
return database.ResourceTypeAIProviderKey
case database.AIGatewayKey:
return database.ResourceTypeAIGatewayKey
case database.AuditableGroupAiBudget:
return database.ResourceTypeGroupAiBudget
case database.Chat:
@@ -366,6 +372,9 @@ func ResourceRequiresOrgID[T Auditable]() bool {
// AI provider keys inherit the deployment scope of their parent
// provider.
return false
case database.AIGatewayKey:
// AI Gateway keys are deployment-scoped, not org-scoped.
return false
case database.AuditableGroupAiBudget:
// Group AI budgets are org-scoped through their parent group.
return true
+3
View File
@@ -6,6 +6,9 @@ type CheckConstraint string
// CheckConstraint enums.
const (
CheckAiGatewayKeysHashedSecretCheck CheckConstraint = "ai_gateway_keys_hashed_secret_check" // ai_gateway_keys
CheckAiGatewayKeysNameCheck CheckConstraint = "ai_gateway_keys_name_check" // ai_gateway_keys
CheckAiGatewayKeysSecretPrefixCheck CheckConstraint = "ai_gateway_keys_secret_prefix_check" // ai_gateway_keys
CheckAiModelPricesCacheReadPriceCheck CheckConstraint = "ai_model_prices_cache_read_price_check" // ai_model_prices
CheckAiModelPricesCacheWritePriceCheck CheckConstraint = "ai_model_prices_cache_write_price_check" // ai_model_prices
CheckAiModelPricesInputPriceCheck CheckConstraint = "ai_model_prices_input_price_check" // ai_model_prices
+32 -2
View File
@@ -253,7 +253,11 @@ CREATE TYPE api_key_scope AS ENUM (
'boundary_log:*',
'boundary_log:create',
'boundary_log:delete',
'boundary_log:read'
'boundary_log:read',
'ai_gateway_key:*',
'ai_gateway_key:create',
'ai_gateway_key:delete',
'ai_gateway_key:read'
);
CREATE TYPE app_sharing_level AS ENUM (
@@ -564,7 +568,8 @@ CREATE TYPE resource_type AS ENUM (
'ai_provider',
'ai_provider_key',
'group_ai_budget',
'user_skill'
'user_skill',
'ai_gateway_key'
);
CREATE TYPE shareable_workspace_owners AS ENUM (
@@ -1287,6 +1292,22 @@ BEGIN
END;
$$;
CREATE TABLE ai_gateway_keys (
id uuid NOT NULL,
created_at timestamp with time zone NOT NULL,
name text NOT NULL,
secret_prefix character varying(11) NOT NULL,
hashed_secret bytea NOT NULL,
last_used_at timestamp with time zone,
CONSTRAINT ai_gateway_keys_hashed_secret_check CHECK ((length(hashed_secret) > 0)),
CONSTRAINT ai_gateway_keys_name_check CHECK (((length(name) <= 64) AND (name ~ '^[a-z0-9]+(-[a-z0-9]+)*$'::text))),
CONSTRAINT ai_gateway_keys_secret_prefix_check CHECK ((length((secret_prefix)::text) = 11))
);
COMMENT ON TABLE ai_gateway_keys IS 'Hashed bearer secrets used by AI Gateway standalone replicas to authenticate into coderd.';
COMMENT ON COLUMN ai_gateway_keys.secret_prefix IS 'Public token prefix for display and audit correlation. Auth uses hashed_secret.';
CREATE TABLE ai_model_prices (
provider text NOT NULL,
model text NOT NULL,
@@ -3763,6 +3784,9 @@ ALTER TABLE ONLY workspace_resource_metadata ALTER COLUMN id SET DEFAULT nextval
ALTER TABLE ONLY workspace_agent_stats
ADD CONSTRAINT agent_stats_pkey PRIMARY KEY (id);
ALTER TABLE ONLY ai_gateway_keys
ADD CONSTRAINT ai_gateway_keys_pkey PRIMARY KEY (id);
ALTER TABLE ONLY ai_model_prices
ADD CONSTRAINT ai_model_prices_pkey PRIMARY KEY (provider, model);
@@ -4147,6 +4171,12 @@ ALTER TABLE ONLY workspace_resources
ALTER TABLE ONLY workspaces
ADD CONSTRAINT workspaces_pkey PRIMARY KEY (id);
CREATE UNIQUE INDEX ai_gateway_keys_hashed_secret_idx ON ai_gateway_keys USING btree (hashed_secret);
CREATE UNIQUE INDEX ai_gateway_keys_name_idx ON ai_gateway_keys USING btree (lower(name));
CREATE UNIQUE INDEX ai_gateway_keys_secret_prefix_idx ON ai_gateway_keys USING btree (secret_prefix);
CREATE UNIQUE INDEX ai_providers_name_unique ON ai_providers USING btree (name) WHERE (deleted = false);
CREATE INDEX api_keys_last_used_idx ON api_keys USING btree (last_used DESC);
@@ -0,0 +1,6 @@
-- Enum additions to resource_type and api_key_scope are intentionally not
-- reverted because Postgres cannot drop enum values safely.
DROP INDEX IF EXISTS ai_gateway_keys_hashed_secret_idx;
DROP INDEX IF EXISTS ai_gateway_keys_secret_prefix_idx;
DROP INDEX IF EXISTS ai_gateway_keys_name_idx;
DROP TABLE IF EXISTS ai_gateway_keys;
@@ -0,0 +1,25 @@
CREATE TABLE ai_gateway_keys (
id uuid PRIMARY KEY,
created_at timestamptz NOT NULL,
name text NOT NULL,
secret_prefix varchar(11) NOT NULL,
hashed_secret bytea NOT NULL,
last_used_at timestamptz NULL,
CONSTRAINT ai_gateway_keys_name_check CHECK (length(name) <= 64 AND name ~ '^[a-z0-9]+(-[a-z0-9]+)*$'),
CONSTRAINT ai_gateway_keys_secret_prefix_check CHECK (length(secret_prefix) = 11),
CONSTRAINT ai_gateway_keys_hashed_secret_check CHECK (length(hashed_secret) > 0)
);
COMMENT ON TABLE ai_gateway_keys IS 'Hashed bearer secrets used by AI Gateway standalone replicas to authenticate into coderd.';
COMMENT ON COLUMN ai_gateway_keys.secret_prefix IS 'Public token prefix for display and audit correlation. Auth uses hashed_secret.';
CREATE UNIQUE INDEX ai_gateway_keys_name_idx ON ai_gateway_keys USING btree (lower(name));
CREATE UNIQUE INDEX ai_gateway_keys_secret_prefix_idx ON ai_gateway_keys USING btree (secret_prefix);
CREATE UNIQUE INDEX ai_gateway_keys_hashed_secret_idx ON ai_gateway_keys USING btree (hashed_secret);
ALTER TYPE resource_type ADD VALUE IF NOT EXISTS 'ai_gateway_key';
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'ai_gateway_key:*';
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'ai_gateway_key:create';
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'ai_gateway_key:delete';
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'ai_gateway_key:read';
@@ -0,0 +1,15 @@
INSERT INTO ai_gateway_keys (
id,
created_at,
name,
secret_prefix,
hashed_secret,
last_used_at
) VALUES (
'8b6f0a82-9a3a-4d2e-8c0c-2c9c9b9b1a01',
'2026-05-21 00:00:00+00',
'example-key',
'cdr_1234567',
'\x00'::bytea,
NULL
);
+28 -2
View File
@@ -324,6 +324,10 @@ const (
ApiKeyScopeBoundaryLogCreate APIKeyScope = "boundary_log:create"
ApiKeyScopeBoundaryLogDelete APIKeyScope = "boundary_log:delete"
ApiKeyScopeBoundaryLogRead APIKeyScope = "boundary_log:read"
ApiKeyScopeAiGatewayKey APIKeyScope = "ai_gateway_key:*"
ApiKeyScopeAiGatewayKeyCreate APIKeyScope = "ai_gateway_key:create"
ApiKeyScopeAiGatewayKeyDelete APIKeyScope = "ai_gateway_key:delete"
ApiKeyScopeAiGatewayKeyRead APIKeyScope = "ai_gateway_key:read"
)
func (e *APIKeyScope) Scan(src interface{}) error {
@@ -588,7 +592,11 @@ func (e APIKeyScope) Valid() bool {
ApiKeyScopeBoundaryLog,
ApiKeyScopeBoundaryLogCreate,
ApiKeyScopeBoundaryLogDelete,
ApiKeyScopeBoundaryLogRead:
ApiKeyScopeBoundaryLogRead,
ApiKeyScopeAiGatewayKey,
ApiKeyScopeAiGatewayKeyCreate,
ApiKeyScopeAiGatewayKeyDelete,
ApiKeyScopeAiGatewayKeyRead:
return true
}
return false
@@ -822,6 +830,10 @@ func AllAPIKeyScopeValues() []APIKeyScope {
ApiKeyScopeBoundaryLogCreate,
ApiKeyScopeBoundaryLogDelete,
ApiKeyScopeBoundaryLogRead,
ApiKeyScopeAiGatewayKey,
ApiKeyScopeAiGatewayKeyCreate,
ApiKeyScopeAiGatewayKeyDelete,
ApiKeyScopeAiGatewayKeyRead,
}
}
@@ -3353,6 +3365,7 @@ const (
ResourceTypeAIProviderKey ResourceType = "ai_provider_key"
ResourceTypeGroupAiBudget ResourceType = "group_ai_budget"
ResourceTypeUserSkill ResourceType = "user_skill"
ResourceTypeAIGatewayKey ResourceType = "ai_gateway_key"
)
func (e *ResourceType) Scan(src interface{}) error {
@@ -3424,7 +3437,8 @@ func (e ResourceType) Valid() bool {
ResourceTypeAIProvider,
ResourceTypeAIProviderKey,
ResourceTypeGroupAiBudget,
ResourceTypeUserSkill:
ResourceTypeUserSkill,
ResourceTypeAIGatewayKey:
return true
}
return false
@@ -3465,6 +3479,7 @@ func AllResourceTypeValues() []ResourceType {
ResourceTypeAIProviderKey,
ResourceTypeGroupAiBudget,
ResourceTypeUserSkill,
ResourceTypeAIGatewayKey,
}
}
@@ -4435,6 +4450,17 @@ type AIBridgeUserPrompt struct {
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
// Hashed bearer secrets used by AI Gateway standalone replicas to authenticate into coderd.
type AIGatewayKey struct {
ID uuid.UUID `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
Name string `db:"name" json:"name"`
// Public token prefix for display and audit correlation. Auth uses hashed_secret.
SecretPrefix string `db:"secret_prefix" json:"secret_prefix"`
HashedSecret []byte `db:"hashed_secret" json:"hashed_secret"`
LastUsedAt sql.NullTime `db:"last_used_at" json:"last_used_at"`
}
// Runtime configuration for AI providers. Authoritative source for the provider set served by aibridged. Replaces deployment-time CODER_AIBRIDGE_* environment variables.
type AIProvider struct {
ID uuid.UUID `db:"id" json:"id"`
+2
View File
@@ -261,8 +261,10 @@ sql:
ai_provider: AIProvider
ai_provider_key: AIProviderKey
ai_provider_type: AIProviderType
ai_gateway_key: AIGatewayKey
resource_type_ai_provider: ResourceTypeAIProvider
resource_type_ai_provider_key: ResourceTypeAIProviderKey
resource_type_ai_gateway_key: ResourceTypeAIGatewayKey
mcp_server_config: MCPServerConfig
mcp_server_configs: MCPServerConfigs
mcp_server_user_token: MCPServerUserToken
+4
View File
@@ -7,6 +7,7 @@ type UniqueConstraint string
// UniqueConstraint enums.
const (
UniqueAgentStatsPkey UniqueConstraint = "agent_stats_pkey" // ALTER TABLE ONLY workspace_agent_stats ADD CONSTRAINT agent_stats_pkey PRIMARY KEY (id);
UniqueAiGatewayKeysPkey UniqueConstraint = "ai_gateway_keys_pkey" // ALTER TABLE ONLY ai_gateway_keys ADD CONSTRAINT ai_gateway_keys_pkey PRIMARY KEY (id);
UniqueAiModelPricesPkey UniqueConstraint = "ai_model_prices_pkey" // ALTER TABLE ONLY ai_model_prices ADD CONSTRAINT ai_model_prices_pkey PRIMARY KEY (provider, model);
UniqueAiProviderKeysPkey UniqueConstraint = "ai_provider_keys_pkey" // ALTER TABLE ONLY ai_provider_keys ADD CONSTRAINT ai_provider_keys_pkey PRIMARY KEY (id);
UniqueAiProvidersPkey UniqueConstraint = "ai_providers_pkey" // ALTER TABLE ONLY ai_providers ADD CONSTRAINT ai_providers_pkey PRIMARY KEY (id);
@@ -135,6 +136,9 @@ const (
UniqueWorkspaceResourceMetadataPkey UniqueConstraint = "workspace_resource_metadata_pkey" // ALTER TABLE ONLY workspace_resource_metadata ADD CONSTRAINT workspace_resource_metadata_pkey PRIMARY KEY (id);
UniqueWorkspaceResourcesPkey UniqueConstraint = "workspace_resources_pkey" // ALTER TABLE ONLY workspace_resources ADD CONSTRAINT workspace_resources_pkey PRIMARY KEY (id);
UniqueWorkspacesPkey UniqueConstraint = "workspaces_pkey" // ALTER TABLE ONLY workspaces ADD CONSTRAINT workspaces_pkey PRIMARY KEY (id);
UniqueAiGatewayKeysHashedSecretIndex UniqueConstraint = "ai_gateway_keys_hashed_secret_idx" // CREATE UNIQUE INDEX ai_gateway_keys_hashed_secret_idx ON ai_gateway_keys USING btree (hashed_secret);
UniqueAiGatewayKeysNameIndex UniqueConstraint = "ai_gateway_keys_name_idx" // CREATE UNIQUE INDEX ai_gateway_keys_name_idx ON ai_gateway_keys USING btree (lower(name));
UniqueAiGatewayKeysSecretPrefixIndex UniqueConstraint = "ai_gateway_keys_secret_prefix_idx" // CREATE UNIQUE INDEX ai_gateway_keys_secret_prefix_idx ON ai_gateway_keys USING btree (secret_prefix);
UniqueAiProvidersNameUnique UniqueConstraint = "ai_providers_name_unique" // CREATE UNIQUE INDEX ai_providers_name_unique ON ai_providers USING btree (name) WHERE (deleted = false);
UniqueIndexAPIKeyName UniqueConstraint = "idx_api_key_name" // CREATE UNIQUE INDEX idx_api_key_name ON api_keys USING btree (user_id, token_name) WHERE (login_type = 'token'::login_type);
UniqueIndexChatDebugRunsIDChat UniqueConstraint = "idx_chat_debug_runs_id_chat" // CREATE UNIQUE INDEX idx_chat_debug_runs_id_chat ON chat_debug_runs USING btree (id, chat_id);
+10
View File
@@ -15,6 +15,15 @@ var (
Type: "*",
}
// ResourceAIGatewayKey
// Valid Actions
// - "ActionCreate" :: create an AI Gateway key
// - "ActionDelete" :: delete an AI Gateway key
// - "ActionRead" :: read AI Gateway keys
ResourceAIGatewayKey = Object{
Type: "ai_gateway_key",
}
// ResourceAiModelPrice
// Valid Actions
// - "ActionRead" :: read AI model prices
@@ -479,6 +488,7 @@ var (
func AllResources() []Objecter {
return []Objecter{
ResourceWildcard,
ResourceAIGatewayKey,
ResourceAiModelPrice,
ResourceAIProvider,
ResourceAiSeat,
+8
View File
@@ -429,6 +429,14 @@ var RBACPermissions = map[string]PermissionDefinition{
ActionDelete: "delete boundary logs",
},
},
"ai_gateway_key": {
Name: "AIGatewayKey",
Actions: map[Action]ActionDefinition{
ActionCreate: "create an AI Gateway key",
ActionRead: "read AI Gateway keys",
ActionDelete: "delete an AI Gateway key",
},
},
"boundary_usage": {
Actions: map[Action]ActionDefinition{
ActionRead: "read boundary usage statistics",
+18
View File
@@ -1204,6 +1204,24 @@ func TestRolePermissions(t *testing.T) {
},
},
},
{
// Only owners can manage AI Gateway keys. They hold
// a hashed bearer secret used to authenticate Gateway
// replicas to coderd. Keys are deployment-wide.
Name: "AIGatewayKey",
Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionDelete},
Resource: rbac.ResourceAIGatewayKey,
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner},
false: {
memberMe, agentsAccessUser,
orgAdmin, otherOrgAdmin,
orgAuditor, otherOrgAuditor,
templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin,
userAdmin, orgUserAdmin, otherOrgUserAdmin,
},
},
},
{
Name: "BoundaryUsage",
Actions: []policy.Action{policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
+9
View File
@@ -7,6 +7,9 @@ package rbac
// declared in code, not here, to avoid duplication.
const (
ScopeAiGatewayKeyCreate ScopeName = "ai_gateway_key:create"
ScopeAiGatewayKeyDelete ScopeName = "ai_gateway_key:delete"
ScopeAiGatewayKeyRead ScopeName = "ai_gateway_key:read"
ScopeAiModelPriceRead ScopeName = "ai_model_price:read"
ScopeAiModelPriceUpdate ScopeName = "ai_model_price:update"
ScopeAiProviderCreate ScopeName = "ai_provider:create"
@@ -187,6 +190,9 @@ func (e ScopeName) Valid() bool {
case ScopeName("coder:all"),
ScopeName("coder:application_connect"),
ScopeName("no_user_data"),
ScopeAiGatewayKeyCreate,
ScopeAiGatewayKeyDelete,
ScopeAiGatewayKeyRead,
ScopeAiModelPriceRead,
ScopeAiModelPriceUpdate,
ScopeAiProviderCreate,
@@ -368,6 +374,9 @@ func AllScopeNameValues() []ScopeName {
ScopeName("coder:all"),
ScopeName("coder:application_connect"),
ScopeName("no_user_data"),
ScopeAiGatewayKeyCreate,
ScopeAiGatewayKeyDelete,
ScopeAiGatewayKeyRead,
ScopeAiModelPriceRead,
ScopeAiModelPriceUpdate,
ScopeAiProviderCreate,