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:
Cian Johnston
2026-04-13 12:31:25 +01:00
committed by GitHub
co-authored by Mathias Fredriksson
parent cb0b84a2d3
commit 22062ec52e
58 changed files with 1533 additions and 581 deletions
+90 -5
View File
@@ -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")
}
+37 -26
View File
@@ -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)