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.
315 lines
14 KiB
Go
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)
|
|
})
|
|
}
|