mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add synthetic gateway keys (#27170)
> Mux is working on behalf of Mike. ## Summary Add a per-user synthetic API key for chatd AI Gateway attribution. Chatd resolves the key from the chat owner, extends it before expiry, and discards the generated bearer token so the key is never a usable credential. There is no mapping table. The key is resolved from `api_keys` by a deterministic token name (`chatd_<owner_id>_session_token`), mirroring the provisionerd session token model, with three deltas that chatd needs: - **Login type guard**: token names are unvalidated user input, so a user can create a bearer token with the colliding name. The lookup excludes `login_type = 'token'` rows, so chatd never picks up (or extends) a real user token. Synthetic keys are minted with the owner's login type, which is never `token`. - **In-place expiry extension instead of delete-and-reinsert**: chat generations have no stop boundary, and an in-flight generation may have already delegated the current key ID to aibridged. Extending `expires_at` keeps the key ID stable forever. - **Advisory-lock mint**: the unique index on token names is partial (`WHERE login_type = 'token'`), so nothing DB-enforces uniqueness for synthetic keys. A per-user advisory lock serializes concurrent mints. Keys carry a minimal scope (`api_key:read`) as defense in depth; the delegated gateway path never evaluates scopes and the secret is discarded at mint. Migration 000544 removes the foreign keys from the legacy message and queue `api_key_id` columns while chatd continues stamping them for rolling compatibility. Stale IDs are tolerated because routing uses `chats.owner_id`. Individual key deletion, delete-all, and password reset remove the key without changing chat history or queue versions, and the next lookup remints it. Suspension does not delete the key; delegated gateway authorization rejects inactive users at request time. This is the first PR in a three-PR rollout and must be fully deployed before #27171. Refs https://linear.app/codercom/issue/CODAGT-561/maintain-synthetic-api-key-per-user-per-chat
This commit is contained in:
@@ -473,6 +473,27 @@ var (
|
||||
}.WithCachedASTValue()
|
||||
}
|
||||
|
||||
subjectChatdKeyMinter = func(userID uuid.UUID) rbac.Subject {
|
||||
return rbac.Subject{
|
||||
Type: rbac.SubjectTypeChatdKeyMinter,
|
||||
FriendlyName: "Chatd Key Minter",
|
||||
ID: userID.String(),
|
||||
Roles: rbac.Roles([]rbac.Role{
|
||||
{
|
||||
Identifier: rbac.RoleIdentifier{Name: "chatdkeyminter"},
|
||||
DisplayName: "Chatd Key Minter",
|
||||
Site: []rbac.Permission{},
|
||||
User: rbac.Permissions(map[string][]policy.Action{
|
||||
rbac.ResourceApiKey.Type: {policy.ActionRead, policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete},
|
||||
rbac.ResourceUser.Type: {policy.ActionReadPersonal},
|
||||
}),
|
||||
ByOrgID: map[string]rbac.OrgPermissions{},
|
||||
},
|
||||
}),
|
||||
Scope: rbac.ScopeAll,
|
||||
}.WithCachedASTValue()
|
||||
}
|
||||
|
||||
subjectSystemRestricted = rbac.Subject{
|
||||
Type: rbac.SubjectTypeSystemRestricted,
|
||||
FriendlyName: "System",
|
||||
@@ -874,6 +895,12 @@ func AsAPIKeyRevoker(ctx context.Context, userID uuid.UUID) context.Context {
|
||||
return As(ctx, subjectAPIKeyRevoker(userID))
|
||||
}
|
||||
|
||||
// AsChatdKeyMinter returns a context with an actor that manages the synthetic
|
||||
// gateway API key owned by the specified user.
|
||||
func AsChatdKeyMinter(ctx context.Context, userID uuid.UUID) context.Context {
|
||||
return As(ctx, subjectChatdKeyMinter(userID))
|
||||
}
|
||||
|
||||
// AsSystemRestricted returns a context with an actor that has permissions
|
||||
// required for various system operations (login, logout, metrics cache).
|
||||
// DO NOT USE THIS UNLESS YOU HAVE ABSOLUTELY NO OTHER CHOICE. Prefer using a
|
||||
@@ -3315,6 +3342,10 @@ func (q *querier) GetChatFilesByIDs(ctx context.Context, ids []uuid.UUID) ([]dat
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (q *querier) GetChatGatewayAPIKey(ctx context.Context, arg database.GetChatGatewayAPIKeyParams) (database.APIKey, error) {
|
||||
return fetch(q.log, q.auth, q.db.GetChatGatewayAPIKey)(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) GetChatGeneralModelOverride(ctx context.Context) (string, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil {
|
||||
return "", err
|
||||
@@ -5072,6 +5103,10 @@ func (q *querier) GetUserCount(ctx context.Context, includeSystem bool) (int64,
|
||||
return q.db.GetUserCount(ctx, includeSystem)
|
||||
}
|
||||
|
||||
func (q *querier) GetUserForChatSyntheticAPIKeyByID(ctx context.Context, id uuid.UUID) (database.User, error) {
|
||||
return fetchWithAction(q.log, q.auth, policy.ActionReadPersonal, q.db.GetUserForChatSyntheticAPIKeyByID)(ctx, id)
|
||||
}
|
||||
|
||||
func (q *querier) GetUserGroupSpendLimit(ctx context.Context, arg database.GetUserGroupSpendLimitParams) (int64, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceChat.WithOwner(arg.UserID.String())); err != nil {
|
||||
return 0, err
|
||||
|
||||
@@ -339,6 +339,20 @@ func defaultIPAddress() pqtype.Inet {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MethodTestSuite) TestChatGatewayAPIKey() {
|
||||
s.Run("GetUserForChatSyntheticAPIKeyByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
user := testutil.Fake(s.T(), faker, database.User{})
|
||||
dbm.EXPECT().GetUserForChatSyntheticAPIKeyByID(gomock.Any(), user.ID).Return(user, nil).AnyTimes()
|
||||
check.Args(user.ID).Asserts(user, policy.ActionReadPersonal).Returns(user)
|
||||
}))
|
||||
s.Run("GetChatGatewayAPIKey", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
key := testutil.Fake(s.T(), faker, database.APIKey{})
|
||||
arg := database.GetChatGatewayAPIKeyParams{UserID: key.UserID, TokenName: key.TokenName}
|
||||
dbm.EXPECT().GetChatGatewayAPIKey(gomock.Any(), arg).Return(key, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(key, policy.ActionRead).Returns(key)
|
||||
}))
|
||||
}
|
||||
|
||||
func (s *MethodTestSuite) TestAPIKey() {
|
||||
s.Run("DeleteAPIKeyByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
key := testutil.Fake(s.T(), faker, database.APIKey{})
|
||||
@@ -7481,6 +7495,24 @@ func TestAsAPIKeyRevoker(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestAsChatdKeyMinter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
userID := uuid.New()
|
||||
ctx := dbauthz.AsChatdKeyMinter(context.Background(), userID)
|
||||
actor, ok := dbauthz.ActorFromContext(ctx)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, rbac.SubjectTypeChatdKeyMinter, actor.Type)
|
||||
require.Equal(t, userID.String(), actor.ID)
|
||||
|
||||
auth := rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry())
|
||||
for _, action := range []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete} {
|
||||
require.NoError(t, auth.Authorize(ctx, actor, action, rbac.ResourceApiKey.WithOwner(userID.String())))
|
||||
require.Error(t, auth.Authorize(ctx, actor, action, rbac.ResourceApiKey.WithOwner(uuid.NewString())))
|
||||
}
|
||||
require.NoError(t, auth.Authorize(ctx, actor, policy.ActionReadPersonal, rbac.ResourceUserObject(userID)))
|
||||
}
|
||||
|
||||
func TestAsChatd(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+16
@@ -1601,6 +1601,14 @@ func (m queryMetricsStore) GetChatFilesByIDs(ctx context.Context, ids []uuid.UUI
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetChatGatewayAPIKey(ctx context.Context, arg database.GetChatGatewayAPIKeyParams) (database.APIKey, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetChatGatewayAPIKey(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("GetChatGatewayAPIKey").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatGatewayAPIKey").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetChatGeneralModelOverride(ctx context.Context) (string, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetChatGeneralModelOverride(ctx)
|
||||
@@ -3273,6 +3281,14 @@ func (m queryMetricsStore) GetUserCount(ctx context.Context, includeSystem bool)
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetUserForChatSyntheticAPIKeyByID(ctx context.Context, id uuid.UUID) (database.User, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetUserForChatSyntheticAPIKeyByID(ctx, id)
|
||||
m.queryLatencies.WithLabelValues("GetUserForChatSyntheticAPIKeyByID").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetUserForChatSyntheticAPIKeyByID").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetUserGroupSpendLimit(ctx context.Context, userID database.GetUserGroupSpendLimitParams) (int64, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetUserGroupSpendLimit(ctx, userID)
|
||||
|
||||
Generated
+30
@@ -2953,6 +2953,21 @@ func (mr *MockStoreMockRecorder) GetChatFilesByIDs(ctx, ids any) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatFilesByIDs", reflect.TypeOf((*MockStore)(nil).GetChatFilesByIDs), ctx, ids)
|
||||
}
|
||||
|
||||
// GetChatGatewayAPIKey mocks base method.
|
||||
func (m *MockStore) GetChatGatewayAPIKey(ctx context.Context, arg database.GetChatGatewayAPIKeyParams) (database.APIKey, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetChatGatewayAPIKey", ctx, arg)
|
||||
ret0, _ := ret[0].(database.APIKey)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetChatGatewayAPIKey indicates an expected call of GetChatGatewayAPIKey.
|
||||
func (mr *MockStoreMockRecorder) GetChatGatewayAPIKey(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatGatewayAPIKey", reflect.TypeOf((*MockStore)(nil).GetChatGatewayAPIKey), ctx, arg)
|
||||
}
|
||||
|
||||
// GetChatGeneralModelOverride mocks base method.
|
||||
func (m *MockStore) GetChatGeneralModelOverride(ctx context.Context) (string, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -6118,6 +6133,21 @@ func (mr *MockStoreMockRecorder) GetUserCount(ctx, includeSystem any) *gomock.Ca
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserCount", reflect.TypeOf((*MockStore)(nil).GetUserCount), ctx, includeSystem)
|
||||
}
|
||||
|
||||
// GetUserForChatSyntheticAPIKeyByID mocks base method.
|
||||
func (m *MockStore) GetUserForChatSyntheticAPIKeyByID(ctx context.Context, id uuid.UUID) (database.User, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetUserForChatSyntheticAPIKeyByID", ctx, id)
|
||||
ret0, _ := ret[0].(database.User)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetUserForChatSyntheticAPIKeyByID indicates an expected call of GetUserForChatSyntheticAPIKeyByID.
|
||||
func (mr *MockStoreMockRecorder) GetUserForChatSyntheticAPIKeyByID(ctx, id any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserForChatSyntheticAPIKeyByID", reflect.TypeOf((*MockStore)(nil).GetUserForChatSyntheticAPIKeyByID), ctx, id)
|
||||
}
|
||||
|
||||
// GetUserGroupSpendLimit mocks base method.
|
||||
func (m *MockStore) GetUserGroupSpendLimit(ctx context.Context, arg database.GetUserGroupSpendLimitParams) (int64, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
Generated
-6
@@ -5139,9 +5139,6 @@ ALTER TABLE ONLY chat_files
|
||||
ALTER TABLE ONLY chat_heartbeats
|
||||
ADD CONSTRAINT chat_heartbeats_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY chat_messages
|
||||
ADD CONSTRAINT chat_messages_api_key_id_fkey FOREIGN KEY (api_key_id) REFERENCES api_keys(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE ONLY chat_messages
|
||||
ADD CONSTRAINT chat_messages_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE;
|
||||
|
||||
@@ -5157,9 +5154,6 @@ ALTER TABLE ONLY chat_model_configs
|
||||
ALTER TABLE ONLY chat_model_configs
|
||||
ADD CONSTRAINT chat_model_configs_updated_by_fkey FOREIGN KEY (updated_by) REFERENCES users(id);
|
||||
|
||||
ALTER TABLE ONLY chat_queued_messages
|
||||
ADD CONSTRAINT chat_queued_messages_api_key_id_fkey FOREIGN KEY (api_key_id) REFERENCES api_keys(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE ONLY chat_queued_messages
|
||||
ADD CONSTRAINT chat_queued_messages_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE;
|
||||
|
||||
|
||||
-2
@@ -24,13 +24,11 @@ const (
|
||||
ForeignKeyChatFilesOrganizationID ForeignKeyConstraint = "chat_files_organization_id_fkey" // ALTER TABLE ONLY chat_files ADD CONSTRAINT chat_files_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
|
||||
ForeignKeyChatFilesOwnerID ForeignKeyConstraint = "chat_files_owner_id_fkey" // ALTER TABLE ONLY chat_files ADD CONSTRAINT chat_files_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
ForeignKeyChatHeartbeatsChatID ForeignKeyConstraint = "chat_heartbeats_chat_id_fkey" // ALTER TABLE ONLY chat_heartbeats ADD CONSTRAINT chat_heartbeats_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE;
|
||||
ForeignKeyChatMessagesAPIKeyID ForeignKeyConstraint = "chat_messages_api_key_id_fkey" // ALTER TABLE ONLY chat_messages ADD CONSTRAINT chat_messages_api_key_id_fkey FOREIGN KEY (api_key_id) REFERENCES api_keys(id) ON DELETE SET NULL;
|
||||
ForeignKeyChatMessagesChatID ForeignKeyConstraint = "chat_messages_chat_id_fkey" // ALTER TABLE ONLY chat_messages ADD CONSTRAINT chat_messages_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE;
|
||||
ForeignKeyChatMessagesModelConfigID ForeignKeyConstraint = "chat_messages_model_config_id_fkey" // ALTER TABLE ONLY chat_messages ADD CONSTRAINT chat_messages_model_config_id_fkey FOREIGN KEY (model_config_id) REFERENCES chat_model_configs(id);
|
||||
ForeignKeyChatModelConfigsAIProviderID ForeignKeyConstraint = "chat_model_configs_ai_provider_id_fkey" // ALTER TABLE ONLY chat_model_configs ADD CONSTRAINT chat_model_configs_ai_provider_id_fkey FOREIGN KEY (ai_provider_id) REFERENCES ai_providers(id);
|
||||
ForeignKeyChatModelConfigsCreatedBy ForeignKeyConstraint = "chat_model_configs_created_by_fkey" // ALTER TABLE ONLY chat_model_configs ADD CONSTRAINT chat_model_configs_created_by_fkey FOREIGN KEY (created_by) REFERENCES users(id);
|
||||
ForeignKeyChatModelConfigsUpdatedBy ForeignKeyConstraint = "chat_model_configs_updated_by_fkey" // ALTER TABLE ONLY chat_model_configs ADD CONSTRAINT chat_model_configs_updated_by_fkey FOREIGN KEY (updated_by) REFERENCES users(id);
|
||||
ForeignKeyChatQueuedMessagesAPIKeyID ForeignKeyConstraint = "chat_queued_messages_api_key_id_fkey" // ALTER TABLE ONLY chat_queued_messages ADD CONSTRAINT chat_queued_messages_api_key_id_fkey FOREIGN KEY (api_key_id) REFERENCES api_keys(id) ON DELETE SET NULL;
|
||||
ForeignKeyChatQueuedMessagesChatID ForeignKeyConstraint = "chat_queued_messages_chat_id_fkey" // ALTER TABLE ONLY chat_queued_messages ADD CONSTRAINT chat_queued_messages_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE;
|
||||
ForeignKeyChatsAgentID ForeignKeyConstraint = "chats_agent_id_fkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE SET NULL;
|
||||
ForeignKeyChatsBuildID ForeignKeyConstraint = "chats_build_id_fkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_build_id_fkey FOREIGN KEY (build_id) REFERENCES workspace_builds(id) ON DELETE SET NULL;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
UPDATE chat_messages
|
||||
SET api_key_id = NULL
|
||||
WHERE api_key_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM api_keys
|
||||
WHERE api_keys.id = chat_messages.api_key_id
|
||||
);
|
||||
|
||||
UPDATE chat_queued_messages
|
||||
SET api_key_id = NULL
|
||||
WHERE api_key_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM api_keys
|
||||
WHERE api_keys.id = chat_queued_messages.api_key_id
|
||||
);
|
||||
|
||||
ALTER TABLE chat_messages
|
||||
ADD CONSTRAINT chat_messages_api_key_id_fkey
|
||||
FOREIGN KEY (api_key_id) REFERENCES api_keys(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE chat_queued_messages
|
||||
ADD CONSTRAINT chat_queued_messages_api_key_id_fkey
|
||||
FOREIGN KEY (api_key_id) REFERENCES api_keys(id) ON DELETE SET NULL;
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE chat_messages
|
||||
DROP CONSTRAINT chat_messages_api_key_id_fkey;
|
||||
|
||||
ALTER TABLE chat_queued_messages
|
||||
DROP CONSTRAINT chat_queued_messages_api_key_id_fkey;
|
||||
@@ -1658,6 +1658,65 @@ func TestMigration000542ChatReasoningEffortBackfill(t *testing.T) {
|
||||
require.Equal(t, sql.NullString{}, got["bedrock:anthropic.invalid-effort"])
|
||||
}
|
||||
|
||||
func TestMigration000546ChatHistoryAPIKeyConstraints(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const priorMigrationVersion = 545
|
||||
|
||||
sqlDB := testSQLDB(t)
|
||||
next, err := migrations.Stepper(sqlDB)
|
||||
require.NoError(t, err)
|
||||
for {
|
||||
version, more, err := next()
|
||||
require.NoError(t, err)
|
||||
if !more || version == priorMigrationVersion {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitSuperLong)
|
||||
constraintNames := []string{
|
||||
"chat_messages_api_key_id_fkey",
|
||||
"chat_queued_messages_api_key_id_fkey",
|
||||
}
|
||||
assertConstraintCount := func(t *testing.T, want int) {
|
||||
t.Helper()
|
||||
for _, name := range constraintNames {
|
||||
var got int
|
||||
err := sqlDB.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*)
|
||||
FROM pg_constraint
|
||||
WHERE conname = $1
|
||||
`, name).Scan(&got)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, want, got, name)
|
||||
}
|
||||
}
|
||||
|
||||
upSQL, err := os.ReadFile("000546_drop_chat_history_api_key_fks.up.sql")
|
||||
require.NoError(t, err)
|
||||
_, err = sqlDB.ExecContext(ctx, string(upSQL))
|
||||
require.NoError(t, err)
|
||||
assertConstraintCount(t, 0)
|
||||
|
||||
downSQL, err := os.ReadFile("000546_drop_chat_history_api_key_fks.down.sql")
|
||||
require.NoError(t, err)
|
||||
_, err = sqlDB.ExecContext(ctx, string(downSQL))
|
||||
require.NoError(t, err)
|
||||
assertConstraintCount(t, 1)
|
||||
|
||||
for _, name := range constraintNames {
|
||||
var count int
|
||||
err := sqlDB.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*)
|
||||
FROM pg_constraint
|
||||
WHERE conname = $1 AND confdeltype = 'n'
|
||||
`, name).Scan(&count)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, count, name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigration000498SoftDeleteStaleWorkspaceAgents(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Generated
+2
@@ -433,6 +433,7 @@ type sqlcQuerier interface {
|
||||
// loading file content.
|
||||
GetChatFileMetadataByChatID(ctx context.Context, chatID uuid.UUID) ([]GetChatFileMetadataByChatIDRow, error)
|
||||
GetChatFilesByIDs(ctx context.Context, ids []uuid.UUID) ([]ChatFile, error)
|
||||
GetChatGatewayAPIKey(ctx context.Context, arg GetChatGatewayAPIKeyParams) (APIKey, error)
|
||||
GetChatGeneralModelOverride(ctx context.Context) (string, error)
|
||||
GetChatHeartbeat(ctx context.Context, arg GetChatHeartbeatParams) (ChatHeartbeat, error)
|
||||
// GetChatIncludeDefaultSystemPrompt preserves the legacy default
|
||||
@@ -857,6 +858,7 @@ type sqlcQuerier interface {
|
||||
GetUserChatSpendInPeriod(ctx context.Context, arg GetUserChatSpendInPeriodParams) (int64, error)
|
||||
GetUserCodeDiffDisplayMode(ctx context.Context, userID uuid.UUID) (string, error)
|
||||
GetUserCount(ctx context.Context, includeSystem bool) (int64, error)
|
||||
GetUserForChatSyntheticAPIKeyByID(ctx context.Context, id uuid.UUID) (User, error)
|
||||
// Returns the minimum (most restrictive) group limit for a user.
|
||||
// Returns -1 if no group limits match the specified scope.
|
||||
// When organization_id is NULL, groups across all organizations are
|
||||
|
||||
Generated
+79
@@ -3163,6 +3163,51 @@ func (q *sqlQuerier) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getChatGatewayAPIKey = `-- name: GetChatGatewayAPIKey :one
|
||||
SELECT
|
||||
id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, token_name, scopes, allow_list
|
||||
FROM
|
||||
api_keys
|
||||
WHERE
|
||||
user_id = $1 AND
|
||||
token_name = $2 AND
|
||||
-- Token names are unvalidated user input, so a user could create a token
|
||||
-- with the chat gateway name. Excluding login_type 'token' ensures chatd
|
||||
-- never picks up (and extends) a real bearer token. Synthetic gateway
|
||||
-- keys are minted with the owner's login type, which is never 'token'.
|
||||
login_type != 'token'
|
||||
ORDER BY
|
||||
created_at ASC, id ASC
|
||||
LIMIT
|
||||
1
|
||||
`
|
||||
|
||||
type GetChatGatewayAPIKeyParams struct {
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
TokenName string `db:"token_name" json:"token_name"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) GetChatGatewayAPIKey(ctx context.Context, arg GetChatGatewayAPIKeyParams) (APIKey, error) {
|
||||
row := q.db.QueryRowContext(ctx, getChatGatewayAPIKey, arg.UserID, arg.TokenName)
|
||||
var i APIKey
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.HashedSecret,
|
||||
&i.UserID,
|
||||
&i.LastUsed,
|
||||
&i.ExpiresAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.LoginType,
|
||||
&i.LifetimeSeconds,
|
||||
&i.IPAddress,
|
||||
&i.TokenName,
|
||||
&i.Scopes,
|
||||
&i.AllowList,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const insertAPIKey = `-- name: InsertAPIKey :one
|
||||
INSERT INTO
|
||||
api_keys (
|
||||
@@ -29857,6 +29902,40 @@ func (q *sqlQuerier) GetUserCount(ctx context.Context, includeSystem bool) (int6
|
||||
return count, err
|
||||
}
|
||||
|
||||
const getUserForChatSyntheticAPIKeyByID = `-- name: GetUserForChatSyntheticAPIKeyByID :one
|
||||
SELECT id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at, is_system, is_service_account, chat_spend_limit_micros
|
||||
FROM users
|
||||
WHERE id = $1::uuid
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) GetUserForChatSyntheticAPIKeyByID(ctx context.Context, id uuid.UUID) (User, error) {
|
||||
row := q.db.QueryRowContext(ctx, getUserForChatSyntheticAPIKeyByID, id)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
&i.Username,
|
||||
&i.HashedPassword,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Status,
|
||||
&i.RBACRoles,
|
||||
&i.LoginType,
|
||||
&i.AvatarURL,
|
||||
&i.Deleted,
|
||||
&i.LastSeenAt,
|
||||
&i.QuietHoursSchedule,
|
||||
&i.Name,
|
||||
&i.GithubComUserID,
|
||||
&i.HashedOneTimePasscode,
|
||||
&i.OneTimePasscodeExpiresAt,
|
||||
&i.IsSystem,
|
||||
&i.IsServiceAccount,
|
||||
&i.ChatSpendLimitMicros,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserShellToolDisplayMode = `-- name: GetUserShellToolDisplayMode :one
|
||||
SELECT
|
||||
value AS shell_tool_display_mode
|
||||
|
||||
@@ -21,6 +21,24 @@ WHERE
|
||||
LIMIT
|
||||
1;
|
||||
|
||||
-- name: GetChatGatewayAPIKey :one
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
api_keys
|
||||
WHERE
|
||||
user_id = @user_id AND
|
||||
token_name = @token_name AND
|
||||
-- Token names are unvalidated user input, so a user could create a token
|
||||
-- with the chat gateway name. Excluding login_type 'token' ensures chatd
|
||||
-- never picks up (and extends) a real bearer token. Synthetic gateway
|
||||
-- keys are minted with the owner's login type, which is never 'token'.
|
||||
login_type != 'token'
|
||||
ORDER BY
|
||||
created_at ASC, id ASC
|
||||
LIMIT
|
||||
1;
|
||||
|
||||
-- name: GetAPIKeysLastUsedAfter :many
|
||||
SELECT * FROM api_keys WHERE last_used > $1;
|
||||
|
||||
|
||||
@@ -689,3 +689,8 @@ SET
|
||||
WHERE
|
||||
id = $1
|
||||
;
|
||||
|
||||
-- name: GetUserForChatSyntheticAPIKeyByID :one
|
||||
SELECT *
|
||||
FROM users
|
||||
WHERE id = @id::uuid;
|
||||
|
||||
Reference in New Issue
Block a user