From dba9f68b117a3c8028051608e1b6ff4699bafb02 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Tue, 24 Mar 2026 12:03:20 +0200 Subject: [PATCH] chore!: remove members' ability to read their own interceptions; rationalize RBAC requirements (#23320) _Disclaimer:_ _produced_ _by_ _Claude_ _Opus_ _4\.6,_ _reviewed_ _by_ _me._ **This is a breaking change.** Users who are not have `owner` or sitewide `auditor` roles will no longer be able to view interceptions. Regular users should not need to view this information; in fact, it could be used by a malicious insider to see what information we track and don't track to exfiltrate data or perform actions unobserved. --- Changed authorization for AI Bridge interception-related operations from system-level permissions to resource-specific permissions. The following functions now authorize against `rbac.ResourceAibridgeInterception` instead of `rbac.ResourceSystem`: - `ListAIBridgeTokenUsagesByInterceptionIDs` - `ListAIBridgeToolUsagesByInterceptionIDs` - `ListAIBridgeUserPromptsByInterceptionIDs` Updated RBAC roles to grant AI Bridge interception permissions: - **User/Member roles**: Can create and update AI Bridge interceptions but cannot read them back - **Service accounts**: Same create/update permissions without read access - **Owners/Auditors**: Retain full read access to all interceptions Removed system-level authorization bypass in `populatedAndConvertAIBridgeInterceptions` function, allowing proper resource-level authorization checks. Updated tests to reflect the new permission model where members cannot view AI Bridge interceptions, even their own, while owners and auditors maintain full visibility. --- coderd/database/dbauthz/dbauthz.go | 12 +--- coderd/database/dbauthz/dbauthz_test.go | 6 +- coderd/rbac/roles.go | 21 +++++- coderd/rbac/roles_test.go | 21 +++++- enterprise/cli/aibridge_test.go | 85 +++++++++++++++++----- enterprise/coderd/aibridge.go | 14 ++-- enterprise/coderd/aibridge_test.go | 95 +++++++++++++++++++------ 7 files changed, 191 insertions(+), 63 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 321f325ef2..e33f160e1a 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -5334,9 +5334,7 @@ func (q *querier) ListAIBridgeSessions(ctx context.Context, arg database.ListAIB } func (q *querier) ListAIBridgeTokenUsagesByInterceptionIDs(ctx context.Context, interceptionIDs []uuid.UUID) ([]database.AIBridgeTokenUsage, error) { - // This function is a system function until we implement a join for aibridge interceptions. - // Matches the behavior of the workspaces listing endpoint. - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAibridgeInterception); err != nil { return nil, err } @@ -5344,9 +5342,7 @@ func (q *querier) ListAIBridgeTokenUsagesByInterceptionIDs(ctx context.Context, } func (q *querier) ListAIBridgeToolUsagesByInterceptionIDs(ctx context.Context, interceptionIDs []uuid.UUID) ([]database.AIBridgeToolUsage, error) { - // This function is a system function until we implement a join for aibridge interceptions. - // Matches the behavior of the workspaces listing endpoint. - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAibridgeInterception); err != nil { return nil, err } @@ -5354,9 +5350,7 @@ func (q *querier) ListAIBridgeToolUsagesByInterceptionIDs(ctx context.Context, i } func (q *querier) ListAIBridgeUserPromptsByInterceptionIDs(ctx context.Context, interceptionIDs []uuid.UUID) ([]database.AIBridgeUserPrompt, error) { - // This function is a system function until we implement a join for aibridge interceptions. - // Matches the behavior of the workspaces listing endpoint. - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAibridgeInterception); err != nil { return nil, err } diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index b38ff83ffd..d6d59ad3a7 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -5545,19 +5545,19 @@ func (s *MethodTestSuite) TestAIBridge() { s.Run("ListAIBridgeTokenUsagesByInterceptionIDs", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { ids := []uuid.UUID{{1}} db.EXPECT().ListAIBridgeTokenUsagesByInterceptionIDs(gomock.Any(), ids).Return([]database.AIBridgeTokenUsage{}, nil).AnyTimes() - check.Args(ids).Asserts(rbac.ResourceSystem, policy.ActionRead).Returns([]database.AIBridgeTokenUsage{}) + check.Args(ids).Asserts(rbac.ResourceAibridgeInterception, policy.ActionRead).Returns([]database.AIBridgeTokenUsage{}) })) s.Run("ListAIBridgeUserPromptsByInterceptionIDs", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { ids := []uuid.UUID{{1}} db.EXPECT().ListAIBridgeUserPromptsByInterceptionIDs(gomock.Any(), ids).Return([]database.AIBridgeUserPrompt{}, nil).AnyTimes() - check.Args(ids).Asserts(rbac.ResourceSystem, policy.ActionRead).Returns([]database.AIBridgeUserPrompt{}) + check.Args(ids).Asserts(rbac.ResourceAibridgeInterception, policy.ActionRead).Returns([]database.AIBridgeUserPrompt{}) })) s.Run("ListAIBridgeToolUsagesByInterceptionIDs", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { ids := []uuid.UUID{{1}} db.EXPECT().ListAIBridgeToolUsagesByInterceptionIDs(gomock.Any(), ids).Return([]database.AIBridgeToolUsage{}, nil).AnyTimes() - check.Args(ids).Asserts(rbac.ResourceSystem, policy.ActionRead).Returns([]database.AIBridgeToolUsage{}) + check.Args(ids).Asserts(rbac.ResourceAibridgeInterception, policy.ActionRead).Returns([]database.AIBridgeToolUsage{}) })) s.Run("UpdateAIBridgeInterceptionEnded", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 03285bd6db..a1005a033d 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -316,13 +316,16 @@ func ReloadBuiltinRoles(opts *RoleOptions) { denyPermissions..., ), User: append( - allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUser, ResourceOrganizationMember, ResourceOrganizationMember, ResourceBoundaryUsage), + allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUser, ResourceOrganizationMember, ResourceOrganizationMember, ResourceBoundaryUsage, ResourceAibridgeInterception), Permissions(map[string][]policy.Action{ // Users cannot do create/update/delete on themselves, but they // can read their own details. ResourceUser.Type: {policy.ActionRead, policy.ActionReadPersonal, policy.ActionUpdatePersonal}, // Users can create provisioner daemons scoped to themselves. ResourceProvisionerDaemon.Type: {policy.ActionRead, policy.ActionCreate, policy.ActionRead, policy.ActionUpdate}, + // Members can create and update AI Bridge interceptions but + // cannot read them back. + ResourceAibridgeInterception.Type: {policy.ActionCreate, policy.ActionUpdate}, })..., ), ByOrgID: map[string]OrgPermissions{}, @@ -345,7 +348,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { // Allow auditors to query deployment stats and insights. ResourceDeploymentStats.Type: {policy.ActionRead}, ResourceDeploymentConfig.Type: {policy.ActionRead}, - // Allow auditors to query aibridge interceptions. + // Allow auditors to query AI Bridge interceptions. ResourceAibridgeInterception.Type: {policy.ActionRead}, }), User: []Permission{}, @@ -998,6 +1001,7 @@ func OrgMemberPermissions(org OrgSettings) OrgRolePermissions { ResourcePrebuiltWorkspace, ResourceUser, ResourceOrganizationMember, + ResourceAibridgeInterception, ), Permissions(map[string][]policy.Action{ // Reduced permission set on dormant workspaces. No build, @@ -1016,6 +1020,12 @@ func OrgMemberPermissions(org OrgSettings) OrgRolePermissions { ResourceOrganizationMember.Type: { policy.ActionRead, }, + // Members can create and update AI Bridge interceptions but + // cannot read them back. + ResourceAibridgeInterception.Type: { + policy.ActionCreate, + policy.ActionUpdate, + }, })..., ) @@ -1073,6 +1083,7 @@ func OrgServiceAccountPermissions(org OrgSettings) OrgRolePermissions { ResourcePrebuiltWorkspace, ResourceUser, ResourceOrganizationMember, + ResourceAibridgeInterception, ), Permissions(map[string][]policy.Action{ // Reduced permission set on dormant workspaces. No build, @@ -1091,6 +1102,12 @@ func OrgServiceAccountPermissions(org OrgSettings) OrgRolePermissions { ResourceOrganizationMember.Type: { policy.ActionRead, }, + // Service accounts can create and update AI Bridge + // interceptions but cannot read them back. + ResourceAibridgeInterception.Type: { + policy.ActionCreate, + policy.ActionUpdate, + }, })..., ) diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 16b14057e4..3421aa408b 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -1023,8 +1023,9 @@ func TestRolePermissions(t *testing.T) { }, }, { - Name: "AIBridgeInterceptions", - Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate}, + // Members can create/update records but can't read them afterwards. + Name: "AIBridgeInterceptionsCreateUpdate", + Actions: []policy.Action{policy.ActionCreate, policy.ActionUpdate}, Resource: rbac.ResourceAibridgeInterception.WithOwner(currentUser.String()), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, memberMe}, @@ -1036,6 +1037,22 @@ func TestRolePermissions(t *testing.T) { }, }, }, + { + // Only owners and site-wide auditors can view interceptions and their sub-resources. + Name: "AIBridgeInterceptionsRead", + Actions: []policy.Action{policy.ActionRead}, + Resource: rbac.ResourceAibridgeInterception.WithOwner(currentUser.String()), + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {owner, auditor}, + false: { + memberMe, + orgAdmin, otherOrgAdmin, + orgAuditor, otherOrgAuditor, + templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, + userAdmin, orgUserAdmin, otherOrgUserAdmin, + }, + }, + }, { Name: "BoundaryUsage", Actions: []policy.Action{policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, diff --git a/enterprise/cli/aibridge_test.go b/enterprise/cli/aibridge_test.go index 21b76d0ad9..018d7bb0c9 100644 --- a/enterprise/cli/aibridge_test.go +++ b/enterprise/cli/aibridge_test.go @@ -28,7 +28,7 @@ func TestAIBridgeListInterceptions(t *testing.T) { dv := coderdtest.DeploymentValues(t) dv.AI.BridgeConfig.Enabled = true - client, db, owner := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + ownerClient, db, owner := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ Options: &coderdtest.Options{ DeploymentValues: dv, }, @@ -38,7 +38,7 @@ func TestAIBridgeListInterceptions(t *testing.T) { }, }, }) - memberClient, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + _, member := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) now := dbtime.Now() interception1 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{ InitiatorID: member.ID, @@ -49,11 +49,11 @@ func TestAIBridgeListInterceptions(t *testing.T) { InitiatorID: member.ID, StartedAt: now, }, &interception2EndedAt) - // Should not be returned because the user can't see it. - _ = dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{ + interception3EndedAt := now.Add(-time.Hour) + interception3 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{ InitiatorID: owner.UserID, StartedAt: now.Add(-2 * time.Hour), - }, nil) + }, &interception3EndedAt) args := []string{ "aibridge", @@ -61,7 +61,8 @@ func TestAIBridgeListInterceptions(t *testing.T) { "list", } inv, root := newCLI(t, args...) - clitest.SetupConfig(t, memberClient, root) + //nolint:gocritic // Owner can read all interceptions. + clitest.SetupConfig(t, ownerClient, root) ctx := testutil.Context(t, testutil.WaitLong) @@ -70,8 +71,8 @@ func TestAIBridgeListInterceptions(t *testing.T) { err := inv.WithContext(ctx).Run() require.NoError(t, err) - // Reverse order because the order is `started_at ASC`. - requireHasInterceptions(t, out.Bytes(), []uuid.UUID{interception2.ID, interception1.ID}) + // Owner sees all interceptions. Ordered by started_at DESC. + requireHasInterceptions(t, out.Bytes(), []uuid.UUID{interception2.ID, interception1.ID, interception3.ID}) }) t.Run("Filter", func(t *testing.T) { @@ -79,7 +80,7 @@ func TestAIBridgeListInterceptions(t *testing.T) { dv := coderdtest.DeploymentValues(t) dv.AI.BridgeConfig.Enabled = true - client, db, owner := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + ownerClient, db, owner := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ Options: &coderdtest.Options{ DeploymentValues: dv, }, @@ -89,7 +90,7 @@ func TestAIBridgeListInterceptions(t *testing.T) { }, }, }) - memberClient, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + _, member := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) now := dbtime.Now() @@ -143,12 +144,13 @@ func TestAIBridgeListInterceptions(t *testing.T) { "list", "--started-after", now.Add(-time.Hour).Format(time.RFC3339), "--started-before", now.Add(time.Hour).Format(time.RFC3339), - "--initiator", codersdk.Me, + "--initiator", member.Username, "--provider", goodInterception.Provider, "--model", goodInterception.Model, } inv, root := newCLI(t, args...) - clitest.SetupConfig(t, memberClient, root) + //nolint:gocritic // Owner can read all interceptions. + clitest.SetupConfig(t, ownerClient, root) ctx := testutil.Context(t, testutil.WaitLong) @@ -160,12 +162,57 @@ func TestAIBridgeListInterceptions(t *testing.T) { requireHasInterceptions(t, out.Bytes(), []uuid.UUID{goodInterception.ID}) }) - t.Run("Pagination", func(t *testing.T) { + t.Run("FilterByMe", func(t *testing.T) { t.Parallel() dv := coderdtest.DeploymentValues(t) dv.AI.BridgeConfig.Enabled = true - client, db, owner := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + ownerClient, db, owner := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAIBridge: 1, + }, + }, + }) + memberClient, member := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + + now := dbtime.Now() + + // Create an interception initiated by the member. + _ = dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{ + InitiatorID: member.ID, + StartedAt: now, + }, nil) + + args := []string{ + "aibridge", + "interceptions", + "list", + "--initiator", codersdk.Me, + } + inv, root := newCLI(t, args...) + clitest.SetupConfig(t, memberClient, root) + + ctx := testutil.Context(t, testutil.WaitLong) + + out := bytes.NewBuffer(nil) + inv.Stdout = out + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + // Member cannot read their own interceptions. + requireHasInterceptions(t, out.Bytes(), []uuid.UUID{}) + }) + + t.Run("Pagination", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.AI.BridgeConfig.Enabled = true + ownerClient, db, owner := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ Options: &coderdtest.Options{ DeploymentValues: dv, }, @@ -175,20 +222,19 @@ func TestAIBridgeListInterceptions(t *testing.T) { }, }, }) - memberClient, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) now := dbtime.Now() firstInterceptionEndedAt := now.Add(time.Minute) firstInterception := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{ - InitiatorID: member.ID, + InitiatorID: owner.UserID, StartedAt: now, }, &firstInterceptionEndedAt) returnedInterception := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{ - InitiatorID: member.ID, + InitiatorID: owner.UserID, StartedAt: now.Add(-time.Hour), }, &now) _ = dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{ - InitiatorID: member.ID, + InitiatorID: owner.UserID, StartedAt: now.Add(-2 * time.Hour), }, nil) @@ -200,7 +246,8 @@ func TestAIBridgeListInterceptions(t *testing.T) { "--after-id", firstInterception.ID.String(), } inv, root := newCLI(t, args...) - clitest.SetupConfig(t, memberClient, root) + //nolint:gocritic // Owner can read all interceptions. + clitest.SetupConfig(t, ownerClient, root) ctx := testutil.Context(t, testutil.WaitLong) diff --git a/enterprise/coderd/aibridge.go b/enterprise/coderd/aibridge.go index 11d972531e..77b6363d07 100644 --- a/enterprise/coderd/aibridge.go +++ b/enterprise/coderd/aibridge.go @@ -15,7 +15,6 @@ import ( "github.com/coder/coder/v2/coderd" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" - "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/searchquery" @@ -358,13 +357,16 @@ func (api *API) aiBridgeListModels(rw http.ResponseWriter, r *http.Request) { } func populatedAndConvertAIBridgeInterceptions(ctx context.Context, db database.Store, dbInterceptions []database.ListAIBridgeInterceptionsRow) ([]codersdk.AIBridgeInterception, error) { + if len(dbInterceptions) == 0 { + return []codersdk.AIBridgeInterception{}, nil + } + ids := make([]uuid.UUID, len(dbInterceptions)) for i, row := range dbInterceptions { ids[i] = row.AIBridgeInterception.ID } - //nolint:gocritic // This is a system function until we implement a join for aibridge interceptions. AI Bridge interception subresources use the same authorization call as their parent. - tokenUsagesRows, err := db.ListAIBridgeTokenUsagesByInterceptionIDs(dbauthz.AsSystemRestricted(ctx), ids) + tokenUsagesRows, err := db.ListAIBridgeTokenUsagesByInterceptionIDs(ctx, ids) if err != nil { return nil, xerrors.Errorf("get linked aibridge token usages from database: %w", err) } @@ -373,8 +375,7 @@ func populatedAndConvertAIBridgeInterceptions(ctx context.Context, db database.S tokenUsagesMap[row.InterceptionID] = append(tokenUsagesMap[row.InterceptionID], row) } - //nolint:gocritic // This is a system function until we implement a join for aibridge interceptions. AI Bridge interception subresources use the same authorization call as their parent. - userPromptRows, err := db.ListAIBridgeUserPromptsByInterceptionIDs(dbauthz.AsSystemRestricted(ctx), ids) + userPromptRows, err := db.ListAIBridgeUserPromptsByInterceptionIDs(ctx, ids) if err != nil { return nil, xerrors.Errorf("get linked aibridge user prompts from database: %w", err) } @@ -383,8 +384,7 @@ func populatedAndConvertAIBridgeInterceptions(ctx context.Context, db database.S userPromptsMap[row.InterceptionID] = append(userPromptsMap[row.InterceptionID], row) } - //nolint:gocritic // This is a system function until we implement a join for aibridge interceptions. AI Bridge interception subresources use the same authorization call as their parent. - toolUsagesRows, err := db.ListAIBridgeToolUsagesByInterceptionIDs(dbauthz.AsSystemRestricted(ctx), ids) + toolUsagesRows, err := db.ListAIBridgeToolUsagesByInterceptionIDs(ctx, ids) if err != nil { return nil, xerrors.Errorf("get linked aibridge tool usages from database: %w", err) } diff --git a/enterprise/coderd/aibridge_test.go b/enterprise/coderd/aibridge_test.go index 197f1763cd..92686f9f61 100644 --- a/enterprise/coderd/aibridge_test.go +++ b/enterprise/coderd/aibridge_test.go @@ -16,6 +16,7 @@ import ( "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/cryptorand" "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" @@ -369,20 +370,21 @@ func TestAIBridgeListInterceptions(t *testing.T) { StartedAt: now.Add(-time.Hour), }, &now) - // Admin can see all interceptions. - res, err := adminClient.AIBridgeListInterceptions(ctx, codersdk.AIBridgeListInterceptionsFilter{}) + // Members cannot read AIBridge interceptions, not even their + // own (i2 is owned by secondUser). + res, err := secondUserClient.AIBridgeListInterceptions(ctx, codersdk.AIBridgeListInterceptionsFilter{}) + require.NoError(t, err) + require.EqualValues(t, 0, res.Count) + require.Empty(t, res.Results) + + // Owner can see all interceptions, including secondUser's, + // proving the data exists and the member was filtered out. + res, err = adminClient.AIBridgeListInterceptions(ctx, codersdk.AIBridgeListInterceptionsFilter{}) require.NoError(t, err) require.EqualValues(t, 2, res.Count) require.Len(t, res.Results, 2) require.Equal(t, i1.ID, res.Results[0].ID) require.Equal(t, i2.ID, res.Results[1].ID) - - // Second user can only see their own interceptions. - res, err = secondUserClient.AIBridgeListInterceptions(ctx, codersdk.AIBridgeListInterceptionsFilter{}) - require.NoError(t, err) - require.EqualValues(t, 1, res.Count) - require.Len(t, res.Results, 1) - require.Equal(t, i2.ID, res.Results[0].ID) }) t.Run("Filter", func(t *testing.T) { @@ -583,6 +585,41 @@ func TestAIBridgeListInterceptions(t *testing.T) { } }) + t.Run("FilterByMe/MemberCannotReadOwn", func(t *testing.T) { + t.Parallel() + dv := coderdtest.DeploymentValues(t) + dv.AI.BridgeConfig.Enabled = serpent.Bool(true) + ownerClient, db, firstUser := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAIBridge: 1, + }, + }, + }) + ctx := testutil.Context(t, testutil.WaitLong) + + memberClient, member := coderdtest.CreateAnotherUser(t, ownerClient, firstUser.OrganizationID) + + now := dbtime.Now() + // Create an interception initiated by the member. + _ = dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{ + InitiatorID: member.ID, + StartedAt: now, + }, nil) + + // Member cannot read their own interceptions, even when + // filtering by "me". + res, err := memberClient.AIBridgeListInterceptions(ctx, codersdk.AIBridgeListInterceptionsFilter{ + Initiator: codersdk.Me, + }) + require.NoError(t, err) + require.EqualValues(t, 0, res.Count) + require.Empty(t, res.Results) + }) + t.Run("FilterErrors", func(t *testing.T) { t.Parallel() dv := coderdtest.DeploymentValues(t) @@ -1005,12 +1042,36 @@ func TestAIBridgeListSessions(t *testing.T) { require.Empty(t, res.Sessions) }) + t.Run("FilterByMe/MemberCannotReadOwn", func(t *testing.T) { + t.Parallel() + ownerClient, db, firstUser := coderdenttest.NewWithDatabase(t, aibridgeOpts(t)) + ctx := testutil.Context(t, testutil.WaitLong) + + memberClient, member := coderdtest.CreateAnotherUser(t, ownerClient, firstUser.OrganizationID) + + now := dbtime.Now() + // Create an interception (session) initiated by the member. + _ = dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{ + InitiatorID: member.ID, + StartedAt: now, + }, nil) + + // Member cannot read their own sessions, even when + // filtering by "me". + res, err := memberClient.AIBridgeListSessions(ctx, codersdk.AIBridgeListSessionsFilter{ + Initiator: codersdk.Me, + }) + require.NoError(t, err) + require.EqualValues(t, 0, res.Count) + require.Empty(t, res.Sessions) + }) + t.Run("Authorized", func(t *testing.T) { t.Parallel() adminClient, db, firstUser := coderdenttest.NewWithDatabase(t, aibridgeOpts(t)) ctx := testutil.Context(t, testutil.WaitLong) - secondUserClient, secondUser := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) + auditorClient, auditorUser := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID, rbac.RoleAuditor()) now := dbtime.Now() i1EndedAt := now.Add(time.Minute) @@ -1019,25 +1080,17 @@ func TestAIBridgeListSessions(t *testing.T) { StartedAt: now, }, &i1EndedAt) i2 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{ - InitiatorID: secondUser.ID, + InitiatorID: auditorUser.ID, StartedAt: now.Add(-time.Hour), }, &now) - // Admin can see all sessions. - //nolint:gocritic // Intentionally testing admin/owner visibility. - res, err := adminClient.AIBridgeListSessions(ctx, codersdk.AIBridgeListSessionsFilter{}) + // Site-level auditors can see all sessions. + res, err := auditorClient.AIBridgeListSessions(ctx, codersdk.AIBridgeListSessionsFilter{}) require.NoError(t, err) require.EqualValues(t, 2, res.Count) require.Len(t, res.Sessions, 2) require.Equal(t, i1.ID.String(), res.Sessions[0].ID) require.Equal(t, i2.ID.String(), res.Sessions[1].ID) - - // Second user can only see their own sessions. - res, err = secondUserClient.AIBridgeListSessions(ctx, codersdk.AIBridgeListSessionsFilter{}) - require.NoError(t, err) - require.EqualValues(t, 1, res.Count) - require.Len(t, res.Sessions, 1) - require.Equal(t, i2.ID.String(), res.Sessions[0].ID) }) t.Run("SessionIDCollisionAcrossUsers", func(t *testing.T) {