feat: add chat sharing foundation (#25041)

This commit is contained in:
Danielle Maywood
2026-05-18 22:32:05 +01:00
committed by GitHub
parent 2732378da2
commit 170a6e1fe9
49 changed files with 1872 additions and 103 deletions
+7 -4
View File
@@ -708,12 +708,15 @@ func ConfigWithoutACL() regosql.ConvertConfig {
}
}
// ConfigChats is the configuration for converting rego to SQL when
// the target table is "chats", which has no ACL
// columns.
// ConfigChats uses a resource converter so SQL filters qualify chat
// ACL columns consistently with GetChats.
func ConfigChats() regosql.ConvertConfig {
converter := regosql.ChatConverter()
if ChatACLDisabled() {
converter = regosql.ChatNoACLConverter()
}
return regosql.ConvertConfig{
VariableConverter: regosql.NoACLConverter(),
VariableConverter: converter,
}
}
+14
View File
@@ -253,3 +253,17 @@ func SetWorkspaceACLDisabled(v bool) {
func WorkspaceACLDisabled() bool {
return workspaceACLDisabled.Load()
}
var chatACLDisabled atomic.Bool
// SetChatACLDisabled is global because database model methods build
// RBAC objects without API instance state.
func SetChatACLDisabled(v bool) {
chatACLDisabled.Store(v)
}
// ChatACLDisabled is global because database model methods build RBAC
// objects without API instance state.
func ChatACLDisabled() bool {
return chatACLDisabled.Load()
}
+1
View File
@@ -103,6 +103,7 @@ var (
// - "ActionCreate" :: create a new chat
// - "ActionDelete" :: delete a chat
// - "ActionRead" :: read chat messages and metadata
// - "ActionShare" :: share a chat with other users or groups
// - "ActionUpdate" :: update chat title or settings
ResourceChat = Object{
Type: "chat",
+1
View File
@@ -82,6 +82,7 @@ var chatActions = map[Action]ActionDefinition{
ActionRead: "read chat messages and metadata",
ActionUpdate: "update chat title or settings",
ActionDelete: "delete a chat",
ActionShare: "share a chat with other users or groups",
}
// RBACPermissions is indexed by the type
+10
View File
@@ -217,6 +217,16 @@ func TestRegoQueries(t *testing.T) {
" OR (workspaces.group_acl#>array['96c55a0e-73b4-44fc-abac-70d53c35c04c', 'permissions'] ? '*'))",
VariableConverter: regosql.WorkspaceConverter(),
},
{
Name: "UserChatACLAllow",
Queries: []string{
`"read" in input.object.acl_user_list["d5389ccc-57a4-4b13-8c3f-31747bcdc9f1"]`,
`"*" in input.object.acl_user_list["d5389ccc-57a4-4b13-8c3f-31747bcdc9f1"]`,
},
ExpectedSQL: "((chats_expanded.user_acl#>array['d5389ccc-57a4-4b13-8c3f-31747bcdc9f1', 'permissions'] ? 'read')" +
" OR (chats_expanded.user_acl#>array['d5389ccc-57a4-4b13-8c3f-31747bcdc9f1', 'permissions'] ? '*'))",
VariableConverter: regosql.ChatConverter(),
},
{
Name: "NoACLConfig",
Queries: []string{
+28
View File
@@ -50,6 +50,34 @@ func WorkspaceConverter() *sqltypes.VariableConverter {
return matcher
}
func ChatConverter() *sqltypes.VariableConverter {
matcher := chatBaseConverter()
matcher.RegisterMatcher(
ACLMappingMatcher(matcher, "chats_expanded.group_acl", []string{"input", "object", "acl_group_list"}).UsingSubfield("permissions"),
ACLMappingMatcher(matcher, "chats_expanded.user_acl", []string{"input", "object", "acl_user_list"}).UsingSubfield("permissions"),
)
return matcher
}
func ChatNoACLConverter() *sqltypes.VariableConverter {
matcher := chatBaseConverter()
matcher.RegisterMatcher(
sqltypes.AlwaysFalse(groupACLMatcher(matcher)),
sqltypes.AlwaysFalse(userACLMatcher(matcher)),
)
return matcher
}
func chatBaseConverter() *sqltypes.VariableConverter {
return sqltypes.NewVariableConverter().RegisterMatcher(
resourceIDMatcher(),
sqltypes.StringVarMatcher("chats_expanded.organization_id :: text", []string{"input", "object", "org_owner"}),
userOwnerMatcher(),
)
}
func AuditLogConverter() *sqltypes.VariableConverter {
matcher := sqltypes.NewVariableConverter().RegisterMatcher(
resourceIDMatcher(),
+11 -4
View File
@@ -243,6 +243,7 @@ var builtInRoles map[string]func(orgID uuid.UUID) Role
type RoleOptions struct {
NoOwnerWorkspaceExec bool
NoWorkspaceSharing bool
NoChatSharing bool
}
// ReservedRoleName exists because the database should only allow unique role
@@ -272,6 +273,13 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
Action: policy.ActionShare,
})
}
if opts.NoChatSharing {
denyPermissions = append(denyPermissions, Permission{
Negate: true,
ResourceType: ResourceChat.Type,
Action: policy.ActionShare,
})
}
ownerWorkspaceActions := ResourceWorkspace.AvailableActions()
if opts.NoOwnerWorkspaceExec {
@@ -589,10 +597,8 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
},
}
},
// agentsAccess grants org members permission to create, read, and
// update chats. ActionDelete is intentionally excluded: no dbauthz
// function checks it on ResourceChat. Hard-deletion goes through
// ResourceSystem (dbpurge).
// ActionDelete is intentionally excluded because hard-deletion goes through
// ResourceSystem in dbpurge.
agentsAccess: func(organizationID uuid.UUID) Role {
return Role{
Identifier: RoleIdentifier{Name: agentsAccess, OrganizationID: organizationID},
@@ -606,6 +612,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
ResourceChat.Type: {
policy.ActionCreate,
policy.ActionRead,
policy.ActionShare,
policy.ActionUpdate,
},
}),
+61
View File
@@ -115,6 +115,58 @@ func TestOrgSharingPermissions(t *testing.T) {
}
}
//nolint:tparallel,paralleltest
func TestChatSharingPermissions(t *testing.T) {
target := rbac.Permission{
Negate: true,
ResourceType: rbac.ResourceChat.Type,
Action: policy.ActionShare,
}
orgID := uuid.New()
userID := uuid.NewString()
resource := rbac.ResourceChat.WithID(uuid.New()).InOrg(orgID).WithOwner(userID)
authorizeAgentsAccessUser := func(t *testing.T) error {
t.Helper()
memberRole, err := rbac.RoleByName(rbac.RoleMember())
require.NoError(t, err)
agentsRole, err := rbac.RoleByName(rbac.ScopedRoleAgentsAccess(orgID))
require.NoError(t, err)
auth := rbac.NewStrictAuthorizer(prometheus.NewRegistry())
return auth.Authorize(context.Background(), rbac.Subject{
ID: userID,
Roles: rbac.Roles{memberRole, agentsRole},
Scope: rbac.ScopeAll,
}, policy.ActionShare, resource)
}
t.Run("Default", func(t *testing.T) {
rbac.ReloadBuiltinRoles(nil)
t.Cleanup(func() { rbac.ReloadBuiltinRoles(nil) })
memberRole, err := rbac.RoleByName(rbac.RoleMember())
require.NoError(t, err)
assert.False(t, permissionGranted(memberRole.Site, target))
require.NoError(t, authorizeAgentsAccessUser(t))
})
t.Run("Disabled", func(t *testing.T) {
rbac.ReloadBuiltinRoles(&rbac.RoleOptions{
NoChatSharing: true,
})
t.Cleanup(func() { rbac.ReloadBuiltinRoles(nil) })
memberRole, err := rbac.RoleByName(rbac.RoleMember())
require.NoError(t, err)
assert.True(t, permissionGranted(memberRole.Site, target))
err = authorizeAgentsAccessUser(t)
require.ErrorAs(t, err, &rbac.UnauthorizedError{})
})
}
//nolint:tparallel,paralleltest
func TestOwnerExec(t *testing.T) {
owner := rbac.Subject{
@@ -1158,6 +1210,15 @@ func TestRolePermissions(t *testing.T) {
false: {setOtherOrg, memberMe, orgMemberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor},
},
},
{
Name: "ChatUsageShare",
Actions: []policy.Action{policy.ActionShare},
Resource: rbac.ResourceChat.WithID(uuid.New()).InOrg(orgID).WithOwner(currentUser.String()),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin, agentsAccessUser},
false: {setOtherOrg, memberMe, orgMemberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor},
},
},
{
Name: "ChatUsageDelete",
Actions: []policy.Action{policy.ActionDelete},
+3
View File
@@ -39,6 +39,7 @@ const (
ScopeChatCreate ScopeName = "chat:create"
ScopeChatDelete ScopeName = "chat:delete"
ScopeChatRead ScopeName = "chat:read"
ScopeChatShare ScopeName = "chat:share"
ScopeChatUpdate ScopeName = "chat:update"
ScopeConnectionLogRead ScopeName = "connection_log:read"
ScopeConnectionLogUpdate ScopeName = "connection_log:update"
@@ -211,6 +212,7 @@ func (e ScopeName) Valid() bool {
ScopeChatCreate,
ScopeChatDelete,
ScopeChatRead,
ScopeChatShare,
ScopeChatUpdate,
ScopeConnectionLogRead,
ScopeConnectionLogUpdate,
@@ -384,6 +386,7 @@ func AllScopeNameValues() []ScopeName {
ScopeChatCreate,
ScopeChatDelete,
ScopeChatRead,
ScopeChatShare,
ScopeChatUpdate,
ScopeConnectionLogRead,
ScopeConnectionLogUpdate,