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:
493
server/channels/app/team_access_control.go
Normal file
493
server/channels/app/team_access_control.go
Normal file
@@ -0,0 +1,493 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/i18n"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
)
|
||||
|
||||
const (
|
||||
teamPoliciesDefaultPerPage = 10
|
||||
teamPoliciesMaxFetch = 1000
|
||||
)
|
||||
|
||||
// SearchTeamAccessPolicies returns team-scoped parent policies that the requester
|
||||
// satisfies. Fetches all matching policies first, applies self-inclusion filtering,
|
||||
// then paginates the result to keep totals accurate.
|
||||
func (a *App) SearchTeamAccessPolicies(rctx request.CTX, teamID, requesterID string, opts model.AccessControlPolicySearch) ([]*model.AccessControlPolicy, int64, *model.AppError) {
|
||||
requestedCursor := opts.Cursor
|
||||
requestedLimit := opts.Limit
|
||||
if requestedLimit <= 0 {
|
||||
requestedLimit = teamPoliciesDefaultPerPage
|
||||
}
|
||||
|
||||
// Fetch policies matching via channel-inference (TeamID filter)
|
||||
channelOpts := opts
|
||||
channelOpts.TeamID = teamID
|
||||
channelOpts.Type = model.AccessControlPolicyTypeParent
|
||||
channelOpts.IncludeChildren = true
|
||||
channelOpts.Cursor = model.AccessControlPolicyCursor{}
|
||||
channelOpts.Limit = teamPoliciesMaxFetch
|
||||
|
||||
policies, _, appErr := a.SearchAccessControlPolicies(rctx, channelOpts)
|
||||
if appErr != nil {
|
||||
return nil, 0, appErr
|
||||
}
|
||||
|
||||
// Also fetch policies matching via explicit scope (for channelless policies)
|
||||
scopeOpts := opts
|
||||
scopeOpts.Type = model.AccessControlPolicyTypeParent
|
||||
scopeOpts.Scope = model.AccessControlPolicyScopeTeam
|
||||
scopeOpts.ScopeID = teamID
|
||||
scopeOpts.IncludeChildren = true
|
||||
scopeOpts.Cursor = model.AccessControlPolicyCursor{}
|
||||
scopeOpts.Limit = teamPoliciesMaxFetch
|
||||
|
||||
scopePolicies, _, appErr := a.SearchAccessControlPolicies(rctx, scopeOpts)
|
||||
if appErr != nil {
|
||||
return nil, 0, appErr
|
||||
}
|
||||
|
||||
// Merge, deduplicating by ID, then sort by ID for stable cursor-based pagination.
|
||||
seen := make(map[string]bool, len(policies))
|
||||
for _, p := range policies {
|
||||
seen[p.ID] = true
|
||||
}
|
||||
for _, p := range scopePolicies {
|
||||
if !seen[p.ID] {
|
||||
policies = append(policies, p)
|
||||
seen[p.ID] = true
|
||||
}
|
||||
}
|
||||
slices.SortFunc(policies, func(a, b *model.AccessControlPolicy) int {
|
||||
return cmp.Compare(a.ID, b.ID)
|
||||
})
|
||||
|
||||
// Single batched Channel lookup for all policies' child_ids so we don't
|
||||
// issue GetChannelsByIds once per policy (N+1).
|
||||
unionSet := make(map[string]struct{})
|
||||
for _, policy := range policies {
|
||||
ids, ok := childIDsFromPolicyProps(policy)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, id := range ids {
|
||||
unionSet[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
union := make([]string, 0, len(unionSet))
|
||||
for id := range unionSet {
|
||||
union = append(union, id)
|
||||
}
|
||||
slices.Sort(union)
|
||||
|
||||
var idToType map[string]model.ChannelType
|
||||
batchLookupFailed := false
|
||||
if len(union) > 0 {
|
||||
channels, err := a.Srv().Store().Channel().GetChannelsByIds(union, true)
|
||||
if err != nil {
|
||||
rctx.Logger().Warn("Failed to look up child channels for self-inclusion gating batch; keeping the filter on",
|
||||
mlog.Err(err),
|
||||
mlog.Int("policy_count", len(policies)),
|
||||
)
|
||||
batchLookupFailed = true
|
||||
} else {
|
||||
if len(channels) != len(union) {
|
||||
rctx.Logger().Warn("Partial batch child-channel lookup for self-inclusion gating",
|
||||
mlog.Int("requested", len(union)),
|
||||
mlog.Int("returned", len(channels)),
|
||||
)
|
||||
}
|
||||
idToType = make(map[string]model.ChannelType, len(channels))
|
||||
for _, ch := range channels {
|
||||
idToType[ch.Id] = ch.Type
|
||||
}
|
||||
}
|
||||
} else {
|
||||
idToType = map[string]model.ChannelType{}
|
||||
}
|
||||
|
||||
// Filter by self-inclusion. Skip the filter for parent policies whose
|
||||
// children are all public channels — public-channel ABAC is advisory and
|
||||
// can never lock the requesting admin out, so a non-matching expression
|
||||
// is not a reason to hide the policy from them.
|
||||
filtered := make([]*model.AccessControlPolicy, 0, len(policies))
|
||||
for _, policy := range policies {
|
||||
if len(policy.Rules) > 0 && policyAppliesToPrivateChannel(rctx, policy, idToType, batchLookupFailed) {
|
||||
expression := policy.Rules[0].Expression
|
||||
matches, matchErr := a.ValidateExpressionAgainstRequester(rctx, expression, requesterID)
|
||||
if matchErr != nil {
|
||||
rctx.Logger().Warn("Failed to validate self-inclusion for policy",
|
||||
mlog.String("policy_id", policy.ID), mlog.Err(matchErr))
|
||||
continue
|
||||
}
|
||||
if !matches {
|
||||
continue
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, policy)
|
||||
}
|
||||
|
||||
total := int64(len(filtered))
|
||||
|
||||
// Paginate the filtered results. If the cursor ID was removed by
|
||||
// self-inclusion filtering, startIdx stays 0 (resets to first page).
|
||||
startIdx := 0
|
||||
if !requestedCursor.IsEmpty() {
|
||||
for i, p := range filtered {
|
||||
if p.ID == requestedCursor.ID {
|
||||
startIdx = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
endIdx := min(startIdx+requestedLimit, len(filtered))
|
||||
|
||||
return filtered[startIdx:endIdx], total, nil
|
||||
}
|
||||
|
||||
// ValidateTeamAdminPolicyOwnership checks whether a policy is scoped to a given team.
|
||||
// Returns (true, nil) if the policy belongs to the team, (false, nil) if it does not,
|
||||
// or (false, err) if an internal error occurred during the check.
|
||||
//
|
||||
// Checks two mechanisms:
|
||||
// 1. Explicit scope: policy has scope=team and scope_id=teamID in Data JSONB
|
||||
// 2. Channel inference: all assigned channels belong to this team (pre-scope policies)
|
||||
func (a *App) ValidateTeamAdminPolicyOwnership(rctx request.CTX, teamID, policyID string) (bool, *model.AppError) {
|
||||
// Check explicit scope first
|
||||
scopeResults, _, err := a.Srv().Store().AccessControlPolicy().SearchPolicies(rctx, model.AccessControlPolicySearch{
|
||||
IDs: []string{policyID},
|
||||
Type: model.AccessControlPolicyTypeParent,
|
||||
Scope: model.AccessControlPolicyScopeTeam,
|
||||
ScopeID: teamID,
|
||||
Limit: 1,
|
||||
})
|
||||
if err != nil {
|
||||
return false, model.NewAppError("ValidateTeamAdminPolicyOwnership",
|
||||
"app.team.access_policies.ownership_check.app_error",
|
||||
nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
if len(scopeResults) > 0 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Fall back to channel-inference for pre-scope policies
|
||||
channelResults, _, err := a.Srv().Store().AccessControlPolicy().SearchPolicies(rctx, model.AccessControlPolicySearch{
|
||||
IDs: []string{policyID},
|
||||
Type: model.AccessControlPolicyTypeParent,
|
||||
TeamID: teamID,
|
||||
Limit: 1,
|
||||
})
|
||||
if err != nil {
|
||||
return false, model.NewAppError("ValidateTeamAdminPolicyOwnership",
|
||||
"app.team.access_policies.ownership_check.app_error",
|
||||
nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
return len(channelResults) > 0, nil
|
||||
}
|
||||
|
||||
// ReconcilePolicyTeamScope evaluates a parent policy's assigned channels and
|
||||
// sets or clears the team scope accordingly:
|
||||
// - All channels belong to a single team → scope = "team", scope_id = that team
|
||||
// - Channels span multiple teams → scope cleared
|
||||
// - No channels remaining → scope unchanged (preserves the team set at creation)
|
||||
//
|
||||
// This runs after every assign/unassign regardless of the caller's role, so that
|
||||
// system admins adding cross-team channels correctly clears the team scope.
|
||||
func (a *App) ReconcilePolicyTeamScope(rctx request.CTX, policyID string) *model.AppError {
|
||||
// Find all child channel policies for this parent.
|
||||
// Limit of 1000 is ok, since In practice a single policy will not have 1000+ channel
|
||||
// private children.
|
||||
children, _, err := a.Srv().Store().AccessControlPolicy().SearchPolicies(rctx, model.AccessControlPolicySearch{
|
||||
ParentID: policyID,
|
||||
Type: model.AccessControlPolicyTypeChannel,
|
||||
Limit: 1000,
|
||||
})
|
||||
if err != nil {
|
||||
return model.NewAppError("ReconcilePolicyTeamScope",
|
||||
"app.team.access_policies.reconcile_scope.app_error",
|
||||
nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
// No channels — keep existing scope unchanged
|
||||
if len(children) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Collect channel IDs (child policy ID = channel ID)
|
||||
channelIDs := make([]string, len(children))
|
||||
for i, child := range children {
|
||||
channelIDs[i] = child.ID
|
||||
}
|
||||
|
||||
// Resolve channels to determine their teams.
|
||||
// If some channels were deleted after being assigned, GetChannels returns
|
||||
// fewer results — skip reconciliation to avoid stamping a stale scope.
|
||||
channels, appErr := a.GetChannels(rctx, channelIDs)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
if len(channels) != len(channelIDs) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if all channels belong to a single team
|
||||
teamIDs := make(map[string]struct{})
|
||||
for _, ch := range channels {
|
||||
teamIDs[ch.TeamId] = struct{}{}
|
||||
}
|
||||
|
||||
// Fetch the parent policy directly from the store (not the enterprise layer)
|
||||
// to avoid unnecessary CEL expression normalization — we only need scope fields.
|
||||
policy, storeErr := a.Srv().Store().AccessControlPolicy().Get(rctx, policyID)
|
||||
if storeErr != nil {
|
||||
return model.NewAppError("ReconcilePolicyTeamScope",
|
||||
"app.team.access_policies.reconcile_scope.app_error",
|
||||
nil, "", http.StatusInternalServerError).Wrap(storeErr)
|
||||
}
|
||||
|
||||
var newScope, newScopeID string
|
||||
if len(teamIDs) == 1 {
|
||||
// All channels belong to one team — set scope
|
||||
for tid := range teamIDs {
|
||||
newScope = model.AccessControlPolicyScopeTeam
|
||||
newScopeID = tid
|
||||
}
|
||||
}
|
||||
// len(teamIDs) > 1: multiple teams — clear scope (newScope/newScopeID stay "")
|
||||
|
||||
// Skip save if nothing changed
|
||||
if policy.Scope == newScope && policy.ScopeID == newScopeID {
|
||||
return nil
|
||||
}
|
||||
|
||||
policy.Scope = newScope
|
||||
policy.ScopeID = newScopeID
|
||||
|
||||
// Save directly via the store — scope is metadata that doesn't require
|
||||
// enterprise-layer CEL normalization. This avoids re-processing expressions
|
||||
// and works consistently regardless of the enterprise layer state.
|
||||
if _, err := a.Srv().Store().AccessControlPolicy().Save(rctx, policy); err != nil {
|
||||
return model.NewAppError("ReconcilePolicyTeamScope",
|
||||
"app.team.access_policies.reconcile_scope.app_error",
|
||||
nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateTeamScopePolicyChannelAssignment validates that all channels are eligible
|
||||
// for team policy assignment, is the gate that run before channels are attached to a 'team' policy:
|
||||
// - At least one channel provided
|
||||
// - All channels exist
|
||||
// - All channels belong to the given team
|
||||
// - All channels are public or private (DM/GM excluded)
|
||||
// - No group-constrained channels
|
||||
// - No shared channels
|
||||
// - No team default channels (e.g. town-square)
|
||||
func (a *App) ValidateTeamScopePolicyChannelAssignment(rctx request.CTX, teamID string, channelIDs []string) *model.AppError {
|
||||
if len(channelIDs) == 0 {
|
||||
return model.NewAppError("ValidateTeamScopePolicyChannelAssignment",
|
||||
"app.team.access_policies.channels_required.app_error",
|
||||
nil, "at least one channel is required", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
channels, appErr := a.GetChannels(rctx, channelIDs)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
if len(channels) != len(channelIDs) {
|
||||
return model.NewAppError("ValidateTeamScopePolicyChannelAssignment",
|
||||
"app.team.access_policies.channel_not_found.app_error",
|
||||
nil, "one or more channels not found", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
for _, channel := range channels {
|
||||
if channel.TeamId != teamID {
|
||||
return model.NewAppError("ValidateTeamScopePolicyChannelAssignment",
|
||||
"app.team.access_policies.channel_wrong_team.app_error",
|
||||
map[string]any{"ChannelId": channel.Id},
|
||||
"channel does not belong to this team", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if appErr := a.ValidateChannelEligibilityForAccessControl(rctx, channel); appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateTeamAdminSelfInclusion ensures the requesting admin satisfies the given
|
||||
// CEL expression. Returns an error if the admin would be excluded by the expression.
|
||||
func (a *App) ValidateTeamAdminSelfInclusion(rctx request.CTX, userID, expression string) *model.AppError {
|
||||
if expression == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
matches, appErr := a.ValidateExpressionAgainstRequester(rctx, expression, userID)
|
||||
if appErr != nil {
|
||||
return model.NewAppError("ValidateTeamAdminSelfInclusion",
|
||||
"app.team.access_policies.validation_error.app_error",
|
||||
nil, "", http.StatusInternalServerError).Wrap(appErr)
|
||||
}
|
||||
|
||||
if !matches {
|
||||
return model.NewAppError("ValidateTeamAdminSelfInclusion",
|
||||
"app.team.access_policies.self_exclusion.app_error",
|
||||
nil, "policy rules would exclude the requesting admin", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendTeamAccessControlRemovalNotification DMs the user, from the system bot,
|
||||
// that the team's membership policy removed them. The message carries only the
|
||||
// team name — never any policy/attribute detail.
|
||||
// The systemBot may be pre-resolved by the caller (the sync worker resolves it
|
||||
// once per team rather than once per user, since GetSystemBot is not free); pass
|
||||
// nil to have it resolved here.
|
||||
func (a *App) SendTeamAccessControlRemovalNotification(rctx request.CTX, systemBot *model.Bot, userID string, team *model.Team) *model.AppError {
|
||||
locale := ""
|
||||
if user, err := a.GetUser(rctx, userID); err == nil {
|
||||
locale = user.Locale
|
||||
}
|
||||
T := i18n.GetUserTranslations(locale)
|
||||
message := T("api.team.access_control.removed.system_message", map[string]any{"TeamName": team.DisplayName})
|
||||
return a.sendTeamAccessControlMembershipDM(rctx, systemBot, userID, team, model.PostTypeAccessControlTeamRemoval, message)
|
||||
}
|
||||
|
||||
// SendTeamAccessControlAdditionNotification audits the auto-add and DMs the
|
||||
// user, from the system bot, that they were added to the team because they meet
|
||||
// its membership policy. Like the removal DM, it leaks no policy detail. The
|
||||
// audit record is always written; only the DM depends on the system bot.
|
||||
func (a *App) SendTeamAccessControlAdditionNotification(rctx request.CTX, systemBot *model.Bot, userID string, team *model.Team) *model.AppError {
|
||||
rec := a.MakeAuditRecord(rctx, model.AuditEventTeamMembershipAdded, model.AuditStatusSuccess)
|
||||
model.AddEventParameterToAuditRec(rec, "user_id", userID)
|
||||
model.AddEventParameterToAuditRec(rec, "team_id", team.Id)
|
||||
a.LogAuditRec(rctx, rec, nil)
|
||||
|
||||
locale := ""
|
||||
if user, err := a.GetUser(rctx, userID); err == nil {
|
||||
locale = user.Locale
|
||||
}
|
||||
T := i18n.GetUserTranslations(locale)
|
||||
message := T("api.team.access_control.added.system_message", map[string]any{"TeamName": team.DisplayName})
|
||||
return a.sendTeamAccessControlMembershipDM(rctx, systemBot, userID, team, model.PostTypeAccessControlTeamAddition, message)
|
||||
}
|
||||
|
||||
func (a *App) sendTeamAccessControlMembershipDM(rctx request.CTX, systemBot *model.Bot, userID string, team *model.Team, postType, message string) *model.AppError {
|
||||
if systemBot == nil {
|
||||
var appErr *model.AppError
|
||||
if systemBot, appErr = a.GetSystemBot(rctx); appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
}
|
||||
|
||||
channel, appErr := a.GetOrCreateDirectChannel(rctx, userID, systemBot.UserId)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
post := &model.Post{
|
||||
ChannelId: channel.Id,
|
||||
Message: message,
|
||||
Type: postType,
|
||||
UserId: systemBot.UserId,
|
||||
Props: model.StringInterface{"team_name": team.DisplayName},
|
||||
}
|
||||
|
||||
if _, _, appErr := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// childIDsFromPolicyProps reads Props["child_ids"], which is normally []string
|
||||
// (set by the policy store) but may be []any after JSON round-trip.
|
||||
// If ok is false, the shape is unsupported and callers should treat it as unknown.
|
||||
func childIDsFromPolicyProps(policy *model.AccessControlPolicy) (ids []string, ok bool) {
|
||||
if policy == nil || policy.Props == nil {
|
||||
return nil, false
|
||||
}
|
||||
switch raw := policy.Props["child_ids"].(type) {
|
||||
case []string:
|
||||
return raw, true
|
||||
case []any:
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
if id, itemOk := item.(string); itemOk {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
// policyAppliesToPrivateChannel reports whether a parent policy has at least
|
||||
// one private child channel — used to gate the self-inclusion filter, which
|
||||
// only matters when a non-matching admin could actually be locked out. Public
|
||||
// channels under ABAC are advisory (no member removal, anyone can join), so
|
||||
// a non-matching admin is never at risk there.
|
||||
//
|
||||
// idToType maps channel ID → channel type from a batched GetChannelsByIds;
|
||||
// batchLookupFailed means the batch store call failed and the conservative
|
||||
// answer is to apply the filter.
|
||||
//
|
||||
// Returns `true` (i.e. apply the filter) on metadata or lookup errors, since
|
||||
// the safer fallback is to keep the existing behavior rather than silently
|
||||
// expose a policy a private channel might depend on.
|
||||
func policyAppliesToPrivateChannel(rctx request.CTX, policy *model.AccessControlPolicy, idToType map[string]model.ChannelType, batchLookupFailed bool) bool {
|
||||
if policy == nil || policy.Props == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
childIDs, propsOk := childIDsFromPolicyProps(policy)
|
||||
if !propsOk {
|
||||
return true
|
||||
}
|
||||
|
||||
if len(childIDs) == 0 {
|
||||
// Channel-less policy (newly created, not yet assigned). Until a
|
||||
// channel is attached there's nothing to lock anyone out of, so the
|
||||
// filter is meaningless here.
|
||||
return false
|
||||
}
|
||||
|
||||
if batchLookupFailed {
|
||||
return true
|
||||
}
|
||||
|
||||
found := 0
|
||||
for _, id := range childIDs {
|
||||
if _, ok := idToType[id]; ok {
|
||||
found++
|
||||
}
|
||||
}
|
||||
if found != len(childIDs) {
|
||||
rctx.Logger().Warn("Partial child-channel lookup for self-inclusion gating; keeping the filter on",
|
||||
mlog.String("policy_id", policy.ID),
|
||||
mlog.Int("requested", len(childIDs)),
|
||||
mlog.Int("returned", found),
|
||||
)
|
||||
return true
|
||||
}
|
||||
for _, id := range childIDs {
|
||||
if idToType[id] == model.ChannelTypePrivate {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user