Files
team/server/channels/api4/team_membership_enforcement_test.go
85dc729756 MM 69100 - Team ABAC Membership - members sync and end user surfaces (#37054) (#37548)
* MM-69063 - Add team ABAC model and constants foundation

* Add team ABAC store EXISTS, channel Type retrofit, policy count split, and index migration

* Add team ABAC app layer: access gate, hydrators, assign/unassign, cleanup, and  GetTeamMembersToRemove store

* Enforce team membership ABAC on join and hide policy governed teams from  non-qualifying users in the directory

* Add team_ids to access policy assign/unassign, expose per-team policy GET,  and support abac_match_only for not_in_team user listing

* Add team ABAC client methods, websocket handler, per-team System Console policy UI, and hide policy-governed teams from non-qualifying users

* Make team ABAC mode-aware: advisory on public teams, strict on private, and surface governed private teams to qualifying users in directory listings

* Flag-gate team ABAC mutation/read APIs and fix policy-save error handling, member-removal limit, team-id  validation, export, and audit cleanup

* coderabbit feedback; Broadcast team policy enforcement updates on policy create/update and activation, not only on delete

* Update team access control policy schema to allow nullable policies and enhance test cases with channel counts

* Enhance access control policy tests to include team policy search alongside channel policy search

* Add team membership access control feature flag to docker-compose generation

* Implement team access control policy checks and refactor related components

* MM-69100 - add team membership ABAC sync worker with mode-aware removal and auto-add

* Add team ABAC removal/auto-add notifications, cascade audit, and team custom-rules save backend

* Add Team Settings team membership tab with custom-rules editor, auto-add toggle, save confirmation, and mode-flip  sync trigger

* Add team access control policy panels, job details team list, and e2e coverage for membership tab and discoverability cards

* ix save double-submit with loading state, team privacy via updateTeamPrivacy, mode-flip count accuracy, sync trigger logic, i18n keys, and e2e test cleanup

* Add team membership policy notices to invite and add-to-team flows, team access control system messages, and a team policy attributes endpoint

* Add team membership recommended tag for qualifying users and team requirements notice in the members modal

* temp

* adjust styling, add new e2e, fix team admin job permissions issues

* e2e clean items created

* fix directory and team integration e2e tests

* Surface team sync results in the Sync Job Details modal via a Teams tab linked from the chained channel job

* code clean up, adjust styling

* Add access control attributes endpoint and enhance E2E tests for team membership

* Enhance team access control by updating permission checks and syncing jobs

* Add API-level enforcement gate tests for ABAC team membership

* Fix formatting in team channel settings component by adding missing semicolon

* Fix formatting in Client4 class by adjusting type annotations for clarity

* revert unwanted changes in package-lock

* Refactor job permission test and simplify error handling in team details component

* Update test assertions and enhance auto-add functionality in team membership tab

* Update onboarding tests to use 'Public Team' card for team access settings

* Refactor AccessSettings and TeamPolicyEditor components to replace allowOpenInviteCheckbox with public and private team buttons, and update access control job dispatching logic.

* Remove redundant state updates for channel assignments in access control policy reducer

* Implement email suppression for team membership notifications and add related tests

* Authorize ABAC team self-join by attribute match instead of the join_private_teams role

* comments clean up

* Gate team privacy type normalization behind active ABAC, keep legacy allow_open_invite-only path  otherwise

* adjust job details styles

* Add team membership policy disconnect confirmation, fix linked-policy affected-member count, and polish job details modal

* Combine custom rules with system policy expressions for accurate confirm counts

* Implement child resource count stamping and deletion gating for access control policies

* Enhance team sync job triggering logic for membership changes without auto-add

* Refactor team privacy handling to align with open-directory model; update comments for clarity on allow_open_invite logic.

* Deduplicate team ABAC sync jobs per policy to prevent concurrent runs emitting duplicate DMs and audit records on HA clusters

* fix linter warnings

* Add confirmation for policy removal in TeamDetails tests

* Update delete policy message to include Teams in the warning

* Add linked teams warning to policy deletion and update translations

* Add Auto-add feature for policy selection and team management

- Enhanced PolicySelectionModal to include an Auto-add checkbox for each policy.
- Updated PolicyList to manage Auto-add state and reflect changes in the UI.
- Modified TeamAccessControl to handle Auto-add functionality and persist changes.
- Added tests to ensure Auto-add behavior works as expected in various scenarios.
- Updated translations for Auto-add related strings.

* Add team membership sync footer and enhance job fetching with policy ID

* Refactor team membership policy handling and sync footer implementation

* Add team sync channel cascade test for private channel membership

---------


(cherry picked from commit a582f03570)

Co-authored-by: Pablo Vélez <[email protected]>
2026-07-17 10:39:37 +03:00

315 lines
14 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/einterfaces/mocks"
)
// teamID returns a team's ID, tolerating nil entries so directory slices that
// may carry gaps don't panic the membership lookups.
func teamID(t *model.Team) string {
if t == nil {
return ""
}
return t.Id
}
// TestTeamDirectoryABACVisibility pins the team-membership ABAC security boundary
// at the HTTP layer. Enforcement is mode-dependent: a private (non-open-invite)
// governed team is strict — surfaced into the directory candidate list only to be
// hidden from non-qualifying regular users and shown to qualifying ones — while a
// public (open-invite) governed team is advisory and stays visible to everyone.
// System Admins remain exempt from directory hiding (visibility != access), yet
// the join gate still denies a System Admin who fails a private team's policy (no
// role bypass).
func TestTeamDirectoryABACVisibility(t *testing.T) {
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.TeamMembershipAccessControl = true
})
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.AccessControlSettings.EnableAttributeBasedAccessControl = true
})
require.True(t, th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)))
defer th.App.Srv().SetLicense(nil)
// Governed team owned by the System Admin so the regular user is a non-member.
// Left non-open-invite (the CreateTeam default) so it is private/strict: a
// regular user could not list it at all absent ABAC — the governed-team listing
// flow surfaces it, and the directory filter then narrows by qualification.
team := th.CreateTeamWithClient(t, th.SystemAdminClient)
require.False(t, team.AllowOpenInvite, "the strict-mode team must not be open-invite")
saveTeamPolicy := func(t *testing.T, teamID string) {
t.Helper()
policy := &model.AccessControlPolicy{
ID: teamID,
Type: model.AccessControlPolicyTypeTeam,
Name: "policy-" + teamID,
Active: true,
Revision: 1,
Version: model.AccessControlPolicyVersionV0_3,
Imports: []string{},
Rules: []model.AccessControlPolicyRule{
{Actions: []string{model.AccessControlPolicyActionMembership}, Expression: "true"},
},
}
_, err := th.App.Srv().Store().AccessControlPolicy().Save(th.Context, policy)
require.NoError(t, err)
t.Cleanup(func() { _ = th.App.Srv().Store().AccessControlPolicy().Delete(th.Context, teamID) })
}
saveTeamPolicy(t, team.Id)
setMockACS := func(t *testing.T) *mocks.AccessControlServiceInterface {
t.Helper()
m := &mocks.AccessControlServiceInterface{}
th.App.Srv().Channels().AccessControl = m
t.Cleanup(func() { th.App.Srv().Channels().AccessControl = nil })
return m
}
t.Run("non-qualifying regular user: governed team hidden from GET /teams", func(t *testing.T) {
m := setMockACS(t)
m.On("AccessEvaluation", mock.Anything, mock.Anything).Return(model.AccessDecision{Decision: false}, (*model.AppError)(nil))
teams, _, err := th.Client.GetAllTeams(context.Background(), "", 0, 200)
require.NoError(t, err)
require.False(t, containsByID(teams, team.Id, teamID), "a non-qualifying non-member must not see the governed team in the directory")
// Response carries no policy metadata: PolicyEnforced is the only ABAC-derived
// field on the wire and it never names the policy/rules/attributes.
for _, tm := range teams {
require.Empty(t, tm.PolicyActions, "directory payload must not leak hydrated policy actions")
}
})
t.Run("non-qualifying regular user: governed team hidden from POST /teams/search", func(t *testing.T) {
m := setMockACS(t)
m.On("AccessEvaluation", mock.Anything, mock.Anything).Return(model.AccessDecision{Decision: false}, (*model.AppError)(nil))
teams, _, err := th.Client.SearchTeams(context.Background(), &model.TeamSearch{Term: team.Name})
require.NoError(t, err)
require.False(t, containsByID(teams, team.Id, teamID), "search must not surface the governed team to a non-qualifying user")
})
t.Run("qualifying regular user sees the governed team", func(t *testing.T) {
m := setMockACS(t)
m.On("AccessEvaluation", mock.Anything, mock.Anything).Return(model.AccessDecision{Decision: true}, (*model.AppError)(nil))
teams, _, err := th.Client.GetAllTeams(context.Background(), "", 0, 200)
require.NoError(t, err)
require.True(t, containsByID(teams, team.Id, teamID), "a qualifying user must see the governed team")
})
t.Run("system admin is exempt from directory hiding (visibility, not access)", func(t *testing.T) {
m := setMockACS(t)
// Decision intentionally not stubbed: the ManageSystem exemption must skip
// the filter entirely, so no PDP call happens for the admin's browse.
teams, _, err := th.SystemAdminClient.GetAllTeams(context.Background(), "", 0, 200)
require.NoError(t, err)
require.True(t, containsByID(teams, team.Id, teamID), "the System Console list must stay complete for admins")
m.AssertNotCalled(t, "AccessEvaluation", mock.Anything, mock.Anything)
})
t.Run("system admin who fails the policy is still denied the join (no role bypass)", func(t *testing.T) {
m := setMockACS(t)
m.On("AccessEvaluation", mock.Anything, mock.Anything).Return(model.AccessDecision{Decision: false}, (*model.AppError)(nil))
// The creator is auto-joined, and the gate only runs on a genuine join — so
// remove the admin first to attempt a real non-member join against the policy.
_, err := th.SystemAdminClient.RemoveTeamMember(context.Background(), team.Id, th.SystemAdminUser.Id)
require.NoError(t, err)
_, resp, err := th.SystemAdminClient.AddTeamMember(context.Background(), team.Id, th.SystemAdminUser.Id)
require.Error(t, err, "directory visibility must not translate into join access for an admin")
CheckForbiddenStatus(t, resp)
})
t.Run("ungoverned private team is never surfaced to a regular user (listing flow stays scoped)", func(t *testing.T) {
// The governed-team listing must widen only to policy-enforced teams; a
// plain private team with no policy must remain invisible to a regular user.
privateTeam := th.CreateTeamWithClient(t, th.SystemAdminClient)
require.False(t, privateTeam.AllowOpenInvite)
m := setMockACS(t)
m.On("AccessEvaluation", mock.Anything, mock.Anything).Return(model.AccessDecision{Decision: true}, (*model.AppError)(nil))
teams, _, err := th.Client.GetAllTeams(context.Background(), "", 0, 200)
require.NoError(t, err)
require.False(t, containsByID(teams, privateTeam.Id, teamID), "an ungoverned private team must never appear in a regular user's directory")
})
t.Run("public governed team stays visible to a non-qualifying regular user (advisory mode)", func(t *testing.T) {
// A public (open-invite) governed team: the policy is advisory, so the team
// is never hidden even from a user the PDP would reject.
publicTeam := th.CreateTeamWithClient(t, th.SystemAdminClient)
allowOpen := true
_, appErr := th.App.PatchTeam(publicTeam.Id, &model.TeamPatch{AllowOpenInvite: &allowOpen})
require.Nil(t, appErr)
saveTeamPolicy(t, publicTeam.Id)
m := setMockACS(t)
m.On("AccessEvaluation", mock.Anything, mock.Anything).Return(model.AccessDecision{Decision: false}, (*model.AppError)(nil))
teams, _, err := th.Client.GetAllTeams(context.Background(), "", 0, 200)
require.NoError(t, err)
require.True(t, containsByID(teams, publicTeam.Id, teamID), "a public governed team must remain visible regardless of qualification")
})
}
// TestTeamSelfJoinABACAttributeGating pins that on an ABAC-governed team, a user's
// self-join is authorized by attribute match (the JoinUserToTeam gate), not by the
// join_private_teams role: a qualifying regular user can self-join a private
// governed team even without that role. Non-governed private teams keep the role
// gate exactly as on master, and the bypass is strictly conditional on team ABAC
// being enabled (license + feature flag + config).
func TestTeamSelfJoinABACAttributeGating(t *testing.T) {
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.TeamMembershipAccessControl = true
})
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.AccessControlSettings.EnableAttributeBasedAccessControl = true
})
require.True(t, th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)))
defer th.App.Srv().SetLicense(nil)
// Log in as BasicUser (a regular user without join_private_teams) so
// AddTeamMember(self) is a real self-join.
th.LoginBasic(t)
require.False(t, th.App.SessionHasPermissionTo(model.Session{Roles: model.SystemUserRoleId}, model.PermissionJoinPrivateTeams),
"the base system_user role must not hold join_private_teams, or this test is meaningless")
saveTeamPolicy := func(t *testing.T, teamID string) {
t.Helper()
policy := &model.AccessControlPolicy{
ID: teamID,
Type: model.AccessControlPolicyTypeTeam,
Name: "policy-" + teamID,
Active: true,
Revision: 1,
Version: model.AccessControlPolicyVersionV0_3,
Imports: []string{},
Rules: []model.AccessControlPolicyRule{
{Actions: []string{model.AccessControlPolicyActionMembership}, Expression: "true"},
},
}
_, err := th.App.Srv().Store().AccessControlPolicy().Save(th.Context, policy)
require.NoError(t, err)
t.Cleanup(func() { _ = th.App.Srv().Store().AccessControlPolicy().Delete(th.Context, teamID) })
}
// Admin-owned so BasicUser is a non-member.
newPrivateTeam := func(t *testing.T, governed bool) *model.Team {
t.Helper()
team := th.CreateTeamWithClient(t, th.SystemAdminClient)
require.False(t, team.AllowOpenInvite, "fixture must be a private/strict team")
if governed {
saveTeamPolicy(t, team.Id)
}
return team
}
setMockACS := func(t *testing.T) *mocks.AccessControlServiceInterface {
t.Helper()
m := &mocks.AccessControlServiceInterface{}
th.App.Srv().Channels().AccessControl = m
t.Cleanup(func() { th.App.Srv().Channels().AccessControl = nil })
return m
}
t.Run("ABAC strict: qualifying regular user self-joins without join_private_teams", func(t *testing.T) {
team := newPrivateTeam(t, true)
m := setMockACS(t)
m.On("AccessEvaluation", mock.Anything, mock.Anything).Return(model.AccessDecision{Decision: true}, (*model.AppError)(nil))
_, resp, err := th.Client.AddTeamMember(context.Background(), team.Id, th.BasicUser.Id)
require.NoError(t, err, "a qualifying regular user must be admitted by attribute match")
CheckCreatedStatus(t, resp)
_, appErr := th.App.GetTeamMember(th.Context, team.Id, th.BasicUser.Id)
require.Nil(t, appErr, "the user must actually be a member after a successful self-join")
})
t.Run("ABAC strict: non-qualifying regular user is denied by the policy", func(t *testing.T) {
team := newPrivateTeam(t, true)
m := setMockACS(t)
m.On("AccessEvaluation", mock.Anything, mock.Anything).Return(model.AccessDecision{Decision: false}, (*model.AppError)(nil))
_, resp, err := th.Client.AddTeamMember(context.Background(), team.Id, th.BasicUser.Id)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("ABAC strict: admin with join_private_teams who fails the policy is still denied (no role bypass)", func(t *testing.T) {
team := newPrivateTeam(t, true)
// Creator is auto-joined; remove first so the gate runs on a real join.
_, err := th.SystemAdminClient.RemoveTeamMember(context.Background(), team.Id, th.SystemAdminUser.Id)
require.NoError(t, err)
m := setMockACS(t)
m.On("AccessEvaluation", mock.Anything, mock.Anything).Return(model.AccessDecision{Decision: false}, (*model.AppError)(nil))
_, resp, err := th.SystemAdminClient.AddTeamMember(context.Background(), team.Id, th.SystemAdminUser.Id)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("non-ABAC private team: regular user still blocked by join_private_teams (master behavior)", func(t *testing.T) {
team := newPrivateTeam(t, false)
_, resp, err := th.Client.AddTeamMember(context.Background(), team.Id, th.BasicUser.Id)
require.Error(t, err, "a non-governed private team must still require the role")
CheckForbiddenStatus(t, resp)
})
t.Run("non-ABAC private team: admin self-join still succeeds (master behavior)", func(t *testing.T) {
team := newPrivateTeam(t, false)
// The admin is auto-joined as creator; remove then re-add to exercise the gate.
_, err := th.SystemAdminClient.RemoveTeamMember(context.Background(), team.Id, th.SystemAdminUser.Id)
require.NoError(t, err)
_, resp, err := th.SystemAdminClient.AddTeamMember(context.Background(), team.Id, th.SystemAdminUser.Id)
require.NoError(t, err)
CheckCreatedStatus(t, resp)
})
t.Run("governed team but ABAC disabled (no license): role gate applies, bypass is strictly gated", func(t *testing.T) {
team := newPrivateTeam(t, true)
// Feature off: the stored policy must not loosen the role gate.
th.App.Srv().SetLicense(nil)
defer func() {
require.True(t, th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)))
}()
_, resp, err := th.Client.AddTeamMember(context.Background(), team.Id, th.BasicUser.Id)
require.Error(t, err, "with team ABAC disabled the join_private_teams role must be required again")
CheckForbiddenStatus(t, resp)
})
t.Run("public governed team (advisory): regular user joins via join_public_teams, PDP not consulted by the gate", func(t *testing.T) {
team := th.CreateTeamWithClient(t, th.SystemAdminClient)
allowOpen := true
_, appErr := th.App.PatchTeam(team.Id, &model.TeamPatch{AllowOpenInvite: &allowOpen})
require.Nil(t, appErr)
saveTeamPolicy(t, team.Id)
m := setMockACS(t)
_, resp, err := th.Client.AddTeamMember(context.Background(), team.Id, th.BasicUser.Id)
require.NoError(t, err)
CheckCreatedStatus(t, resp)
m.AssertNotCalled(t, "AccessEvaluation", mock.Anything, mock.Anything)
})
}