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
parent cb0b84a2d3
commit 22062ec52e
58 changed files with 1533 additions and 581 deletions
+2 -2
View File
@@ -689,11 +689,11 @@ func ConfigWithoutACL() regosql.ConvertConfig {
}
// ConfigChats is the configuration for converting rego to SQL when
// the target table is "chats", which has no organization_id or ACL
// the target table is "chats", which has no ACL
// columns.
func ConfigChats() regosql.ConvertConfig {
return regosql.ConvertConfig{
VariableConverter: regosql.ChatConverter(),
VariableConverter: regosql.NoACLConverter(),
}
}
+4 -5
View File
@@ -287,16 +287,15 @@ neq(input.object.owner, "");
Queries: []string{
`"me" = input.object.owner; input.object.owner != ""; input.object.org_owner = ""`,
},
ExpectedSQL: p(p("'me' = owner_id :: text") + " AND " + p("owner_id :: text != ''") + " AND " + p("'' = ''")),
VariableConverter: regosql.ChatConverter(),
ExpectedSQL: p(p("'me' = owner_id :: text") + " AND " + p("owner_id :: text != ''") + " AND " + p("organization_id :: text = ''")),
VariableConverter: regosql.NoACLConverter(),
},
{
Name: "ChatOrgScopedNeverMatches",
Name: "ChatOrgScopedMatches",
Queries: []string{
`input.object.org_owner = "org-id"`,
},
ExpectedSQL: p("'' = 'org-id'"),
VariableConverter: regosql.ChatConverter(),
ExpectedSQL: p("organization_id :: text = 'org-id'"), VariableConverter: regosql.NoACLConverter(),
},
{
Name: "AuditLogUUID",
-24
View File
@@ -126,30 +126,6 @@ func NoACLConverter() *sqltypes.VariableConverter {
return matcher
}
// ChatConverter should be used for the chats table, which has no
// organization_id, group_acl, or user_acl columns.
func ChatConverter() *sqltypes.VariableConverter {
matcher := sqltypes.NewVariableConverter().RegisterMatcher(
resourceIDMatcher(),
// The chats table has no organization_id column. Map org_owner
// to a literal empty string so that:
// - User-level ownership checks (org_owner = '') activate correctly.
// - Org-scoped permissions never match (org_owner will never equal
// a real org UUID), which is intentional since chats are not
// org-scoped resources.
// Note: custom org roles that include "chat" permissions will
// silently have no effect because of this mapping.
sqltypes.StringVarMatcher("''", []string{"input", "object", "org_owner"}),
userOwnerMatcher(),
)
matcher.RegisterMatcher(
sqltypes.AlwaysFalse(groupACLMatcher(matcher)),
sqltypes.AlwaysFalse(userACLMatcher(matcher)),
)
return matcher
}
func DefaultVariableConverter() *sqltypes.VariableConverter {
matcher := sqltypes.NewVariableConverter().RegisterMatcher(
resourceIDMatcher(),
+7 -2
View File
@@ -437,17 +437,22 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
return auditorRole
},
// templateAdmin grants all actions on templates, files,
// provisioner daemons, and prebuilt workspaces.
templateAdmin: func(_ uuid.UUID) Role {
return templateAdminRole
},
// userAdmin grants all actions on users, groups, roles,
// and organization membership.
userAdmin: func(_ uuid.UUID) Role {
return userAdminRole
},
// agentsAccess grants all actions on chat resources owned
// by the user. Without this role, members cannot create
// or interact with chats.
// by the user. Without this role, members can still read,
// update, and delete their own chats via org membership,
// but cannot create chats or trigger AI inference.
agentsAccess: func(_ uuid.UUID) Role {
return agentsAccessRole
},
+31 -9
View File
@@ -205,6 +205,34 @@ func TestRolePermissions(t *testing.T) {
orgTemplateAdmin := authSubject{Name: "org_template_admin", Actor: rbac.Subject{ID: userAdminID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.ScopedRoleOrgTemplateAdmin(orgID)}, Scope: rbac.ScopeAll}.WithCachedASTValue()}
orgAdminBanWorkspace := authSubject{Name: "org_admin_workspace_ban", Actor: rbac.Subject{ID: adminID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.ScopedRoleOrgAdmin(orgID), rbac.ScopedRoleOrgWorkspaceCreationBan(orgID)}, Scope: rbac.ScopeAll}.WithCachedASTValue()}
agentsAccessUser := authSubject{Name: "chat_access", Actor: rbac.Subject{ID: currentUser.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.RoleAgentsAccess()}, Scope: rbac.ScopeAll}.WithCachedASTValue()}
orgMemberMe := func() authSubject {
memberRole, err := rbac.RoleByName(rbac.RoleMember())
require.NoError(t, err)
perms := rbac.OrgMemberPermissions(rbac.OrgSettings{
ShareableWorkspaceOwners: rbac.ShareableWorkspaceOwnersEveryone,
})
return authSubject{
Name: "org_member_me",
Actor: rbac.Subject{
ID: currentUser.String(),
Roles: rbac.Roles{
memberRole,
{
Identifier: rbac.ScopedRoleOrgMember(orgID),
Site: []rbac.Permission{},
User: []rbac.Permission{},
ByOrgID: map[string]rbac.OrgPermissions{
orgID.String(): {
Org: perms.Org,
Member: perms.Member,
},
},
},
},
Scope: rbac.ScopeAll,
}.WithCachedASTValue(),
}
}()
setOrgNotMe := authSubjectSet{orgAdmin, orgAuditor, orgUserAdmin, orgTemplateAdmin}
otherOrgAdmin := authSubject{Name: "org_admin_other", Actor: rbac.Subject{ID: uuid.NewString(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.ScopedRoleOrgAdmin(otherOrg)}, Scope: rbac.ScopeAll}.WithCachedASTValue()}
@@ -1070,16 +1098,10 @@ func TestRolePermissions(t *testing.T) {
{
Name: "ChatUsage",
Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
Resource: rbac.ResourceChat.WithOwner(currentUser.String()),
Resource: rbac.ResourceChat.WithID(uuid.New()).InOrg(orgID).WithOwner(currentUser.String()),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, agentsAccessUser},
false: {
memberMe,
orgAdmin, otherOrgAdmin,
orgAuditor, otherOrgAuditor,
templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin,
userAdmin, orgUserAdmin, otherOrgUserAdmin,
},
true: {owner, orgAdmin, orgMemberMe},
false: {setOtherOrg, memberMe, agentsAccessUser, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor},
},
},
}