mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: add chat sharing foundation (#25041)
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user