Files
team/server/channels/api4/team_membership_enforcement_test.go
zeekay 8feb24ea1f One name for this fork, and it is ours
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.
2026-08-28 23:35:36 -07: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/hanzoteam/server/server/public/model"
"github.com/hanzoteam/server/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)
})
}