From 01d15322384e997666329f0933b41fc23d3fdd25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Banaszewski?= Date: Wed, 17 Jun 2026 16:30:59 +0000 Subject: [PATCH 1/2] feat(coderd/database): add AI Gateway key auth lookup and last-used queries Part of AIGOV-308. Generated with Coder Agents. --- coderd/database/dbauthz/dbauthz.go | 18 +++++ coderd/database/dbauthz/dbauthz_test.go | 11 ++++ coderd/database/dbmetrics/querymetrics.go | 16 +++++ coderd/database/dbmock/dbmock.go | 29 ++++++++ coderd/database/querier.go | 8 +++ coderd/database/querier_test.go | 73 +++++++++++++++++++++ coderd/database/queries.sql.go | 30 +++++++++ coderd/database/queries/ai_gateway_keys.sql | 16 +++++ 8 files changed, 201 insertions(+) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 51684b42ac544..f537df28cc797 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2753,6 +2753,15 @@ func (q *querier) GetAIBridgeUserPromptsByInterceptionID(ctx context.Context, in return q.db.GetAIBridgeUserPromptsByInterceptionID(ctx, interceptionID) } +// Authenticates a standalone AI Gateway replica by its hashed key secret, returning the key ID used to record liveness. +func (q *querier) GetAIGatewayKeyIDByHashedSecret(ctx context.Context, hashedSecret []byte) (uuid.UUID, error) { + // Standalone AI Gateway has no Coder identity. Credential read is a system operation. + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { + return uuid.Nil, err + } + return q.db.GetAIGatewayKeyIDByHashedSecret(ctx, hashedSecret) +} + func (q *querier) GetAIModelPriceByProviderModel(ctx context.Context, arg database.GetAIModelPriceByProviderModelParams) (database.AIModelPrice, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAiModelPrice); err != nil { return database.AIModelPrice{}, err @@ -7022,6 +7031,15 @@ func (q *querier) UpdateAIBridgeInterceptionEnded(ctx context.Context, params da return q.db.UpdateAIBridgeInterceptionEnded(ctx, params) } +// Records liveness for an active DRPC sessions between coderd and standalone AI Gateway. +func (q *querier) UpdateAIGatewayKeyLastUsedAt(ctx context.Context, id uuid.UUID) error { + // Standalone AI Gateway has no Coder identity. DRPC connection liveness update is a system operation. + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil { + return err + } + return q.db.UpdateAIGatewayKeyLastUsedAt(ctx, id) +} + func (q *querier) UpdateAIProvider(ctx context.Context, arg database.UpdateAIProviderParams) (database.AIProvider, error) { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceAIProvider); err != nil { return database.AIProvider{}, err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 0754b9959638c..08f9e43f75068 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -6968,6 +6968,17 @@ func (s *MethodTestSuite) TestAIBridge() { dbm.EXPECT().DeleteAIGatewayKey(gomock.Any(), id).Return(database.DeleteAIGatewayKeyRow{}, nil).AnyTimes() check.Args(id).Asserts(rbac.ResourceAIGatewayKey, policy.ActionDelete).Returns(database.DeleteAIGatewayKeyRow{}) })) + s.Run("GetAIGatewayKeyIDByHashedSecret", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + hashedSecret := []byte("hashed-secret") + id := uuid.New() + dbm.EXPECT().GetAIGatewayKeyIDByHashedSecret(gomock.Any(), hashedSecret).Return(id, nil).AnyTimes() + check.Args(hashedSecret).Asserts(rbac.ResourceSystem, policy.ActionRead).Returns(id) + })) + s.Run("UpdateAIGatewayKeyLastUsedAt", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + id := uuid.New() + dbm.EXPECT().UpdateAIGatewayKeyLastUsedAt(gomock.Any(), id).Return(nil).AnyTimes() + check.Args(id).Asserts(rbac.ResourceSystem, policy.ActionUpdate).Returns() + })) } func (s *MethodTestSuite) TestTelemetry() { diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 5f5761a97e613..dc4d351d27188 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1130,6 +1130,14 @@ func (m queryMetricsStore) GetAIBridgeUserPromptsByInterceptionID(ctx context.Co return r0, r1 } +func (m queryMetricsStore) GetAIGatewayKeyIDByHashedSecret(ctx context.Context, hashedSecret []byte) (uuid.UUID, error) { + start := time.Now() + r0, r1 := m.s.GetAIGatewayKeyIDByHashedSecret(ctx, hashedSecret) + m.queryLatencies.WithLabelValues("GetAIGatewayKeyIDByHashedSecret").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetAIGatewayKeyIDByHashedSecret").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetAIModelPriceByProviderModel(ctx context.Context, arg database.GetAIModelPriceByProviderModelParams) (database.AIModelPrice, error) { start := time.Now() r0, r1 := m.s.GetAIModelPriceByProviderModel(ctx, arg) @@ -5042,6 +5050,14 @@ func (m queryMetricsStore) UpdateAIBridgeInterceptionEnded(ctx context.Context, return r0, r1 } +func (m queryMetricsStore) UpdateAIGatewayKeyLastUsedAt(ctx context.Context, arg uuid.UUID) error { + start := time.Now() + r0 := m.s.UpdateAIGatewayKeyLastUsedAt(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateAIGatewayKeyLastUsedAt").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateAIGatewayKeyLastUsedAt").Inc() + return r0 +} + func (m queryMetricsStore) UpdateAIProvider(ctx context.Context, arg database.UpdateAIProviderParams) (database.AIProvider, error) { start := time.Now() r0, r1 := m.s.UpdateAIProvider(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 87ab8d05f2b81..8377fad8289f3 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1947,6 +1947,21 @@ func (mr *MockStoreMockRecorder) GetAIBridgeUserPromptsByInterceptionID(ctx, int return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAIBridgeUserPromptsByInterceptionID", reflect.TypeOf((*MockStore)(nil).GetAIBridgeUserPromptsByInterceptionID), ctx, interceptionID) } +// GetAIGatewayKeyIDByHashedSecret mocks base method. +func (m *MockStore) GetAIGatewayKeyIDByHashedSecret(ctx context.Context, hashedSecret []byte) (uuid.UUID, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAIGatewayKeyIDByHashedSecret", ctx, hashedSecret) + ret0, _ := ret[0].(uuid.UUID) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAIGatewayKeyIDByHashedSecret indicates an expected call of GetAIGatewayKeyIDByHashedSecret. +func (mr *MockStoreMockRecorder) GetAIGatewayKeyIDByHashedSecret(ctx, hashedSecret any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAIGatewayKeyIDByHashedSecret", reflect.TypeOf((*MockStore)(nil).GetAIGatewayKeyIDByHashedSecret), ctx, hashedSecret) +} + // GetAIModelPriceByProviderModel mocks base method. func (m *MockStore) GetAIModelPriceByProviderModel(ctx context.Context, arg database.GetAIModelPriceByProviderModelParams) (database.AIModelPrice, error) { m.ctrl.T.Helper() @@ -9503,6 +9518,20 @@ func (mr *MockStoreMockRecorder) UpdateAIBridgeInterceptionEnded(ctx, arg any) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAIBridgeInterceptionEnded", reflect.TypeOf((*MockStore)(nil).UpdateAIBridgeInterceptionEnded), ctx, arg) } +// UpdateAIGatewayKeyLastUsedAt mocks base method. +func (m *MockStore) UpdateAIGatewayKeyLastUsedAt(ctx context.Context, id uuid.UUID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateAIGatewayKeyLastUsedAt", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateAIGatewayKeyLastUsedAt indicates an expected call of UpdateAIGatewayKeyLastUsedAt. +func (mr *MockStoreMockRecorder) UpdateAIGatewayKeyLastUsedAt(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAIGatewayKeyLastUsedAt", reflect.TypeOf((*MockStore)(nil).UpdateAIGatewayKeyLastUsedAt), ctx, id) +} + // UpdateAIProvider mocks base method. func (m *MockStore) UpdateAIProvider(ctx context.Context, arg database.UpdateAIProviderParams) (database.AIProvider, error) { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index fe56f6e4f17d1..7519438474316 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -301,6 +301,10 @@ type sqlcQuerier interface { GetAIBridgeTokenUsagesByInterceptionID(ctx context.Context, interceptionID uuid.UUID) ([]AIBridgeTokenUsage, error) GetAIBridgeToolUsagesByInterceptionID(ctx context.Context, interceptionID uuid.UUID) ([]AIBridgeToolUsage, error) GetAIBridgeUserPromptsByInterceptionID(ctx context.Context, interceptionID uuid.UUID) ([]AIBridgeUserPrompt, error) + // Authenticates a standalone AI Gateway replica by its hashed key secret, + // returning the key ID used to record liveness. The lookup is an exact match + // on a unique index, so a returned row is itself proof the secret is valid. + GetAIGatewayKeyIDByHashedSecret(ctx context.Context, hashedSecret []byte) (uuid.UUID, error) GetAIModelPriceByProviderModel(ctx context.Context, arg GetAIModelPriceByProviderModelParams) (AIModelPrice, error) GetAIProviderByID(ctx context.Context, id uuid.UUID) (AIProvider, error) // Lock the provider row until the model-config write completes. The @@ -1307,6 +1311,10 @@ type sqlcQuerier interface { UnpinChatByID(ctx context.Context, id uuid.UUID) error UnsetDefaultChatModelConfigs(ctx context.Context) error UpdateAIBridgeInterceptionEnded(ctx context.Context, arg UpdateAIBridgeInterceptionEndedParams) (AIBridgeInterception, error) + // Records liveness for an active Gateway DRPC session. The database sets the + // timestamp so it stays consistent regardless of clock drift between API + // replicas. + UpdateAIGatewayKeyLastUsedAt(ctx context.Context, id uuid.UUID) error UpdateAIProvider(ctx context.Context, arg UpdateAIProviderParams) (AIProvider, error) UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error UpdateChatACLByID(ctx context.Context, arg UpdateChatACLByIDParams) error diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 5d8d4a600e132..c9362454a8e23 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -14958,6 +14958,79 @@ func TestAIGatewayKeysQueries(t *testing.T) { requireAIGatewayKeysRow(t, keys[0], second, secondRow.CreatedAt) } +func TestGetAIGatewayKeyIDByHashedSecret(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + + first := aiGatewayKeyParams("lookup-first", "key_lookup1") + second := aiGatewayKeyParams("lookup-second", "key_lookup2") + + _, err := db.InsertAIGatewayKey(ctx, first) + require.NoError(t, err) + _, err = db.InsertAIGatewayKey(ctx, second) + require.NoError(t, err) + + id, err := db.GetAIGatewayKeyIDByHashedSecret(ctx, first.HashedSecret) + require.NoError(t, err) + require.Equal(t, first.ID, id) + + id, err = db.GetAIGatewayKeyIDByHashedSecret(ctx, second.HashedSecret) + require.NoError(t, err) + require.Equal(t, second.ID, id) + + // An unknown secret returns no rows + id, err = db.GetAIGatewayKeyIDByHashedSecret(ctx, []byte("does-not-exist")) + require.ErrorIs(t, err, sql.ErrNoRows) + require.Empty(t, id) +} + +func TestUpdateAIGatewayKeyLastUsedAt(t *testing.T) { + t.Parallel() + + db, _, sqlDB := dbtestutil.NewDBWithSQLDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + + params := aiGatewayKeyParams("liveness-key", "key_live___") + row, err := db.InsertAIGatewayKey(ctx, params) + require.NoError(t, err) + + // last_used_at starts NULL until a session records liveness. + keys, err := db.ListAIGatewayKeys(ctx) + require.NoError(t, err) + require.Len(t, keys, 1) + require.False(t, keys[0].LastUsedAt.Valid) + + err = db.UpdateAIGatewayKeyLastUsedAt(ctx, params.ID) + require.NoError(t, err) + + keys, err = db.ListAIGatewayKeys(ctx) + require.NoError(t, err) + require.Len(t, keys, 1) + require.True(t, keys[0].LastUsedAt.Valid) + // The database stamps the timestamp, so compare against the row's + // DB-generated CreatedAt to avoid client clock skew. + require.False(t, keys[0].LastUsedAt.Time.Before(row.CreatedAt)) + + // Updating a key that does not exist is a no-op, not an error. + err = db.UpdateAIGatewayKeyLastUsedAt(ctx, uuid.New()) + require.NoError(t, err) + + // Set last_used_at to old time to confirm the update overwrites it with a fresh timestamp. + staleTime := row.CreatedAt.Add(-time.Hour) + _, err = sqlDB.ExecContext(ctx, "UPDATE ai_gateway_keys SET last_used_at = $1 WHERE id = $2", staleTime, params.ID) + require.NoError(t, err) + + err = db.UpdateAIGatewayKeyLastUsedAt(ctx, params.ID) + require.NoError(t, err) + + keys, err = db.ListAIGatewayKeys(ctx) + require.NoError(t, err) + require.Len(t, keys, 1) + require.True(t, keys[0].LastUsedAt.Time.After(staleTime)) +} + func aiGatewayKeyParams(name string, secretPrefix string) database.InsertAIGatewayKeyParams { return database.InsertAIGatewayKeyParams{ ID: uuid.New(), diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 176998b4c94f7..6514153aa0ae0 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -137,6 +137,22 @@ func (q *sqlQuerier) DeleteAIGatewayKey(ctx context.Context, id uuid.UUID) (Dele return i, err } +const getAIGatewayKeyIDByHashedSecret = `-- name: GetAIGatewayKeyIDByHashedSecret :one +SELECT id +FROM ai_gateway_keys +WHERE hashed_secret = $1 +` + +// Authenticates a standalone AI Gateway replica by its hashed key secret, +// returning the key ID used to record liveness. The lookup is an exact match +// on a unique index, so a returned row is itself proof the secret is valid. +func (q *sqlQuerier) GetAIGatewayKeyIDByHashedSecret(ctx context.Context, hashedSecret []byte) (uuid.UUID, error) { + row := q.db.QueryRowContext(ctx, getAIGatewayKeyIDByHashedSecret, hashedSecret) + var id uuid.UUID + err := row.Scan(&id) + return id, err +} + const insertAIGatewayKey = `-- name: InsertAIGatewayKey :one INSERT INTO ai_gateway_keys (id, name, secret_prefix, hashed_secret, created_at) VALUES ($1, $4, $2, $3, NOW()) @@ -217,6 +233,20 @@ func (q *sqlQuerier) ListAIGatewayKeys(ctx context.Context) ([]ListAIGatewayKeys return items, nil } +const updateAIGatewayKeyLastUsedAt = `-- name: UpdateAIGatewayKeyLastUsedAt :exec +UPDATE ai_gateway_keys +SET last_used_at = NOW() +WHERE id = $1 +` + +// Records liveness for an active Gateway DRPC session. The database sets the +// timestamp so it stays consistent regardless of clock drift between API +// replicas. +func (q *sqlQuerier) UpdateAIGatewayKeyLastUsedAt(ctx context.Context, id uuid.UUID) error { + _, err := q.db.ExecContext(ctx, updateAIGatewayKeyLastUsedAt, id) + return err +} + const deleteAIProviderKey = `-- name: DeleteAIProviderKey :exec DELETE FROM ai_provider_keys diff --git a/coderd/database/queries/ai_gateway_keys.sql b/coderd/database/queries/ai_gateway_keys.sql index 308d0cb89d1aa..1e94bed7c8a66 100644 --- a/coderd/database/queries/ai_gateway_keys.sql +++ b/coderd/database/queries/ai_gateway_keys.sql @@ -11,3 +11,19 @@ ORDER BY created_at ASC; -- name: DeleteAIGatewayKey :one DELETE FROM ai_gateway_keys WHERE id = $1 RETURNING id, name, secret_prefix, created_at, last_used_at; + +-- name: GetAIGatewayKeyIDByHashedSecret :one +-- Authenticates a standalone AI Gateway replica by its hashed key secret, +-- returning the key ID used to record liveness. The lookup is an exact match +-- on a unique index, so a returned row is itself proof the secret is valid. +SELECT id +FROM ai_gateway_keys +WHERE hashed_secret = $1; + +-- name: UpdateAIGatewayKeyLastUsedAt :exec +-- Records liveness for an active Gateway DRPC session. The database sets the +-- timestamp so it stays consistent regardless of clock drift between API +-- replicas. +UPDATE ai_gateway_keys +SET last_used_at = NOW() +WHERE id = $1; From 36e5b2ad0b8f4a327e357c5a66bfb7dd8a7b11cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Banaszewski?= Date: Thu, 25 Jun 2026 11:54:30 +0000 Subject: [PATCH 2/2] review 1: used ResourceAIGatewayKey in both GetByHash and UpdateLastUsed methods, updated subject permissions --- coderd/apidoc/docs.go | 2 ++ coderd/apidoc/swagger.json | 2 ++ coderd/database/dbauthz/dbauthz.go | 25 +++++++------ coderd/database/dbauthz/dbauthz_test.go | 12 +++---- coderd/database/dbmetrics/querymetrics.go | 14 ++++---- coderd/database/dbmock/dbmock.go | 21 +++++------ coderd/database/dump.sql | 3 +- ...00531_ai_gateway_key_update_scope.down.sql | 2 ++ .../000531_ai_gateway_key_update_scope.up.sql | 1 + coderd/database/models.go | 5 ++- coderd/database/querier.go | 8 ++--- coderd/database/querier_test.go | 26 ++++++++------ coderd/database/queries.sql.go | 36 ++++++++++++------- coderd/database/queries/ai_gateway_keys.sql | 10 +++--- coderd/rbac/object_gen.go | 1 + coderd/rbac/policy/policy.go | 1 + coderd/rbac/roles.go | 6 +++- coderd/rbac/roles_test.go | 19 ++++++++++ coderd/rbac/scopes_constants_gen.go | 3 ++ codersdk/apikey_scopes_gen.go | 1 + codersdk/rbacresources_gen.go | 2 +- docs/reference/api/schemas.md | 6 ++-- site/src/api/rbacresourcesGenerated.ts | 1 + site/src/api/typesGenerated.ts | 2 ++ 24 files changed, 136 insertions(+), 73 deletions(-) create mode 100644 coderd/database/migrations/000531_ai_gateway_key_update_scope.down.sql create mode 100644 coderd/database/migrations/000531_ai_gateway_key_update_scope.up.sql diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index a76cce2536bae..bc3c7d3235938 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -15490,6 +15490,7 @@ const docTemplate = `{ "ai_gateway_key:create", "ai_gateway_key:delete", "ai_gateway_key:read", + "ai_gateway_key:update", "ai_model_price:*", "ai_model_price:read", "ai_model_price:update", @@ -15724,6 +15725,7 @@ const docTemplate = `{ "APIKeyScopeAiGatewayKeyCreate", "APIKeyScopeAiGatewayKeyDelete", "APIKeyScopeAiGatewayKeyRead", + "APIKeyScopeAiGatewayKeyUpdate", "APIKeyScopeAiModelPriceAll", "APIKeyScopeAiModelPriceRead", "APIKeyScopeAiModelPriceUpdate", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 135e16c67baa7..38b3e2d94c6a4 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -13830,6 +13830,7 @@ "ai_gateway_key:create", "ai_gateway_key:delete", "ai_gateway_key:read", + "ai_gateway_key:update", "ai_model_price:*", "ai_model_price:read", "ai_model_price:update", @@ -14064,6 +14065,7 @@ "APIKeyScopeAiGatewayKeyCreate", "APIKeyScopeAiGatewayKeyDelete", "APIKeyScopeAiGatewayKeyRead", + "APIKeyScopeAiGatewayKeyUpdate", "APIKeyScopeAiModelPriceAll", "APIKeyScopeAiModelPriceRead", "APIKeyScopeAiModelPriceUpdate", diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index f537df28cc797..9c042ba16bac1 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -486,6 +486,7 @@ var ( rbac.ResourceOauth2AppSecret.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, rbac.ResourceChat.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, rbac.ResourceAIProvider.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, + rbac.ResourceAIGatewayKey.Type: {policy.ActionRead, policy.ActionUpdate}, }), User: []rbac.Permission{}, ByOrgID: map[string]rbac.OrgPermissions{}, @@ -2753,13 +2754,14 @@ func (q *querier) GetAIBridgeUserPromptsByInterceptionID(ctx context.Context, in return q.db.GetAIBridgeUserPromptsByInterceptionID(ctx, interceptionID) } -// Authenticates a standalone AI Gateway replica by its hashed key secret, returning the key ID used to record liveness. -func (q *querier) GetAIGatewayKeyIDByHashedSecret(ctx context.Context, hashedSecret []byte) (uuid.UUID, error) { - // Standalone AI Gateway has no Coder identity. Credential read is a system operation. - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { - return uuid.Nil, err +// Authenticates a standalone AI Gateway replica by its hashed key secret, returning the matched key. +func (q *querier) GetAIGatewayKeyByHashedSecret(ctx context.Context, hashedSecret []byte) (database.AIGatewayKey, error) { + // Standalone AI Gateway has no Coder identity, so this runs under the + // system actor reading the AI Gateway key it authenticates against. + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAIGatewayKey); err != nil { + return database.AIGatewayKey{}, err } - return q.db.GetAIGatewayKeyIDByHashedSecret(ctx, hashedSecret) + return q.db.GetAIGatewayKeyByHashedSecret(ctx, hashedSecret) } func (q *querier) GetAIModelPriceByProviderModel(ctx context.Context, arg database.GetAIModelPriceByProviderModelParams) (database.AIModelPrice, error) { @@ -7031,11 +7033,12 @@ func (q *querier) UpdateAIBridgeInterceptionEnded(ctx context.Context, params da return q.db.UpdateAIBridgeInterceptionEnded(ctx, params) } -// Records liveness for an active DRPC sessions between coderd and standalone AI Gateway. -func (q *querier) UpdateAIGatewayKeyLastUsedAt(ctx context.Context, id uuid.UUID) error { - // Standalone AI Gateway has no Coder identity. DRPC connection liveness update is a system operation. - if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil { - return err +// Records liveness for a key used in active DRPC session between coderd and standalone AI Gateway. +func (q *querier) UpdateAIGatewayKeyLastUsedAt(ctx context.Context, id uuid.UUID) (int64, error) { + // Standalone AI Gateway has no Coder identity, so this runs under the + // system actor recording connection liveness on the AI Gateway key. + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceAIGatewayKey); err != nil { + return 0, err } return q.db.UpdateAIGatewayKeyLastUsedAt(ctx, id) } diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 08f9e43f75068..903983486e942 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -6968,16 +6968,16 @@ func (s *MethodTestSuite) TestAIBridge() { dbm.EXPECT().DeleteAIGatewayKey(gomock.Any(), id).Return(database.DeleteAIGatewayKeyRow{}, nil).AnyTimes() check.Args(id).Asserts(rbac.ResourceAIGatewayKey, policy.ActionDelete).Returns(database.DeleteAIGatewayKeyRow{}) })) - s.Run("GetAIGatewayKeyIDByHashedSecret", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + s.Run("GetAIGatewayKeyByHashedSecret", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { hashedSecret := []byte("hashed-secret") - id := uuid.New() - dbm.EXPECT().GetAIGatewayKeyIDByHashedSecret(gomock.Any(), hashedSecret).Return(id, nil).AnyTimes() - check.Args(hashedSecret).Asserts(rbac.ResourceSystem, policy.ActionRead).Returns(id) + key := database.AIGatewayKey{ID: uuid.New(), HashedSecret: hashedSecret} + dbm.EXPECT().GetAIGatewayKeyByHashedSecret(gomock.Any(), hashedSecret).Return(key, nil).AnyTimes() + check.Args(hashedSecret).Asserts(rbac.ResourceAIGatewayKey, policy.ActionRead).Returns(key) })) s.Run("UpdateAIGatewayKeyLastUsedAt", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { id := uuid.New() - dbm.EXPECT().UpdateAIGatewayKeyLastUsedAt(gomock.Any(), id).Return(nil).AnyTimes() - check.Args(id).Asserts(rbac.ResourceSystem, policy.ActionUpdate).Returns() + dbm.EXPECT().UpdateAIGatewayKeyLastUsedAt(gomock.Any(), id).Return(int64(1), nil).AnyTimes() + check.Args(id).Asserts(rbac.ResourceAIGatewayKey, policy.ActionUpdate).Returns(int64(1)) })) } diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index dc4d351d27188..37825cd4a713e 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1130,11 +1130,11 @@ func (m queryMetricsStore) GetAIBridgeUserPromptsByInterceptionID(ctx context.Co return r0, r1 } -func (m queryMetricsStore) GetAIGatewayKeyIDByHashedSecret(ctx context.Context, hashedSecret []byte) (uuid.UUID, error) { +func (m queryMetricsStore) GetAIGatewayKeyByHashedSecret(ctx context.Context, hashedSecret []byte) (database.AIGatewayKey, error) { start := time.Now() - r0, r1 := m.s.GetAIGatewayKeyIDByHashedSecret(ctx, hashedSecret) - m.queryLatencies.WithLabelValues("GetAIGatewayKeyIDByHashedSecret").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetAIGatewayKeyIDByHashedSecret").Inc() + r0, r1 := m.s.GetAIGatewayKeyByHashedSecret(ctx, hashedSecret) + m.queryLatencies.WithLabelValues("GetAIGatewayKeyByHashedSecret").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetAIGatewayKeyByHashedSecret").Inc() return r0, r1 } @@ -5050,12 +5050,12 @@ func (m queryMetricsStore) UpdateAIBridgeInterceptionEnded(ctx context.Context, return r0, r1 } -func (m queryMetricsStore) UpdateAIGatewayKeyLastUsedAt(ctx context.Context, arg uuid.UUID) error { +func (m queryMetricsStore) UpdateAIGatewayKeyLastUsedAt(ctx context.Context, arg uuid.UUID) (int64, error) { start := time.Now() - r0 := m.s.UpdateAIGatewayKeyLastUsedAt(ctx, arg) + r0, r1 := m.s.UpdateAIGatewayKeyLastUsedAt(ctx, arg) m.queryLatencies.WithLabelValues("UpdateAIGatewayKeyLastUsedAt").Observe(time.Since(start).Seconds()) m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateAIGatewayKeyLastUsedAt").Inc() - return r0 + return r0, r1 } func (m queryMetricsStore) UpdateAIProvider(ctx context.Context, arg database.UpdateAIProviderParams) (database.AIProvider, error) { diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 8377fad8289f3..5bee53da34e05 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1947,19 +1947,19 @@ func (mr *MockStoreMockRecorder) GetAIBridgeUserPromptsByInterceptionID(ctx, int return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAIBridgeUserPromptsByInterceptionID", reflect.TypeOf((*MockStore)(nil).GetAIBridgeUserPromptsByInterceptionID), ctx, interceptionID) } -// GetAIGatewayKeyIDByHashedSecret mocks base method. -func (m *MockStore) GetAIGatewayKeyIDByHashedSecret(ctx context.Context, hashedSecret []byte) (uuid.UUID, error) { +// GetAIGatewayKeyByHashedSecret mocks base method. +func (m *MockStore) GetAIGatewayKeyByHashedSecret(ctx context.Context, hashedSecret []byte) (database.AIGatewayKey, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAIGatewayKeyIDByHashedSecret", ctx, hashedSecret) - ret0, _ := ret[0].(uuid.UUID) + ret := m.ctrl.Call(m, "GetAIGatewayKeyByHashedSecret", ctx, hashedSecret) + ret0, _ := ret[0].(database.AIGatewayKey) ret1, _ := ret[1].(error) return ret0, ret1 } -// GetAIGatewayKeyIDByHashedSecret indicates an expected call of GetAIGatewayKeyIDByHashedSecret. -func (mr *MockStoreMockRecorder) GetAIGatewayKeyIDByHashedSecret(ctx, hashedSecret any) *gomock.Call { +// GetAIGatewayKeyByHashedSecret indicates an expected call of GetAIGatewayKeyByHashedSecret. +func (mr *MockStoreMockRecorder) GetAIGatewayKeyByHashedSecret(ctx, hashedSecret any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAIGatewayKeyIDByHashedSecret", reflect.TypeOf((*MockStore)(nil).GetAIGatewayKeyIDByHashedSecret), ctx, hashedSecret) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAIGatewayKeyByHashedSecret", reflect.TypeOf((*MockStore)(nil).GetAIGatewayKeyByHashedSecret), ctx, hashedSecret) } // GetAIModelPriceByProviderModel mocks base method. @@ -9519,11 +9519,12 @@ func (mr *MockStoreMockRecorder) UpdateAIBridgeInterceptionEnded(ctx, arg any) * } // UpdateAIGatewayKeyLastUsedAt mocks base method. -func (m *MockStore) UpdateAIGatewayKeyLastUsedAt(ctx context.Context, id uuid.UUID) error { +func (m *MockStore) UpdateAIGatewayKeyLastUsedAt(ctx context.Context, id uuid.UUID) (int64, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateAIGatewayKeyLastUsedAt", ctx, id) - ret0, _ := ret[0].(error) - return ret0 + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 } // UpdateAIGatewayKeyLastUsedAt indicates an expected call of UpdateAIGatewayKeyLastUsedAt. diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 7bb6c2c97266a..ba0fc82fa7148 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -257,7 +257,8 @@ CREATE TYPE api_key_scope AS ENUM ( 'ai_gateway_key:*', 'ai_gateway_key:create', 'ai_gateway_key:delete', - 'ai_gateway_key:read' + 'ai_gateway_key:read', + 'ai_gateway_key:update' ); CREATE TYPE app_sharing_level AS ENUM ( diff --git a/coderd/database/migrations/000531_ai_gateway_key_update_scope.down.sql b/coderd/database/migrations/000531_ai_gateway_key_update_scope.down.sql new file mode 100644 index 0000000000000..04f101ceb4e84 --- /dev/null +++ b/coderd/database/migrations/000531_ai_gateway_key_update_scope.down.sql @@ -0,0 +1,2 @@ +-- Enum additions to api_key_scope are intentionally not reverted because +-- Postgres cannot drop enum values safely. diff --git a/coderd/database/migrations/000531_ai_gateway_key_update_scope.up.sql b/coderd/database/migrations/000531_ai_gateway_key_update_scope.up.sql new file mode 100644 index 0000000000000..d196bef408e38 --- /dev/null +++ b/coderd/database/migrations/000531_ai_gateway_key_update_scope.up.sql @@ -0,0 +1 @@ +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'ai_gateway_key:update'; diff --git a/coderd/database/models.go b/coderd/database/models.go index 5a5e2cbe67ce7..ff2df8eef0cdf 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -386,6 +386,7 @@ const ( ApiKeyScopeAIGatewayKeyCreate APIKeyScope = "ai_gateway_key:create" ApiKeyScopeAIGatewayKeyDelete APIKeyScope = "ai_gateway_key:delete" ApiKeyScopeAIGatewayKeyRead APIKeyScope = "ai_gateway_key:read" + ApiKeyScopeAIGatewayKeyUpdate APIKeyScope = "ai_gateway_key:update" ) func (e *APIKeyScope) Scan(src interface{}) error { @@ -654,7 +655,8 @@ func (e APIKeyScope) Valid() bool { ApiKeyScopeAIGatewayKey, ApiKeyScopeAIGatewayKeyCreate, ApiKeyScopeAIGatewayKeyDelete, - ApiKeyScopeAIGatewayKeyRead: + ApiKeyScopeAIGatewayKeyRead, + ApiKeyScopeAIGatewayKeyUpdate: return true } return false @@ -892,6 +894,7 @@ func AllAPIKeyScopeValues() []APIKeyScope { ApiKeyScopeAIGatewayKeyCreate, ApiKeyScopeAIGatewayKeyDelete, ApiKeyScopeAIGatewayKeyRead, + ApiKeyScopeAIGatewayKeyUpdate, } } diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 7519438474316..0cbd03e639184 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -302,9 +302,9 @@ type sqlcQuerier interface { GetAIBridgeToolUsagesByInterceptionID(ctx context.Context, interceptionID uuid.UUID) ([]AIBridgeToolUsage, error) GetAIBridgeUserPromptsByInterceptionID(ctx context.Context, interceptionID uuid.UUID) ([]AIBridgeUserPrompt, error) // Authenticates a standalone AI Gateway replica by its hashed key secret, - // returning the key ID used to record liveness. The lookup is an exact match - // on a unique index, so a returned row is itself proof the secret is valid. - GetAIGatewayKeyIDByHashedSecret(ctx context.Context, hashedSecret []byte) (uuid.UUID, error) + // returning the matched key. The lookup is an exact match on a unique index, + // so a returned row is itself proof the secret is valid. + GetAIGatewayKeyByHashedSecret(ctx context.Context, hashedSecret []byte) (AIGatewayKey, error) GetAIModelPriceByProviderModel(ctx context.Context, arg GetAIModelPriceByProviderModelParams) (AIModelPrice, error) GetAIProviderByID(ctx context.Context, id uuid.UUID) (AIProvider, error) // Lock the provider row until the model-config write completes. The @@ -1314,7 +1314,7 @@ type sqlcQuerier interface { // Records liveness for an active Gateway DRPC session. The database sets the // timestamp so it stays consistent regardless of clock drift between API // replicas. - UpdateAIGatewayKeyLastUsedAt(ctx context.Context, id uuid.UUID) error + UpdateAIGatewayKeyLastUsedAt(ctx context.Context, id uuid.UUID) (int64, error) UpdateAIProvider(ctx context.Context, arg UpdateAIProviderParams) (AIProvider, error) UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error UpdateChatACLByID(ctx context.Context, arg UpdateChatACLByIDParams) error diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index c9362454a8e23..e3799ad592652 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -14958,7 +14958,7 @@ func TestAIGatewayKeysQueries(t *testing.T) { requireAIGatewayKeysRow(t, keys[0], second, secondRow.CreatedAt) } -func TestGetAIGatewayKeyIDByHashedSecret(t *testing.T) { +func TestGetAIGatewayKeyByHashedSecret(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) @@ -14972,18 +14972,21 @@ func TestGetAIGatewayKeyIDByHashedSecret(t *testing.T) { _, err = db.InsertAIGatewayKey(ctx, second) require.NoError(t, err) - id, err := db.GetAIGatewayKeyIDByHashedSecret(ctx, first.HashedSecret) + key, err := db.GetAIGatewayKeyByHashedSecret(ctx, first.HashedSecret) require.NoError(t, err) - require.Equal(t, first.ID, id) + require.Equal(t, first.ID, key.ID) + require.Equal(t, first.Name, key.Name) + require.Equal(t, first.SecretPrefix, key.SecretPrefix) + require.Equal(t, first.HashedSecret, key.HashedSecret) - id, err = db.GetAIGatewayKeyIDByHashedSecret(ctx, second.HashedSecret) + key, err = db.GetAIGatewayKeyByHashedSecret(ctx, second.HashedSecret) require.NoError(t, err) - require.Equal(t, second.ID, id) + require.Equal(t, second.ID, key.ID) // An unknown secret returns no rows - id, err = db.GetAIGatewayKeyIDByHashedSecret(ctx, []byte("does-not-exist")) + key, err = db.GetAIGatewayKeyByHashedSecret(ctx, []byte("does-not-exist")) require.ErrorIs(t, err, sql.ErrNoRows) - require.Empty(t, id) + require.Empty(t, key.ID) } func TestUpdateAIGatewayKeyLastUsedAt(t *testing.T) { @@ -15002,8 +15005,9 @@ func TestUpdateAIGatewayKeyLastUsedAt(t *testing.T) { require.Len(t, keys, 1) require.False(t, keys[0].LastUsedAt.Valid) - err = db.UpdateAIGatewayKeyLastUsedAt(ctx, params.ID) + rows, err := db.UpdateAIGatewayKeyLastUsedAt(ctx, params.ID) require.NoError(t, err) + require.EqualValues(t, 1, rows) keys, err = db.ListAIGatewayKeys(ctx) require.NoError(t, err) @@ -15014,16 +15018,18 @@ func TestUpdateAIGatewayKeyLastUsedAt(t *testing.T) { require.False(t, keys[0].LastUsedAt.Time.Before(row.CreatedAt)) // Updating a key that does not exist is a no-op, not an error. - err = db.UpdateAIGatewayKeyLastUsedAt(ctx, uuid.New()) + rows, err = db.UpdateAIGatewayKeyLastUsedAt(ctx, uuid.New()) require.NoError(t, err) + require.EqualValues(t, 0, rows) // Set last_used_at to old time to confirm the update overwrites it with a fresh timestamp. staleTime := row.CreatedAt.Add(-time.Hour) _, err = sqlDB.ExecContext(ctx, "UPDATE ai_gateway_keys SET last_used_at = $1 WHERE id = $2", staleTime, params.ID) require.NoError(t, err) - err = db.UpdateAIGatewayKeyLastUsedAt(ctx, params.ID) + rows, err = db.UpdateAIGatewayKeyLastUsedAt(ctx, params.ID) require.NoError(t, err) + require.EqualValues(t, 1, rows) keys, err = db.ListAIGatewayKeys(ctx) require.NoError(t, err) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 6514153aa0ae0..860750cd4d062 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -137,20 +137,27 @@ func (q *sqlQuerier) DeleteAIGatewayKey(ctx context.Context, id uuid.UUID) (Dele return i, err } -const getAIGatewayKeyIDByHashedSecret = `-- name: GetAIGatewayKeyIDByHashedSecret :one -SELECT id +const getAIGatewayKeyByHashedSecret = `-- name: GetAIGatewayKeyByHashedSecret :one +SELECT id, created_at, name, secret_prefix, hashed_secret, last_used_at FROM ai_gateway_keys WHERE hashed_secret = $1 ` // Authenticates a standalone AI Gateway replica by its hashed key secret, -// returning the key ID used to record liveness. The lookup is an exact match -// on a unique index, so a returned row is itself proof the secret is valid. -func (q *sqlQuerier) GetAIGatewayKeyIDByHashedSecret(ctx context.Context, hashedSecret []byte) (uuid.UUID, error) { - row := q.db.QueryRowContext(ctx, getAIGatewayKeyIDByHashedSecret, hashedSecret) - var id uuid.UUID - err := row.Scan(&id) - return id, err +// returning the matched key. The lookup is an exact match on a unique index, +// so a returned row is itself proof the secret is valid. +func (q *sqlQuerier) GetAIGatewayKeyByHashedSecret(ctx context.Context, hashedSecret []byte) (AIGatewayKey, error) { + row := q.db.QueryRowContext(ctx, getAIGatewayKeyByHashedSecret, hashedSecret) + var i AIGatewayKey + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.Name, + &i.SecretPrefix, + &i.HashedSecret, + &i.LastUsedAt, + ) + return i, err } const insertAIGatewayKey = `-- name: InsertAIGatewayKey :one @@ -233,7 +240,7 @@ func (q *sqlQuerier) ListAIGatewayKeys(ctx context.Context) ([]ListAIGatewayKeys return items, nil } -const updateAIGatewayKeyLastUsedAt = `-- name: UpdateAIGatewayKeyLastUsedAt :exec +const updateAIGatewayKeyLastUsedAt = `-- name: UpdateAIGatewayKeyLastUsedAt :execrows UPDATE ai_gateway_keys SET last_used_at = NOW() WHERE id = $1 @@ -242,9 +249,12 @@ WHERE id = $1 // Records liveness for an active Gateway DRPC session. The database sets the // timestamp so it stays consistent regardless of clock drift between API // replicas. -func (q *sqlQuerier) UpdateAIGatewayKeyLastUsedAt(ctx context.Context, id uuid.UUID) error { - _, err := q.db.ExecContext(ctx, updateAIGatewayKeyLastUsedAt, id) - return err +func (q *sqlQuerier) UpdateAIGatewayKeyLastUsedAt(ctx context.Context, id uuid.UUID) (int64, error) { + result, err := q.db.ExecContext(ctx, updateAIGatewayKeyLastUsedAt, id) + if err != nil { + return 0, err + } + return result.RowsAffected() } const deleteAIProviderKey = `-- name: DeleteAIProviderKey :exec diff --git a/coderd/database/queries/ai_gateway_keys.sql b/coderd/database/queries/ai_gateway_keys.sql index 1e94bed7c8a66..ba74d97e7877b 100644 --- a/coderd/database/queries/ai_gateway_keys.sql +++ b/coderd/database/queries/ai_gateway_keys.sql @@ -12,15 +12,15 @@ ORDER BY created_at ASC; DELETE FROM ai_gateway_keys WHERE id = $1 RETURNING id, name, secret_prefix, created_at, last_used_at; --- name: GetAIGatewayKeyIDByHashedSecret :one +-- name: GetAIGatewayKeyByHashedSecret :one -- Authenticates a standalone AI Gateway replica by its hashed key secret, --- returning the key ID used to record liveness. The lookup is an exact match --- on a unique index, so a returned row is itself proof the secret is valid. -SELECT id +-- returning the matched key. The lookup is an exact match on a unique index, +-- so a returned row is itself proof the secret is valid. +SELECT * FROM ai_gateway_keys WHERE hashed_secret = $1; --- name: UpdateAIGatewayKeyLastUsedAt :exec +-- name: UpdateAIGatewayKeyLastUsedAt :execrows -- Records liveness for an active Gateway DRPC session. The database sets the -- timestamp so it stays consistent regardless of clock drift between API -- replicas. diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index 5ff60562b147b..a18b02405bb60 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -20,6 +20,7 @@ var ( // - "ActionCreate" :: create an AI Gateway key // - "ActionDelete" :: delete an AI Gateway key // - "ActionRead" :: read AI Gateway keys + // - "ActionUpdate" :: update an AI Gateway key ResourceAIGatewayKey = Object{ Type: "ai_gateway_key", } diff --git a/coderd/rbac/policy/policy.go b/coderd/rbac/policy/policy.go index f97b2a78bc2e1..035a0a34754e3 100644 --- a/coderd/rbac/policy/policy.go +++ b/coderd/rbac/policy/policy.go @@ -434,6 +434,7 @@ var RBACPermissions = map[string]PermissionDefinition{ Actions: map[Action]ActionDefinition{ ActionCreate: "create an AI Gateway key", ActionRead: "read AI Gateway keys", + ActionUpdate: "update an AI Gateway key", ActionDelete: "delete an AI Gateway key", }, }, diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 4404c071f2dd3..3dbaa162dcac9 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -409,12 +409,16 @@ func ReloadBuiltinRoles(opts *RoleOptions) { // Workspace is specifically handled based on the opts.NoOwnerWorkspaceExec. // Owners can inspect and delete personal skills for operability and // abuse handling, but cannot create or edit user-authored instructions. - allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUserSecret, ResourceUserSkill, ResourceUsageEvent, ResourceBoundaryUsage, ResourceBoundaryLog, ResourceAiSeat), + allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUserSecret, ResourceUserSkill, ResourceUsageEvent, ResourceBoundaryUsage, ResourceBoundaryLog, ResourceAiSeat, ResourceAIGatewayKey), // This adds back in the Workspace permissions. Permissions(map[string][]policy.Action{ ResourceWorkspace.Type: ownerWorkspaceActions, ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionDelete, policy.ActionCreate, policy.ActionUpdate, policy.ActionWorkspaceStop, policy.ActionCreateAgent, policy.ActionDeleteAgent, policy.ActionUpdateAgent}, ResourceUserSkill.Type: {policy.ActionRead, policy.ActionDelete}, + // Owners manage AI Gateway keys but cannot update them. The + // update action records last-used liveness and is reserved + // for the system actor authenticating Gateway replicas. + ResourceAIGatewayKey.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionDelete}, // PrebuiltWorkspaces are a subset of Workspaces. // Explicitly setting PrebuiltWorkspace permissions for clarity. // Note: even without PrebuiltWorkspace permissions, access is still granted via Workspace permissions. diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 9b0054d97bba7..341a89cf9751a 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -1311,6 +1311,25 @@ func TestRolePermissions(t *testing.T) { }, }, }, + { + // Updating an AI Gateway key records last-used liveness when a + // Gateway replica authenticates. It is reserved for the system + // actor, so no user-facing role, including owner, is authorized. + Name: "AIGatewayKeyUpdate", + Actions: []policy.Action{policy.ActionUpdate}, + Resource: rbac.ResourceAIGatewayKey, + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {}, + false: { + owner, + orgWorkspaceAccessUser, memberMe, agentsAccessUser, + orgAdmin, otherOrgAdmin, + orgAuditor, otherOrgAuditor, + templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, + userAdmin, orgUserAdmin, otherOrgUserAdmin, + }, + }, + }, { Name: "BoundaryUsage", Actions: []policy.Action{policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, diff --git a/coderd/rbac/scopes_constants_gen.go b/coderd/rbac/scopes_constants_gen.go index 3adad84a59050..e58519bddff8e 100644 --- a/coderd/rbac/scopes_constants_gen.go +++ b/coderd/rbac/scopes_constants_gen.go @@ -10,6 +10,7 @@ const ( ScopeAiGatewayKeyCreate ScopeName = "ai_gateway_key:create" ScopeAiGatewayKeyDelete ScopeName = "ai_gateway_key:delete" ScopeAiGatewayKeyRead ScopeName = "ai_gateway_key:read" + ScopeAiGatewayKeyUpdate ScopeName = "ai_gateway_key:update" ScopeAiModelPriceRead ScopeName = "ai_model_price:read" ScopeAiModelPriceUpdate ScopeName = "ai_model_price:update" ScopeAiProviderCreate ScopeName = "ai_provider:create" @@ -193,6 +194,7 @@ func (e ScopeName) Valid() bool { ScopeAiGatewayKeyCreate, ScopeAiGatewayKeyDelete, ScopeAiGatewayKeyRead, + ScopeAiGatewayKeyUpdate, ScopeAiModelPriceRead, ScopeAiModelPriceUpdate, ScopeAiProviderCreate, @@ -377,6 +379,7 @@ func AllScopeNameValues() []ScopeName { ScopeAiGatewayKeyCreate, ScopeAiGatewayKeyDelete, ScopeAiGatewayKeyRead, + ScopeAiGatewayKeyUpdate, ScopeAiModelPriceRead, ScopeAiModelPriceUpdate, ScopeAiProviderCreate, diff --git a/codersdk/apikey_scopes_gen.go b/codersdk/apikey_scopes_gen.go index f22712981624d..5471823e7b364 100644 --- a/codersdk/apikey_scopes_gen.go +++ b/codersdk/apikey_scopes_gen.go @@ -10,6 +10,7 @@ const ( APIKeyScopeAiGatewayKeyCreate APIKeyScope = "ai_gateway_key:create" APIKeyScopeAiGatewayKeyDelete APIKeyScope = "ai_gateway_key:delete" APIKeyScopeAiGatewayKeyRead APIKeyScope = "ai_gateway_key:read" + APIKeyScopeAiGatewayKeyUpdate APIKeyScope = "ai_gateway_key:update" APIKeyScopeAiModelPriceAll APIKeyScope = "ai_model_price:*" APIKeyScopeAiModelPriceRead APIKeyScope = "ai_model_price:read" APIKeyScopeAiModelPriceUpdate APIKeyScope = "ai_model_price:update" diff --git a/codersdk/rbacresources_gen.go b/codersdk/rbacresources_gen.go index 622c59c54bf40..bc71930ef3a90 100644 --- a/codersdk/rbacresources_gen.go +++ b/codersdk/rbacresources_gen.go @@ -83,7 +83,7 @@ const ( // said resource type. var RBACResourceActions = map[RBACResource][]RBACAction{ ResourceWildcard: {}, - ResourceAIGatewayKey: {ActionCreate, ActionDelete, ActionRead}, + ResourceAIGatewayKey: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceAiModelPrice: {ActionRead, ActionUpdate}, ResourceAIProvider: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceAiSeat: {ActionCreate, ActionRead}, diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 879f5afadfb96..382d185a28301 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1201,9 +1201,9 @@ None #### Enumerated Values -| Value(s) | -|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `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`, `ai_provider:*`, `ai_provider:create`, `ai_provider:delete`, `ai_provider:read`, `ai_provider:update`, `ai_seat:*`, `ai_seat:create`, `ai_seat:read`, `aibridge_interception:*`, `aibridge_interception:create`, `aibridge_interception:read`, `aibridge_interception:update`, `all`, `api_key:*`, `api_key:create`, `api_key:delete`, `api_key:read`, `api_key:update`, `application_connect`, `assign_org_role:*`, `assign_org_role:assign`, `assign_org_role:create`, `assign_org_role:delete`, `assign_org_role:read`, `assign_org_role:unassign`, `assign_org_role:update`, `assign_role:*`, `assign_role:assign`, `assign_role:read`, `assign_role:unassign`, `audit_log:*`, `audit_log:create`, `audit_log:read`, `boundary_log:*`, `boundary_log:create`, `boundary_log:delete`, `boundary_log:read`, `boundary_usage:*`, `boundary_usage:delete`, `boundary_usage:read`, `boundary_usage:update`, `chat:*`, `chat:create`, `chat:delete`, `chat:read`, `chat:share`, `chat:update`, `coder:all`, `coder:apikeys.manage_self`, `coder:application_connect`, `coder:templates.author`, `coder:templates.build`, `coder:workspaces.access`, `coder:workspaces.create`, `coder:workspaces.delete`, `coder:workspaces.operate`, `connection_log:*`, `connection_log:read`, `connection_log:update`, `crypto_key:*`, `crypto_key:create`, `crypto_key:delete`, `crypto_key:read`, `crypto_key:update`, `debug_info:*`, `debug_info:read`, `deployment_config:*`, `deployment_config:read`, `deployment_config:update`, `deployment_stats:*`, `deployment_stats:read`, `file:*`, `file:create`, `file:read`, `group:*`, `group:create`, `group:delete`, `group:read`, `group:update`, `group_member:*`, `group_member:read`, `idpsync_settings:*`, `idpsync_settings:read`, `idpsync_settings:update`, `inbox_notification:*`, `inbox_notification:create`, `inbox_notification:read`, `inbox_notification:update`, `license:*`, `license:create`, `license:delete`, `license:read`, `notification_message:*`, `notification_message:create`, `notification_message:delete`, `notification_message:read`, `notification_message:update`, `notification_preference:*`, `notification_preference:read`, `notification_preference:update`, `notification_template:*`, `notification_template:read`, `notification_template:update`, `oauth2_app:*`, `oauth2_app:create`, `oauth2_app:delete`, `oauth2_app:read`, `oauth2_app:update`, `oauth2_app_code_token:*`, `oauth2_app_code_token:create`, `oauth2_app_code_token:delete`, `oauth2_app_code_token:read`, `oauth2_app_secret:*`, `oauth2_app_secret:create`, `oauth2_app_secret:delete`, `oauth2_app_secret:read`, `oauth2_app_secret:update`, `organization:*`, `organization:create`, `organization:delete`, `organization:read`, `organization:update`, `organization_member:*`, `organization_member:create`, `organization_member:delete`, `organization_member:read`, `organization_member:update`, `prebuilt_workspace:*`, `prebuilt_workspace:delete`, `prebuilt_workspace:update`, `provisioner_daemon:*`, `provisioner_daemon:create`, `provisioner_daemon:delete`, `provisioner_daemon:read`, `provisioner_daemon:update`, `provisioner_jobs:*`, `provisioner_jobs:create`, `provisioner_jobs:read`, `provisioner_jobs:update`, `replicas:*`, `replicas:read`, `system:*`, `system:create`, `system:delete`, `system:read`, `system:update`, `tailnet_coordinator:*`, `tailnet_coordinator:create`, `tailnet_coordinator:delete`, `tailnet_coordinator:read`, `tailnet_coordinator:update`, `task:*`, `task:create`, `task:delete`, `task:read`, `task:update`, `template:*`, `template:create`, `template:delete`, `template:read`, `template:update`, `template:use`, `template:view_insights`, `usage_event:*`, `usage_event:create`, `usage_event:read`, `usage_event:update`, `user:*`, `user:create`, `user:delete`, `user:read`, `user:read_personal`, `user:update`, `user:update_personal`, `user_secret:*`, `user_secret:create`, `user_secret:delete`, `user_secret:read`, `user_secret:update`, `user_skill:*`, `user_skill:create`, `user_skill:delete`, `user_skill:read`, `user_skill:update`, `webpush_subscription:*`, `webpush_subscription:create`, `webpush_subscription:delete`, `webpush_subscription:read`, `workspace:*`, `workspace:application_connect`, `workspace:create`, `workspace:create_agent`, `workspace:delete`, `workspace:delete_agent`, `workspace:read`, `workspace:share`, `workspace:ssh`, `workspace:start`, `workspace:stop`, `workspace:update`, `workspace:update_agent`, `workspace_agent_devcontainers:*`, `workspace_agent_devcontainers:create`, `workspace_agent_resource_monitor:*`, `workspace_agent_resource_monitor:create`, `workspace_agent_resource_monitor:read`, `workspace_agent_resource_monitor:update`, `workspace_dormant:*`, `workspace_dormant:application_connect`, `workspace_dormant:create`, `workspace_dormant:create_agent`, `workspace_dormant:delete`, `workspace_dormant:delete_agent`, `workspace_dormant:read`, `workspace_dormant:share`, `workspace_dormant:ssh`, `workspace_dormant:start`, `workspace_dormant:stop`, `workspace_dormant:update`, `workspace_dormant:update_agent`, `workspace_proxy:*`, `workspace_proxy:create`, `workspace_proxy:delete`, `workspace_proxy:read`, `workspace_proxy:update` | +| Value(s) | +|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ai_gateway_key:*`, `ai_gateway_key:create`, `ai_gateway_key:delete`, `ai_gateway_key:read`, `ai_gateway_key:update`, `ai_model_price:*`, `ai_model_price:read`, `ai_model_price:update`, `ai_provider:*`, `ai_provider:create`, `ai_provider:delete`, `ai_provider:read`, `ai_provider:update`, `ai_seat:*`, `ai_seat:create`, `ai_seat:read`, `aibridge_interception:*`, `aibridge_interception:create`, `aibridge_interception:read`, `aibridge_interception:update`, `all`, `api_key:*`, `api_key:create`, `api_key:delete`, `api_key:read`, `api_key:update`, `application_connect`, `assign_org_role:*`, `assign_org_role:assign`, `assign_org_role:create`, `assign_org_role:delete`, `assign_org_role:read`, `assign_org_role:unassign`, `assign_org_role:update`, `assign_role:*`, `assign_role:assign`, `assign_role:read`, `assign_role:unassign`, `audit_log:*`, `audit_log:create`, `audit_log:read`, `boundary_log:*`, `boundary_log:create`, `boundary_log:delete`, `boundary_log:read`, `boundary_usage:*`, `boundary_usage:delete`, `boundary_usage:read`, `boundary_usage:update`, `chat:*`, `chat:create`, `chat:delete`, `chat:read`, `chat:share`, `chat:update`, `coder:all`, `coder:apikeys.manage_self`, `coder:application_connect`, `coder:templates.author`, `coder:templates.build`, `coder:workspaces.access`, `coder:workspaces.create`, `coder:workspaces.delete`, `coder:workspaces.operate`, `connection_log:*`, `connection_log:read`, `connection_log:update`, `crypto_key:*`, `crypto_key:create`, `crypto_key:delete`, `crypto_key:read`, `crypto_key:update`, `debug_info:*`, `debug_info:read`, `deployment_config:*`, `deployment_config:read`, `deployment_config:update`, `deployment_stats:*`, `deployment_stats:read`, `file:*`, `file:create`, `file:read`, `group:*`, `group:create`, `group:delete`, `group:read`, `group:update`, `group_member:*`, `group_member:read`, `idpsync_settings:*`, `idpsync_settings:read`, `idpsync_settings:update`, `inbox_notification:*`, `inbox_notification:create`, `inbox_notification:read`, `inbox_notification:update`, `license:*`, `license:create`, `license:delete`, `license:read`, `notification_message:*`, `notification_message:create`, `notification_message:delete`, `notification_message:read`, `notification_message:update`, `notification_preference:*`, `notification_preference:read`, `notification_preference:update`, `notification_template:*`, `notification_template:read`, `notification_template:update`, `oauth2_app:*`, `oauth2_app:create`, `oauth2_app:delete`, `oauth2_app:read`, `oauth2_app:update`, `oauth2_app_code_token:*`, `oauth2_app_code_token:create`, `oauth2_app_code_token:delete`, `oauth2_app_code_token:read`, `oauth2_app_secret:*`, `oauth2_app_secret:create`, `oauth2_app_secret:delete`, `oauth2_app_secret:read`, `oauth2_app_secret:update`, `organization:*`, `organization:create`, `organization:delete`, `organization:read`, `organization:update`, `organization_member:*`, `organization_member:create`, `organization_member:delete`, `organization_member:read`, `organization_member:update`, `prebuilt_workspace:*`, `prebuilt_workspace:delete`, `prebuilt_workspace:update`, `provisioner_daemon:*`, `provisioner_daemon:create`, `provisioner_daemon:delete`, `provisioner_daemon:read`, `provisioner_daemon:update`, `provisioner_jobs:*`, `provisioner_jobs:create`, `provisioner_jobs:read`, `provisioner_jobs:update`, `replicas:*`, `replicas:read`, `system:*`, `system:create`, `system:delete`, `system:read`, `system:update`, `tailnet_coordinator:*`, `tailnet_coordinator:create`, `tailnet_coordinator:delete`, `tailnet_coordinator:read`, `tailnet_coordinator:update`, `task:*`, `task:create`, `task:delete`, `task:read`, `task:update`, `template:*`, `template:create`, `template:delete`, `template:read`, `template:update`, `template:use`, `template:view_insights`, `usage_event:*`, `usage_event:create`, `usage_event:read`, `usage_event:update`, `user:*`, `user:create`, `user:delete`, `user:read`, `user:read_personal`, `user:update`, `user:update_personal`, `user_secret:*`, `user_secret:create`, `user_secret:delete`, `user_secret:read`, `user_secret:update`, `user_skill:*`, `user_skill:create`, `user_skill:delete`, `user_skill:read`, `user_skill:update`, `webpush_subscription:*`, `webpush_subscription:create`, `webpush_subscription:delete`, `webpush_subscription:read`, `workspace:*`, `workspace:application_connect`, `workspace:create`, `workspace:create_agent`, `workspace:delete`, `workspace:delete_agent`, `workspace:read`, `workspace:share`, `workspace:ssh`, `workspace:start`, `workspace:stop`, `workspace:update`, `workspace:update_agent`, `workspace_agent_devcontainers:*`, `workspace_agent_devcontainers:create`, `workspace_agent_resource_monitor:*`, `workspace_agent_resource_monitor:create`, `workspace_agent_resource_monitor:read`, `workspace_agent_resource_monitor:update`, `workspace_dormant:*`, `workspace_dormant:application_connect`, `workspace_dormant:create`, `workspace_dormant:create_agent`, `workspace_dormant:delete`, `workspace_dormant:delete_agent`, `workspace_dormant:read`, `workspace_dormant:share`, `workspace_dormant:ssh`, `workspace_dormant:start`, `workspace_dormant:stop`, `workspace_dormant:update`, `workspace_dormant:update_agent`, `workspace_proxy:*`, `workspace_proxy:create`, `workspace_proxy:delete`, `workspace_proxy:read`, `workspace_proxy:update` | ## codersdk.AddLicenseRequest diff --git a/site/src/api/rbacresourcesGenerated.ts b/site/src/api/rbacresourcesGenerated.ts index 15fd4a0f43a17..261abb90bda7b 100644 --- a/site/src/api/rbacresourcesGenerated.ts +++ b/site/src/api/rbacresourcesGenerated.ts @@ -12,6 +12,7 @@ export const RBACResourceActions: Partial< create: "create an AI Gateway key", delete: "delete an AI Gateway key", read: "read AI Gateway keys", + update: "update an AI Gateway key", }, ai_model_price: { read: "read AI model prices", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 0f529fe491e66..aaf8c207d676f 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -491,6 +491,7 @@ export type APIKeyScope = | "ai_gateway_key:create" | "ai_gateway_key:delete" | "ai_gateway_key:read" + | "ai_gateway_key:update" | "ai_model_price:*" | "ai_model_price:read" | "ai_model_price:update" @@ -725,6 +726,7 @@ export const APIKeyScopes: APIKeyScope[] = [ "ai_gateway_key:create", "ai_gateway_key:delete", "ai_gateway_key:read", + "ai_gateway_key:update", "ai_model_price:*", "ai_model_price:read", "ai_model_price:update",