mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add organization scoping to chats (#23827)
Fixes https://github.com/coder/internal/issues/1436 * Adds organization_id to chats with backfill (workspace org → user org membership → default org) * No support yet for ACLs (follow-up issue) - Cross-org workspace binding rejected (both in `CreateChatRequest` and in `create_workspace` tool - Adds `OrganizationAutocomplete` to `AgentCreateForm` - Docs updated with `organization_id` in chats-api.md > 🤖 Written by a Coder Agent. Reviewed by many humans and many agents. --------- Co-authored-by: Mathias Fredriksson <mafredri@gmail.com>
This commit is contained in:
co-authored by
Mathias Fredriksson
parent
cb0b84a2d3
commit
22062ec52e
@@ -14,6 +14,8 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chattest"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
|
||||
@@ -30,7 +32,7 @@ func TestChatStreamRelay(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
db, pubsub := dbtestutil.NewDB(t)
|
||||
firstClient, _ := coderdenttest.New(t, &coderdenttest.Options{
|
||||
firstClient, firstUser := coderdenttest.New(t, &coderdenttest.Options{
|
||||
Options: &coderdtest.Options{
|
||||
Database: db,
|
||||
Pubsub: pubsub,
|
||||
@@ -93,6 +95,7 @@ func TestChatStreamRelay(t *testing.T) {
|
||||
|
||||
// Create a chat on the first replica
|
||||
chat, err := codersdk.NewExperimentalClient(firstClient).CreateChat(ctx, codersdk.CreateChatRequest{
|
||||
OrganizationID: firstUser.OrganizationID,
|
||||
Content: []codersdk.ChatInputPart{{
|
||||
Type: codersdk.ChatInputPartTypeText,
|
||||
Text: "Test chat for relay",
|
||||
@@ -186,7 +189,7 @@ func TestChatStreamRelay(t *testing.T) {
|
||||
|
||||
certificates := []tls.Certificate{testutil.GenerateTLSCertificate(t, "localhost")}
|
||||
db, pubsub := dbtestutil.NewDB(t)
|
||||
firstClient, _ := coderdenttest.New(t, &coderdenttest.Options{
|
||||
firstClient, firstUser := coderdenttest.New(t, &coderdenttest.Options{
|
||||
Options: &coderdtest.Options{
|
||||
Database: db,
|
||||
Pubsub: pubsub,
|
||||
@@ -281,6 +284,7 @@ func TestChatStreamRelay(t *testing.T) {
|
||||
|
||||
// Create a chat on the first replica.
|
||||
chat, err := codersdk.NewExperimentalClient(firstClient).CreateChat(ctx, codersdk.CreateChatRequest{
|
||||
OrganizationID: firstUser.OrganizationID,
|
||||
Content: []codersdk.ChatInputPart{{
|
||||
Type: codersdk.ChatInputPartTypeText,
|
||||
Text: "Test chat for TLS relay",
|
||||
@@ -380,7 +384,7 @@ func TestChatStreamRelay(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
db, pubsub := dbtestutil.NewDB(t)
|
||||
firstClient, _ := coderdenttest.New(t, &coderdenttest.Options{
|
||||
firstClient, firstUser := coderdenttest.New(t, &coderdenttest.Options{
|
||||
Options: &coderdtest.Options{
|
||||
Database: db,
|
||||
Pubsub: pubsub,
|
||||
@@ -450,6 +454,7 @@ func TestChatStreamRelay(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
chat, err := codersdk.NewExperimentalClient(firstClient).CreateChat(ctx, codersdk.CreateChatRequest{
|
||||
OrganizationID: firstUser.OrganizationID,
|
||||
Content: []codersdk.ChatInputPart{{
|
||||
Type: codersdk.ChatInputPartTypeText,
|
||||
Text: "Test cookie-only relay",
|
||||
@@ -547,7 +552,7 @@ func TestChatStreamRelay(t *testing.T) {
|
||||
dv.HTTPCookies.EnableHostPrefix = true
|
||||
dv.HTTPCookies.Secure = true
|
||||
})
|
||||
firstClient, _ := coderdenttest.New(t, &coderdenttest.Options{
|
||||
firstClient, firstUser := coderdenttest.New(t, &coderdenttest.Options{
|
||||
Options: &coderdtest.Options{
|
||||
Database: db,
|
||||
Pubsub: pubsub,
|
||||
@@ -621,6 +626,7 @@ func TestChatStreamRelay(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
chat, err := codersdk.NewExperimentalClient(firstClient).CreateChat(ctx, codersdk.CreateChatRequest{
|
||||
OrganizationID: firstUser.OrganizationID,
|
||||
Content: []codersdk.ChatInputPart{{
|
||||
Type: codersdk.ChatInputPartTypeText,
|
||||
Text: "Test host-prefix relay",
|
||||
@@ -706,7 +712,7 @@ func TestChatStreamRelay(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
db, pubsub := dbtestutil.NewDB(t)
|
||||
firstClient, _ := coderdenttest.New(t, &coderdenttest.Options{
|
||||
firstClient, firstUser := coderdenttest.New(t, &coderdenttest.Options{
|
||||
Options: &coderdtest.Options{
|
||||
Database: db,
|
||||
Pubsub: pubsub,
|
||||
@@ -768,6 +774,7 @@ func TestChatStreamRelay(t *testing.T) {
|
||||
|
||||
// Create a chat on the first replica.
|
||||
chat, err := codersdk.NewExperimentalClient(firstClient).CreateChat(ctx, codersdk.CreateChatRequest{
|
||||
OrganizationID: firstUser.OrganizationID,
|
||||
Content: []codersdk.ChatInputPart{{
|
||||
Type: codersdk.ChatInputPartTypeText,
|
||||
Text: "Test chat for buffered relay",
|
||||
@@ -1087,3 +1094,81 @@ func (p cookieOnlySessionTokenProvider) SetDialOption(opts *websocket.DialOption
|
||||
}
|
||||
opts.HTTPHeader.Set("Cookie", cookieName+"="+p.token)
|
||||
}
|
||||
|
||||
func TestCreateChatNonDefaultOrg(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
client, firstUser := coderdenttest.New(t, &coderdenttest.Options{
|
||||
Options: &coderdtest.Options{
|
||||
DeploymentValues: func() *codersdk.DeploymentValues {
|
||||
v := coderdtest.DeploymentValues(t)
|
||||
v.Experiments = []string{string(codersdk.ExperimentAgents)}
|
||||
return v
|
||||
}(),
|
||||
},
|
||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||
Features: license.Features{
|
||||
codersdk.FeatureMultipleOrganizations: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
expClient := codersdk.NewExperimentalClient(client)
|
||||
|
||||
// Set up a chat provider and model config.
|
||||
provider, err := expClient.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{
|
||||
Provider: "openai",
|
||||
DisplayName: "OpenAI",
|
||||
APIKey: "test-key",
|
||||
BaseURL: "https://example.com",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = expClient.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{
|
||||
Provider: provider.Provider,
|
||||
Model: "gpt-4o-mini",
|
||||
DisplayName: "Test Model",
|
||||
IsDefault: ptr.Ref(true),
|
||||
ContextLimit: ptr.Ref(int64(1000)),
|
||||
CompressionThreshold: ptr.Ref(int32(70)),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a second (non-default) org via the API.
|
||||
secondOrg := coderdenttest.CreateOrganization(t, client, coderdenttest.CreateOrganizationOptions{})
|
||||
|
||||
// Create a member in the default org, then add them to the second org.
|
||||
memberClientRaw, member := coderdtest.CreateAnotherUser(
|
||||
t, client, firstUser.OrganizationID, rbac.RoleAgentsAccess(),
|
||||
)
|
||||
_, err = client.PostOrganizationMember(ctx, secondOrg.ID, member.Username)
|
||||
require.NoError(t, err)
|
||||
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
|
||||
|
||||
// Create a chat in the non-default org.
|
||||
chat, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
|
||||
OrganizationID: secondOrg.ID,
|
||||
Content: []codersdk.ChatInputPart{
|
||||
{
|
||||
Type: codersdk.ChatInputPartTypeText,
|
||||
Text: "hello from non-default org",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, secondOrg.ID, chat.OrganizationID)
|
||||
require.Equal(t, member.ID, chat.OwnerID)
|
||||
|
||||
// Verify the chat is visible when listing.
|
||||
chats, err := memberClient.ListChats(ctx, nil)
|
||||
require.NoError(t, err)
|
||||
var found bool
|
||||
for _, c := range chats {
|
||||
if c.ID == chat.ID {
|
||||
found = true
|
||||
require.Equal(t, secondOrg.ID, c.OrganizationID)
|
||||
break
|
||||
}
|
||||
}
|
||||
require.True(t, found, "chat should be visible in list")
|
||||
}
|
||||
|
||||
@@ -86,13 +86,13 @@ func newActiveWorkerServer(
|
||||
return server
|
||||
}
|
||||
|
||||
// seedChatDependencies creates a user and chat model config in the
|
||||
// database for use in relay tests.
|
||||
// seedChatDependencies creates a user, organization, and chat model
|
||||
// config in the database for use in relay tests.
|
||||
func seedChatDependencies(
|
||||
ctx context.Context,
|
||||
t *testing.T,
|
||||
db database.Store,
|
||||
) (database.User, database.ChatModelConfig) {
|
||||
) (database.User, database.Organization, database.ChatModelConfig) {
|
||||
t.Helper()
|
||||
|
||||
safetyNet := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
@@ -103,6 +103,11 @@ func seedChatDependencies(
|
||||
t.Cleanup(safetyNet.Close)
|
||||
|
||||
user := dbgen.User(t, db, database.User{})
|
||||
org := dbgen.Organization(t, db, database.Organization{})
|
||||
dbgen.OrganizationMember(t, db, database.OrganizationMember{
|
||||
UserID: user.ID,
|
||||
OrganizationID: org.ID,
|
||||
})
|
||||
_, err := db.InsertChatProvider(ctx, database.InsertChatProviderParams{
|
||||
Provider: "openai",
|
||||
DisplayName: "OpenAI",
|
||||
@@ -127,13 +132,14 @@ func seedChatDependencies(
|
||||
Options: json.RawMessage(`{}`),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return user, model
|
||||
return user, org, model
|
||||
}
|
||||
|
||||
func seedWaitingChat(
|
||||
ctx context.Context,
|
||||
t *testing.T,
|
||||
db database.Store,
|
||||
orgID uuid.UUID,
|
||||
user database.User,
|
||||
model database.ChatModelConfig,
|
||||
title string,
|
||||
@@ -141,6 +147,7 @@ func seedWaitingChat(
|
||||
t.Helper()
|
||||
|
||||
chat, err := db.InsertChat(ctx, database.InsertChatParams{
|
||||
OrganizationID: orgID,
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.ID,
|
||||
LastModelConfigID: model.ID,
|
||||
@@ -155,6 +162,7 @@ func seedRemoteRunningChat(
|
||||
ctx context.Context,
|
||||
t *testing.T,
|
||||
db database.Store,
|
||||
orgID uuid.UUID,
|
||||
user database.User,
|
||||
model database.ChatModelConfig,
|
||||
workerID uuid.UUID,
|
||||
@@ -162,7 +170,7 @@ func seedRemoteRunningChat(
|
||||
) database.Chat {
|
||||
t.Helper()
|
||||
|
||||
chat := seedWaitingChat(ctx, t, db, user, model, title)
|
||||
chat := seedWaitingChat(ctx, t, db, orgID, user, model, title)
|
||||
now := time.Now()
|
||||
chat, err := db.UpdateChatStatus(ctx, database.UpdateChatStatusParams{
|
||||
ID: chat.ID,
|
||||
@@ -247,9 +255,9 @@ func TestSubscribeRelayReconnectsOnDrop(t *testing.T) {
|
||||
subscriber := newTestServer(t, db, ps, subscriberID, provider, mclk)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
user, model := seedChatDependencies(ctx, t, db)
|
||||
user, org, model := seedChatDependencies(ctx, t, db)
|
||||
|
||||
chat := seedRemoteRunningChat(ctx, t, db, user, model, workerID, "relay-reconnect")
|
||||
chat := seedRemoteRunningChat(ctx, t, db, org.ID, user, model, workerID, "relay-reconnect")
|
||||
|
||||
_, events, cancel, ok := subscriber.Subscribe(ctx, chat.ID, nil, 0)
|
||||
require.True(t, ok)
|
||||
@@ -325,11 +333,11 @@ func TestSubscribeRelayAsyncDoesNotBlock(t *testing.T) {
|
||||
subscriber := newTestServer(t, db, ps, subscriberID, provider, nil)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
user, model := seedChatDependencies(ctx, t, db)
|
||||
user, org, model := seedChatDependencies(ctx, t, db)
|
||||
|
||||
// Seed a waiting chat so Subscribe does not trigger a synchronous
|
||||
// relay.
|
||||
chat := seedWaitingChat(ctx, t, db, user, model, "relay-async-nonblock")
|
||||
chat := seedWaitingChat(ctx, t, db, org.ID, user, model, "relay-async-nonblock")
|
||||
|
||||
// Subscribe before the chat is marked running so the relay opens
|
||||
// via pubsub notification (openRelayAsync path).
|
||||
@@ -427,9 +435,9 @@ func TestSubscribeRelaySnapshotDelivered(t *testing.T) {
|
||||
subscriber := newTestServer(t, db, ps, subscriberID, provider, nil)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
user, model := seedChatDependencies(ctx, t, db)
|
||||
user, org, model := seedChatDependencies(ctx, t, db)
|
||||
|
||||
chat := seedRemoteRunningChat(ctx, t, db, user, model, workerID, "relay-snapshot")
|
||||
chat := seedRemoteRunningChat(ctx, t, db, org.ID, user, model, workerID, "relay-snapshot")
|
||||
|
||||
initialSnapshot, events, cancel, ok := subscriber.Subscribe(ctx, chat.ID, nil, 0)
|
||||
require.True(t, ok)
|
||||
@@ -515,10 +523,11 @@ func TestSubscribeRetryEventAcrossInstances(t *testing.T) {
|
||||
}, nil)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
user, model := seedChatDependencies(ctx, t, db)
|
||||
user, org, model := seedChatDependencies(ctx, t, db)
|
||||
setOpenAIProviderBaseURL(ctx, t, db, openAIURL)
|
||||
|
||||
chat, err := worker.CreateChat(ctx, osschatd.CreateOptions{
|
||||
OrganizationID: org.ID,
|
||||
OwnerID: user.ID,
|
||||
Title: "retry-across-instances",
|
||||
ModelConfigID: model.ID,
|
||||
@@ -651,11 +660,11 @@ func TestSubscribeRelayStaleDialDiscardedAfterInterrupt(t *testing.T) {
|
||||
subscriber := newTestServer(t, db, ps, subscriberID, provider, nil)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
user, model := seedChatDependencies(ctx, t, db)
|
||||
user, org, model := seedChatDependencies(ctx, t, db)
|
||||
|
||||
// Seed the chat in waiting state so Subscribe does not try an initial
|
||||
// relay.
|
||||
chat := seedWaitingChat(ctx, t, db, user, model, "stale-dial-test")
|
||||
chat := seedWaitingChat(ctx, t, db, org.ID, user, model, "stale-dial-test")
|
||||
|
||||
// Subscribe while chat is in "waiting" state — no relay opened.
|
||||
_, events, cancel, ok := subscriber.Subscribe(ctx, chat.ID, nil, 0)
|
||||
@@ -803,11 +812,11 @@ func TestSubscribeCancelDuringInFlightDial(t *testing.T) {
|
||||
subscriber := newTestServer(t, db, ps, subscriberID, provider, nil)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
user, model := seedChatDependencies(ctx, t, db)
|
||||
user, org, model := seedChatDependencies(ctx, t, db)
|
||||
|
||||
// Seed the chat in waiting state so Subscribe does not open a
|
||||
// synchronous relay.
|
||||
chat := seedWaitingChat(ctx, t, db, user, model, "cancel-inflight-dial")
|
||||
chat := seedWaitingChat(ctx, t, db, org.ID, user, model, "cancel-inflight-dial")
|
||||
|
||||
_, _, cancel, ok := subscriber.Subscribe(ctx, chat.ID, nil, 0)
|
||||
require.True(t, ok)
|
||||
@@ -889,10 +898,10 @@ func TestSubscribeRelayRunningToRunningSwitch(t *testing.T) {
|
||||
subscriber := newTestServer(t, db, ps, subscriberID, provider, nil)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
user, model := seedChatDependencies(ctx, t, db)
|
||||
user, org, model := seedChatDependencies(ctx, t, db)
|
||||
|
||||
// Seed the chat in waiting state so Subscribe does not open a relay.
|
||||
chat := seedWaitingChat(ctx, t, db, user, model, "running-to-running")
|
||||
chat := seedWaitingChat(ctx, t, db, org.ID, user, model, "running-to-running")
|
||||
|
||||
_, events, cancel, ok := subscriber.Subscribe(ctx, chat.ID, nil, 0)
|
||||
require.True(t, ok)
|
||||
@@ -997,11 +1006,11 @@ func TestSubscribeRelayFailedDialRetries(t *testing.T) {
|
||||
subscriber := newTestServer(t, db, ps, subscriberID, provider, mclk)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
user, model := seedChatDependencies(ctx, t, db)
|
||||
user, org, model := seedChatDependencies(ctx, t, db)
|
||||
|
||||
// Seed the chat in waiting state so Subscribe does not open a
|
||||
// synchronous relay dial.
|
||||
chat := seedWaitingChat(ctx, t, db, user, model, "failed-dial-retry")
|
||||
chat := seedWaitingChat(ctx, t, db, org.ID, user, model, "failed-dial-retry")
|
||||
|
||||
_, events, cancel, ok := subscriber.Subscribe(ctx, chat.ID, nil, 0)
|
||||
require.True(t, ok)
|
||||
@@ -1093,12 +1102,13 @@ func TestSubscribeRunningLocalWorkerClosesRelay(t *testing.T) {
|
||||
subscriber := newTestServer(t, db, ps, subscriberID, provider, nil)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
user, model := seedChatDependencies(ctx, t, db)
|
||||
user, org, model := seedChatDependencies(ctx, t, db)
|
||||
|
||||
chat := seedRemoteRunningChat(
|
||||
ctx,
|
||||
t,
|
||||
db,
|
||||
org.ID,
|
||||
user,
|
||||
model,
|
||||
remoteWorkerID,
|
||||
@@ -1192,12 +1202,13 @@ func TestSubscribeRelayMultipleReconnects(t *testing.T) {
|
||||
subscriber := newTestServer(t, db, ps, subscriberID, provider, mclk)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
user, model := seedChatDependencies(ctx, t, db)
|
||||
user, org, model := seedChatDependencies(ctx, t, db)
|
||||
|
||||
chat := seedRemoteRunningChat(
|
||||
ctx,
|
||||
t,
|
||||
db,
|
||||
org.ID,
|
||||
user,
|
||||
model,
|
||||
workerID,
|
||||
@@ -1334,13 +1345,13 @@ func TestSubscribeRelayDialCanceledOnFastCompletion(t *testing.T) {
|
||||
}, nil)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
user, model := seedChatDependencies(ctx, t, db)
|
||||
user, org, model := seedChatDependencies(ctx, t, db)
|
||||
setOpenAIProviderBaseURL(ctx, t, db, openAIURL)
|
||||
|
||||
// Create the chat in waiting state so the subscriber sees it
|
||||
// before the worker picks it up (avoids the synchronous relay
|
||||
// path in Subscribe).
|
||||
chat := seedWaitingChat(ctx, t, db, user, model, "fast-completion-relay-race")
|
||||
chat := seedWaitingChat(ctx, t, db, org.ID, user, model, "fast-completion-relay-race")
|
||||
|
||||
// Subscribe from the subscriber replica while the chat is idle.
|
||||
// No relay is opened because the chat is in waiting state.
|
||||
@@ -1496,11 +1507,11 @@ func TestSubscribeRelayEstablishedMidStream(t *testing.T) {
|
||||
// call) involves multiple DB round-trips that can be slow under
|
||||
// load.
|
||||
ctx := testutil.Context(t, testutil.WaitSuperLong)
|
||||
user, model := seedChatDependencies(ctx, t, db)
|
||||
user, org, model := seedChatDependencies(ctx, t, db)
|
||||
setOpenAIProviderBaseURL(ctx, t, db, openAIURL)
|
||||
|
||||
// Create the chat in waiting state.
|
||||
chat := seedWaitingChat(ctx, t, db, user, model, "mid-stream-relay")
|
||||
chat := seedWaitingChat(ctx, t, db, org.ID, user, model, "mid-stream-relay")
|
||||
|
||||
// Subscribe from the subscriber replica while the chat is idle.
|
||||
_, events, subCancel, ok := subscriber.Subscribe(ctx, chat.ID, nil, 0)
|
||||
|
||||
Reference in New Issue
Block a user