Files
team/server/channels/api4/team_abac_api_test.go
de4bcf9c66 MM - 69063 - team abac backend and security gate (#36903)
* 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

* Audit-log team ABAC policy removal on team archive and delete

---------

Co-authored-by: Mattermost Build <[email protected]>
2026-06-12 23:39:16 +02:00

365 lines
14 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"encoding/json"
"net/http"
"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"
)
// enableTeamABAC turns on the license + config the team ABAC paths require and
// injects a mock PDP, returning it for per-test stubbing.
func enableTeamABAC(t *testing.T, th *TestHelper) *mocks.AccessControlServiceInterface {
t.Helper()
require.True(t, th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)))
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.AccessControlSettings.EnableAttributeBasedAccessControl = true
cfg.FeatureFlags.TeamMembershipAccessControl = true
})
m := &mocks.AccessControlServiceInterface{}
th.App.Srv().Channels().AccessControl = m
t.Cleanup(func() {
th.App.Srv().Channels().AccessControl = nil
th.App.Srv().SetLicense(nil)
})
return m
}
// saveTeamMembershipPolicy persists an active team-type policy for teamID so
// PolicyEnforced/HasMembershipPolicyAction resolve true through the store.
func saveTeamMembershipPolicy(t *testing.T, th *TestHelper, teamID string) *model.AccessControlPolicy {
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) })
return policy
}
// assignBody is the additive request shape for the assign/unassign handlers.
type assignBody struct {
ChannelIds []string `json:"channel_ids,omitempty"`
TeamID string `json:"team_id,omitempty"`
TeamIds []string `json:"team_ids,omitempty"`
}
// doAssign/doUnassign return the HTTP status. The raw client helpers surface an
// error for any status >= 300, so we read the code off the response rather than
// asserting on err.
func doAssign(t *testing.T, client *model.Client4, policyID string, body assignBody) int {
t.Helper()
resp, _ := client.DoAPIPostJSON(context.Background(), "/access_control_policies/"+policyID+"/assign", body)
require.NotNil(t, resp)
defer resp.Body.Close()
return resp.StatusCode
}
func doUnassign(t *testing.T, client *model.Client4, policyID string, body assignBody) int {
t.Helper()
resp, _ := client.DoAPIDeleteJSON(context.Background(), "/access_control_policies/"+policyID+"/unassign", body)
require.NotNil(t, resp)
defer resp.Body.Close()
return resp.StatusCode
}
func TestAssignAccessPolicyTeamIds(t *testing.T) {
th := SetupConfig(t, func(cfg *model.Config) { cfg.FeatureFlags.AttributeBasedAccessControl = true }).InitBasic(t)
parent := &model.AccessControlPolicy{
ID: model.NewId(),
Type: model.AccessControlPolicyTypeParent,
Version: model.AccessControlPolicyVersionV0_3,
Revision: 1,
Rules: []model.AccessControlPolicyRule{
{Expression: "true", Actions: []string{model.AccessControlPolicyActionMembership}},
},
}
t.Run("non-sysadmin sending team_ids is forbidden", func(t *testing.T) {
enableTeamABAC(t, th)
status := doAssign(t, th.Client, parent.ID, assignBody{TeamIds: []string{th.BasicTeam.Id}})
require.Equal(t, http.StatusForbidden, status)
})
t.Run("all-invalid team_ids returns 400 (not 404)", func(t *testing.T) {
enableTeamABAC(t, th)
status := doAssign(t, th.SystemAdminClient, parent.ID, assignBody{TeamIds: []string{model.NewId(), model.NewId()}})
require.Equal(t, http.StatusBadRequest, status)
})
t.Run("one invalid id among valid ones returns 400", func(t *testing.T) {
enableTeamABAC(t, th)
status := doAssign(t, th.SystemAdminClient, parent.ID, assignBody{TeamIds: []string{th.BasicTeam.Id, model.NewId()}})
require.Equal(t, http.StatusBadRequest, status)
})
t.Run("sysadmin team_ids assigns the policy", func(t *testing.T) {
m := enableTeamABAC(t, th)
notFound := model.NewAppError("GetPolicy", "app.access_control.not_found.app_error", nil, "", http.StatusNotFound)
child := &model.AccessControlPolicy{
ID: th.BasicTeam.Id,
Type: model.AccessControlPolicyTypeTeam,
Version: model.AccessControlPolicyVersionV0_3,
Imports: []string{parent.ID},
Props: map[string]any{},
}
m.On("GetPolicy", mock.AnythingOfType("*request.Context"), parent.ID).Return(parent, nil).Once()
m.On("GetPolicy", mock.AnythingOfType("*request.Context"), th.BasicTeam.Id).Return(nil, notFound).Once()
m.On("SavePolicy", mock.AnythingOfType("*request.Context"), mock.AnythingOfType("*model.AccessControlPolicy")).Return(child, nil).Once()
status := doAssign(t, th.SystemAdminClient, parent.ID, assignBody{TeamIds: []string{th.BasicTeam.Id}})
require.Equal(t, http.StatusOK, status)
m.AssertExpectations(t)
})
t.Run("team_ids is rejected with 501 when team membership ABAC is disabled", func(t *testing.T) {
enableTeamABAC(t, th)
// Dark-launch guard: even a sysadmin cannot create team child policy rows
// while the sub-flag is off, so they can't go live on a later flag flip.
th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.TeamMembershipAccessControl = false })
status := doAssign(t, th.SystemAdminClient, parent.ID, assignBody{TeamIds: []string{th.BasicTeam.Id}})
require.Equal(t, http.StatusNotImplemented, status)
})
}
func TestUnassignAccessPolicyTeamIds(t *testing.T) {
th := SetupConfig(t, func(cfg *model.Config) { cfg.FeatureFlags.AttributeBasedAccessControl = true }).InitBasic(t)
policyID := model.NewId()
t.Run("non-sysadmin sending team_ids is forbidden", func(t *testing.T) {
enableTeamABAC(t, th)
status := doUnassign(t, th.Client, policyID, assignBody{TeamIds: []string{th.BasicTeam.Id}})
require.Equal(t, http.StatusForbidden, status)
})
t.Run("unknown team_ids returns 400", func(t *testing.T) {
enableTeamABAC(t, th)
status := doUnassign(t, th.SystemAdminClient, policyID, assignBody{TeamIds: []string{model.NewId()}})
require.Equal(t, http.StatusBadRequest, status)
})
t.Run("sysadmin team_ids with no assigned child is a no-op", func(t *testing.T) {
enableTeamABAC(t, th)
// No child policy exists for the team, so UnassignPoliciesFromTeams skips it
// (warn + continue) and returns success.
status := doUnassign(t, th.SystemAdminClient, policyID, assignBody{TeamIds: []string{th.BasicTeam.Id}})
require.Equal(t, http.StatusOK, status)
})
t.Run("team_ids is rejected with 501 when team membership ABAC is disabled", func(t *testing.T) {
enableTeamABAC(t, th)
th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.TeamMembershipAccessControl = false })
status := doUnassign(t, th.SystemAdminClient, policyID, assignBody{TeamIds: []string{th.BasicTeam.Id}})
require.Equal(t, http.StatusNotImplemented, status)
})
}
func TestGetTeamAccessControlPolicy(t *testing.T) {
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.AttributeBasedAccessControl = true
cfg.FeatureFlags.TeamMembershipAccessControl = true
}).InitBasic(t)
getPolicy := func(t *testing.T, client *model.Client4, teamID string) (*http.Response, error) {
t.Helper()
return client.DoAPIGet(context.Background(), "/teams/"+teamID+"/access_control/policy", "")
}
t.Run("regular member without permission is forbidden", func(t *testing.T) {
enableTeamABAC(t, th)
resp, _ := getPolicy(t, th.Client, th.BasicTeam.Id)
require.NotNil(t, resp)
defer resp.Body.Close()
require.Equal(t, http.StatusForbidden, resp.StatusCode)
})
t.Run("sysadmin sees enforced=false when no policy is assigned", func(t *testing.T) {
m := enableTeamABAC(t, th)
notFound := model.NewAppError("GetPolicy", "app.access_control.not_found.app_error", nil, "", http.StatusNotFound)
m.On("GetPolicy", mock.AnythingOfType("*request.Context"), th.BasicTeam.Id).Return(nil, notFound)
resp, err := getPolicy(t, th.SystemAdminClient, th.BasicTeam.Id)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
var body struct {
Policy *model.AccessControlPolicy `json:"policy"`
Enforced bool `json:"enforced"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
require.Nil(t, body.Policy)
require.False(t, body.Enforced)
})
t.Run("sysadmin sees enforced=true with the policy when assigned", func(t *testing.T) {
m := enableTeamABAC(t, th)
policy := saveTeamMembershipPolicy(t, th, th.BasicTeam.Id)
m.On("GetPolicy", mock.AnythingOfType("*request.Context"), th.BasicTeam.Id).Return(policy, nil)
resp, err := getPolicy(t, th.SystemAdminClient, th.BasicTeam.Id)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
var body struct {
Policy *model.AccessControlPolicy `json:"policy"`
Enforced bool `json:"enforced"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
require.NotNil(t, body.Policy)
require.Equal(t, th.BasicTeam.Id, body.Policy.ID)
require.True(t, body.Enforced)
})
t.Run("team admin of the team is allowed", func(t *testing.T) {
m := enableTeamABAC(t, th)
notFound := model.NewAppError("GetPolicy", "app.access_control.not_found.app_error", nil, "", http.StatusNotFound)
m.On("GetPolicy", mock.AnythingOfType("*request.Context"), th.BasicTeam.Id).Return(nil, notFound)
th.AddPermissionToRole(t, model.PermissionManageTeamAccessRules.Id, model.TeamAdminRoleId)
defer th.RemovePermissionFromRole(t, model.PermissionManageTeamAccessRules.Id, model.TeamAdminRoleId)
th.LinkUserToTeam(t, th.TeamAdminUser, th.BasicTeam)
th.UpdateUserToTeamAdmin(t, th.TeamAdminUser, th.BasicTeam)
th.LoginTeamAdmin(t)
defer th.LoginBasic(t)
resp, err := getPolicy(t, th.Client, th.BasicTeam.Id)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("disabled flag hides a policy row created while dark", func(t *testing.T) {
enableTeamABAC(t, th)
// A child policy persisted while the feature was on must not leak through
// this endpoint once the sub-flag is turned off again (dark-launch/rollback).
saveTeamMembershipPolicy(t, th, th.BasicTeam.Id)
th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.TeamMembershipAccessControl = false })
resp, err := getPolicy(t, th.SystemAdminClient, th.BasicTeam.Id)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
var body struct {
Policy *model.AccessControlPolicy `json:"policy"`
Enforced bool `json:"enforced"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
require.Nil(t, body.Policy)
require.False(t, body.Enforced)
})
}
func TestGetUsersNotInTeamAbacMatchOnly(t *testing.T) {
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.AttributeBasedAccessControl = true
cfg.FeatureFlags.TeamMembershipAccessControl = true
}).InitBasic(t)
t.Run("without abac_match_only the listing is unchanged", func(t *testing.T) {
users, resp, err := th.SystemAdminClient.GetUsersNotInTeam(context.Background(), th.BasicTeam.Id, 0, 200, "")
require.NoError(t, err)
CheckOKStatus(t, resp)
// BasicUser/BasicUser2 are team members, so they must not appear.
require.NotContains(t, userIDs(users), th.BasicUser.Id)
})
t.Run("abac_match_only=true routes to the policy-matched candidates", func(t *testing.T) {
m := enableTeamABAC(t, th)
saveTeamMembershipPolicy(t, th, th.BasicTeam.Id)
qualifying := th.CreateUser(t)
m.On("QueryUsersForResource", mock.AnythingOfType("*request.Context"), th.BasicTeam.Id, model.AccessControlPolicyActionMembership, mock.Anything).
Return([]*model.User{qualifying}, int64(1), (*model.AppError)(nil))
resp, err := th.SystemAdminClient.DoAPIGet(context.Background(), "/users?not_in_team="+th.BasicTeam.Id+"&abac_match_only=true&per_page=200", "")
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
var users []*model.User
require.NoError(t, json.NewDecoder(resp.Body).Decode(&users))
require.Equal(t, []string{qualifying.Id}, userIDs(users))
m.AssertExpectations(t)
})
t.Run("caller without view_team permission is forbidden", func(t *testing.T) {
// Owned by the admin so BasicUser is a genuine non-member without ViewTeam.
otherTeam := th.CreateTeamWithClient(t, th.SystemAdminClient)
_, resp, err := th.Client.GetUsersNotInTeam(context.Background(), otherTeam.Id, 0, 60, "")
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
}
func userIDs(users []*model.User) []string {
ids := make([]string, 0, len(users))
for _, u := range users {
ids = append(ids, u.Id)
}
return ids
}
// TestAddTeamMembersGracefulABACError pins P1-21: a per-user ABAC denial in the
// batch add surfaces as that user's Error in graceful mode without aborting the
// batch; qualifying users in the same batch are still added.
func TestAddTeamMembersGracefulABACError(t *testing.T) {
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.AttributeBasedAccessControl = true
cfg.FeatureFlags.TeamMembershipAccessControl = true
}).InitBasic(t)
m := enableTeamABAC(t, th)
team := th.CreateTeam(t)
saveTeamMembershipPolicy(t, th, team.Id)
admitted := th.CreateUser(t)
denied := th.CreateUser(t)
m.On("AccessEvaluation", mock.Anything, mock.MatchedBy(func(req model.AccessRequest) bool {
return req.Subject.ID == admitted.Id
})).Return(model.AccessDecision{Decision: true}, (*model.AppError)(nil))
m.On("AccessEvaluation", mock.Anything, mock.MatchedBy(func(req model.AccessRequest) bool {
return req.Subject.ID == denied.Id
})).Return(model.AccessDecision{Decision: false}, (*model.AppError)(nil))
result, resp, err := th.SystemAdminClient.AddTeamMembersGracefully(context.Background(), team.Id, []string{admitted.Id, denied.Id})
require.NoError(t, err)
CheckCreatedStatus(t, resp)
var admittedOK, deniedErr bool
for _, r := range result {
switch r.UserId {
case admitted.Id:
admittedOK = r.Error == nil && r.Member != nil
case denied.Id:
deniedErr = r.Error != nil
}
}
require.True(t, admittedOK, "qualifying user must be added")
require.True(t, deniedErr, "non-qualifying user must carry an error without aborting the batch")
}