mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
dba9f68b11
_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.
275 lines
8.3 KiB
Go
275 lines
8.3 KiB
Go
package cli_test
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/coder/coder/v2/cli/clitest"
|
|
"github.com/coder/coder/v2/coderd/coderdtest"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbgen"
|
|
"github.com/coder/coder/v2/coderd/database/dbtime"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
|
|
"github.com/coder/coder/v2/enterprise/coderd/license"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
func TestAIBridgeListInterceptions(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("OK", 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,
|
|
},
|
|
LicenseOptions: &coderdenttest.LicenseOptions{
|
|
Features: license.Features{
|
|
codersdk.FeatureAIBridge: 1,
|
|
},
|
|
},
|
|
})
|
|
_, member := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
|
|
now := dbtime.Now()
|
|
interception1 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
|
InitiatorID: member.ID,
|
|
StartedAt: now.Add(-time.Hour),
|
|
}, &now)
|
|
interception2EndedAt := now.Add(time.Minute)
|
|
interception2 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
|
InitiatorID: member.ID,
|
|
StartedAt: now,
|
|
}, &interception2EndedAt)
|
|
interception3EndedAt := now.Add(-time.Hour)
|
|
interception3 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
|
InitiatorID: owner.UserID,
|
|
StartedAt: now.Add(-2 * time.Hour),
|
|
}, &interception3EndedAt)
|
|
|
|
args := []string{
|
|
"aibridge",
|
|
"interceptions",
|
|
"list",
|
|
}
|
|
inv, root := newCLI(t, args...)
|
|
//nolint:gocritic // Owner can read all interceptions.
|
|
clitest.SetupConfig(t, ownerClient, root)
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
out := bytes.NewBuffer(nil)
|
|
inv.Stdout = out
|
|
err := inv.WithContext(ctx).Run()
|
|
require.NoError(t, err)
|
|
|
|
// 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) {
|
|
t.Parallel()
|
|
|
|
dv := coderdtest.DeploymentValues(t)
|
|
dv.AI.BridgeConfig.Enabled = true
|
|
ownerClient, db, owner := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
|
|
Options: &coderdtest.Options{
|
|
DeploymentValues: dv,
|
|
},
|
|
LicenseOptions: &coderdenttest.LicenseOptions{
|
|
Features: license.Features{
|
|
codersdk.FeatureAIBridge: 1,
|
|
},
|
|
},
|
|
})
|
|
_, member := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
|
|
|
|
now := dbtime.Now()
|
|
|
|
// This interception should be returned since it matches all filters.
|
|
goodInterceptionEndedAt := now.Add(time.Minute)
|
|
goodInterception := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
|
InitiatorID: member.ID,
|
|
Provider: "real-provider",
|
|
Model: "real-model",
|
|
StartedAt: now,
|
|
}, &goodInterceptionEndedAt)
|
|
|
|
// These interceptions should not be returned since they don't match the
|
|
// filters.
|
|
_ = dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
|
InitiatorID: owner.UserID,
|
|
Provider: goodInterception.Provider,
|
|
Model: goodInterception.Model,
|
|
StartedAt: goodInterception.StartedAt,
|
|
}, nil)
|
|
_ = dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
|
InitiatorID: goodInterception.InitiatorID,
|
|
Provider: "bad-provider",
|
|
Model: goodInterception.Model,
|
|
StartedAt: goodInterception.StartedAt,
|
|
}, nil)
|
|
_ = dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
|
InitiatorID: goodInterception.InitiatorID,
|
|
Provider: goodInterception.Provider,
|
|
Model: "bad-model",
|
|
StartedAt: goodInterception.StartedAt,
|
|
}, nil)
|
|
_ = dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
|
InitiatorID: goodInterception.InitiatorID,
|
|
Provider: goodInterception.Provider,
|
|
Model: goodInterception.Model,
|
|
// Violates the started after filter.
|
|
StartedAt: now.Add(-2 * time.Hour),
|
|
}, nil)
|
|
_ = dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
|
InitiatorID: goodInterception.InitiatorID,
|
|
Provider: goodInterception.Provider,
|
|
Model: goodInterception.Model,
|
|
// Violates the started before filter.
|
|
StartedAt: now.Add(2 * time.Hour),
|
|
}, nil)
|
|
|
|
args := []string{
|
|
"aibridge",
|
|
"interceptions",
|
|
"list",
|
|
"--started-after", now.Add(-time.Hour).Format(time.RFC3339),
|
|
"--started-before", now.Add(time.Hour).Format(time.RFC3339),
|
|
"--initiator", member.Username,
|
|
"--provider", goodInterception.Provider,
|
|
"--model", goodInterception.Model,
|
|
}
|
|
inv, root := newCLI(t, args...)
|
|
//nolint:gocritic // Owner can read all interceptions.
|
|
clitest.SetupConfig(t, ownerClient, root)
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
out := bytes.NewBuffer(nil)
|
|
inv.Stdout = out
|
|
err := inv.WithContext(ctx).Run()
|
|
require.NoError(t, err)
|
|
|
|
requireHasInterceptions(t, out.Bytes(), []uuid.UUID{goodInterception.ID})
|
|
})
|
|
|
|
t.Run("FilterByMe", 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,
|
|
},
|
|
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,
|
|
},
|
|
LicenseOptions: &coderdenttest.LicenseOptions{
|
|
Features: license.Features{
|
|
codersdk.FeatureAIBridge: 1,
|
|
},
|
|
},
|
|
})
|
|
|
|
now := dbtime.Now()
|
|
firstInterceptionEndedAt := now.Add(time.Minute)
|
|
firstInterception := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
|
InitiatorID: owner.UserID,
|
|
StartedAt: now,
|
|
}, &firstInterceptionEndedAt)
|
|
returnedInterception := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
|
InitiatorID: owner.UserID,
|
|
StartedAt: now.Add(-time.Hour),
|
|
}, &now)
|
|
_ = dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
|
InitiatorID: owner.UserID,
|
|
StartedAt: now.Add(-2 * time.Hour),
|
|
}, nil)
|
|
|
|
args := []string{
|
|
"aibridge",
|
|
"interceptions",
|
|
"list",
|
|
"--limit", "1",
|
|
"--after-id", firstInterception.ID.String(),
|
|
}
|
|
inv, root := newCLI(t, args...)
|
|
//nolint:gocritic // Owner can read all interceptions.
|
|
clitest.SetupConfig(t, ownerClient, root)
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
out := bytes.NewBuffer(nil)
|
|
inv.Stdout = out
|
|
err := inv.WithContext(ctx).Run()
|
|
require.NoError(t, err)
|
|
|
|
// Only contains the second interception because after_id is the first
|
|
// interception, and we set a limit of 1.
|
|
requireHasInterceptions(t, out.Bytes(), []uuid.UUID{returnedInterception.ID})
|
|
})
|
|
}
|
|
|
|
func requireHasInterceptions(t *testing.T, out []byte, ids []uuid.UUID) {
|
|
t.Helper()
|
|
|
|
var results []codersdk.AIBridgeInterception
|
|
require.NoError(t, json.Unmarshal(out, &results))
|
|
require.Len(t, results, len(ids))
|
|
for i, id := range ids {
|
|
require.Equal(t, id, results[i].ID)
|
|
}
|
|
}
|