mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
Generated
+2
@@ -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",
|
||||
|
||||
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Generated
+30
@@ -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()
|
||||
|
||||
Generated
+2
-1
@@ -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';
|
||||
Generated
+4
-1
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Generated
+8
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Generated
+40
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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},
|
||||
|
||||
Generated
+3
-3
File diff suppressed because one or more lines are too long
@@ -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",
|
||||
|
||||
Generated
+2
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user