diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index a76cce2536b..bc3c7d32359 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 135e16c67ba..38b3e2d94c6 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 51684b42ac5..9c042ba16ba 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,6 +2754,16 @@ 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 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.GetAIGatewayKeyByHashedSecret(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 +7033,16 @@ func (q *querier) UpdateAIBridgeInterceptionEnded(ctx context.Context, params da return q.db.UpdateAIBridgeInterceptionEnded(ctx, params) } +// 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) +} + 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 0754b995963..903983486e9 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("GetAIGatewayKeyByHashedSecret", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + hashedSecret := []byte("hashed-secret") + 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(int64(1), nil).AnyTimes() + check.Args(id).Asserts(rbac.ResourceAIGatewayKey, policy.ActionUpdate).Returns(int64(1)) + })) } func (s *MethodTestSuite) TestTelemetry() { diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 5f5761a97e6..37825cd4a71 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) GetAIGatewayKeyByHashedSecret(ctx context.Context, hashedSecret []byte) (database.AIGatewayKey, error) { + start := time.Now() + 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 +} + 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) (int64, error) { + start := time.Now() + 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, r1 +} + 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 87ab8d05f2b..5bee53da34e 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) } +// 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, "GetAIGatewayKeyByHashedSecret", ctx, hashedSecret) + ret0, _ := ret[0].(database.AIGatewayKey) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// 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, "GetAIGatewayKeyByHashedSecret", reflect.TypeOf((*MockStore)(nil).GetAIGatewayKeyByHashedSecret), 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,21 @@ 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) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateAIGatewayKeyLastUsedAt", ctx, id) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// 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/dump.sql b/coderd/database/dump.sql index 7bb6c2c9726..ba0fc82fa71 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 00000000000..04f101ceb4e --- /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 00000000000..d196bef408e --- /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 5a5e2cbe67c..ff2df8eef0c 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 fe56f6e4f17..0cbd03e6391 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 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 @@ -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) (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 5d8d4a600e1..e3799ad5926 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -14958,6 +14958,85 @@ func TestAIGatewayKeysQueries(t *testing.T) { requireAIGatewayKeysRow(t, keys[0], second, secondRow.CreatedAt) } +func TestGetAIGatewayKeyByHashedSecret(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) + + key, err := db.GetAIGatewayKeyByHashedSecret(ctx, first.HashedSecret) + require.NoError(t, err) + 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) + + key, err = db.GetAIGatewayKeyByHashedSecret(ctx, second.HashedSecret) + require.NoError(t, err) + require.Equal(t, second.ID, key.ID) + + // An unknown secret returns no rows + key, err = db.GetAIGatewayKeyByHashedSecret(ctx, []byte("does-not-exist")) + require.ErrorIs(t, err, sql.ErrNoRows) + require.Empty(t, key.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) + + 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) + 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. + 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) + + 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) + 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 176998b4c94..860750cd4d0 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -137,6 +137,29 @@ func (q *sqlQuerier) DeleteAIGatewayKey(ctx context.Context, id uuid.UUID) (Dele return i, err } +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 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 INSERT INTO ai_gateway_keys (id, name, secret_prefix, hashed_secret, created_at) VALUES ($1, $4, $2, $3, NOW()) @@ -217,6 +240,23 @@ func (q *sqlQuerier) ListAIGatewayKeys(ctx context.Context) ([]ListAIGatewayKeys return items, nil } +const updateAIGatewayKeyLastUsedAt = `-- name: UpdateAIGatewayKeyLastUsedAt :execrows +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) (int64, error) { + result, err := q.db.ExecContext(ctx, updateAIGatewayKeyLastUsedAt, id) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + 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 308d0cb89d1..ba74d97e787 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: GetAIGatewayKeyByHashedSecret :one +-- Authenticates a standalone AI Gateway replica by its hashed key secret, +-- 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 :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. +UPDATE ai_gateway_keys +SET last_used_at = NOW() +WHERE id = $1; diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index 5ff60562b14..a18b02405bb 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 f97b2a78bc2..035a0a34754 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 4404c071f2d..3dbaa162dca 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 9b0054d97bb..341a89cf975 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 3adad84a590..e58519bddff 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 f2271298162..5471823e7b3 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 622c59c54bf..bc71930ef3a 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 879f5afadfb..382d185a283 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 15fd4a0f43a..261abb90bda 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 0f529fe491e..aaf8c207d67 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",