Files
upstream-mirror 240db4e2a7
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
snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
2026-09-16 00:29:55 +05:00

404 lines
16 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"errors"
"net/http"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
// getChannelIDFromJobData extracts channel ID from access control sync job data.
// Returns channel ID if the job is for a specific channel, empty string if it's a system-wide job.
func (a *App) getChannelIDFromJobData(jobData model.StringMap) string {
policyID, ok := jobData["policy_id"]
if !ok || policyID == "" {
return ""
}
// In the access control system:
// - Channel policies have ID == channelID
// - Parent policies have their own system-wide ID
//
// For channel admin jobs: policy_id is channelID (since channel policy ID equals channel ID)
// For system admin jobs: policy_id could be either channel policy ID or parent policy ID
//
// We return the parent_id as channelID because:
// 1. If it's a channel policy ID, it equals the channel ID
// 2. If it's a parent policy ID, the permission check will fail safely
// 3. This maintains security: only users with permission to that specific ID can create the job
return policyID
}
func (a *App) GetJob(rctx request.CTX, id string) (*model.Job, *model.AppError) {
job, err := a.Srv().Store().Job().Get(rctx, id)
if err != nil {
var nfErr *store.ErrNotFound
switch {
case errors.As(err, &nfErr):
return nil, model.NewAppError("GetJob", "app.job.get.app_error", nil, "", http.StatusNotFound).Wrap(err)
default:
return nil, model.NewAppError("GetJob", "app.job.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
return job, nil
}
func (a *App) GetJobsByTypePage(rctx request.CTX, jobType string, page int, perPage int) ([]*model.Job, *model.AppError) {
jobs, err := a.Srv().Store().Job().GetAllByTypePage(rctx, jobType, page, perPage)
if err != nil {
return nil, model.NewAppError("GetJobsByType", "app.job.get_all.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return jobs, nil
}
func (a *App) GetJobsByTypesPage(rctx request.CTX, jobType []string, page int, perPage int) ([]*model.Job, *model.AppError) {
jobs, err := a.Srv().Store().Job().GetAllByTypesPage(rctx, jobType, page, perPage)
if err != nil {
return nil, model.NewAppError("GetJobsByType", "app.job.get_all.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return jobs, nil
}
func (a *App) GetJobsByTypeAndData(rctx request.CTX, jobType string, data map[string]string) ([]*model.Job, *model.AppError) {
jobs, err := a.Srv().Store().Job().GetByTypeAndData(rctx, jobType, data, false)
if err != nil {
return nil, model.NewAppError("GetJobsByTypeAndData", "app.job.get_all.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return jobs, nil
}
func (a *App) GetJobsByTypesAndStatuses(rctx request.CTX, jobTypes []string, status []string, page int, perPage int) ([]*model.Job, *model.AppError) {
jobs, err := a.Srv().Store().Job().GetAllByTypesAndStatusesPage(rctx, jobTypes, status, page*perPage, perPage)
if err != nil {
return nil, model.NewAppError("GetAllByTypesAndStatusesPage", "app.job.get_all.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return jobs, nil
}
func (a *App) CreateJob(rctx request.CTX, job *model.Job) (*model.Job, *model.AppError) {
switch job.Type {
case model.JobTypeAccessControlSync:
// Route ABAC jobs to specialized deduplication handler
return a.CreateAccessControlSyncJob(rctx, job.Data)
case model.JobTypeAccessControlTeamSync:
// Route team ABAC jobs to specialized deduplication handler
return a.CreateAccessControlTeamSyncJob(rctx, job.Data)
default:
return a.Srv().Jobs.CreateJob(rctx, job.Type, job.Data)
}
}
func (a *App) CreateAccessControlSyncJob(rctx request.CTX, jobData map[string]string) (*model.Job, *model.AppError) {
policyID := jobData["policy_id"]
teamID := jobData["team_id"]
requesterID := jobData["requester_id"]
isAdmin := jobData["requester_is_admin"] == "true"
// Remove transient fields before persisting.
delete(jobData, "requester_id")
delete(jobData, "requester_is_admin")
if policyID != "" {
a.cancelExistingAccessControlSyncJobs(rctx, model.JobTypeAccessControlSync, map[string]string{"policy_id": policyID}, "policy_id", policyID)
}
if teamID != "" && policyID == "" {
a.cancelExistingAccessControlSyncJobs(rctx, model.JobTypeAccessControlSync, map[string]string{"team_id": teamID}, "team_id", teamID)
}
// For team-scoped syncs by non-admins, resolve which channels the
// requester can sync based on self-inclusion (attribute match).
if teamID != "" && policyID == "" && requesterID != "" && !isAdmin {
channelIDs, appErr := a.resolveTeamSyncChannelIDs(rctx, teamID, requesterID)
if appErr != nil {
return nil, appErr
}
if len(channelIDs) > 0 {
jobData["channel_ids"] = strings.Join(channelIDs, ",")
}
}
return a.Srv().Jobs.CreateJob(rctx, model.JobTypeAccessControlSync, jobData)
}
// CreateAccessControlTeamSyncJob creates a team membership sync job, first
// canceling any pending/in-progress sync for the same policy so two never run
// concurrently. A team-type policy's ID equals its team ID, so deduplicating on
// policy_id deduplicates per team. Without this, overlapping syncs on separate
// HA nodes emit duplicate removal/addition DMs and audit records for one change.
func (a *App) CreateAccessControlTeamSyncJob(rctx request.CTX, jobData map[string]string) (*model.Job, *model.AppError) {
policyID := jobData["policy_id"]
// Strip transient fields before persisting. Only channel syncs inject these,
// so this is defensive against a hand-crafted request body.
delete(jobData, "requester_id")
delete(jobData, "requester_is_admin")
if policyID != "" {
a.cancelExistingAccessControlSyncJobs(rctx, model.JobTypeAccessControlTeamSync, map[string]string{"policy_id": policyID}, "policy_id", policyID)
}
return a.Srv().Jobs.CreateJob(rctx, model.JobTypeAccessControlTeamSync, jobData)
}
// cancelExistingAccessControlSyncJobs cancels pending/in-progress sync jobs of
// the given type matching the data filter, for deduplication.
func (a *App) cancelExistingAccessControlSyncJobs(rctx request.CTX, jobType string, dataFilter map[string]string, logKey, logValue string) {
existingJobs, err := a.Srv().Store().Job().GetByTypeAndData(rctx, jobType, dataFilter, true, model.JobStatusPending, model.JobStatusInProgress)
if err != nil {
rctx.Logger().Warn("Failed to query existing sync jobs for deduplication", mlog.Err(err))
return
}
for _, job := range existingJobs {
rctx.Logger().Info("Canceling existing access control sync job before creating new one",
mlog.String("job_id", job.Id),
mlog.String(logKey, logValue),
mlog.String("status", job.Status))
if err := a.Srv().Jobs.SetJobCanceled(job); err != nil {
rctx.Logger().Warn("Failed to cancel existing access control sync job",
mlog.String("job_id", job.Id),
mlog.String(logKey, logValue),
mlog.Err(err))
}
}
}
// resolveTeamSyncChannelIDs returns channel IDs the requester can sync,
// filtered by self-inclusion (same logic as SearchTeamAccessPolicies).
func (a *App) resolveTeamSyncChannelIDs(rctx request.CTX, teamID, requesterID string) ([]string, *model.AppError) {
policies, _, appErr := a.SearchTeamAccessPolicies(rctx, teamID, requesterID, model.AccessControlPolicySearch{
Limit: teamPoliciesMaxFetch,
})
if appErr != nil {
return nil, appErr
}
var channelIDs []string
for _, policy := range policies {
if policy.Props == nil {
continue
}
childIDs, ok := policy.Props["child_ids"].([]string)
if !ok {
// Handle []any from JSON unmarshaling
if rawIDs, ok2 := policy.Props["child_ids"].([]any); ok2 {
for _, raw := range rawIDs {
if id, ok3 := raw.(string); ok3 {
channelIDs = append(channelIDs, id)
}
}
continue
}
continue
}
channelIDs = append(channelIDs, childIDs...)
}
return channelIDs, nil
}
func (a *App) CancelJob(rctx request.CTX, jobId string) *model.AppError {
return a.Srv().Jobs.RequestCancellation(rctx, jobId)
}
func (a *App) UpdateJobStatus(rctx request.CTX, job *model.Job, newStatus string) *model.AppError {
switch newStatus {
case model.JobStatusPending:
return a.Srv().Jobs.SetJobPending(job)
case model.JobStatusCancelRequested:
return a.Srv().Jobs.RequestCancellation(rctx, job.Id)
case model.JobStatusCanceled:
return a.Srv().Jobs.SetJobCanceled(job)
default:
return model.NewAppError("UpdateJobStatus", "app.job.update_status.app_error", nil, "", http.StatusInternalServerError)
}
}
func (a *App) SessionHasPermissionToCreateJob(session model.Session, job *model.Job) (bool, *model.Permission) {
switch job.Type {
case model.JobTypeDataRetention:
return a.SessionHasPermissionTo(session, model.PermissionCreateDataRetentionJob), model.PermissionCreateDataRetentionJob
case model.JobTypeMessageExport:
return a.SessionHasPermissionTo(session, model.PermissionCreateComplianceExportJob), model.PermissionCreateComplianceExportJob
case model.JobTypeElasticsearchPostIndexing:
return a.SessionHasPermissionTo(session, model.PermissionCreateElasticsearchPostIndexingJob), model.PermissionCreateElasticsearchPostIndexingJob
case model.JobTypeElasticsearchPostAggregation:
return a.SessionHasPermissionTo(session, model.PermissionCreateElasticsearchPostAggregationJob), model.PermissionCreateElasticsearchPostAggregationJob
case model.JobTypeLdapSync:
return a.SessionHasPermissionTo(session, model.PermissionCreateLdapSyncJob), model.PermissionCreateLdapSyncJob
case
model.JobTypeMigrations,
model.JobTypePlugins,
model.JobTypeProductNotices,
model.JobTypeExpiryNotify,
model.JobTypeActiveUsers,
model.JobTypeImportProcess,
model.JobTypeImportDelete,
model.JobTypeExportProcess,
model.JobTypeExportDelete,
model.JobTypeCloud,
model.JobTypeExtractContent,
model.JobTypeCleanupExpiredAccessTokens,
model.JobTypeNotifyExpiringAccessTokens:
return a.SessionHasPermissionTo(session, model.PermissionManageJobs), model.PermissionManageJobs
case model.JobTypeAccessControlTeamSync:
if a.SessionHasPermissionTo(session, model.PermissionManageSystem) {
return true, model.PermissionManageSystem
}
// Team-type policies use the team ID as the policy ID.
// Allow a team admin to trigger a sync job for their own team's policy.
if policyID, ok := job.Data["policy_id"]; ok && policyID != "" && model.IsValidId(policyID) {
if a.SessionHasPermissionToTeam(session, policyID, model.PermissionManageTeamAccessRules) {
return true, model.PermissionManageTeamAccessRules
}
}
return false, model.PermissionManageSystem
case model.JobTypeAccessControlSync:
// Allow system admins to create access control sync jobs
hasSystemPermission := a.SessionHasPermissionTo(session, model.PermissionManageSystem)
if hasSystemPermission {
return true, model.PermissionManageSystem
}
// For channel admins, check if they have permission for the specific channel/policy
channelID := a.getChannelIDFromJobData(job.Data)
if channelID != "" {
// Check specific channel permission
ctx := request.EmptyContext(a.Srv().Log())
hasChannelPermission, _ := a.HasPermissionToChannel(ctx, session.UserId, channelID, model.PermissionManageChannelAccessRules)
if hasChannelPermission {
return true, model.PermissionManageChannelAccessRules
}
}
// Check team admin permission. This is needed for jobs that are scoped to a team but don't specify a channel (i.e. team-level syncs).
teamID, hasTeamID := job.Data["team_id"]
if hasTeamID && teamID != "" && model.IsValidId(teamID) && job.Data["policy_id"] == "" {
if a.SessionHasPermissionToTeam(session, teamID, model.PermissionManageTeamAccessRules) {
return true, model.PermissionManageTeamAccessRules
}
}
// Check team admin permission for policy-scoped syncs (e.g. triggered after policy creation).
// Verify the team admin owns the policy via ValidateTeamAdminPolicyOwnership.
policyID, hasPolicyID := job.Data["policy_id"]
if hasPolicyID && policyID != "" && model.IsValidId(policyID) && hasTeamID && teamID != "" && model.IsValidId(teamID) {
if a.SessionHasPermissionToTeam(session, teamID, model.PermissionManageTeamAccessRules) {
ctx := request.EmptyContext(a.Srv().Log())
if owned, appErr := a.ValidateTeamAdminPolicyOwnership(ctx, teamID, policyID); appErr == nil && owned {
return true, model.PermissionManageTeamAccessRules
}
}
}
return false, model.PermissionManageSystem
}
return false, nil
}
func (a *App) SessionHasPermissionToManageJob(session model.Session, job *model.Job) (bool, *model.Permission) {
var permission *model.Permission
switch job.Type {
case model.JobTypeDataRetention:
permission = model.PermissionManageDataRetentionJob
case model.JobTypeMessageExport:
permission = model.PermissionManageComplianceExportJob
case model.JobTypeElasticsearchPostIndexing:
permission = model.PermissionManageElasticsearchPostIndexingJob
case model.JobTypeElasticsearchPostAggregation:
permission = model.PermissionManageElasticsearchPostAggregationJob
case model.JobTypeLdapSync:
permission = model.PermissionManageLdapSyncJob
case
model.JobTypeMigrations,
model.JobTypePlugins,
model.JobTypeProductNotices,
model.JobTypeExpiryNotify,
model.JobTypeActiveUsers,
model.JobTypeImportProcess,
model.JobTypeImportDelete,
model.JobTypeExportProcess,
model.JobTypeExportDelete,
model.JobTypeCloud,
model.JobTypeExtractContent,
model.JobTypeCleanupExpiredAccessTokens:
permission = model.PermissionManageJobs
case model.JobTypeAccessControlSync:
permission = model.PermissionManageSystem
case model.JobTypeAccessControlTeamSync:
if a.SessionHasPermissionTo(session, model.PermissionManageSystem) {
return true, model.PermissionManageSystem
}
if policyID, ok := job.Data["policy_id"]; ok && policyID != "" && model.IsValidId(policyID) {
if a.SessionHasPermissionToTeam(session, policyID, model.PermissionManageTeamAccessRules) {
return true, model.PermissionManageTeamAccessRules
}
}
return false, model.PermissionManageSystem
}
if permission == nil {
return false, nil
}
return a.SessionHasPermissionTo(session, permission), permission
}
func (a *App) SessionHasPermissionToReadJob(session model.Session, jobType string) (bool, *model.Permission) {
var permission *model.Permission
switch jobType {
case model.JobTypeDataRetention:
permission = model.PermissionReadDataRetentionJob
case model.JobTypeMessageExport:
permission = model.PermissionReadComplianceExportJob
case model.JobTypeElasticsearchPostIndexing:
permission = model.PermissionReadElasticsearchPostIndexingJob
case model.JobTypeElasticsearchPostAggregation:
permission = model.PermissionReadElasticsearchPostAggregationJob
case model.JobTypeLdapSync:
permission = model.PermissionReadLdapSyncJob
case
model.JobTypeMigrations,
model.JobTypePlugins,
model.JobTypeProductNotices,
model.JobTypeExpiryNotify,
model.JobTypeActiveUsers,
model.JobTypeImportProcess,
model.JobTypeImportDelete,
model.JobTypeExportProcess,
model.JobTypeExportDelete,
model.JobTypeCloud,
model.JobTypeMobileSessionMetadata,
model.JobTypeExtractContent,
model.JobTypePostPersistentNotifications,
model.JobTypeDeleteExpiredPosts,
model.JobTypeCleanupExpiredAccessTokens:
permission = model.PermissionReadJobs
case model.JobTypeAccessControlSync:
permission = model.PermissionManageSystem
case model.JobTypeAccessControlTeamSync:
if a.SessionHasPermissionTo(session, model.PermissionManageSystem) {
return true, model.PermissionManageSystem
}
permission = model.PermissionManageTeamAccessRules
}
if permission == nil {
return false, nil
}
return a.SessionHasPermissionTo(session, permission), permission
}