mirror of
https://github.com/hanzoai/team.git
synced 2026-09-30 01:57:35 +00:00
The server module named github.com/hanzoai/team/server/v8 — a path that resolves to the retired workspace's repository, not this one — while server/public still published github.com/mattermost/mattermost/server/public. Both are github.com/hanzoteam/server/... now, which is where this code actually lives. That second half is what made the binary unbootable. Upstream's public path stayed claimed by upstream, mattermost-plugin-ai imported it, and the build linked public/model twice; public/plugin's gob.Register then ran twice under one name and panicked at init, before main. Reverting the rename traded the brand for a working binary. Rebranding the plugin alongside it needs no such trade: the agents fork is github.com/hanzoteam/agents/v2 against our public module, so no module named mattermost/mattermost is in the graph at all. Measured: `go list -deps ./cmd/mattermost` names exactly one public/model, and it is ours.
803 lines
31 KiB
Go
803 lines
31 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package app
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/hanzoteam/server/server/public/model"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/mock"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/hanzoteam/server/server/v8/einterfaces/mocks"
|
|
)
|
|
|
|
// createTestPolicyHierarchy creates a parent policy and child channel policies in the real store,
|
|
// and configures the AccessControl mock to return the parent via GetPolicy.
|
|
// It returns (parentPolicy, mockACS) for further configuration.
|
|
func createTestPolicyHierarchy(
|
|
t *testing.T,
|
|
th *TestHelper,
|
|
channels []*model.Channel,
|
|
) (*model.AccessControlPolicy, *mocks.AccessControlServiceInterface) {
|
|
t.Helper()
|
|
|
|
parentPolicy := &model.AccessControlPolicy{
|
|
ID: model.NewId(),
|
|
Type: model.AccessControlPolicyTypeParent,
|
|
Version: model.AccessControlPolicyVersionV0_2,
|
|
Revision: 1,
|
|
Name: "Test Team Policy " + model.NewId(),
|
|
Rules: []model.AccessControlPolicyRule{
|
|
{Expression: "true", Actions: []string{"*"}},
|
|
},
|
|
}
|
|
|
|
var err error
|
|
parentPolicy, err = th.App.Srv().Store().AccessControlPolicy().Save(th.Context, parentPolicy)
|
|
require.NoError(t, err)
|
|
|
|
for _, ch := range channels {
|
|
child := &model.AccessControlPolicy{
|
|
ID: ch.Id,
|
|
Type: model.AccessControlPolicyTypeChannel,
|
|
Version: model.AccessControlPolicyVersionV0_2,
|
|
Revision: 1,
|
|
}
|
|
appErr := child.Inherit(parentPolicy)
|
|
require.Nil(t, appErr)
|
|
|
|
_, err = th.App.Srv().Store().AccessControlPolicy().Save(th.Context, child)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
originalACS := th.App.Srv().ch.AccessControl
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
t.Cleanup(func() {
|
|
th.App.Srv().ch.AccessControl = originalACS
|
|
mockACS.AssertExpectations(t)
|
|
})
|
|
|
|
mockACS.On("GetPolicy", mock.AnythingOfType("*request.Context"), mock.AnythingOfType("string")).
|
|
Return((*model.AccessControlPolicy)(nil), model.NewAppError("test", "test.policy_not_found", nil, "", 404)).Maybe()
|
|
mockACS.On("NormalizePolicy", mock.AnythingOfType("*request.Context"), mock.AnythingOfType("*model.AccessControlPolicy")).
|
|
Return((*model.AccessControlPolicy)(nil), model.NewAppError("test", "test.normalize_skip", nil, "", 500)).Maybe()
|
|
|
|
return parentPolicy, mockACS
|
|
}
|
|
|
|
func TestValidateTeamScopePolicyChannelAssignment(t *testing.T) {
|
|
th := Setup(t).InitBasic(t)
|
|
|
|
t.Run("empty channel list returns error", func(t *testing.T) {
|
|
appErr := th.App.ValidateTeamScopePolicyChannelAssignment(th.Context, th.BasicTeam.Id, []string{})
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, "app.team.access_policies.channels_required.app_error", appErr.Id)
|
|
})
|
|
|
|
t.Run("nonexistent channel returns error", func(t *testing.T) {
|
|
appErr := th.App.ValidateTeamScopePolicyChannelAssignment(th.Context, th.BasicTeam.Id, []string{model.NewId()})
|
|
require.NotNil(t, appErr)
|
|
})
|
|
|
|
t.Run("public channel is eligible", func(t *testing.T) {
|
|
appErr := th.App.ValidateTeamScopePolicyChannelAssignment(th.Context, th.BasicTeam.Id, []string{th.BasicChannel.Id})
|
|
require.Nil(t, appErr)
|
|
})
|
|
|
|
t.Run("channel from wrong team returns error", func(t *testing.T) {
|
|
otherTeam := th.CreateTeam(t)
|
|
otherChannel := th.CreatePrivateChannel(t, otherTeam)
|
|
|
|
appErr := th.App.ValidateTeamScopePolicyChannelAssignment(th.Context, th.BasicTeam.Id, []string{otherChannel.Id})
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, "app.team.access_policies.channel_wrong_team.app_error", appErr.Id)
|
|
})
|
|
|
|
t.Run("shared channel returns error", func(t *testing.T) {
|
|
channel := th.CreatePrivateChannel(t, th.BasicTeam)
|
|
channel.Shared = new(true)
|
|
_, err := th.App.UpdateChannel(th.Context, channel)
|
|
require.Nil(t, err)
|
|
|
|
appErr := th.App.ValidateTeamScopePolicyChannelAssignment(th.Context, th.BasicTeam.Id, []string{channel.Id})
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, "app.pap.access_control.channel_shared", appErr.Id)
|
|
})
|
|
|
|
t.Run("group-constrained channel returns error", func(t *testing.T) {
|
|
channel := th.CreatePrivateChannel(t, th.BasicTeam)
|
|
channel.GroupConstrained = new(true)
|
|
_, err := th.App.UpdateChannel(th.Context, channel)
|
|
require.Nil(t, err)
|
|
|
|
appErr := th.App.ValidateTeamScopePolicyChannelAssignment(th.Context, th.BasicTeam.Id, []string{channel.Id})
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, "app.pap.access_control.channel_group_constrained", appErr.Id)
|
|
})
|
|
|
|
t.Run("default channel returns error", func(t *testing.T) {
|
|
townSquare, appErr := th.App.GetChannelByName(th.Context, model.DefaultChannelName, th.BasicTeam.Id, false)
|
|
require.Nil(t, appErr)
|
|
|
|
appErr = th.App.ValidateTeamScopePolicyChannelAssignment(th.Context, th.BasicTeam.Id, []string{townSquare.Id})
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, "app.pap.access_control.channel_default", appErr.Id)
|
|
})
|
|
|
|
t.Run("valid private channel in team succeeds", func(t *testing.T) {
|
|
channel := th.CreatePrivateChannel(t, th.BasicTeam)
|
|
|
|
appErr := th.App.ValidateTeamScopePolicyChannelAssignment(th.Context, th.BasicTeam.Id, []string{channel.Id})
|
|
require.Nil(t, appErr)
|
|
})
|
|
|
|
t.Run("multiple valid private channels succeed", func(t *testing.T) {
|
|
ch1 := th.CreatePrivateChannel(t, th.BasicTeam)
|
|
ch2 := th.CreatePrivateChannel(t, th.BasicTeam)
|
|
|
|
appErr := th.App.ValidateTeamScopePolicyChannelAssignment(th.Context, th.BasicTeam.Id, []string{ch1.Id, ch2.Id})
|
|
require.Nil(t, appErr)
|
|
})
|
|
|
|
t.Run("mix of valid and invalid channels returns error", func(t *testing.T) {
|
|
validChannel := th.CreatePrivateChannel(t, th.BasicTeam)
|
|
|
|
townSquare, appErr := th.App.GetChannelByName(th.Context, model.DefaultChannelName, th.BasicTeam.Id, false)
|
|
require.Nil(t, appErr)
|
|
|
|
appErr = th.App.ValidateTeamScopePolicyChannelAssignment(th.Context, th.BasicTeam.Id, []string{
|
|
validChannel.Id,
|
|
townSquare.Id, // default channel — invalid
|
|
})
|
|
require.NotNil(t, appErr)
|
|
})
|
|
}
|
|
|
|
func TestValidateTeamAdminSelfInclusion(t *testing.T) {
|
|
th := Setup(t).InitBasic(t)
|
|
|
|
t.Run("empty expression passes", func(t *testing.T) {
|
|
appErr := th.App.ValidateTeamAdminSelfInclusion(th.Context, th.BasicUser.Id, "")
|
|
require.Nil(t, appErr)
|
|
})
|
|
|
|
t.Run("matching expression passes", func(t *testing.T) {
|
|
originalACS := th.App.Srv().ch.AccessControl
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
t.Cleanup(func() {
|
|
th.App.Srv().ch.AccessControl = originalACS
|
|
mockACS.AssertExpectations(t)
|
|
})
|
|
|
|
mockACS.On("QueryUsersForExpression", mock.AnythingOfType("*request.Context"), "true", mock.Anything).
|
|
Return([]*model.User{th.BasicUser}, int64(1), nil)
|
|
|
|
appErr := th.App.ValidateTeamAdminSelfInclusion(th.Context, th.BasicUser.Id, "true")
|
|
require.Nil(t, appErr)
|
|
})
|
|
|
|
t.Run("non-matching expression returns error", func(t *testing.T) {
|
|
originalACS := th.App.Srv().ch.AccessControl
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
t.Cleanup(func() {
|
|
th.App.Srv().ch.AccessControl = originalACS
|
|
mockACS.AssertExpectations(t)
|
|
})
|
|
|
|
mockACS.On("QueryUsersForExpression", mock.AnythingOfType("*request.Context"), "false", mock.Anything).
|
|
Return([]*model.User{}, int64(0), nil)
|
|
|
|
appErr := th.App.ValidateTeamAdminSelfInclusion(th.Context, th.BasicUser.Id, "false")
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, "app.team.access_policies.self_exclusion.app_error", appErr.Id)
|
|
})
|
|
|
|
t.Run("expression evaluation error wraps as validation error", func(t *testing.T) {
|
|
originalACS := th.App.Srv().ch.AccessControl
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
t.Cleanup(func() {
|
|
th.App.Srv().ch.AccessControl = originalACS
|
|
mockACS.AssertExpectations(t)
|
|
})
|
|
|
|
mockACS.On("QueryUsersForExpression", mock.AnythingOfType("*request.Context"), "bad_expr", mock.Anything).
|
|
Return(([]*model.User)(nil), int64(0), model.NewAppError("test", "test.eval_failed", nil, "", 500))
|
|
|
|
appErr := th.App.ValidateTeamAdminSelfInclusion(th.Context, th.BasicUser.Id, "bad_expr")
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, "app.team.access_policies.validation_error.app_error", appErr.Id)
|
|
})
|
|
}
|
|
|
|
func TestExpressionWithTeamContext(t *testing.T) {
|
|
th := Setup(t).InitBasic(t)
|
|
|
|
expression := "user.attributes.team == 'Engineering'"
|
|
|
|
t.Run("requester who does not match the expression gets empty results", func(t *testing.T) {
|
|
originalACS := th.App.Srv().ch.AccessControl
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
t.Cleanup(func() {
|
|
th.App.Srv().ch.AccessControl = originalACS
|
|
mockACS.AssertExpectations(t)
|
|
})
|
|
|
|
rctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id, Id: model.NewId()})
|
|
|
|
// Requester self-check returns no match → guard returns empty without
|
|
// ever querying the full user set.
|
|
mockACS.On("QueryUsersForExpression", mock.AnythingOfType("*request.Context"), expression, mock.MatchedBy(func(opts model.SubjectSearchOptions) bool {
|
|
return opts.SubjectID == th.BasicUser.Id
|
|
})).Return([]*model.User{}, int64(0), nil)
|
|
|
|
users, count, appErr := th.App.TestExpressionWithTeamContext(rctx, expression, model.SubjectSearchOptions{})
|
|
require.Nil(t, appErr)
|
|
require.Empty(t, users)
|
|
require.Zero(t, count)
|
|
})
|
|
|
|
t.Run("requester who matches sees the full result set", func(t *testing.T) {
|
|
originalACS := th.App.Srv().ch.AccessControl
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
t.Cleanup(func() {
|
|
th.App.Srv().ch.AccessControl = originalACS
|
|
mockACS.AssertExpectations(t)
|
|
})
|
|
|
|
rctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id, Id: model.NewId()})
|
|
|
|
// Self-check matches the requester.
|
|
mockACS.On("QueryUsersForExpression", mock.AnythingOfType("*request.Context"), expression, mock.MatchedBy(func(opts model.SubjectSearchOptions) bool {
|
|
return opts.SubjectID == th.BasicUser.Id
|
|
})).Return([]*model.User{th.BasicUser}, int64(1), nil)
|
|
|
|
// Full query (TestExpression) for the unrestricted set.
|
|
matched := []*model.User{th.BasicUser, th.BasicUser2}
|
|
mockACS.On("QueryUsersForExpression", mock.AnythingOfType("*request.Context"), expression, mock.MatchedBy(func(opts model.SubjectSearchOptions) bool {
|
|
return opts.SubjectID == ""
|
|
})).Return(matched, int64(2), nil)
|
|
|
|
users, count, appErr := th.App.TestExpressionWithTeamContext(rctx, expression, model.SubjectSearchOptions{})
|
|
require.Nil(t, appErr)
|
|
require.Len(t, users, 2)
|
|
require.Equal(t, int64(2), count)
|
|
})
|
|
}
|
|
|
|
func TestSearchTeamAccessPolicies(t *testing.T) {
|
|
th := Setup(t).InitBasic(t)
|
|
|
|
t.Run("returns empty when no policies match", func(t *testing.T) {
|
|
originalACS := th.App.Srv().ch.AccessControl
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
t.Cleanup(func() {
|
|
th.App.Srv().ch.AccessControl = originalACS
|
|
mockACS.AssertExpectations(t)
|
|
})
|
|
mockACS.On("GetPolicy", mock.AnythingOfType("*request.Context"), mock.AnythingOfType("string")).
|
|
Return((*model.AccessControlPolicy)(nil), model.NewAppError("test", "test.not_found", nil, "", 404)).Maybe()
|
|
mockACS.On("NormalizePolicy", mock.AnythingOfType("*request.Context"), mock.AnythingOfType("*model.AccessControlPolicy")).
|
|
Return((*model.AccessControlPolicy)(nil), model.NewAppError("test", "test.normalize_skip", nil, "", 500)).Maybe()
|
|
|
|
policies, total, appErr := th.App.SearchTeamAccessPolicies(th.Context, th.BasicTeam.Id, th.BasicUser.Id, model.AccessControlPolicySearch{})
|
|
require.Nil(t, appErr)
|
|
assert.Empty(t, policies)
|
|
assert.Equal(t, int64(0), total)
|
|
})
|
|
|
|
t.Run("filters out system-scoped policies", func(t *testing.T) {
|
|
// Create a parent policy with NO child channels -> system-scoped
|
|
_, _ = createTestPolicyHierarchy(t, th, []*model.Channel{})
|
|
|
|
policies, total, appErr := th.App.SearchTeamAccessPolicies(th.Context, th.BasicTeam.Id, th.BasicUser.Id, model.AccessControlPolicySearch{})
|
|
require.Nil(t, appErr)
|
|
assert.Empty(t, policies, "system-scoped policy should be filtered out")
|
|
assert.Equal(t, int64(0), total)
|
|
})
|
|
|
|
t.Run("includes team-scoped policy", func(t *testing.T) {
|
|
ch1 := th.CreatePrivateChannel(t, th.BasicTeam)
|
|
teamPolicy, mockACS := createTestPolicyHierarchy(t, th, []*model.Channel{ch1})
|
|
|
|
mockACS.On("QueryUsersForExpression", mock.AnythingOfType("*request.Context"), "true", mock.Anything).
|
|
Return([]*model.User{th.BasicUser}, int64(1), nil)
|
|
|
|
policies, total, appErr := th.App.SearchTeamAccessPolicies(th.Context, th.BasicTeam.Id, th.BasicUser.Id, model.AccessControlPolicySearch{})
|
|
require.Nil(t, appErr)
|
|
found := false
|
|
for _, p := range policies {
|
|
if p.ID == teamPolicy.ID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
assert.True(t, found, "team-scoped policy should be in results")
|
|
assert.True(t, total >= 1, "at least one policy expected")
|
|
})
|
|
|
|
t.Run("filters out cross-team policies", func(t *testing.T) {
|
|
otherTeam := th.CreateTeam(t)
|
|
crossTeamChannel := th.CreatePrivateChannel(t, otherTeam)
|
|
crossTeamPolicy, mockACS := createTestPolicyHierarchy(t, th, []*model.Channel{crossTeamChannel})
|
|
|
|
mockACS.On("QueryUsersForExpression", mock.AnythingOfType("*request.Context"), mock.AnythingOfType("string"), mock.Anything).
|
|
Return([]*model.User{th.BasicUser}, int64(1), nil).Maybe()
|
|
|
|
policies, _, appErr := th.App.SearchTeamAccessPolicies(th.Context, th.BasicTeam.Id, th.BasicUser.Id, model.AccessControlPolicySearch{})
|
|
require.Nil(t, appErr)
|
|
for _, p := range policies {
|
|
assert.NotEqual(t, crossTeamPolicy.ID, p.ID, "cross-team policy should be filtered out")
|
|
}
|
|
})
|
|
|
|
t.Run("includes channelless policy found via scope query", func(t *testing.T) {
|
|
// Policy with no channels but with scope=team — found only via the second
|
|
// (scope-based) query in SearchTeamAccessPolicies, not via channel inference.
|
|
policy, mockACS := createTestPolicyHierarchy(t, th, []*model.Channel{})
|
|
policy.Scope = model.AccessControlPolicyScopeTeam
|
|
policy.ScopeID = th.BasicTeam.Id
|
|
_, err := th.App.Srv().Store().AccessControlPolicy().Save(th.Context, policy)
|
|
require.NoError(t, err)
|
|
|
|
mockACS.On("QueryUsersForExpression", mock.AnythingOfType("*request.Context"), "true", mock.Anything).
|
|
Return([]*model.User{th.BasicUser}, int64(1), nil)
|
|
|
|
policies, _, appErr := th.App.SearchTeamAccessPolicies(th.Context, th.BasicTeam.Id, th.BasicUser.Id, model.AccessControlPolicySearch{})
|
|
require.Nil(t, appErr)
|
|
found := false
|
|
for _, p := range policies {
|
|
if p.ID == policy.ID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
assert.True(t, found, "channelless policy with team scope should appear in search results")
|
|
})
|
|
}
|
|
|
|
// TestSearchTeamAccessPolicies_SelfExclusionFiltering verifies that policies
|
|
// whose rules the admin doesn't satisfy are filtered out of search results.
|
|
func TestSearchTeamAccessPolicies_SelfExclusionFiltering(t *testing.T) {
|
|
th := Setup(t).InitBasic(t)
|
|
|
|
ch1 := th.CreatePrivateChannel(t, th.BasicTeam)
|
|
|
|
parentPolicy := &model.AccessControlPolicy{
|
|
ID: model.NewId(),
|
|
Type: model.AccessControlPolicyTypeParent,
|
|
Version: model.AccessControlPolicyVersionV0_2,
|
|
Revision: 1,
|
|
Name: "Exclusive Policy",
|
|
Rules: []model.AccessControlPolicyRule{
|
|
{Expression: "user.attributes.clearance == 'top-secret'", Actions: []string{"*"}},
|
|
},
|
|
}
|
|
var err error
|
|
parentPolicy, err = th.App.Srv().Store().AccessControlPolicy().Save(th.Context, parentPolicy)
|
|
require.NoError(t, err)
|
|
|
|
child := &model.AccessControlPolicy{
|
|
ID: ch1.Id,
|
|
Type: model.AccessControlPolicyTypeChannel,
|
|
Version: model.AccessControlPolicyVersionV0_2,
|
|
Revision: 1,
|
|
}
|
|
appErr := child.Inherit(parentPolicy)
|
|
require.Nil(t, appErr)
|
|
_, err = th.App.Srv().Store().AccessControlPolicy().Save(th.Context, child)
|
|
require.NoError(t, err)
|
|
|
|
originalACS := th.App.Srv().ch.AccessControl
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
t.Cleanup(func() {
|
|
th.App.Srv().ch.AccessControl = originalACS
|
|
mockACS.AssertExpectations(t)
|
|
})
|
|
|
|
mockACS.On("GetPolicy", mock.AnythingOfType("*request.Context"), mock.AnythingOfType("string")).
|
|
Return((*model.AccessControlPolicy)(nil), model.NewAppError("test", "test.policy_not_found", nil, "", 404)).Maybe()
|
|
mockACS.On("NormalizePolicy", mock.AnythingOfType("*request.Context"), mock.AnythingOfType("*model.AccessControlPolicy")).
|
|
Return((*model.AccessControlPolicy)(nil), model.NewAppError("test", "test.normalize_skip", nil, "", 500)).Maybe()
|
|
mockACS.On("QueryUsersForExpression", mock.AnythingOfType("*request.Context"),
|
|
"user.attributes.clearance == 'top-secret'", mock.Anything).
|
|
Return([]*model.User{}, int64(0), nil)
|
|
|
|
policies, total, appErr := th.App.SearchTeamAccessPolicies(th.Context, th.BasicTeam.Id, th.BasicUser.Id, model.AccessControlPolicySearch{})
|
|
require.Nil(t, appErr)
|
|
assert.Empty(t, policies, "policy where admin doesn't satisfy rules should be filtered out")
|
|
assert.Equal(t, int64(0), total, "total should reflect filtered count")
|
|
}
|
|
|
|
func TestValidateTeamAdminPolicyOwnership(t *testing.T) {
|
|
th := Setup(t).InitBasic(t)
|
|
|
|
t.Run("policy scoped to team passes", func(t *testing.T) {
|
|
ch1 := th.CreatePrivateChannel(t, th.BasicTeam)
|
|
teamPolicy, _ := createTestPolicyHierarchy(t, th, []*model.Channel{ch1})
|
|
|
|
owned, appErr := th.App.ValidateTeamAdminPolicyOwnership(th.Context, th.BasicTeam.Id, teamPolicy.ID)
|
|
require.Nil(t, appErr)
|
|
assert.True(t, owned)
|
|
})
|
|
|
|
t.Run("policy scoped to different team fails", func(t *testing.T) {
|
|
otherTeam := th.CreateTeam(t)
|
|
ch1 := th.CreatePrivateChannel(t, otherTeam)
|
|
otherTeamPolicy, _ := createTestPolicyHierarchy(t, th, []*model.Channel{ch1})
|
|
|
|
owned, appErr := th.App.ValidateTeamAdminPolicyOwnership(th.Context, th.BasicTeam.Id, otherTeamPolicy.ID)
|
|
require.Nil(t, appErr)
|
|
assert.False(t, owned)
|
|
})
|
|
|
|
t.Run("cross-team policy fails", func(t *testing.T) {
|
|
otherTeam := th.CreateTeam(t)
|
|
ch1 := th.CreatePrivateChannel(t, th.BasicTeam)
|
|
ch2 := th.CreatePrivateChannel(t, otherTeam)
|
|
crossPolicy, _ := createTestPolicyHierarchy(t, th, []*model.Channel{ch1, ch2})
|
|
|
|
owned, appErr := th.App.ValidateTeamAdminPolicyOwnership(th.Context, th.BasicTeam.Id, crossPolicy.ID)
|
|
require.Nil(t, appErr)
|
|
assert.False(t, owned)
|
|
})
|
|
|
|
t.Run("policy with no channels but with scope passes", func(t *testing.T) {
|
|
noChannelPolicy, _ := createTestPolicyHierarchy(t, th, []*model.Channel{})
|
|
// Set scope on the channelless policy
|
|
noChannelPolicy.Scope = model.AccessControlPolicyScopeTeam
|
|
noChannelPolicy.ScopeID = th.BasicTeam.Id
|
|
_, err := th.App.Srv().Store().AccessControlPolicy().Save(th.Context, noChannelPolicy)
|
|
require.NoError(t, err)
|
|
|
|
owned, appErr := th.App.ValidateTeamAdminPolicyOwnership(th.Context, th.BasicTeam.Id, noChannelPolicy.ID)
|
|
require.Nil(t, appErr)
|
|
assert.True(t, owned)
|
|
})
|
|
|
|
t.Run("policy with no channels and no scope fails", func(t *testing.T) {
|
|
noChannelPolicy, _ := createTestPolicyHierarchy(t, th, []*model.Channel{})
|
|
|
|
owned, appErr := th.App.ValidateTeamAdminPolicyOwnership(th.Context, th.BasicTeam.Id, noChannelPolicy.ID)
|
|
require.Nil(t, appErr)
|
|
assert.False(t, owned)
|
|
})
|
|
|
|
t.Run("policy with scope for different team fails", func(t *testing.T) {
|
|
otherTeam := th.CreateTeam(t)
|
|
noChannelPolicy, _ := createTestPolicyHierarchy(t, th, []*model.Channel{})
|
|
noChannelPolicy.Scope = model.AccessControlPolicyScopeTeam
|
|
noChannelPolicy.ScopeID = otherTeam.Id
|
|
_, err := th.App.Srv().Store().AccessControlPolicy().Save(th.Context, noChannelPolicy)
|
|
require.NoError(t, err)
|
|
|
|
owned, appErr := th.App.ValidateTeamAdminPolicyOwnership(th.Context, th.BasicTeam.Id, noChannelPolicy.ID)
|
|
require.Nil(t, appErr)
|
|
assert.False(t, owned)
|
|
})
|
|
|
|
t.Run("nonexistent policy fails", func(t *testing.T) {
|
|
owned, appErr := th.App.ValidateTeamAdminPolicyOwnership(th.Context, th.BasicTeam.Id, model.NewId())
|
|
require.Nil(t, appErr)
|
|
assert.False(t, owned)
|
|
})
|
|
}
|
|
|
|
func TestReconcilePolicyTeamScope(t *testing.T) {
|
|
th := Setup(t).InitBasic(t)
|
|
|
|
// Helper to read scope directly from the store (bypasses enterprise layer).
|
|
getScope := func(t *testing.T, policyID string) (string, string) {
|
|
t.Helper()
|
|
p, err := th.App.Srv().Store().AccessControlPolicy().Get(th.Context, policyID)
|
|
require.NoError(t, err)
|
|
return p.Scope, p.ScopeID
|
|
}
|
|
|
|
t.Run("single team channels sets scope", func(t *testing.T) {
|
|
ch1 := th.CreatePrivateChannel(t, th.BasicTeam)
|
|
ch2 := th.CreatePrivateChannel(t, th.BasicTeam)
|
|
policy, _ := createTestPolicyHierarchy(t, th, []*model.Channel{ch1, ch2})
|
|
|
|
appErr := th.App.ReconcilePolicyTeamScope(th.Context, policy.ID)
|
|
require.Nil(t, appErr)
|
|
|
|
scope, scopeID := getScope(t, policy.ID)
|
|
assert.Equal(t, model.AccessControlPolicyScopeTeam, scope)
|
|
assert.Equal(t, th.BasicTeam.Id, scopeID)
|
|
})
|
|
|
|
t.Run("cross-team channels clears scope", func(t *testing.T) {
|
|
otherTeam := th.CreateTeam(t)
|
|
ch1 := th.CreatePrivateChannel(t, th.BasicTeam)
|
|
ch2 := th.CreatePrivateChannel(t, otherTeam)
|
|
|
|
// Create policy with scope already set
|
|
policy, _ := createTestPolicyHierarchy(t, th, []*model.Channel{ch1, ch2})
|
|
policy.Scope = model.AccessControlPolicyScopeTeam
|
|
policy.ScopeID = th.BasicTeam.Id
|
|
_, err := th.App.Srv().Store().AccessControlPolicy().Save(th.Context, policy)
|
|
require.NoError(t, err)
|
|
|
|
appErr := th.App.ReconcilePolicyTeamScope(th.Context, policy.ID)
|
|
require.Nil(t, appErr)
|
|
|
|
scope, scopeID := getScope(t, policy.ID)
|
|
assert.Empty(t, scope)
|
|
assert.Empty(t, scopeID)
|
|
})
|
|
|
|
t.Run("no channels preserves existing scope", func(t *testing.T) {
|
|
policy, _ := createTestPolicyHierarchy(t, th, []*model.Channel{})
|
|
policy.Scope = model.AccessControlPolicyScopeTeam
|
|
policy.ScopeID = th.BasicTeam.Id
|
|
_, err := th.App.Srv().Store().AccessControlPolicy().Save(th.Context, policy)
|
|
require.NoError(t, err)
|
|
|
|
appErr := th.App.ReconcilePolicyTeamScope(th.Context, policy.ID)
|
|
require.Nil(t, appErr)
|
|
|
|
scope, scopeID := getScope(t, policy.ID)
|
|
assert.Equal(t, model.AccessControlPolicyScopeTeam, scope)
|
|
assert.Equal(t, th.BasicTeam.Id, scopeID)
|
|
})
|
|
|
|
t.Run("no channels and no scope stays empty", func(t *testing.T) {
|
|
policy, _ := createTestPolicyHierarchy(t, th, []*model.Channel{})
|
|
|
|
appErr := th.App.ReconcilePolicyTeamScope(th.Context, policy.ID)
|
|
require.Nil(t, appErr)
|
|
|
|
scope, scopeID := getScope(t, policy.ID)
|
|
assert.Empty(t, scope)
|
|
assert.Empty(t, scopeID)
|
|
})
|
|
|
|
t.Run("skip save when scope already correct", func(t *testing.T) {
|
|
ch1 := th.CreatePrivateChannel(t, th.BasicTeam)
|
|
policy, _ := createTestPolicyHierarchy(t, th, []*model.Channel{ch1})
|
|
|
|
// Pre-set the correct scope
|
|
policy.Scope = model.AccessControlPolicyScopeTeam
|
|
policy.ScopeID = th.BasicTeam.Id
|
|
_, err := th.App.Srv().Store().AccessControlPolicy().Save(th.Context, policy)
|
|
require.NoError(t, err)
|
|
|
|
// SavePolicy should NOT be called since scope is already correct
|
|
appErr := th.App.ReconcilePolicyTeamScope(th.Context, policy.ID)
|
|
require.Nil(t, appErr)
|
|
|
|
// Verify scope unchanged
|
|
scope, scopeID := getScope(t, policy.ID)
|
|
assert.Equal(t, model.AccessControlPolicyScopeTeam, scope)
|
|
assert.Equal(t, th.BasicTeam.Id, scopeID)
|
|
})
|
|
|
|
t.Run("stale channel reference skips reconciliation", func(t *testing.T) {
|
|
// Simulate a channel being deleted after it was assigned to a policy.
|
|
// GetChannels returns fewer channels than channelIDs, so reconciliation
|
|
// must be skipped to avoid stamping an incorrect scope.
|
|
ch1 := th.CreatePrivateChannel(t, th.BasicTeam)
|
|
policy, _ := createTestPolicyHierarchy(t, th, []*model.Channel{ch1})
|
|
|
|
// Pre-set a scope so we can verify it is preserved after the no-op.
|
|
policy.Scope = model.AccessControlPolicyScopeTeam
|
|
policy.ScopeID = th.BasicTeam.Id
|
|
_, err := th.App.Srv().Store().AccessControlPolicy().Save(th.Context, policy)
|
|
require.NoError(t, err)
|
|
|
|
// Delete the channel from the DB to make the reference stale.
|
|
err = th.App.Srv().Store().Channel().PermanentDelete(th.Context, ch1.Id)
|
|
require.NoError(t, err)
|
|
|
|
appErr := th.App.ReconcilePolicyTeamScope(th.Context, policy.ID)
|
|
require.Nil(t, appErr)
|
|
|
|
scope, scopeID := getScope(t, policy.ID)
|
|
assert.Equal(t, model.AccessControlPolicyScopeTeam, scope, "scope should be preserved when channel is stale")
|
|
assert.Equal(t, th.BasicTeam.Id, scopeID)
|
|
})
|
|
|
|
t.Run("pre-scope policy migration: reconcile before unassign sets scope", func(t *testing.T) {
|
|
// Simulates a pre-scope policy (no scope field) with channels in a team.
|
|
// The pre-flight reconcile before unassign should set the scope.
|
|
ch1 := th.CreatePrivateChannel(t, th.BasicTeam)
|
|
policy, _ := createTestPolicyHierarchy(t, th, []*model.Channel{ch1})
|
|
|
|
// Verify scope is initially empty (pre-scope policy)
|
|
scope, _ := getScope(t, policy.ID)
|
|
assert.Empty(t, scope)
|
|
|
|
// Pre-flight reconcile (called before unassign in the handler)
|
|
appErr := th.App.ReconcilePolicyTeamScope(th.Context, policy.ID)
|
|
require.Nil(t, appErr)
|
|
|
|
// Scope is now set
|
|
scope, scopeID := getScope(t, policy.ID)
|
|
assert.Equal(t, model.AccessControlPolicyScopeTeam, scope)
|
|
assert.Equal(t, th.BasicTeam.Id, scopeID)
|
|
|
|
// Simulate unassign: remove the child policy
|
|
err := th.App.Srv().Store().AccessControlPolicy().Delete(th.Context, ch1.Id)
|
|
require.NoError(t, err)
|
|
|
|
// Post-unassign reconcile: no channels → no-op, scope preserved
|
|
appErr = th.App.ReconcilePolicyTeamScope(th.Context, policy.ID)
|
|
require.Nil(t, appErr)
|
|
|
|
scope, scopeID = getScope(t, policy.ID)
|
|
assert.Equal(t, model.AccessControlPolicyScopeTeam, scope)
|
|
assert.Equal(t, th.BasicTeam.Id, scopeID)
|
|
|
|
// Ownership check should pass now
|
|
owned, appErr := th.App.ValidateTeamAdminPolicyOwnership(th.Context, th.BasicTeam.Id, policy.ID)
|
|
require.Nil(t, appErr)
|
|
assert.True(t, owned, "channelless policy with scope should pass ownership check")
|
|
})
|
|
}
|
|
|
|
// TestCreateOrUpdateAccessControlPolicy_TeamSelfInclusion verifies that the
|
|
// team-specific self-inclusion guard at CreateOrUpdateAccessControlPolicy:124
|
|
// is correctly wired into the create/update path. ValidateTeamAdminSelfInclusion
|
|
// is tested in isolation elsewhere; these tests confirm the integration.
|
|
func TestCreateOrUpdateAccessControlPolicy_TeamSelfInclusion(t *testing.T) {
|
|
t.Run("team admin excluded by own expression is rejected before save", func(t *testing.T) {
|
|
// AttributeValueMasking defaults to true
|
|
th := SetupConfig(t, func(cfg *model.Config) {
|
|
*cfg.AccessControlSettings.EnableAttributeBasedAccessControl = true
|
|
cfg.FeatureFlags.AttributeValueMasking = false // keep the masking path out of scope
|
|
}).InitBasic(t)
|
|
|
|
callerID := th.BasicUser.Id
|
|
rctx := th.Context.WithSession(&model.Session{
|
|
UserId: callerID,
|
|
Id: model.NewId(),
|
|
Roles: model.SystemUserRoleId, // not a system admin
|
|
})
|
|
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
originalACS := th.App.Srv().ch.AccessControl
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
t.Cleanup(func() {
|
|
th.App.Srv().ch.AccessControl = originalACS
|
|
mockACS.AssertExpectations(t)
|
|
})
|
|
|
|
// Guard calls QueryUsersForExpression with the caller as subject; return
|
|
// empty — caller does not satisfy the expression.
|
|
mockACS.On("QueryUsersForExpression",
|
|
mock.AnythingOfType("*request.Context"),
|
|
"false",
|
|
mock.MatchedBy(func(opts model.SubjectSearchOptions) bool {
|
|
return opts.SubjectID == callerID
|
|
}),
|
|
).Return([]*model.User{}, int64(0), nil).Once()
|
|
|
|
// SavePolicy must NOT be called — the guard rejects the request first.
|
|
|
|
teamPolicy := &model.AccessControlPolicy{
|
|
ID: th.BasicTeam.Id,
|
|
Type: model.AccessControlPolicyTypeTeam,
|
|
Rules: []model.AccessControlPolicyRule{
|
|
{Actions: []string{"membership"}, Expression: "false"},
|
|
},
|
|
}
|
|
|
|
result, appErr := th.App.CreateOrUpdateAccessControlPolicy(rctx, teamPolicy)
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, "app.team.access_policies.self_exclusion.app_error", appErr.Id)
|
|
assert.Nil(t, result)
|
|
})
|
|
|
|
t.Run("team admin included by own expression passes the guard and saves", func(t *testing.T) {
|
|
th := SetupConfig(t, func(cfg *model.Config) {
|
|
*cfg.AccessControlSettings.EnableAttributeBasedAccessControl = true
|
|
cfg.FeatureFlags.AttributeValueMasking = false
|
|
}).InitBasic(t)
|
|
|
|
callerID := th.BasicUser.Id
|
|
rctx := th.Context.WithSession(&model.Session{
|
|
UserId: callerID,
|
|
Id: model.NewId(),
|
|
Roles: model.SystemUserRoleId,
|
|
})
|
|
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
originalACS := th.App.Srv().ch.AccessControl
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
t.Cleanup(func() {
|
|
th.App.Srv().ch.AccessControl = originalACS
|
|
mockACS.AssertExpectations(t)
|
|
})
|
|
|
|
// Guard: caller satisfies the expression → guard passes.
|
|
mockACS.On("QueryUsersForExpression",
|
|
mock.AnythingOfType("*request.Context"),
|
|
"true",
|
|
mock.MatchedBy(func(opts model.SubjectSearchOptions) bool {
|
|
return opts.SubjectID == callerID
|
|
}),
|
|
).Return([]*model.User{{Id: callerID}}, int64(1), nil).Once()
|
|
|
|
savedPolicy := &model.AccessControlPolicy{
|
|
ID: th.BasicTeam.Id,
|
|
Type: model.AccessControlPolicyTypeTeam,
|
|
Rules: []model.AccessControlPolicyRule{
|
|
{Actions: []string{"membership"}, Expression: "true"},
|
|
},
|
|
}
|
|
// SavePolicy is reached after the guard passes.
|
|
mockACS.On("SavePolicy", mock.AnythingOfType("*request.Context"), mock.Anything).
|
|
Return(savedPolicy, (*model.AppError)(nil)).Once()
|
|
|
|
teamPolicy := &model.AccessControlPolicy{
|
|
ID: th.BasicTeam.Id,
|
|
Type: model.AccessControlPolicyTypeTeam,
|
|
Rules: []model.AccessControlPolicyRule{
|
|
{Actions: []string{"membership"}, Expression: "true"},
|
|
},
|
|
}
|
|
|
|
result, appErr := th.App.CreateOrUpdateAccessControlPolicy(rctx, teamPolicy)
|
|
require.Nil(t, appErr)
|
|
require.NotNil(t, result)
|
|
})
|
|
|
|
t.Run("system admin bypasses the team self-inclusion guard", func(t *testing.T) {
|
|
th := SetupConfig(t, func(cfg *model.Config) {
|
|
*cfg.AccessControlSettings.EnableAttributeBasedAccessControl = true
|
|
cfg.FeatureFlags.AttributeValueMasking = false
|
|
}).InitBasic(t)
|
|
|
|
// System admin session — HasPermissionTo(PermissionManageSystem) returns true.
|
|
rctx := th.Context.WithSession(&model.Session{
|
|
UserId: th.SystemAdminUser.Id,
|
|
Id: model.NewId(),
|
|
Roles: model.SystemUserRoleId + " " + model.SystemAdminRoleId,
|
|
})
|
|
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
originalACS := th.App.Srv().ch.AccessControl
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
t.Cleanup(func() {
|
|
th.App.Srv().ch.AccessControl = originalACS
|
|
mockACS.AssertExpectations(t)
|
|
})
|
|
|
|
// QueryUsersForExpression must NOT be called — guard is skipped for system admins.
|
|
|
|
savedPolicy := &model.AccessControlPolicy{
|
|
ID: th.BasicTeam.Id,
|
|
Type: model.AccessControlPolicyTypeTeam,
|
|
Rules: []model.AccessControlPolicyRule{
|
|
{Actions: []string{"membership"}, Expression: "false"},
|
|
},
|
|
}
|
|
mockACS.On("SavePolicy", mock.AnythingOfType("*request.Context"), mock.Anything).
|
|
Return(savedPolicy, (*model.AppError)(nil)).Once()
|
|
|
|
teamPolicy := &model.AccessControlPolicy{
|
|
ID: th.BasicTeam.Id,
|
|
Type: model.AccessControlPolicyTypeTeam,
|
|
Rules: []model.AccessControlPolicyRule{
|
|
// Expression that would exclude most users — system admin saves it anyway.
|
|
{Actions: []string{"membership"}, Expression: "false"},
|
|
},
|
|
}
|
|
|
|
result, appErr := th.App.CreateOrUpdateAccessControlPolicy(rctx, teamPolicy)
|
|
require.Nil(t, appErr)
|
|
require.NotNil(t, result)
|
|
})
|
|
}
|