snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
This commit is contained in:
490
server/channels/api4/team_abac_api_test.go
Normal file
490
server/channels/api4/team_abac_api_test.go
Normal file
@@ -0,0 +1,490 @@
|
||||
// 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. Feature flags are
|
||||
// read-only by default in tests, so unlock them first — otherwise the
|
||||
// TeamMembershipAccessControl toggle below is silently dropped.
|
||||
func enableTeamABAC(t *testing.T, th *TestHelper) *mocks.AccessControlServiceInterface {
|
||||
t.Helper()
|
||||
require.True(t, th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)))
|
||||
th.ConfigStore.SetReadOnlyFF(false)
|
||||
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 := Setup(t).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 := Setup(t).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.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("resolves imported parent policies into parent_policies for the banner", func(t *testing.T) {
|
||||
m := enableTeamABAC(t, th)
|
||||
|
||||
// Team child policy that imports a system-level parent policy.
|
||||
parentID := model.NewId()
|
||||
teamPolicy := saveTeamMembershipPolicy(t, th, th.BasicTeam.Id)
|
||||
teamPolicy.Imports = []string{parentID}
|
||||
_, err := th.App.Srv().Store().AccessControlPolicy().Save(th.Context, teamPolicy)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The stored parent carries assignment metadata (scope, child_ids) that spans
|
||||
// other teams — the response must not leak any of it.
|
||||
parentPolicy := &model.AccessControlPolicy{
|
||||
ID: parentID,
|
||||
Type: model.AccessControlPolicyTypeParent,
|
||||
Name: "Engineering",
|
||||
Active: true,
|
||||
Revision: 1,
|
||||
Version: model.AccessControlPolicyVersionV0_3,
|
||||
Scope: model.AccessControlPolicyScopeTeam,
|
||||
ScopeID: model.NewId(),
|
||||
Props: map[string]any{"child_ids": []string{model.NewId(), model.NewId()}},
|
||||
Rules: []model.AccessControlPolicyRule{
|
||||
{Actions: []string{model.AccessControlPolicyActionMembership}, Expression: `user.attributes.Department == "Engineering"`},
|
||||
},
|
||||
}
|
||||
|
||||
m.On("GetPolicy", mock.AnythingOfType("*request.Context"), th.BasicTeam.Id).Return(teamPolicy, nil)
|
||||
m.On("GetPolicy", mock.AnythingOfType("*request.Context"), parentID).Return(parentPolicy, nil)
|
||||
|
||||
// AttributeValueMasking is on by default, so the handler masks parent
|
||||
// expressions through the PDP. Pass them through unchanged here.
|
||||
m.On("MaskExpressionForCaller", mock.Anything, mock.Anything, mock.Anything).
|
||||
Return(`user.attributes.Department == "Engineering"`, false, (*model.AppError)(nil))
|
||||
|
||||
assertBanner := func(t *testing.T, client *model.Client4) {
|
||||
t.Helper()
|
||||
resp, err := getPolicy(t, client, 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"`
|
||||
ParentPolicies []*model.AccessControlPolicy `json:"parent_policies"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
require.Len(t, body.ParentPolicies, 1)
|
||||
got := body.ParentPolicies[0]
|
||||
require.Equal(t, parentID, got.ID)
|
||||
require.Equal(t, "Engineering", got.Name)
|
||||
require.Len(t, got.Rules, 1)
|
||||
require.Equal(t, `user.attributes.Department == "Engineering"`, got.Rules[0].Expression)
|
||||
|
||||
// Assignment metadata must be stripped.
|
||||
require.Empty(t, got.Scope)
|
||||
require.Empty(t, got.ScopeID)
|
||||
require.Nil(t, got.Props)
|
||||
}
|
||||
|
||||
// System admin gets the resolved parent — the pre-existing behavior.
|
||||
t.Run("system admin", func(t *testing.T) {
|
||||
assertBanner(t, th.SystemAdminClient)
|
||||
})
|
||||
|
||||
// Team admin gets the SAME banner data, without needing to fetch the parent
|
||||
// policy directly (which requires ManageSystem). This is the MM-70056 fix.
|
||||
t.Run("team admin", func(t *testing.T) {
|
||||
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)
|
||||
|
||||
assertBanner(t, th.Client)
|
||||
})
|
||||
})
|
||||
|
||||
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.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, pluckIDs(users, func(u *model.User) string { return u.Id }), 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}, pluckIDs(users, func(u *model.User) string { return u.Id }))
|
||||
m.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("term query param is forwarded to the policy query", 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.MatchedBy(func(opts model.SubjectSearchOptions) bool {
|
||||
return opts.Term == "eng"
|
||||
})).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&term=eng&per_page=200", "")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
m.AssertExpectations(t)
|
||||
})
|
||||
|
||||
// The term search must not let a non-admin probe users' real names when
|
||||
// PrivacySettings.ShowFullName is off — mirroring the normal user-search gate.
|
||||
// System admin (asAdmin) is never restricted.
|
||||
privacyCases := []struct {
|
||||
name string
|
||||
asAdmin bool
|
||||
showFullName bool
|
||||
wantExcludeNames bool
|
||||
}{
|
||||
{"non-admin with ShowFullName off excludes full names", false, false, true},
|
||||
{"non-admin with ShowFullName on allows full names", false, true, false},
|
||||
{"system admin always allows full names", true, false, false},
|
||||
}
|
||||
for _, tc := range privacyCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
m := enableTeamABAC(t, th)
|
||||
saveTeamMembershipPolicy(t, th, th.BasicTeam.Id)
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.PrivacySettings.ShowFullName = model.NewPointer(tc.showFullName)
|
||||
})
|
||||
|
||||
m.On("QueryUsersForResource", mock.AnythingOfType("*request.Context"), th.BasicTeam.Id, model.AccessControlPolicyActionMembership, mock.MatchedBy(func(opts model.SubjectSearchOptions) bool {
|
||||
return opts.ExcludeFullNames == tc.wantExcludeNames
|
||||
})).Return([]*model.User{}, int64(0), (*model.AppError)(nil))
|
||||
|
||||
// BasicUser is a member of BasicTeam, so it holds ViewTeam but is not a sysadmin.
|
||||
client := th.Client
|
||||
if tc.asAdmin {
|
||||
client = th.SystemAdminClient
|
||||
}
|
||||
resp, err := client.DoAPIGet(context.Background(), "/users?not_in_team="+th.BasicTeam.Id+"&abac_match_only=true&term=smith&per_page=200", "")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
// 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.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")
|
||||
}
|
||||
Reference in New Issue
Block a user