From 2d92d97d5bc4983641dd2a1c8a7d00cb39341a1c Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Fri, 12 Sep 2025 13:34:11 +0200 Subject: [PATCH 1/3] chore: aibridge database & RBAC --- coderd/apidoc/docs.go | 2 + coderd/apidoc/swagger.json | 2 + coderd/database/dbauthz/dbauthz.go | 82 ++++++++++ coderd/database/dbauthz/dbauthz_test.go | 52 ++++++ coderd/database/dbmetrics/querymetrics.go | 35 +++++ coderd/database/dbmock/dbmock.go | 72 +++++++++ coderd/database/dump.sql | 66 ++++++++ .../migrations/000370_aibridge.down.sql | 4 + .../migrations/000370_aibridge.up.sql | 53 +++++++ coderd/database/modelmethods.go | 4 + coderd/database/models.go | 40 +++++ coderd/database/querier.go | 5 + coderd/database/queries.sql.go | 148 ++++++++++++++++++ coderd/database/queries/aibridge.sql | 29 ++++ coderd/database/sqlc.yaml | 4 + coderd/database/unique_constraint.go | 4 + coderd/rbac/authz.go | 1 + coderd/rbac/object_gen.go | 10 ++ coderd/rbac/policy/policy.go | 7 + coderd/rbac/roles_test.go | 16 ++ codersdk/rbacresources_gen.go | 2 + docs/reference/api/members.md | 5 + docs/reference/api/schemas.md | 1 + site/src/api/rbacresourcesGenerated.ts | 5 + site/src/api/typesGenerated.ts | 2 + 25 files changed, 651 insertions(+) create mode 100644 coderd/database/migrations/000370_aibridge.down.sql create mode 100644 coderd/database/migrations/000370_aibridge.up.sql create mode 100644 coderd/database/queries/aibridge.sql diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 2e33ac8f4cdd0..9b37de23ef435 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -16001,6 +16001,7 @@ const docTemplate = `{ "type": "string", "enum": [ "*", + "aibridge_interception", "api_key", "assign_org_role", "assign_role", @@ -16043,6 +16044,7 @@ const docTemplate = `{ ], "x-enum-varnames": [ "ResourceWildcard", + "ResourceAibridgeInterception", "ResourceApiKey", "ResourceAssignOrgRole", "ResourceAssignRole", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index fcb05080ca24a..7a754a732a27c 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -14536,6 +14536,7 @@ "type": "string", "enum": [ "*", + "aibridge_interception", "api_key", "assign_org_role", "assign_role", @@ -14578,6 +14579,7 @@ ], "x-enum-varnames": [ "ResourceWildcard", + "ResourceAibridgeInterception", "ResourceApiKey", "ResourceAssignOrgRole", "ResourceAssignRole", diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index e38a174f83e8a..ec78963fed47d 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -175,6 +175,27 @@ func (q *querier) authorizePrebuiltWorkspace(ctx context.Context, action policy. return xerrors.Errorf("authorize context: %w", workspaceErr) } +// authorizeAIBridgeInterceptionUpdate validates that the context's actor matches the initiator of the AIBridgeInterception. +// This is used by all of the sub-resources which fall under the [ResourceAibridgeInterception] umbrella. +func (q *querier) authorizeAIBridgeInterceptionUpdate(ctx context.Context, sessID uuid.UUID) error { + act, ok := ActorFromContext(ctx) + if !ok { + return ErrNoActor + } + + sess, err := q.db.GetAIBridgeInterceptionByID(ctx, sessID) + if err != nil { + return xerrors.Errorf("fetch aibridge session %q: %w", sessID, err) + } + + err = q.auth.Authorize(ctx, act, policy.ActionUpdate, sess.RBACObject()) + if err != nil { + return logNotAuthorizedError(ctx, q.log, err) + } + + return nil +} + type authContextKey struct{} // ActorFromContext returns the authorization subject from the context. @@ -542,6 +563,29 @@ var ( }), Scope: rbac.ScopeAll, }.WithCachedASTValue() + + // See aibridged package. + subjectAibridged = rbac.Subject{ + Type: rbac.SubjectAibridged, + FriendlyName: "AIBridge Daemon", + ID: uuid.Nil.String(), + Roles: rbac.Roles([]rbac.Role{ + { + Identifier: rbac.RoleIdentifier{Name: "aibridged"}, + DisplayName: "AIBridge Daemon", + Site: rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceUser.Type: { + policy.ActionReadPersonal, // Required to read users' external auth links. // TODO: this is too broad; reduce scope to just external_auth_links by creating separate resource. + }, + rbac.ResourceApiKey.Type: {policy.ActionRead}, // Validate API keys. + rbac.ResourceAibridgeInterception.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate}, + }), + Org: map[string][]rbac.Permission{}, + User: []rbac.Permission{}, + }, + }), + Scope: rbac.ScopeAll, + }.WithCachedASTValue() ) // AsProvisionerd returns a context with an actor that has permissions required @@ -624,6 +668,12 @@ func AsUsagePublisher(ctx context.Context) context.Context { return As(ctx, subjectUsagePublisher) } +// AsAIBridged returns a context with an actor that has permissions +// required for creating, reading, and updating aibridge-related resources. +func AsAIBridged(ctx context.Context) context.Context { + return As(ctx, subjectAibridged) +} + var AsRemoveActor = rbac.Subject{ ID: "remove-actor", } @@ -1878,6 +1928,10 @@ func (q *querier) FindMatchingPresetID(ctx context.Context, arg database.FindMat return q.db.FindMatchingPresetID(ctx, arg) } +func (q *querier) GetAIBridgeInterceptionByID(ctx context.Context, id uuid.UUID) (database.AIBridgeInterception, error) { + return fetch(q.log, q.auth, q.db.GetAIBridgeInterceptionByID)(ctx, id) +} + func (q *querier) GetAPIKeyByID(ctx context.Context, id string) (database.APIKey, error) { return fetch(q.log, q.auth, q.db.GetAPIKeyByID)(ctx, id) } @@ -3757,6 +3811,34 @@ func (q *querier) GetWorkspacesEligibleForTransition(ctx context.Context, now ti return q.db.GetWorkspacesEligibleForTransition(ctx, now) } +func (q *querier) InsertAIBridgeInterception(ctx context.Context, arg database.InsertAIBridgeInterceptionParams) (database.AIBridgeInterception, error) { + return insert(q.log, q.auth, rbac.ResourceAibridgeInterception.WithOwner(arg.InitiatorID.String()), q.db.InsertAIBridgeInterception)(ctx, arg) +} + +func (q *querier) InsertAIBridgeTokenUsage(ctx context.Context, arg database.InsertAIBridgeTokenUsageParams) error { + // All aibridge_token_usages records belong to the initiator of their associated session. + if err := q.authorizeAIBridgeInterceptionUpdate(ctx, arg.InterceptionID); err != nil { + return err + } + return q.db.InsertAIBridgeTokenUsage(ctx, arg) +} + +func (q *querier) InsertAIBridgeToolUsage(ctx context.Context, arg database.InsertAIBridgeToolUsageParams) error { + // All aibridge_tool_usages records belong to the initiator of their associated session. + if err := q.authorizeAIBridgeInterceptionUpdate(ctx, arg.InterceptionID); err != nil { + return err + } + return q.db.InsertAIBridgeToolUsage(ctx, arg) +} + +func (q *querier) InsertAIBridgeUserPrompt(ctx context.Context, arg database.InsertAIBridgeUserPromptParams) error { + // All aibridge_user_prompts records belong to the initiator of their associated session. + if err := q.authorizeAIBridgeInterceptionUpdate(ctx, arg.InterceptionID); err != nil { + return err + } + return q.db.InsertAIBridgeUserPrompt(ctx, arg) +} + func (q *querier) InsertAPIKey(ctx context.Context, arg database.InsertAPIKeyParams) (database.APIKey, error) { // TODO(Cian): ideally this would be encoded in the policy, but system users are just members and we // don't currently have a capability to conditionally deny creating resources by owner ID in a role. diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 08e87b80c6076..174cc88002df4 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -4332,3 +4332,55 @@ func TestInsertAPIKey_AsPrebuildsUser(t *testing.T) { _, err := dbz.InsertAPIKey(ctx, testutil.Fake(t, faker, database.InsertAPIKeyParams{})) require.True(t, dbauthz.IsNotAuthorizedError(err)) } + +func (s *MethodTestSuite) TestAIBridge() { + s.Run("GetAIBridgeInterceptionByID", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + sessID := uuid.UUID{2} + sess := testutil.Fake(s.T(), faker, database.AIBridgeInterception{ID: sessID}) + db.EXPECT().GetAIBridgeInterceptionByID(gomock.Any(), sessID).Return(sess, nil).AnyTimes() + check.Args(sessID).Asserts(sess, policy.ActionRead).Returns(sess) + })) + + s.Run("InsertAIBridgeInterception", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + initID := uuid.UUID{3} + user := testutil.Fake(s.T(), faker, database.User{ID: initID}) + // testutil.Fake cannot distinguish between a zero value and an explicitly requested value which is equivalent. + user.IsSystem = false + user.Deleted = false + + sessID := uuid.UUID{2} + sess := testutil.Fake(s.T(), faker, database.AIBridgeInterception{ID: sessID, InitiatorID: initID}) + + params := database.InsertAIBridgeInterceptionParams{ID: sess.ID, InitiatorID: sess.InitiatorID, Provider: sess.Provider, Model: sess.Model} + db.EXPECT().GetUserByID(gomock.Any(), initID).Return(user, nil).AnyTimes() // Validation. + db.EXPECT().InsertAIBridgeInterception(gomock.Any(), params).Return(sess, nil).AnyTimes() + check.Args(params).Asserts(sess, policy.ActionCreate) + })) + + s.Run("InsertAIBridgeTokenUsage", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + sessID := uuid.UUID{2} + sess := testutil.Fake(s.T(), faker, database.AIBridgeInterception{ID: sessID}) + params := database.InsertAIBridgeTokenUsageParams{InterceptionID: sess.ID} + db.EXPECT().GetAIBridgeInterceptionByID(gomock.Any(), sessID).Return(sess, nil).AnyTimes() // Validation. + db.EXPECT().InsertAIBridgeTokenUsage(gomock.Any(), params).Return(nil).AnyTimes() + check.Args(params).Asserts(sess, policy.ActionUpdate) + })) + + s.Run("InsertAIBridgeToolUsage", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + sessID := uuid.UUID{2} + sess := testutil.Fake(s.T(), faker, database.AIBridgeInterception{ID: sessID}) + params := database.InsertAIBridgeToolUsageParams{InterceptionID: sess.ID} + db.EXPECT().GetAIBridgeInterceptionByID(gomock.Any(), sessID).Return(sess, nil).AnyTimes() // Validation. + db.EXPECT().InsertAIBridgeToolUsage(gomock.Any(), params).Return(nil).AnyTimes() + check.Args(params).Asserts(sess, policy.ActionUpdate) + })) + + s.Run("InsertAIBridgeUserPrompt", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + sessID := uuid.UUID{2} + sess := testutil.Fake(s.T(), faker, database.AIBridgeInterception{ID: sessID}) + params := database.InsertAIBridgeUserPromptParams{InterceptionID: sess.ID} + db.EXPECT().GetAIBridgeInterceptionByID(gomock.Any(), sessID).Return(sess, nil).AnyTimes() // Validation. + db.EXPECT().InsertAIBridgeUserPrompt(gomock.Any(), params).Return(nil).AnyTimes() + check.Args(params).Asserts(sess, policy.ActionUpdate) + })) +} diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 014ec0c12880e..6f520a904a29e 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -586,6 +586,13 @@ func (m queryMetricsStore) FindMatchingPresetID(ctx context.Context, arg databas return r0, r1 } +func (m queryMetricsStore) GetAIBridgeInterceptionByID(ctx context.Context, id uuid.UUID) (database.AIBridgeInterception, error) { + start := time.Now() + r0, r1 := m.s.GetAIBridgeInterceptionByID(ctx, id) + m.queryLatencies.WithLabelValues("GetAIBridgeInterceptionByID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetAPIKeyByID(ctx context.Context, id string) (database.APIKey, error) { start := time.Now() apiKey, err := m.s.GetAPIKeyByID(ctx, id) @@ -2168,6 +2175,34 @@ func (m queryMetricsStore) GetWorkspacesEligibleForTransition(ctx context.Contex return workspaces, err } +func (m queryMetricsStore) InsertAIBridgeInterception(ctx context.Context, arg database.InsertAIBridgeInterceptionParams) (database.AIBridgeInterception, error) { + start := time.Now() + r0, r1 := m.s.InsertAIBridgeInterception(ctx, arg) + m.queryLatencies.WithLabelValues("InsertAIBridgeInterception").Observe(time.Since(start).Seconds()) + return r0, r1 +} + +func (m queryMetricsStore) InsertAIBridgeTokenUsage(ctx context.Context, arg database.InsertAIBridgeTokenUsageParams) error { + start := time.Now() + r0 := m.s.InsertAIBridgeTokenUsage(ctx, arg) + m.queryLatencies.WithLabelValues("InsertAIBridgeTokenUsage").Observe(time.Since(start).Seconds()) + return r0 +} + +func (m queryMetricsStore) InsertAIBridgeToolUsage(ctx context.Context, arg database.InsertAIBridgeToolUsageParams) error { + start := time.Now() + r0 := m.s.InsertAIBridgeToolUsage(ctx, arg) + m.queryLatencies.WithLabelValues("InsertAIBridgeToolUsage").Observe(time.Since(start).Seconds()) + return r0 +} + +func (m queryMetricsStore) InsertAIBridgeUserPrompt(ctx context.Context, arg database.InsertAIBridgeUserPromptParams) error { + start := time.Now() + r0 := m.s.InsertAIBridgeUserPrompt(ctx, arg) + m.queryLatencies.WithLabelValues("InsertAIBridgeUserPrompt").Observe(time.Since(start).Seconds()) + return r0 +} + func (m queryMetricsStore) InsertAPIKey(ctx context.Context, arg database.InsertAPIKeyParams) (database.APIKey, error) { start := time.Now() key, err := m.s.InsertAPIKey(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 2a33ea2d52a95..e96759f8288ab 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1094,6 +1094,21 @@ func (mr *MockStoreMockRecorder) FindMatchingPresetID(ctx, arg any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindMatchingPresetID", reflect.TypeOf((*MockStore)(nil).FindMatchingPresetID), ctx, arg) } +// GetAIBridgeInterceptionByID mocks base method. +func (m *MockStore) GetAIBridgeInterceptionByID(ctx context.Context, id uuid.UUID) (database.AIBridgeInterception, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAIBridgeInterceptionByID", ctx, id) + ret0, _ := ret[0].(database.AIBridgeInterception) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAIBridgeInterceptionByID indicates an expected call of GetAIBridgeInterceptionByID. +func (mr *MockStoreMockRecorder) GetAIBridgeInterceptionByID(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAIBridgeInterceptionByID", reflect.TypeOf((*MockStore)(nil).GetAIBridgeInterceptionByID), ctx, id) +} + // GetAPIKeyByID mocks base method. func (m *MockStore) GetAPIKeyByID(ctx context.Context, id string) (database.APIKey, error) { m.ctrl.T.Helper() @@ -4633,6 +4648,63 @@ func (mr *MockStoreMockRecorder) InTx(arg0, arg1 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InTx", reflect.TypeOf((*MockStore)(nil).InTx), arg0, arg1) } +// InsertAIBridgeInterception mocks base method. +func (m *MockStore) InsertAIBridgeInterception(ctx context.Context, arg database.InsertAIBridgeInterceptionParams) (database.AIBridgeInterception, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertAIBridgeInterception", ctx, arg) + ret0, _ := ret[0].(database.AIBridgeInterception) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InsertAIBridgeInterception indicates an expected call of InsertAIBridgeInterception. +func (mr *MockStoreMockRecorder) InsertAIBridgeInterception(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertAIBridgeInterception", reflect.TypeOf((*MockStore)(nil).InsertAIBridgeInterception), ctx, arg) +} + +// InsertAIBridgeTokenUsage mocks base method. +func (m *MockStore) InsertAIBridgeTokenUsage(ctx context.Context, arg database.InsertAIBridgeTokenUsageParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertAIBridgeTokenUsage", ctx, arg) + ret0, _ := ret[0].(error) + return ret0 +} + +// InsertAIBridgeTokenUsage indicates an expected call of InsertAIBridgeTokenUsage. +func (mr *MockStoreMockRecorder) InsertAIBridgeTokenUsage(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertAIBridgeTokenUsage", reflect.TypeOf((*MockStore)(nil).InsertAIBridgeTokenUsage), ctx, arg) +} + +// InsertAIBridgeToolUsage mocks base method. +func (m *MockStore) InsertAIBridgeToolUsage(ctx context.Context, arg database.InsertAIBridgeToolUsageParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertAIBridgeToolUsage", ctx, arg) + ret0, _ := ret[0].(error) + return ret0 +} + +// InsertAIBridgeToolUsage indicates an expected call of InsertAIBridgeToolUsage. +func (mr *MockStoreMockRecorder) InsertAIBridgeToolUsage(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertAIBridgeToolUsage", reflect.TypeOf((*MockStore)(nil).InsertAIBridgeToolUsage), ctx, arg) +} + +// InsertAIBridgeUserPrompt mocks base method. +func (m *MockStore) InsertAIBridgeUserPrompt(ctx context.Context, arg database.InsertAIBridgeUserPromptParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertAIBridgeUserPrompt", ctx, arg) + ret0, _ := ret[0].(error) + return ret0 +} + +// InsertAIBridgeUserPrompt indicates an expected call of InsertAIBridgeUserPrompt. +func (mr *MockStoreMockRecorder) InsertAIBridgeUserPrompt(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertAIBridgeUserPrompt", reflect.TypeOf((*MockStore)(nil).InsertAIBridgeUserPrompt), ctx, arg) +} + // InsertAPIKey mocks base method. func (m *MockStore) InsertAPIKey(ctx context.Context, arg database.InsertAPIKeyParams) (database.APIKey, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index b097766f0d51e..711f35b6ea3a5 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -847,6 +847,46 @@ BEGIN END; $$; +CREATE TABLE aibridge_interceptions ( + id uuid NOT NULL, + initiator_id uuid NOT NULL, + provider text NOT NULL, + model text NOT NULL, + started_at timestamp with time zone NOT NULL +); + +CREATE TABLE aibridge_token_usages ( + id uuid NOT NULL, + interception_id uuid NOT NULL, + provider_response_id text NOT NULL, + input_tokens bigint NOT NULL, + output_tokens bigint NOT NULL, + metadata jsonb, + created_at timestamp with time zone NOT NULL +); + +CREATE TABLE aibridge_tool_usages ( + id uuid NOT NULL, + interception_id uuid NOT NULL, + provider_response_id text NOT NULL, + server_url text, + tool text NOT NULL, + input text NOT NULL, + injected boolean DEFAULT false NOT NULL, + invocation_error text, + metadata jsonb, + created_at timestamp with time zone NOT NULL +); + +CREATE TABLE aibridge_user_prompts ( + id uuid NOT NULL, + interception_id uuid NOT NULL, + provider_response_id text NOT NULL, + prompt text NOT NULL, + metadata jsonb, + created_at timestamp with time zone NOT NULL +); + CREATE TABLE api_keys ( id text NOT NULL, hashed_secret bytea NOT NULL, @@ -2597,6 +2637,18 @@ ALTER TABLE ONLY workspace_resource_metadata ALTER COLUMN id SET DEFAULT nextval ALTER TABLE ONLY workspace_agent_stats ADD CONSTRAINT agent_stats_pkey PRIMARY KEY (id); +ALTER TABLE ONLY aibridge_interceptions + ADD CONSTRAINT aibridge_interceptions_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY aibridge_token_usages + ADD CONSTRAINT aibridge_token_usages_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY aibridge_tool_usages + ADD CONSTRAINT aibridge_tool_usages_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY aibridge_user_prompts + ADD CONSTRAINT aibridge_user_prompts_pkey PRIMARY KEY (id); + ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_pkey PRIMARY KEY (id); @@ -2896,6 +2948,20 @@ CREATE INDEX idx_agent_stats_created_at ON workspace_agent_stats USING btree (cr CREATE INDEX idx_agent_stats_user_id ON workspace_agent_stats USING btree (user_id); +CREATE INDEX idx_aibridge_interceptions_initiator_id ON aibridge_interceptions USING btree (initiator_id); + +CREATE INDEX idx_aibridge_token_usages_interception_id ON aibridge_token_usages USING btree (interception_id); + +CREATE INDEX idx_aibridge_token_usages_provider_response_id ON aibridge_token_usages USING btree (provider_response_id); + +CREATE INDEX idx_aibridge_tool_usages_interception_id ON aibridge_tool_usages USING btree (interception_id); + +CREATE INDEX idx_aibridge_tool_usagesprovider_response_id ON aibridge_tool_usages USING btree (provider_response_id); + +CREATE INDEX idx_aibridge_user_prompts_interception_id ON aibridge_user_prompts USING btree (interception_id); + +CREATE INDEX idx_aibridge_user_prompts_provider_response_id ON aibridge_user_prompts USING btree (provider_response_id); + CREATE UNIQUE INDEX idx_api_key_name ON api_keys USING btree (user_id, token_name) WHERE (login_type = 'token'::login_type); CREATE INDEX idx_api_keys_user ON api_keys USING btree (user_id); diff --git a/coderd/database/migrations/000370_aibridge.down.sql b/coderd/database/migrations/000370_aibridge.down.sql new file mode 100644 index 0000000000000..1107b68778900 --- /dev/null +++ b/coderd/database/migrations/000370_aibridge.down.sql @@ -0,0 +1,4 @@ +DROP TABLE IF EXISTS aibridge_tool_usages CASCADE; +DROP TABLE IF EXISTS aibridge_user_prompts CASCADE; +DROP TABLE IF EXISTS aibridge_token_usages CASCADE; +DROP TABLE IF EXISTS aibridge_interceptions CASCADE; diff --git a/coderd/database/migrations/000370_aibridge.up.sql b/coderd/database/migrations/000370_aibridge.up.sql new file mode 100644 index 0000000000000..876e48198cde7 --- /dev/null +++ b/coderd/database/migrations/000370_aibridge.up.sql @@ -0,0 +1,53 @@ +CREATE TABLE IF NOT EXISTS aibridge_interceptions ( + id UUID PRIMARY KEY, + initiator_id uuid NOT NULL, + provider TEXT NOT NULL, + model TEXT NOT NULL, + started_at TIMESTAMP WITH TIME ZONE NOT NULL +); + +CREATE INDEX idx_aibridge_interceptions_initiator_id ON aibridge_interceptions (initiator_id); + +CREATE TABLE IF NOT EXISTS aibridge_token_usages ( + id UUID PRIMARY KEY, + interception_id UUID NOT NULL, + provider_response_id TEXT NOT NULL, -- The ID for the response in which the tokens were used, produced by the provider. + input_tokens BIGINT NOT NULL, + output_tokens BIGINT NOT NULL, + metadata JSONB DEFAULT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL +); + +CREATE INDEX idx_aibridge_token_usages_interception_id ON aibridge_token_usages (interception_id); + +CREATE INDEX idx_aibridge_token_usages_provider_response_id ON aibridge_token_usages (provider_response_id); + +CREATE TABLE IF NOT EXISTS aibridge_user_prompts ( + id UUID PRIMARY KEY, + interception_id UUID NOT NULL, + provider_response_id TEXT NOT NULL, -- The ID for the response in which the tokens were used, produced by the provider. + prompt TEXT NOT NULL, + metadata JSONB DEFAULT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL +); + +CREATE INDEX idx_aibridge_user_prompts_interception_id ON aibridge_user_prompts (interception_id); + +CREATE INDEX idx_aibridge_user_prompts_provider_response_id ON aibridge_user_prompts (provider_response_id); + +CREATE TABLE IF NOT EXISTS aibridge_tool_usages ( + id UUID PRIMARY KEY, + interception_id UUID NOT NULL, + provider_response_id TEXT NOT NULL, -- The ID for the response in which the tokens were used, produced by the provider. + server_url TEXT NULL, -- The name of the MCP server against which this tool was invoked. May be NULL, in which case the tool was defined by the client, not injected. + tool TEXT NOT NULL, + input TEXT NOT NULL, + injected BOOLEAN NOT NULL DEFAULT FALSE, -- Whether this tool was injected; i.e. Bridge injected these tools into the request from an MCP server. If false it means a tool was defined by the client and already existed in the request (MCP or built-in). + invocation_error TEXT NULL, -- Only injected tools are invoked. + metadata JSONB DEFAULT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL +); + +CREATE INDEX idx_aibridge_tool_usages_interception_id ON aibridge_tool_usages (interception_id); + +CREATE INDEX idx_aibridge_tool_usagesprovider_response_id ON aibridge_tool_usages (provider_response_id); diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index e080c7d7e4217..3f3d7b3b85275 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -636,3 +636,7 @@ func (m WorkspaceAgentVolumeResourceMonitor) Debounce( func (s UserSecret) RBACObject() rbac.Object { return rbac.ResourceUserSecret.WithID(s.ID).WithOwner(s.UserID.String()) } + +func (s AIBridgeInterception) RBACObject() rbac.Object { + return rbac.ResourceAibridgeInterception.WithOwner(s.InitiatorID.String()) +} diff --git a/coderd/database/models.go b/coderd/database/models.go index 7edc4277e4812..de945ca8c1595 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -2955,6 +2955,46 @@ func AllWorkspaceTransitionValues() []WorkspaceTransition { } } +type AIBridgeInterception struct { + ID uuid.UUID `db:"id" json:"id"` + InitiatorID uuid.UUID `db:"initiator_id" json:"initiator_id"` + Provider string `db:"provider" json:"provider"` + Model string `db:"model" json:"model"` + StartedAt time.Time `db:"started_at" json:"started_at"` +} + +type AIBridgeTokenUsage struct { + ID uuid.UUID `db:"id" json:"id"` + InterceptionID uuid.UUID `db:"interception_id" json:"interception_id"` + ProviderResponseID string `db:"provider_response_id" json:"provider_response_id"` + InputTokens int64 `db:"input_tokens" json:"input_tokens"` + OutputTokens int64 `db:"output_tokens" json:"output_tokens"` + Metadata pqtype.NullRawMessage `db:"metadata" json:"metadata"` + CreatedAt time.Time `db:"created_at" json:"created_at"` +} + +type AIBridgeToolUsage struct { + ID uuid.UUID `db:"id" json:"id"` + InterceptionID uuid.UUID `db:"interception_id" json:"interception_id"` + ProviderResponseID string `db:"provider_response_id" json:"provider_response_id"` + ServerUrl sql.NullString `db:"server_url" json:"server_url"` + Tool string `db:"tool" json:"tool"` + Input string `db:"input" json:"input"` + Injected bool `db:"injected" json:"injected"` + InvocationError sql.NullString `db:"invocation_error" json:"invocation_error"` + Metadata pqtype.NullRawMessage `db:"metadata" json:"metadata"` + CreatedAt time.Time `db:"created_at" json:"created_at"` +} + +type AIBridgeUserPrompt struct { + ID uuid.UUID `db:"id" json:"id"` + InterceptionID uuid.UUID `db:"interception_id" json:"interception_id"` + ProviderResponseID string `db:"provider_response_id" json:"provider_response_id"` + Prompt string `db:"prompt" json:"prompt"` + Metadata pqtype.NullRawMessage `db:"metadata" json:"metadata"` + CreatedAt time.Time `db:"created_at" json:"created_at"` +} + type APIKey struct { ID string `db:"id" json:"id"` // hashed_secret contains a SHA256 hash of the key secret. This is considered a secret and MUST NOT be returned from the API as it is used for API key encryption in app proxying code. diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 1c46afa39821e..2cd8bd3b25f74 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -148,6 +148,7 @@ type sqlcQuerier interface { // The query finds presets where all preset parameters are present in the provided parameters, // and returns the preset with the most parameters (largest subset). FindMatchingPresetID(ctx context.Context, arg FindMatchingPresetIDParams) (uuid.UUID, error) + GetAIBridgeInterceptionByID(ctx context.Context, id uuid.UUID) (AIBridgeInterception, error) GetAPIKeyByID(ctx context.Context, id string) (APIKey, error) // there is no unique constraint on empty token names GetAPIKeyByName(ctx context.Context, arg GetAPIKeyByNameParams) (APIKey, error) @@ -502,6 +503,10 @@ type sqlcQuerier interface { GetWorkspacesAndAgentsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]GetWorkspacesAndAgentsByOwnerIDRow, error) GetWorkspacesByTemplateID(ctx context.Context, templateID uuid.UUID) ([]WorkspaceTable, error) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]GetWorkspacesEligibleForTransitionRow, error) + InsertAIBridgeInterception(ctx context.Context, arg InsertAIBridgeInterceptionParams) (AIBridgeInterception, error) + InsertAIBridgeTokenUsage(ctx context.Context, arg InsertAIBridgeTokenUsageParams) error + InsertAIBridgeToolUsage(ctx context.Context, arg InsertAIBridgeToolUsageParams) error + InsertAIBridgeUserPrompt(ctx context.Context, arg InsertAIBridgeUserPromptParams) error InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error) // We use the organization_id as the id // for simplicity since all users is diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index ebff2c5453150..c0bbb5a04aae0 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -111,6 +111,154 @@ func (q *sqlQuerier) ActivityBumpWorkspace(ctx context.Context, arg ActivityBump return err } +const getAIBridgeInterceptionByID = `-- name: GetAIBridgeInterceptionByID :one +SELECT id, initiator_id, provider, model, started_at FROM aibridge_interceptions WHERE id = $1::uuid +LIMIT 1 +` + +func (q *sqlQuerier) GetAIBridgeInterceptionByID(ctx context.Context, id uuid.UUID) (AIBridgeInterception, error) { + row := q.db.QueryRowContext(ctx, getAIBridgeInterceptionByID, id) + var i AIBridgeInterception + err := row.Scan( + &i.ID, + &i.InitiatorID, + &i.Provider, + &i.Model, + &i.StartedAt, + ) + return i, err +} + +const insertAIBridgeInterception = `-- name: InsertAIBridgeInterception :one +INSERT INTO aibridge_interceptions (id, initiator_id, provider, model, started_at) +VALUES ($1::uuid, $2::uuid, $3, $4, $5) +RETURNING id, initiator_id, provider, model, started_at +` + +type InsertAIBridgeInterceptionParams struct { + ID uuid.UUID `db:"id" json:"id"` + InitiatorID uuid.UUID `db:"initiator_id" json:"initiator_id"` + Provider string `db:"provider" json:"provider"` + Model string `db:"model" json:"model"` + StartedAt time.Time `db:"started_at" json:"started_at"` +} + +func (q *sqlQuerier) InsertAIBridgeInterception(ctx context.Context, arg InsertAIBridgeInterceptionParams) (AIBridgeInterception, error) { + row := q.db.QueryRowContext(ctx, insertAIBridgeInterception, + arg.ID, + arg.InitiatorID, + arg.Provider, + arg.Model, + arg.StartedAt, + ) + var i AIBridgeInterception + err := row.Scan( + &i.ID, + &i.InitiatorID, + &i.Provider, + &i.Model, + &i.StartedAt, + ) + return i, err +} + +const insertAIBridgeTokenUsage = `-- name: InsertAIBridgeTokenUsage :exec +INSERT INTO aibridge_token_usages ( + id, interception_id, provider_response_id, input_tokens, output_tokens, metadata, created_at +) VALUES ( + $1, $2, $3, $4, $5, COALESCE($6::jsonb, '{}'::jsonb), $7 +) +` + +type InsertAIBridgeTokenUsageParams struct { + ID uuid.UUID `db:"id" json:"id"` + InterceptionID uuid.UUID `db:"interception_id" json:"interception_id"` + ProviderResponseID string `db:"provider_response_id" json:"provider_response_id"` + InputTokens int64 `db:"input_tokens" json:"input_tokens"` + OutputTokens int64 `db:"output_tokens" json:"output_tokens"` + Metadata json.RawMessage `db:"metadata" json:"metadata"` + CreatedAt time.Time `db:"created_at" json:"created_at"` +} + +func (q *sqlQuerier) InsertAIBridgeTokenUsage(ctx context.Context, arg InsertAIBridgeTokenUsageParams) error { + _, err := q.db.ExecContext(ctx, insertAIBridgeTokenUsage, + arg.ID, + arg.InterceptionID, + arg.ProviderResponseID, + arg.InputTokens, + arg.OutputTokens, + arg.Metadata, + arg.CreatedAt, + ) + return err +} + +const insertAIBridgeToolUsage = `-- name: InsertAIBridgeToolUsage :exec +INSERT INTO aibridge_tool_usages ( + id, interception_id, provider_response_id, tool, server_url, input, injected, invocation_error, metadata, created_at +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, COALESCE($9::jsonb, '{}'::jsonb), $10 +) +` + +type InsertAIBridgeToolUsageParams struct { + ID uuid.UUID `db:"id" json:"id"` + InterceptionID uuid.UUID `db:"interception_id" json:"interception_id"` + ProviderResponseID string `db:"provider_response_id" json:"provider_response_id"` + Tool string `db:"tool" json:"tool"` + ServerUrl sql.NullString `db:"server_url" json:"server_url"` + Input string `db:"input" json:"input"` + Injected bool `db:"injected" json:"injected"` + InvocationError sql.NullString `db:"invocation_error" json:"invocation_error"` + Metadata json.RawMessage `db:"metadata" json:"metadata"` + CreatedAt time.Time `db:"created_at" json:"created_at"` +} + +func (q *sqlQuerier) InsertAIBridgeToolUsage(ctx context.Context, arg InsertAIBridgeToolUsageParams) error { + _, err := q.db.ExecContext(ctx, insertAIBridgeToolUsage, + arg.ID, + arg.InterceptionID, + arg.ProviderResponseID, + arg.Tool, + arg.ServerUrl, + arg.Input, + arg.Injected, + arg.InvocationError, + arg.Metadata, + arg.CreatedAt, + ) + return err +} + +const insertAIBridgeUserPrompt = `-- name: InsertAIBridgeUserPrompt :exec +INSERT INTO aibridge_user_prompts ( + id, interception_id, provider_response_id, prompt, metadata, created_at +) VALUES ( + $1, $2, $3, $4, COALESCE($5::jsonb, '{}'::jsonb), $6 +) +` + +type InsertAIBridgeUserPromptParams struct { + ID uuid.UUID `db:"id" json:"id"` + InterceptionID uuid.UUID `db:"interception_id" json:"interception_id"` + ProviderResponseID string `db:"provider_response_id" json:"provider_response_id"` + Prompt string `db:"prompt" json:"prompt"` + Metadata json.RawMessage `db:"metadata" json:"metadata"` + CreatedAt time.Time `db:"created_at" json:"created_at"` +} + +func (q *sqlQuerier) InsertAIBridgeUserPrompt(ctx context.Context, arg InsertAIBridgeUserPromptParams) error { + _, err := q.db.ExecContext(ctx, insertAIBridgeUserPrompt, + arg.ID, + arg.InterceptionID, + arg.ProviderResponseID, + arg.Prompt, + arg.Metadata, + arg.CreatedAt, + ) + return err +} + const deleteAPIKeyByID = `-- name: DeleteAPIKeyByID :exec DELETE FROM api_keys diff --git a/coderd/database/queries/aibridge.sql b/coderd/database/queries/aibridge.sql new file mode 100644 index 0000000000000..aa701deb22cb8 --- /dev/null +++ b/coderd/database/queries/aibridge.sql @@ -0,0 +1,29 @@ +-- name: InsertAIBridgeInterception :one +INSERT INTO aibridge_interceptions (id, initiator_id, provider, model, started_at) +VALUES (@id::uuid, @initiator_id::uuid, @provider, @model, @started_at) +RETURNING *; + +-- name: InsertAIBridgeTokenUsage :exec +INSERT INTO aibridge_token_usages ( + id, interception_id, provider_response_id, input_tokens, output_tokens, metadata, created_at +) VALUES ( + @id, @interception_id, @provider_response_id, @input_tokens, @output_tokens, COALESCE(@metadata::jsonb, '{}'::jsonb), @created_at +); + +-- name: InsertAIBridgeUserPrompt :exec +INSERT INTO aibridge_user_prompts ( + id, interception_id, provider_response_id, prompt, metadata, created_at +) VALUES ( + @id, @interception_id, @provider_response_id, @prompt, COALESCE(@metadata::jsonb, '{}'::jsonb), @created_at +); + +-- name: InsertAIBridgeToolUsage :exec +INSERT INTO aibridge_tool_usages ( + id, interception_id, provider_response_id, tool, server_url, input, injected, invocation_error, metadata, created_at +) VALUES ( + @id, @interception_id, @provider_response_id, @tool, @server_url, @input, @injected, @invocation_error, COALESCE(@metadata::jsonb, '{}'::jsonb), @created_at +); + +-- name: GetAIBridgeInterceptionByID :one +SELECT * FROM aibridge_interceptions WHERE id = @id::uuid +LIMIT 1; diff --git a/coderd/database/sqlc.yaml b/coderd/database/sqlc.yaml index 689eb1aaeb53b..f23d8df2aa043 100644 --- a/coderd/database/sqlc.yaml +++ b/coderd/database/sqlc.yaml @@ -163,6 +163,10 @@ sql: ai_task_sidebar_app_id: AITaskSidebarAppID latest_build_has_ai_task: LatestBuildHasAITask cors_behavior: CorsBehavior + aibridge_interception: AIBridgeInterception + aibridge_tool_usage: AIBridgeToolUsage + aibridge_token_usage: AIBridgeTokenUsage + aibridge_user_prompt: AIBridgeUserPrompt rules: - name: do-not-use-public-schema-in-queries message: "do not use public schema in queries" diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 02982edc517fb..36fca8f058135 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -7,6 +7,10 @@ type UniqueConstraint string // UniqueConstraint enums. const ( UniqueAgentStatsPkey UniqueConstraint = "agent_stats_pkey" // ALTER TABLE ONLY workspace_agent_stats ADD CONSTRAINT agent_stats_pkey PRIMARY KEY (id); + UniqueAibridgeInterceptionsPkey UniqueConstraint = "aibridge_interceptions_pkey" // ALTER TABLE ONLY aibridge_interceptions ADD CONSTRAINT aibridge_interceptions_pkey PRIMARY KEY (id); + UniqueAibridgeTokenUsagesPkey UniqueConstraint = "aibridge_token_usages_pkey" // ALTER TABLE ONLY aibridge_token_usages ADD CONSTRAINT aibridge_token_usages_pkey PRIMARY KEY (id); + UniqueAibridgeToolUsagesPkey UniqueConstraint = "aibridge_tool_usages_pkey" // ALTER TABLE ONLY aibridge_tool_usages ADD CONSTRAINT aibridge_tool_usages_pkey PRIMARY KEY (id); + UniqueAibridgeUserPromptsPkey UniqueConstraint = "aibridge_user_prompts_pkey" // ALTER TABLE ONLY aibridge_user_prompts ADD CONSTRAINT aibridge_user_prompts_pkey PRIMARY KEY (id); UniqueAPIKeysPkey UniqueConstraint = "api_keys_pkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_pkey PRIMARY KEY (id); UniqueAuditLogsPkey UniqueConstraint = "audit_logs_pkey" // ALTER TABLE ONLY audit_logs ADD CONSTRAINT audit_logs_pkey PRIMARY KEY (id); UniqueConnectionLogsPkey UniqueConstraint = "connection_logs_pkey" // ALTER TABLE ONLY connection_logs ADD CONSTRAINT connection_logs_pkey PRIMARY KEY (id); diff --git a/coderd/rbac/authz.go b/coderd/rbac/authz.go index 0b48a24aebe83..0715a8ead7783 100644 --- a/coderd/rbac/authz.go +++ b/coderd/rbac/authz.go @@ -77,6 +77,7 @@ const ( SubjectTypeSubAgentAPI SubjectType = "sub_agent_api" SubjectTypeFileReader SubjectType = "file_reader" SubjectTypeUsagePublisher SubjectType = "usage_publisher" + SubjectAibridged SubjectType = "aibridged" ) const ( diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index de05dced2693d..d0c78bd480766 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -15,6 +15,15 @@ var ( Type: "*", } + // ResourceAibridgeInterception + // Valid Actions + // - "ActionCreate" :: create aibridge interceptions & related records + // - "ActionRead" :: read aibridge interceptions & related records + // - "ActionUpdate" :: update aibridge interceptions & related records + ResourceAibridgeInterception = Object{ + Type: "aibridge_interception", + } + // ResourceApiKey // Valid Actions // - "ActionCreate" :: create an api key @@ -391,6 +400,7 @@ var ( func AllResources() []Objecter { return []Objecter{ ResourceWildcard, + ResourceAibridgeInterception, ResourceApiKey, ResourceAssignOrgRole, ResourceAssignRole, diff --git a/coderd/rbac/policy/policy.go b/coderd/rbac/policy/policy.go index 25fb87bfc2d94..93b0ba4e76215 100644 --- a/coderd/rbac/policy/policy.go +++ b/coderd/rbac/policy/policy.go @@ -358,4 +358,11 @@ var RBACPermissions = map[string]PermissionDefinition{ ActionUpdate: "update usage events", }, }, + "aibridge_interception": { + Actions: map[Action]ActionDefinition{ + ActionRead: "read aibridge interceptions & related records", + ActionUpdate: "update aibridge interceptions & related records", + ActionCreate: "create aibridge interceptions & related records", + }, + }, } diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 57a5022392b51..1c68e63246424 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -888,6 +888,22 @@ func TestRolePermissions(t *testing.T) { }, }, }, + { + Name: "AIBridgeInterceptions", + Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate}, + Resource: rbac.ResourceAibridgeInterception, + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {}, + false: { + owner, + memberMe, orgMemberMe, otherOrgMember, + orgAdmin, otherOrgAdmin, + orgAuditor, otherOrgAuditor, + templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, + userAdmin, orgUserAdmin, otherOrgUserAdmin, + }, + }, + }, } // We expect every permission to be tested above. diff --git a/codersdk/rbacresources_gen.go b/codersdk/rbacresources_gen.go index 54532106a6fd1..6a8185e6eb62a 100644 --- a/codersdk/rbacresources_gen.go +++ b/codersdk/rbacresources_gen.go @@ -5,6 +5,7 @@ type RBACResource string const ( ResourceWildcard RBACResource = "*" + ResourceAibridgeInterception RBACResource = "aibridge_interception" ResourceApiKey RBACResource = "api_key" ResourceAssignOrgRole RBACResource = "assign_org_role" ResourceAssignRole RBACResource = "assign_role" @@ -71,6 +72,7 @@ const ( // said resource type. var RBACResourceActions = map[RBACResource][]RBACAction{ ResourceWildcard: {}, + ResourceAibridgeInterception: {ActionCreate, ActionRead, ActionUpdate}, ResourceApiKey: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceAssignOrgRole: {ActionAssign, ActionCreate, ActionDelete, ActionRead, ActionUnassign, ActionUpdate}, ResourceAssignRole: {ActionAssign, ActionRead, ActionUnassign}, diff --git a/docs/reference/api/members.md b/docs/reference/api/members.md index 5a6bd2c861bac..6e02b90bc6b9d 100644 --- a/docs/reference/api/members.md +++ b/docs/reference/api/members.md @@ -183,6 +183,7 @@ Status Code **200** | `action` | `start` | | `action` | `stop` | | `resource_type` | `*` | +| `resource_type` | `aibridge_interception` | | `resource_type` | `api_key` | | `resource_type` | `assign_org_role` | | `resource_type` | `assign_role` | @@ -355,6 +356,7 @@ Status Code **200** | `action` | `start` | | `action` | `stop` | | `resource_type` | `*` | +| `resource_type` | `aibridge_interception` | | `resource_type` | `api_key` | | `resource_type` | `assign_org_role` | | `resource_type` | `assign_role` | @@ -527,6 +529,7 @@ Status Code **200** | `action` | `start` | | `action` | `stop` | | `resource_type` | `*` | +| `resource_type` | `aibridge_interception` | | `resource_type` | `api_key` | | `resource_type` | `assign_org_role` | | `resource_type` | `assign_role` | @@ -668,6 +671,7 @@ Status Code **200** | `action` | `start` | | `action` | `stop` | | `resource_type` | `*` | +| `resource_type` | `aibridge_interception` | | `resource_type` | `api_key` | | `resource_type` | `assign_org_role` | | `resource_type` | `assign_role` | @@ -1031,6 +1035,7 @@ Status Code **200** | `action` | `start` | | `action` | `stop` | | `resource_type` | `*` | +| `resource_type` | `aibridge_interception` | | `resource_type` | `api_key` | | `resource_type` | `assign_org_role` | | `resource_type` | `assign_role` | diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 24f3725ee321e..611cf61d76e09 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -6519,6 +6519,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| | Value | |------------------------------------| | `*` | +| `aibridge_interception` | | `api_key` | | `assign_org_role` | | `assign_role` | diff --git a/site/src/api/rbacresourcesGenerated.ts b/site/src/api/rbacresourcesGenerated.ts index 145b9ff9f8d7f..e2a394894965f 100644 --- a/site/src/api/rbacresourcesGenerated.ts +++ b/site/src/api/rbacresourcesGenerated.ts @@ -8,6 +8,11 @@ import type { RBACAction, RBACResource } from "./typesGenerated"; export const RBACResourceActions: Partial< Record>> > = { + aibridge_interception: { + create: "create aibridge interceptions & related records", + read: "read aibridge interceptions & related records", + update: "update aibridge interceptions & related records", + }, api_key: { create: "create an api key", delete: "delete an api key", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index f225c5af17e51..f924e56c7a0a7 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2415,6 +2415,7 @@ export const RBACActions: RBACAction[] = [ // From codersdk/rbacresources_gen.go export type RBACResource = + | "aibridge_interception" | "api_key" | "assign_org_role" | "assign_role" @@ -2457,6 +2458,7 @@ export type RBACResource = | "workspace_proxy"; export const RBACResources: RBACResource[] = [ + "aibridge_interception", "api_key", "assign_org_role", "assign_role", From d4a58dacd86cfdc590f15f5bf9ce53a8384c3a2e Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 15 Sep 2025 14:55:56 +0200 Subject: [PATCH 2/3] chore: add fixture Signed-off-by: Danny Kopping --- .../testdata/fixtures/000370_aibridge.up.sql | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 coderd/database/migrations/testdata/fixtures/000370_aibridge.up.sql diff --git a/coderd/database/migrations/testdata/fixtures/000370_aibridge.up.sql b/coderd/database/migrations/testdata/fixtures/000370_aibridge.up.sql new file mode 100644 index 0000000000000..0a66555eea0f1 --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000370_aibridge.up.sql @@ -0,0 +1,79 @@ +INSERT INTO + aibridge_interceptions ( + id, + initiator_id, + provider, + model, + started_at + ) +VALUES ( + 'be003e1e-b38f-43bf-847d-928074dd0aa8', + '30095c71-380b-457a-8995-97b8ee6e5307', + 'openai', + 'gpt-5', + '2025-09-15 12:45:13.921148+00' + ); + +INSERT INTO + aibridge_token_usages ( + id, + interception_id, + provider_response_id, + input_tokens, + output_tokens, + metadata, + created_at + ) +VALUES ( + 'c56ca89d-af65-47b0-871f-0b9cd2af6575', + 'be003e1e-b38f-43bf-847d-928074dd0aa8', + 'chatcmpl-CG2s28QlpKIoooUtXuLTmGbdtyS1k', + 10950, + 118, + '{"prompt_audio": 0, "prompt_cached": 5376, "completion_audio": 0, "completion_reasoning": 64, "completion_accepted_prediction": 0, "completion_rejected_prediction": 0}', + '2025-09-15 12:45:21.674413+00' + ); + +INSERT INTO + aibridge_tool_usages ( + id, + interception_id, + provider_response_id, + server_url, + tool, + input, + injected, + invocation_error, + metadata, + created_at + ) +VALUES ( + '613b4cfa-a257-4e88-99e6-4d2e99ea25f0', + 'be003e1e-b38f-43bf-847d-928074dd0aa8', + 'chatcmpl-CG2ryDxMp6n53aMjgo7P6BHno3fTr', + 'http://localhost:3000/api/experimental/mcp/http', + 'coder_list_workspaces', + '{}', + true, + NULL, + '{}', + '2025-09-15 12:45:17.65274+00' + ); + +INSERT INTO + aibridge_user_prompts ( + id, + interception_id, + provider_response_id, + prompt, + metadata, + created_at + ) +VALUES ( + 'ac1ea8c3-5109-4105-9b62-489fca220ef7', + 'be003e1e-b38f-43bf-847d-928074dd0aa8', + 'chatcmpl-CG2s28QlpKIoooUtXuLTmGbdtyS1k', + 'how many workspaces do i have', + '{}', + '2025-09-15 12:45:21.674335+00' + ); From 04c584e17a7e71f741c673bf96bc8952d15138ec Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 15 Sep 2025 14:56:04 +0200 Subject: [PATCH 3/3] chore: fix role test Signed-off-by: Danny Kopping --- coderd/rbac/roles_test.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 1c68e63246424..b6b8a95298b93 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -893,9 +893,8 @@ func TestRolePermissions(t *testing.T) { Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate}, Resource: rbac.ResourceAibridgeInterception, AuthorizeMap: map[bool][]hasAuthSubjects{ - true: {}, + true: {owner}, false: { - owner, memberMe, orgMemberMe, otherOrgMember, orgAdmin, otherOrgAdmin, orgAuditor, otherOrgAuditor,