feat(coderd/database): add AI Gateway key auth lookup and last-used queries (#26505)

Adds DB methods`GetAIGatewayKeyIDByHashedSecret` and `UpdateAIGatewayKeyLastUsedAt`.
`GetAIGatewayKeyIDByHashedSecret` - returns AI Gateway key ID by hashed secret value.
`UpdateAIGatewayKeyLastUsedAt` - updates last used timestamp for given AI Gateway key. 
Used by standalone AI Gateway for authentication and keeping track of currently used keys.
This commit is contained in:
Paweł Banaszewski
2026-06-26 18:16:01 +02:00
committed by GitHub
parent e71d4ca69b
commit 0f1e792f3f
24 changed files with 271 additions and 7 deletions
+2
View File
@@ -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",
+2
View File
@@ -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",
+21
View File
@@ -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
+11
View File
@@ -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() {
+16
View File
@@ -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)
+30
View File
@@ -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()
+2 -1
View File
@@ -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 (
@@ -0,0 +1,2 @@
-- Enum additions to api_key_scope are intentionally not reverted because
-- Postgres cannot drop enum values safely.
@@ -0,0 +1 @@
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'ai_gateway_key:update';
+4 -1
View File
@@ -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,
}
}
+8
View File
@@ -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
+79
View File
@@ -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(),
+40
View File
@@ -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
@@ -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;
+1
View File
@@ -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",
}
+1
View File
@@ -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",
},
},
+5 -1
View File
@@ -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.
+19
View File
@@ -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},
+3
View File
@@ -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,
+1
View File
@@ -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"
+1 -1
View File
@@ -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},
+3 -3
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -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",
+2
View File
@@ -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",