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:
755
server/public/model/access_policy.go
Normal file
755
server/public/model/access_policy.go
Normal file
@@ -0,0 +1,755 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
const (
|
||||
AccessControlPolicyTypeParent = "parent"
|
||||
AccessControlPolicyTypeChannel = "channel"
|
||||
AccessControlPolicyTypePermission = "permission"
|
||||
AccessControlPolicyTypeTeam = "team"
|
||||
|
||||
MaxPolicyNameLength = 128
|
||||
|
||||
// PluginAccessControlPolicyTypeSeparator separates the owning plugin ID
|
||||
// from the resource-type segment of a plugin-owned policy type, e.g.
|
||||
// "mattermost-ai:agent". Ownership is encoded in the key itself, so core
|
||||
// treats these types opaquely.
|
||||
PluginAccessControlPolicyTypeSeparator = ":"
|
||||
|
||||
// MaxPluginResourceTypeLength bounds the resource-type segment of a
|
||||
// plugin-owned policy type.
|
||||
MaxPluginResourceTypeLength = 64
|
||||
|
||||
// MaxPolicyTypeLength bounds a whole policy type to the width of the
|
||||
// AccessControlPolicies.Type column. A valid plugin ID alone can exceed
|
||||
// it, so the combined key must be bounded here or an otherwise-valid type
|
||||
// would fail at insert time instead of validation.
|
||||
MaxPolicyTypeLength = 128
|
||||
|
||||
// MaxPolicyActionLength bounds an action name.
|
||||
MaxPolicyActionLength = 64
|
||||
|
||||
AccessControlPolicyVersionV0_1 = "v0.1"
|
||||
AccessControlPolicyVersionV0_2 = "v0.2"
|
||||
AccessControlPolicyVersionV0_3 = "v0.3"
|
||||
AccessControlPolicyVersionV0_4 = "v0.4"
|
||||
AccessControlPolicyVersionV0_5 = "v0.5"
|
||||
|
||||
AccessControlPolicyActionMembership = "membership"
|
||||
AccessControlPolicyActionUploadFileAttachment = "upload_file_attachment"
|
||||
AccessControlPolicyActionDownloadFileAttachment = "download_file_attachment"
|
||||
|
||||
AccessControlPolicyScopeTeam = "team"
|
||||
)
|
||||
|
||||
var allowedActionsV0_3 = map[string]bool{
|
||||
AccessControlPolicyActionMembership: true,
|
||||
AccessControlPolicyActionUploadFileAttachment: true,
|
||||
AccessControlPolicyActionDownloadFileAttachment: true,
|
||||
}
|
||||
|
||||
// allowedChannelRolesV0_4 is the set of channel-scoped roles that may appear
|
||||
// on a v0.4 channel resource policy rule.
|
||||
var allowedChannelRolesV0_4 = map[string]bool{
|
||||
ChannelGuestRoleId: true,
|
||||
ChannelUserRoleId: true,
|
||||
ChannelAdminRoleId: true,
|
||||
}
|
||||
|
||||
// allowedPermissionActionsV0_4 is the set of non-membership actions that may
|
||||
// appear on a v0.4 channel resource policy rule. These rules govern per-action
|
||||
// behavior (file upload/download) and must carry a channel-scoped role.
|
||||
var allowedPermissionActionsV0_4 = map[string]bool{
|
||||
AccessControlPolicyActionUploadFileAttachment: true,
|
||||
AccessControlPolicyActionDownloadFileAttachment: true,
|
||||
}
|
||||
|
||||
// IsPermissionAction reports whether the given action is a non-membership
|
||||
// permission action governed by a v0.4 channel rule.
|
||||
func IsPermissionAction(action string) bool {
|
||||
return allowedPermissionActionsV0_4[action]
|
||||
}
|
||||
|
||||
// pluginResourceTypeRe constrains the resource-type segment of a plugin-owned
|
||||
// policy type to the same charset as a plugin ID.
|
||||
var pluginResourceTypeRe = regexp.MustCompile(ValidIdRegex)
|
||||
|
||||
// policyActionRe constrains action names to lowercase alphanumeric segments
|
||||
// joined by single underscores, matching the shape of the built-in actions
|
||||
// (membership, upload_file_attachment). It rejects the "*" wildcard.
|
||||
var policyActionRe = regexp.MustCompile(`^[a-z0-9]+(_[a-z0-9]+)*$`)
|
||||
|
||||
// SplitPluginAccessControlPolicyType splits a plugin-owned policy type into
|
||||
// its owning plugin ID and resource-type segment. ok is false unless both
|
||||
// segments are well formed.
|
||||
func SplitPluginAccessControlPolicyType(policyType string) (pluginID string, resourceType string, ok bool) {
|
||||
if len(policyType) > MaxPolicyTypeLength {
|
||||
return "", "", false
|
||||
}
|
||||
pluginID, resourceType, found := strings.Cut(policyType, PluginAccessControlPolicyTypeSeparator)
|
||||
if !found || !IsValidPluginId(pluginID) {
|
||||
return "", "", false
|
||||
}
|
||||
if resourceType == "" || len(resourceType) > MaxPluginResourceTypeLength || !pluginResourceTypeRe.MatchString(resourceType) {
|
||||
return "", "", false
|
||||
}
|
||||
return pluginID, resourceType, true
|
||||
}
|
||||
|
||||
// IsPluginAccessControlPolicyType reports whether policyType is a well-formed
|
||||
// plugin-owned resource-policy type ("<pluginID>:<resourceType>").
|
||||
func IsPluginAccessControlPolicyType(policyType string) bool {
|
||||
_, _, ok := SplitPluginAccessControlPolicyType(policyType)
|
||||
return ok
|
||||
}
|
||||
|
||||
// PluginOwnsAccessControlPolicyType reports whether policyType is a
|
||||
// well-formed plugin policy type whose plugin-ID prefix is pluginID. The
|
||||
// prefix is the entire ownership check. The comparison is exact: the stored
|
||||
// type is matched byte-for-byte everywhere (delete, evaluation), so accepting
|
||||
// case variants here would let a mixed-case type be read but never deleted or
|
||||
// evaluated.
|
||||
func PluginOwnsAccessControlPolicyType(pluginID, policyType string) bool {
|
||||
owner, _, ok := SplitPluginAccessControlPolicyType(policyType)
|
||||
return ok && owner == pluginID
|
||||
}
|
||||
|
||||
// IsValidPolicyAction reports whether action is a well-formed action name.
|
||||
// Plugin policies choose their own action names, so core validates only the
|
||||
// format.
|
||||
func IsValidPolicyAction(action string) bool {
|
||||
return len(action) <= MaxPolicyActionLength && policyActionRe.MatchString(action)
|
||||
}
|
||||
|
||||
// HasPermissionRuleAction reports whether ANY rule on this policy
|
||||
// carries a non-membership permission action (file upload/download).
|
||||
// Used by the API4 layer to gate channel-scope policies behind the
|
||||
// ChannelPermissionPolicies feature flag: if a channel policy
|
||||
// includes a permission rule and the flag is off, the request is
|
||||
// rejected before reaching the PAP. Returns false for a nil/empty
|
||||
// policy so callers can use it as a guard without nil checks.
|
||||
func (p *AccessControlPolicy) HasPermissionRuleAction() bool {
|
||||
if p == nil {
|
||||
return false
|
||||
}
|
||||
for i := range p.Rules {
|
||||
if slices.ContainsFunc(p.Rules[i].Actions, IsPermissionAction) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AccessControlAttribute represents a user attribute with its name and possible values
|
||||
type AccessControlAttribute struct {
|
||||
Attribute PropertyField `json:"attribute"`
|
||||
Values []string `json:"values"`
|
||||
}
|
||||
|
||||
type AccessControlPolicyTestResponse struct {
|
||||
Users []*User `json:"users"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
type GetAccessControlPolicyOptions struct {
|
||||
Type string `json:"type"`
|
||||
ParentID string `json:"parent_id"`
|
||||
Cursor AccessControlPolicyCursor `json:"cursor"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
type AccessControlPolicySearch struct {
|
||||
Term string `json:"term"`
|
||||
Type string `json:"type"`
|
||||
ParentID string `json:"parent_id"`
|
||||
IDs []string `json:"ids"`
|
||||
Cursor AccessControlPolicyCursor `json:"cursor"`
|
||||
Limit int `json:"limit"`
|
||||
IncludeChildren bool `json:"include_children"`
|
||||
Active bool `json:"active"`
|
||||
TeamID string `json:"team_id"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
ScopeID string `json:"scope_id,omitempty"`
|
||||
Actions []string `json:"actions"`
|
||||
}
|
||||
|
||||
type AccessControlPolicyCursor struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type AccessControlPoliciesWithCount struct {
|
||||
Policies []*AccessControlPolicy `json:"policies"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
type AccessControlPolicy struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Active bool `json:"active"`
|
||||
CreateAt int64 `json:"create_at"`
|
||||
|
||||
Revision int `json:"revision"`
|
||||
Version string `json:"version"`
|
||||
|
||||
Roles []string `json:"roles"`
|
||||
Imports []string `json:"imports"`
|
||||
Rules []AccessControlPolicyRule `json:"rules"`
|
||||
|
||||
Scope string `json:"scope,omitempty"` // "" (system) or "team"
|
||||
ScopeID string `json:"scope_id,omitempty"` // team ID when scope="team"
|
||||
|
||||
Props map[string]any `json:"props"` // add auto-sync property here, also maybe the attributes being used in the expression
|
||||
}
|
||||
|
||||
type AccessControlPolicyRule struct {
|
||||
Actions []string `json:"actions"`
|
||||
Expression string `json:"expression"`
|
||||
// Name is an admin-facing label for the rule. Required for v0.4 permission
|
||||
// rules and must be unique within the same policy.
|
||||
Name string `json:"name,omitempty"`
|
||||
// Role is the channel-scoped role this rule applies to (channel_guest,
|
||||
// channel_user, channel_admin) for v0.4 permission rules. Membership rules
|
||||
// must leave this empty.
|
||||
Role string `json:"role,omitempty"`
|
||||
}
|
||||
|
||||
type CELExpressionError struct {
|
||||
Line int `json:"line"`
|
||||
Column int `json:"column"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type AccessControlQueryResult struct {
|
||||
MatchedSubjectIDs []string `json:"matched_subject_ids"`
|
||||
}
|
||||
|
||||
// AccessControlPolicyActiveUpdate represents a single policy's active status update.
|
||||
type AccessControlPolicyActiveUpdate struct {
|
||||
ID string `json:"id"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
// AccessControlPolicyActiveUpdateRequest is used in the API to update active status for multiple policies.
|
||||
type AccessControlPolicyActiveUpdateRequest struct {
|
||||
Entries []AccessControlPolicyActiveUpdate `json:"entries"`
|
||||
TeamID string `json:"team_id,omitempty"`
|
||||
}
|
||||
|
||||
func (r *AccessControlPolicyActiveUpdateRequest) Auditable() map[string]any {
|
||||
entries := make([]map[string]any, 0, len(r.Entries))
|
||||
for _, entry := range r.Entries {
|
||||
entries = append(entries, map[string]any{
|
||||
"id": entry.ID,
|
||||
"active": entry.Active,
|
||||
})
|
||||
}
|
||||
result := map[string]any{
|
||||
"entries": entries,
|
||||
}
|
||||
if r.TeamID != "" {
|
||||
result["team_id"] = r.TeamID
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (p *AccessControlPolicy) IsValid() *AppError {
|
||||
if p.Scope != "" || p.ScopeID != "" {
|
||||
if appErr := p.validateScope(); appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
}
|
||||
|
||||
switch p.Version {
|
||||
case AccessControlPolicyVersionV0_1:
|
||||
return p.accessPolicyVersionV0_1()
|
||||
case AccessControlPolicyVersionV0_2:
|
||||
return p.accessPolicyVersionV0_2()
|
||||
case AccessControlPolicyVersionV0_3:
|
||||
return p.accessPolicyVersionV0_3()
|
||||
case AccessControlPolicyVersionV0_4:
|
||||
return p.accessPolicyVersionV0_4()
|
||||
case AccessControlPolicyVersionV0_5:
|
||||
return p.accessPolicyVersionV0_5()
|
||||
default:
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.version.app_error", nil, "", 400)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AccessControlPolicy) validateScope() *AppError {
|
||||
switch p.Scope {
|
||||
case "":
|
||||
if p.ScopeID != "" {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.scope_id_without_scope.app_error", nil, "", 400)
|
||||
}
|
||||
case AccessControlPolicyScopeTeam:
|
||||
if !IsValidId(p.ScopeID) {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.scope_id.app_error", nil, "", 400)
|
||||
}
|
||||
default:
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.scope.app_error", nil, "", 400)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *AccessControlPolicy) accessPolicyVersionV0_1() *AppError {
|
||||
if !slices.Contains([]string{AccessControlPolicyTypeParent, AccessControlPolicyTypeChannel}, p.Type) {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.type.app_error", nil, "", 400)
|
||||
}
|
||||
|
||||
if !IsValidId(p.ID) {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.id.app_error", nil, "", 400)
|
||||
}
|
||||
|
||||
if p.Type == AccessControlPolicyTypeParent && (p.Name == "" || len(p.Name) > MaxPolicyNameLength) {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.name.app_error", nil, "", 400)
|
||||
}
|
||||
|
||||
if p.Revision < 0 {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.revision.app_error", nil, "", 400)
|
||||
}
|
||||
|
||||
if !semver.IsValid(p.Version) {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.version.app_error", nil, "", 400)
|
||||
}
|
||||
|
||||
switch p.Type {
|
||||
case AccessControlPolicyTypeParent:
|
||||
if len(p.Rules) == 0 {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rules.app_error", nil, "", 400)
|
||||
}
|
||||
|
||||
if len(p.Imports) > 0 {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.imports.app_error", nil, "", 400)
|
||||
}
|
||||
case AccessControlPolicyTypeChannel:
|
||||
if len(p.Rules) == 0 && len(p.Imports) == 0 {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rules_imports.app_error", nil, "", 400)
|
||||
}
|
||||
|
||||
if len(p.Rules) == 0 {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rules.app_error", nil, "", 400)
|
||||
}
|
||||
|
||||
if len(p.Imports) > 1 {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.imports.app_error", nil, "", 400)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *AccessControlPolicy) accessPolicyVersionV0_2() *AppError {
|
||||
if !slices.Contains([]string{AccessControlPolicyTypeParent, AccessControlPolicyTypeChannel}, p.Type) {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.type.app_error", nil, "", 400)
|
||||
}
|
||||
|
||||
if !IsValidId(p.ID) {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.id.app_error", nil, "", 400)
|
||||
}
|
||||
|
||||
if p.Type == AccessControlPolicyTypeParent && (p.Name == "" || len(p.Name) > MaxPolicyNameLength) {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.name.app_error", nil, "", 400)
|
||||
}
|
||||
|
||||
if p.Revision < 0 {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.revision.app_error", nil, "", 400)
|
||||
}
|
||||
|
||||
if !semver.IsValid(p.Version) {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.version.app_error", nil, "", 400)
|
||||
}
|
||||
|
||||
switch p.Type {
|
||||
case AccessControlPolicyTypeParent:
|
||||
if len(p.Rules) == 0 {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rules.app_error", nil, "", 400)
|
||||
}
|
||||
|
||||
if len(p.Imports) > 0 {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.imports.app_error", nil, "", 400)
|
||||
}
|
||||
case AccessControlPolicyTypeChannel:
|
||||
if len(p.Rules) == 0 && len(p.Imports) == 0 {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rules_imports.app_error", nil, "", 400)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *AccessControlPolicy) accessPolicyVersionV0_3() *AppError {
|
||||
if !slices.Contains([]string{AccessControlPolicyTypeParent, AccessControlPolicyTypeChannel, AccessControlPolicyTypePermission, AccessControlPolicyTypeTeam}, p.Type) {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.type.app_error", nil, "", 400)
|
||||
}
|
||||
|
||||
if !IsValidId(p.ID) {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.id.app_error", nil, "", 400)
|
||||
}
|
||||
|
||||
if (p.Type == AccessControlPolicyTypeParent || p.Type == AccessControlPolicyTypePermission) && (p.Name == "" || len(p.Name) > MaxPolicyNameLength) {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.name.app_error", nil, "", 400)
|
||||
}
|
||||
|
||||
if p.Revision < 0 {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.revision.app_error", nil, "", 400)
|
||||
}
|
||||
|
||||
if !semver.IsValid(p.Version) {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.version.app_error", nil, "", 400)
|
||||
}
|
||||
|
||||
switch p.Type {
|
||||
case AccessControlPolicyTypeParent:
|
||||
if len(p.Rules) == 0 {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rules.app_error", nil, "", 400)
|
||||
}
|
||||
|
||||
if len(p.Imports) > 0 {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.imports.app_error", nil, "", 400)
|
||||
}
|
||||
case AccessControlPolicyTypeChannel:
|
||||
if len(p.Rules) == 0 && len(p.Imports) == 0 {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rules_imports.app_error", nil, "", 400)
|
||||
}
|
||||
case AccessControlPolicyTypeTeam:
|
||||
if len(p.Rules) == 0 && len(p.Imports) == 0 {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rules_imports.app_error", nil, "", 400)
|
||||
}
|
||||
case AccessControlPolicyTypePermission:
|
||||
if len(p.Rules) == 0 && len(p.Imports) == 0 {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rules_imports.app_error", nil, "", 400)
|
||||
}
|
||||
|
||||
// Permissions are only allowed to be applied to a single role as of v0.3
|
||||
// role hierarchy is resolved at the PDP
|
||||
if len(p.Roles) != 1 {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.roles.app_error", nil, "", 400)
|
||||
}
|
||||
for _, role := range p.Roles {
|
||||
if strings.TrimSpace(role) == "" {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.roles.app_error", nil, "", 400)
|
||||
}
|
||||
}
|
||||
|
||||
if len(p.Imports) > 0 {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.imports.app_error", nil, "", 400)
|
||||
}
|
||||
}
|
||||
|
||||
for _, rule := range p.Rules {
|
||||
if len(rule.Actions) == 0 {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.actions.app_error", nil, "actions must not be empty", 400)
|
||||
}
|
||||
for _, action := range rule.Actions {
|
||||
if !allowedActionsV0_3[action] {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.actions.app_error", nil, fmt.Sprintf("unrecognized action: %s", action), 400)
|
||||
}
|
||||
}
|
||||
if slices.Contains(rule.Actions, AccessControlPolicyActionMembership) && strings.Contains(rule.Expression, "user.session") {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.session_attribute_on_membership.app_error", nil, "", 400)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// accessPolicyVersionV0_4 validates a v0.4 policy. v0.4 extends v0.3 by
|
||||
// allowing channel resource policies to carry channel-role-scoped permission
|
||||
// rules (upload/download file attachments) alongside membership rules.
|
||||
//
|
||||
// Constraints layered on top of v0.3:
|
||||
// - Permission action rules MUST carry a non-empty Name (unique within the
|
||||
// policy) and a Role in {channel_guest, channel_user, channel_admin}.
|
||||
// - Membership rules MUST NOT carry a Role and MUST be alone in their rule
|
||||
// entry (cannot be combined with permission actions).
|
||||
// - Permission action rules are only allowed on `channel` policy types.
|
||||
// `parent` and system `permission` policy types remain membership-only at
|
||||
// v0.4 (multi-action support there is a follow-up iteration).
|
||||
func (p *AccessControlPolicy) accessPolicyVersionV0_4() *AppError {
|
||||
if !slices.Contains([]string{AccessControlPolicyTypeParent, AccessControlPolicyTypeChannel, AccessControlPolicyTypePermission, AccessControlPolicyTypeTeam}, p.Type) {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.type.app_error", nil, "", 400)
|
||||
}
|
||||
|
||||
if !IsValidId(p.ID) {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.id.app_error", nil, "", 400)
|
||||
}
|
||||
|
||||
if (p.Type == AccessControlPolicyTypeParent || p.Type == AccessControlPolicyTypePermission) && (p.Name == "" || len(p.Name) > MaxPolicyNameLength) {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.name.app_error", nil, "", 400)
|
||||
}
|
||||
|
||||
if p.Revision < 0 {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.revision.app_error", nil, "", 400)
|
||||
}
|
||||
|
||||
if !semver.IsValid(p.Version) {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.version.app_error", nil, "", 400)
|
||||
}
|
||||
|
||||
switch p.Type {
|
||||
case AccessControlPolicyTypeParent:
|
||||
if len(p.Rules) == 0 {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rules.app_error", nil, "", 400)
|
||||
}
|
||||
if len(p.Imports) > 0 {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.imports.app_error", nil, "", 400)
|
||||
}
|
||||
case AccessControlPolicyTypeChannel:
|
||||
if len(p.Rules) == 0 && len(p.Imports) == 0 {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rules_imports.app_error", nil, "", 400)
|
||||
}
|
||||
case AccessControlPolicyTypeTeam:
|
||||
if len(p.Rules) == 0 && len(p.Imports) == 0 {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rules_imports.app_error", nil, "", 400)
|
||||
}
|
||||
case AccessControlPolicyTypePermission:
|
||||
if len(p.Rules) == 0 && len(p.Imports) == 0 {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rules_imports.app_error", nil, "", 400)
|
||||
}
|
||||
if len(p.Roles) != 1 {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.roles.app_error", nil, "", 400)
|
||||
}
|
||||
for _, role := range p.Roles {
|
||||
if strings.TrimSpace(role) == "" {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.roles.app_error", nil, "", 400)
|
||||
}
|
||||
}
|
||||
if len(p.Imports) > 0 {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.imports.app_error", nil, "", 400)
|
||||
}
|
||||
}
|
||||
|
||||
seenNames := make(map[string]struct{})
|
||||
for _, rule := range p.Rules {
|
||||
if len(rule.Actions) == 0 {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.actions.app_error", nil, "actions must not be empty", 400)
|
||||
}
|
||||
|
||||
hasMembership := false
|
||||
hasPermission := false
|
||||
for _, action := range rule.Actions {
|
||||
if !allowedActionsV0_3[action] {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.actions.app_error", nil, fmt.Sprintf("unrecognized action: %s", action), 400)
|
||||
}
|
||||
if action == AccessControlPolicyActionMembership {
|
||||
hasMembership = true
|
||||
}
|
||||
if allowedPermissionActionsV0_4[action] {
|
||||
hasPermission = true
|
||||
}
|
||||
}
|
||||
|
||||
// Membership cannot be combined with permission actions in the same rule.
|
||||
if hasMembership && hasPermission {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.actions.membership_combined.app_error", nil, "membership cannot be combined with other actions in the same rule", 400)
|
||||
}
|
||||
|
||||
// Permission rules are only allowed on channel-type policies in v0.4.
|
||||
if hasPermission && p.Type != AccessControlPolicyTypeChannel {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.actions.permission_type.app_error", nil, "permission action rules are only allowed on channel policies", 400)
|
||||
}
|
||||
|
||||
// Permission rules require a Name (unique within policy) and a Role.
|
||||
// Normalise once: TrimSpace lets the empty-/length-/uniqueness
|
||||
// checks share the same view of the name, so authoring errors
|
||||
// like "Uploads" vs "Uploads " are caught as duplicates instead
|
||||
// of slipping through and forming two visually identical rules.
|
||||
if hasPermission {
|
||||
n := strings.TrimSpace(rule.Name)
|
||||
if n == "" || len(n) > MaxPolicyNameLength {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rule_name.app_error", nil, "permission rules require a non-empty name within the policy max length", 400)
|
||||
}
|
||||
if !allowedChannelRolesV0_4[rule.Role] {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rule_role.app_error", nil, fmt.Sprintf("invalid channel role: %q", rule.Role), 400)
|
||||
}
|
||||
if _, exists := seenNames[n]; exists {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rule_name_unique.app_error", nil, fmt.Sprintf("duplicate rule name: %q", n), 400)
|
||||
}
|
||||
seenNames[n] = struct{}{}
|
||||
}
|
||||
|
||||
// Membership rules must not carry a role.
|
||||
if hasMembership && rule.Role != "" {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rule_role.app_error", nil, "membership rules must not have a role", 400)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// accessPolicyVersionV0_5 validates a v0.5 policy. v0.5 is the lane for
|
||||
// plugin-owned resource policies, which differ from the core versions in ways
|
||||
// that are not expressible there: the type is a "<pluginID>:<resourceType>"
|
||||
// key rather than one of the core type constants, and actions are
|
||||
// plugin-defined rather than drawn from the core action set. On top of that it
|
||||
// pins resource-scoped policies to system scope with no imports or roles.
|
||||
func (p *AccessControlPolicy) accessPolicyVersionV0_5() *AppError {
|
||||
if !IsPluginAccessControlPolicyType(p.Type) {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.plugin_type.app_error", nil, "", 400)
|
||||
}
|
||||
|
||||
if !IsValidId(p.ID) {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.id.app_error", nil, "", 400)
|
||||
}
|
||||
|
||||
// Name is required — no backing entity supplies one for plugin types.
|
||||
if p.Name == "" || len(p.Name) > MaxPolicyNameLength {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.name.app_error", nil, "", 400)
|
||||
}
|
||||
|
||||
if p.Revision < 0 {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.revision.app_error", nil, "", 400)
|
||||
}
|
||||
|
||||
if !semver.IsValid(p.Version) {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.version.app_error", nil, "", 400)
|
||||
}
|
||||
|
||||
if len(p.Rules) == 0 {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rules.app_error", nil, "", 400)
|
||||
}
|
||||
|
||||
if len(p.Imports) > 0 {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.imports.app_error", nil, "", 400)
|
||||
}
|
||||
|
||||
if len(p.Roles) > 0 {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.roles.app_error", nil, "", 400)
|
||||
}
|
||||
|
||||
// validateScope allows team scopes; plugin policies are system scope only.
|
||||
if p.Scope != "" || p.ScopeID != "" {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.scope.app_error", nil, "", 400)
|
||||
}
|
||||
|
||||
for _, rule := range p.Rules {
|
||||
if len(rule.Actions) == 0 {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.actions.app_error", nil, "actions must not be empty", 400)
|
||||
}
|
||||
for _, action := range rule.Actions {
|
||||
if !IsValidPolicyAction(action) {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.actions.app_error", nil, fmt.Sprintf("malformed action: %s", action), 400)
|
||||
}
|
||||
}
|
||||
if rule.Role != "" {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rule_role.app_error", nil, "plugin policy rules must not have a role", 400)
|
||||
}
|
||||
if len(rule.Name) > MaxPolicyNameLength {
|
||||
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rule_name.app_error", nil, "rule name exceeds the policy max length", 400)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *AccessControlPolicy) Inherit(parent *AccessControlPolicy) *AppError {
|
||||
rules := make([]AccessControlPolicyRule, len(p.Rules))
|
||||
|
||||
switch p.Version {
|
||||
case AccessControlPolicyVersionV0_1:
|
||||
p.Imports = []string{parent.ID}
|
||||
for i, rule := range p.Rules {
|
||||
actions := make([]string, len(rule.Actions))
|
||||
copy(actions, rule.Actions)
|
||||
rules[i] = AccessControlPolicyRule{
|
||||
Actions: actions,
|
||||
Expression: fmt.Sprintf("policies.id_%s", p.ID),
|
||||
}
|
||||
}
|
||||
case AccessControlPolicyVersionV0_2:
|
||||
if slices.Contains(p.Imports, parent.ID) {
|
||||
return NewAppError("AccessControlPolicy.Inherit", "model.access_policy.inherit.already_imported.app_error", nil, "", 400)
|
||||
}
|
||||
p.Imports = append(p.Imports, parent.ID)
|
||||
case AccessControlPolicyVersionV0_3:
|
||||
if p.Type == AccessControlPolicyTypePermission || parent.Type == AccessControlPolicyTypePermission {
|
||||
return NewAppError("AccessControlPolicy.Inherit", "model.access_policy.inherit.permission.app_error", nil, "", 400)
|
||||
}
|
||||
if parent.Type != AccessControlPolicyTypeParent {
|
||||
return NewAppError("AccessControlPolicy.Inherit", "model.access_policy.inherit.parent_type.app_error", nil, "imports must target a parent-type policy", 400)
|
||||
}
|
||||
if parent.Version != AccessControlPolicyVersionV0_3 {
|
||||
return NewAppError("AccessControlPolicy.Inherit", "model.access_policy.inherit.version.app_error", nil, "", 400)
|
||||
}
|
||||
if slices.Contains(p.Imports, parent.ID) {
|
||||
return NewAppError("AccessControlPolicy.Inherit", "model.access_policy.inherit.already_imported.app_error", nil, "", 400)
|
||||
}
|
||||
p.Imports = append(p.Imports, parent.ID)
|
||||
case AccessControlPolicyVersionV0_4:
|
||||
if p.Type == AccessControlPolicyTypePermission || parent.Type == AccessControlPolicyTypePermission {
|
||||
return NewAppError("AccessControlPolicy.Inherit", "model.access_policy.inherit.permission.app_error", nil, "", 400)
|
||||
}
|
||||
// v0.4 channel policies may import v0.3 or v0.4 parent policies.
|
||||
// Parents themselves remain membership-only at v0.4 (validator enforces).
|
||||
if parent.Version != AccessControlPolicyVersionV0_3 && parent.Version != AccessControlPolicyVersionV0_4 {
|
||||
return NewAppError("AccessControlPolicy.Inherit", "model.access_policy.inherit.version.app_error", nil, "", 400)
|
||||
}
|
||||
// v0.4 inherit is strictly child-channel → parent-membership.
|
||||
// A channel→channel or permission→channel import has no
|
||||
// well-defined semantics in the v0.4 model (parents are the
|
||||
// only carriers of reusable membership rules), so reject the
|
||||
// import rather than silently appending a peer policy's ID
|
||||
// into Imports where the loader would later treat it as a
|
||||
// membership parent.
|
||||
if parent.Type != AccessControlPolicyTypeParent {
|
||||
return NewAppError("AccessControlPolicy.Inherit", "model.access_policy.inherit.parent_type.app_error", nil, "v0.4 imports must target a membership parent policy", 400)
|
||||
}
|
||||
if slices.Contains(p.Imports, parent.ID) {
|
||||
return NewAppError("AccessControlPolicy.Inherit", "model.access_policy.inherit.already_imported.app_error", nil, "", 400)
|
||||
}
|
||||
// Stage Imports on a probe copy so a post-merge IsValid failure
|
||||
// leaves the receiver untouched (transactional contract).
|
||||
newImports := append(slices.Clone(p.Imports), parent.ID)
|
||||
probe := *p
|
||||
probe.Imports = newImports
|
||||
if appErr := probe.IsValid(); appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
p.Imports = newImports
|
||||
return nil
|
||||
default:
|
||||
return NewAppError("AccessControlPolicy.Inherit", "model.access_policy.inherit.version.app_error", nil, "", 400)
|
||||
}
|
||||
|
||||
if appErr := p.IsValid(); appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *AccessControlPolicyCursor) IsEmpty() bool {
|
||||
return c.ID == ""
|
||||
}
|
||||
|
||||
func (c *AccessControlPolicyCursor) IsValid() error {
|
||||
if c.IsEmpty() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !IsValidId(c.ID) {
|
||||
return errors.New("cursor id is invalid")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *AccessControlPolicy) Auditable() map[string]any {
|
||||
return map[string]any{
|
||||
"id": p.ID,
|
||||
"type": p.Type,
|
||||
"revision": p.Revision,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user