mirror of
https://github.com/mattermost/mattermost.git
synced 2026-05-28 04:35:04 -04:00
2246 lines
89 KiB
Go
2246 lines
89 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package app
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"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"
|
|
"github.com/mattermost/mattermost/server/v8/einterfaces"
|
|
)
|
|
|
|
const attributeViewRefreshInterval = 30 * time.Second
|
|
const accessControlChildPolicySearchLimit = 1000
|
|
|
|
func (a *App) GetChannelsForPolicy(rctx request.CTX, policyID string, cursor model.AccessControlPolicyCursor, limit int) ([]*model.ChannelWithTeamData, int64, *model.AppError) {
|
|
policy, appErr := a.GetAccessControlPolicy(rctx, policyID)
|
|
if appErr != nil {
|
|
return nil, 0, appErr
|
|
}
|
|
|
|
switch policy.Type {
|
|
case model.AccessControlPolicyTypeParent:
|
|
policies, total, err := a.Srv().Store().AccessControlPolicy().SearchPolicies(rctx, model.AccessControlPolicySearch{
|
|
Type: model.AccessControlPolicyTypeChannel,
|
|
ParentID: policyID,
|
|
Cursor: cursor,
|
|
Limit: limit,
|
|
})
|
|
if err != nil {
|
|
return nil, 0, model.NewAppError("GetChannelsForPolicy", "app.pap.get_all_access_control_policies.app_error", nil, err.Error(), http.StatusInternalServerError)
|
|
}
|
|
channelIDs := make([]string, 0, len(policies))
|
|
|
|
// channel IDs are the same as policy IDs
|
|
for _, p := range policies {
|
|
channelIDs = append(channelIDs, p.ID)
|
|
}
|
|
|
|
chs, err := a.Srv().Store().Channel().GetChannelsWithTeamDataByIds(channelIDs, true)
|
|
if err != nil {
|
|
return nil, 0, model.NewAppError("GetChannelsForPolicy", "app.pap.get_all_access_control_policies.app_error", nil, err.Error(), http.StatusInternalServerError)
|
|
}
|
|
|
|
return chs, total, nil
|
|
case model.AccessControlPolicyTypeChannel:
|
|
chs, err := a.Srv().Store().Channel().GetChannelsWithTeamDataByIds([]string{policyID}, true)
|
|
if err != nil {
|
|
return nil, 0, model.NewAppError("GetChannelsForPolicy", "app.pap.get_all_access_control_policies.app_error", nil, err.Error(), http.StatusInternalServerError)
|
|
}
|
|
|
|
total := int64(len(chs))
|
|
return chs, total, nil
|
|
default:
|
|
return nil, 0, model.NewAppError("GetChannelsForPolicy", "app.pap.get_all_access_control_policies.app_error", nil, "Invalid policy type", http.StatusBadRequest)
|
|
}
|
|
}
|
|
|
|
func (a *App) GetAccessControlPolicy(rctx request.CTX, id string) (*model.AccessControlPolicy, *model.AppError) {
|
|
acs := a.Srv().ch.AccessControl
|
|
if acs == nil {
|
|
return nil, model.NewAppError("GetPolicy", "app.pap.get_policy.app_error", nil, "Policy Administration Point is not initialized", http.StatusNotImplemented)
|
|
}
|
|
|
|
policy, appErr := acs.GetPolicy(rctx, id)
|
|
if appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
|
|
return policy, nil
|
|
}
|
|
|
|
func (a *App) CreateOrUpdateAccessControlPolicy(rctx request.CTX, policy *model.AccessControlPolicy) (*model.AccessControlPolicy, *model.AppError) {
|
|
acs := a.Srv().ch.AccessControl
|
|
if acs == nil {
|
|
return nil, model.NewAppError("CreateAccessControlPolicy", "app.pap.create_access_control_policy.app_error", nil, "Policy Administration Point is not initialized", http.StatusNotImplemented)
|
|
}
|
|
|
|
if policy.ID == "" {
|
|
policy.ID = model.NewId()
|
|
}
|
|
|
|
// Channel-scope policies are pinned to a single channel by ID. Validate
|
|
// channel eligibility here (default / DM / GM / group-constrained / shared
|
|
// channels are ineligible) so this guard protects all callers — including
|
|
// system admins, whose request goes through the api4 handler's permission
|
|
// fast-path that skips the per-channel ValidateChannelAccessControlPolicyCreation
|
|
// check, and the parent-policy AssignAccessControlPolicyToChannels flow,
|
|
// which validates eligibility there but bypasses this entry point.
|
|
if policy.Type == model.AccessControlPolicyTypeChannel {
|
|
channel, appErr := a.GetChannel(rctx, policy.ID)
|
|
if appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
if appErr := a.ValidateChannelEligibilityForAccessControl(rctx, channel); appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
}
|
|
|
|
policy.Version = model.AccessControlPolicyVersionV0_3
|
|
for i, rule := range policy.Rules {
|
|
for j, action := range rule.Actions {
|
|
if action == "*" {
|
|
policy.Rules[i].Actions[j] = model.AccessControlPolicyActionMembership
|
|
}
|
|
}
|
|
}
|
|
|
|
// ABAC is gated at route registration; only check masking here. Masking is
|
|
// attribute-based: edits are allowed with masked values present as long as
|
|
// the caller doesn't drop a condition holding values they couldn't see.
|
|
if a.Config().FeatureFlags.AttributeValueMasking {
|
|
session := rctx.Session()
|
|
if session == nil {
|
|
return nil, model.NewAppError("CreateOrUpdateAccessControlPolicy", "api.context.session_expired.app_error", nil, "session required for masking validation", http.StatusUnauthorized)
|
|
}
|
|
callerID := session.UserId
|
|
|
|
// Validate submitted values BEFORE merge: only the values the caller
|
|
// actually submitted should be checked against their holdings. Running
|
|
// validation after merge would reject the re-injected hidden values
|
|
// (e.g. Bravo, Charlie) that the caller legitimately cannot see.
|
|
if appErr := a.validatePolicyExpressionValues(rctx, policy, callerID); appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
|
|
// Merge hidden values back in and block deletion of masked conditions.
|
|
if appErr := a.mergeStoredPolicyExpressions(rctx, policy, callerID); appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
|
|
// Self-inclusion check applies only to non-admins. System admins may
|
|
// legitimately set conditions for attributes they do not personally hold
|
|
// (e.g., creating a "Clearance == Top Secret" rule without holding that
|
|
// clearance themselves). Masking and write-path value validation still
|
|
// apply to system admins above.
|
|
if !a.HasPermissionTo(callerID, model.PermissionManageSystem) {
|
|
if appErr := a.checkSelfInclusion(rctx, policy, callerID); appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
}
|
|
}
|
|
|
|
var appErr *model.AppError
|
|
policy, appErr = acs.SavePolicy(rctx, policy)
|
|
if appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
|
|
switch policy.Type {
|
|
case model.AccessControlPolicyTypeChannel:
|
|
a.publishChannelPolicyEnforcedUpdate(rctx, policy.ID)
|
|
case model.AccessControlPolicyTypeParent:
|
|
a.publishChannelPolicyEnforcedForChannelPoliciesWithImport(rctx, policy.ID)
|
|
}
|
|
|
|
return policy, nil
|
|
}
|
|
|
|
// policyHasMaskedValuesForCaller returns true if policy contains any attribute values
|
|
// that are not visible to callerID under the current masking rules.
|
|
// A nil policy is treated as "no hidden values" — there's nothing to protect.
|
|
func (a *App) policyHasMaskedValuesForCaller(rctx request.CTX, policy *model.AccessControlPolicy, callerID string) (bool, *model.AppError) {
|
|
if policy == nil {
|
|
return false, nil
|
|
}
|
|
|
|
for _, rule := range policy.Rules {
|
|
if rule.Expression == "" || rule.Expression == "true" {
|
|
continue
|
|
}
|
|
maskedAST, appErr := a.GetMaskedVisualAST(rctx, rule.Expression, callerID)
|
|
if appErr != nil {
|
|
return false, appErr
|
|
}
|
|
for _, cond := range maskedAST.Conditions {
|
|
if cond.HasMaskedValues {
|
|
return true, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return false, nil
|
|
}
|
|
|
|
// mergeStoredPolicyExpressions re-injects hidden values from the stored policy into the
|
|
// submitted one, and blocks the save if the caller removed a condition that contained
|
|
// values they cannot see (which would silently widen access beyond what they could audit).
|
|
// No-op for new policies (not found in store).
|
|
func (a *App) mergeStoredPolicyExpressions(rctx request.CTX, policy *model.AccessControlPolicy, callerID string) *model.AppError {
|
|
acs := a.Srv().ch.AccessControl
|
|
if acs == nil {
|
|
return nil
|
|
}
|
|
|
|
existingPolicy, appErr := acs.GetPolicy(rctx, policy.ID)
|
|
if appErr != nil {
|
|
if appErr.StatusCode == http.StatusNotFound {
|
|
return nil
|
|
}
|
|
return appErr
|
|
}
|
|
|
|
// Pair submitted and stored rules by Name so that a reorder /
|
|
// insert / delete in the editor doesn't swap one rule's masked
|
|
// values into a sibling rule's expression. v0.4 permission rules
|
|
// are required to carry a unique Name; the membership rule (no
|
|
// Name) is pinned by its membership Action so it round-trips
|
|
// through reorders too.
|
|
storedByName := make(map[string]*model.AccessControlPolicyRule, len(existingPolicy.Rules))
|
|
var storedMembership *model.AccessControlPolicyRule
|
|
for i := range existingPolicy.Rules {
|
|
r := &existingPolicy.Rules[i]
|
|
switch {
|
|
case r.Name != "":
|
|
storedByName[r.Name] = r
|
|
case isMembershipRule(r):
|
|
if storedMembership == nil {
|
|
storedMembership = r
|
|
}
|
|
}
|
|
}
|
|
|
|
pairedNames := make(map[string]bool, len(existingPolicy.Rules))
|
|
membershipPaired := false
|
|
|
|
for i := range policy.Rules {
|
|
rule := &policy.Rules[i]
|
|
var stored *model.AccessControlPolicyRule
|
|
switch {
|
|
case rule.Name != "":
|
|
stored = storedByName[rule.Name]
|
|
if stored != nil {
|
|
pairedNames[rule.Name] = true
|
|
}
|
|
case isMembershipRule(rule):
|
|
if !membershipPaired {
|
|
stored = storedMembership
|
|
membershipPaired = true
|
|
}
|
|
}
|
|
if stored == nil {
|
|
// New rule with no corresponding stored entry — nothing to
|
|
// re-inject. The validate step (when run from the save
|
|
// path) is what rejects forbidden literals on a brand-new
|
|
// rule; the merge has nothing useful to do here.
|
|
continue
|
|
}
|
|
if stored.Expression == "" || stored.Expression == "true" {
|
|
continue
|
|
}
|
|
// Snapshot the caller-submitted expression so we can tell
|
|
// post-merge whether mergeExpressionWithMaskedValues actually
|
|
// re-injected hidden literals (vs. echoing the submission
|
|
// back unchanged). Doing this here, before the merge call,
|
|
// lets the Actions-locking guard below use a plain `!=` check
|
|
// regardless of whether `rule` is a pointer or a copy.
|
|
submittedExpr := rule.Expression
|
|
mergedExpr, appErr := a.mergeExpressionWithMaskedValues(rctx, policy.ID, submittedExpr, stored.Expression, callerID)
|
|
if appErr != nil {
|
|
return appErr
|
|
}
|
|
rule.Expression = mergedExpr
|
|
// Hidden values were re-injected → caller was working from a
|
|
// masked view. Lock Actions AND Role to stored so they can't
|
|
// silently swap the gate's action type or role audience while
|
|
// reusing the hidden CEL.
|
|
if mergedExpr != submittedExpr {
|
|
rule.Actions = stored.Actions
|
|
rule.Role = stored.Role
|
|
}
|
|
}
|
|
|
|
// Any stored rule the caller didn't include in the submission was
|
|
// dropped. If a dropped rule carries values the caller couldn't
|
|
// see, block the save — otherwise we'd silently widen access by
|
|
// removing a rule whose hidden conditions the caller could not
|
|
// audit. Same side-channel reasoning as the per-condition
|
|
// deletion guard inside mergeExpressionWithMaskedValues.
|
|
for i := range existingPolicy.Rules {
|
|
stored := &existingPolicy.Rules[i]
|
|
switch {
|
|
case stored.Name != "":
|
|
if pairedNames[stored.Name] {
|
|
continue
|
|
}
|
|
case isMembershipRule(stored):
|
|
if membershipPaired {
|
|
continue
|
|
}
|
|
default:
|
|
// Legacy anonymous non-membership rule — can't safely
|
|
// identify it across the submission boundary, skip the
|
|
// guard rather than reject every save.
|
|
continue
|
|
}
|
|
if stored.Expression == "" || stored.Expression == "true" {
|
|
continue
|
|
}
|
|
hasMasked, appErr := a.expressionHasMaskedValuesForCaller(rctx, stored.Expression, callerID)
|
|
if appErr != nil {
|
|
return appErr
|
|
}
|
|
if hasMasked {
|
|
return model.NewAppError("MergeStoredPolicyExpressions", "app.pap.save_policy.masked_rule_deleted", nil,
|
|
"cannot remove a rule that contains attribute values you do not hold", http.StatusForbidden)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// isMembershipRule reports whether a rule fills the policy's
|
|
// membership slot for the merge-time pairing logic. v0.4 membership
|
|
// rules carry no Name and the membership action; legacy v0.1/v0.2
|
|
// channel policies used the wildcard "*" (rejected at v0.3+ IsValid)
|
|
// for the same role, so both anchor the same single storedMembership
|
|
// pairing slot.
|
|
func isMembershipRule(rule *model.AccessControlPolicyRule) bool {
|
|
if rule == nil || rule.Name != "" {
|
|
return false
|
|
}
|
|
return slices.Contains(rule.Actions, model.AccessControlPolicyActionMembership) ||
|
|
slices.Contains(rule.Actions, "*")
|
|
}
|
|
|
|
// expressionHasMaskedValuesForCaller reports whether storedExpr contains any value the caller cannot see.
|
|
func (a *App) expressionHasMaskedValuesForCaller(rctx request.CTX, storedExpr, callerID string) (bool, *model.AppError) {
|
|
maskedAST, appErr := a.GetMaskedVisualAST(rctx, storedExpr, callerID)
|
|
if appErr != nil {
|
|
return false, appErr
|
|
}
|
|
for _, cond := range maskedAST.Conditions {
|
|
if cond.HasMaskedValues {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// mergeExpressionWithMaskedValues re-injects hidden values into submittedExpr and
|
|
// returns 403 if the caller dropped a condition with values they cannot see.
|
|
//
|
|
// Two fail-closed shortcuts before the merge:
|
|
// 1. Caller has no masked values on storedExpr → return submitted as-is.
|
|
// 2. storedExpr isn't faithfully representable by the Visual AST (|| or grouping
|
|
// would flatten into ANDs on rebuild) → accept only no-op saves (e.g., rename),
|
|
// reject real edits. Role-neutral: masking is attribute-based, so a sysadmin
|
|
// without the values lands here too.
|
|
//
|
|
// Stopgap until the canonical CEL AST walker refactor.
|
|
func (a *App) mergeExpressionWithMaskedValues(rctx request.CTX, policyID, submittedExpr, storedExpr, callerID string) (string, *model.AppError) {
|
|
hasMasked, appErr := a.expressionHasMaskedValuesForCaller(rctx, storedExpr, callerID)
|
|
if appErr != nil {
|
|
return "", appErr
|
|
}
|
|
if !hasMasked {
|
|
return submittedExpr, nil
|
|
}
|
|
|
|
submittedAST, appErr := a.ExpressionToVisualAST(rctx, submittedExpr)
|
|
if appErr != nil {
|
|
return "", appErr
|
|
}
|
|
|
|
storedAST, appErr := a.ExpressionToVisualAST(rctx, storedExpr)
|
|
if appErr != nil {
|
|
return "", appErr
|
|
}
|
|
|
|
if !isVisualASTRepresentable(storedExpr, storedAST) {
|
|
masked, maskErr := a.GetMaskedExpression(rctx, storedExpr, callerID)
|
|
if maskErr != nil {
|
|
return "", maskErr
|
|
}
|
|
if normalizedEqual(submittedExpr, masked) {
|
|
// no-op edit (e.g., rename) — keep stored expression as-is
|
|
return storedExpr, nil
|
|
}
|
|
rctx.Logger().Info("save refused: stored rule not representable by Visual AST",
|
|
mlog.String("policy_id", policyID),
|
|
mlog.String("caller_id", callerID),
|
|
)
|
|
return "", model.NewAppError("mergeExpressionWithMaskedValues",
|
|
"app.pap.save_policy.advanced_expression_blocked", nil,
|
|
"this rule expression cannot be safely edited while restricted values are present",
|
|
http.StatusForbidden)
|
|
}
|
|
|
|
cpaGroup, appErr := a.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
|
|
if appErr != nil {
|
|
return "", model.NewAppError("mergeExpressionWithMaskedValues", "app.pap.merge_expression.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
|
|
}
|
|
cpaGroupID := cpaGroup.ID
|
|
|
|
rctxWithCaller := RequestContextWithCallerID(rctx, callerID)
|
|
|
|
// Pre-fetch fields once for all stored conditions. We require every referenced field
|
|
// to resolve — proceeding with an incomplete map would silently strip hidden values
|
|
// from stored conditions and bypass the masked-condition-delete block.
|
|
fieldsByName := a.fetchConditionFields(rctxWithCaller, storedAST.Conditions, cpaGroupID)
|
|
if appErr := requireAllFieldsResolved(rctxWithCaller, storedAST.Conditions, fieldsByName); appErr != nil {
|
|
return "", appErr
|
|
}
|
|
|
|
// Count submitted conditions per attribute. A simple set isn't enough because the parser
|
|
// can produce two conditions on the same attribute (e.g. `attr in [...] && attr == "x"`);
|
|
// dropping one of them while keeping the other must still trigger the deletion guard if
|
|
// the dropped condition had hidden values.
|
|
submittedCounts := make(map[string]int, len(submittedAST.Conditions))
|
|
for _, cond := range submittedAST.Conditions {
|
|
submittedCounts[cond.Attribute]++
|
|
}
|
|
|
|
storedCounts := make(map[string]int, len(storedAST.Conditions))
|
|
for _, cond := range storedAST.Conditions {
|
|
storedCounts[cond.Attribute]++
|
|
}
|
|
|
|
// Block deletion of any stored condition that has hidden values for this caller.
|
|
// We walk stored conditions and, when one with hidden values appears, require that
|
|
// the submitted set still has at least as many conditions on the same attribute as
|
|
// stored had — otherwise some stored condition was dropped.
|
|
for i := range storedAST.Conditions {
|
|
hidden := a.getHiddenValues(rctxWithCaller, callerID, &storedAST.Conditions[i], cpaGroupID, fieldsByName)
|
|
if len(hidden) == 0 {
|
|
continue
|
|
}
|
|
attr := storedAST.Conditions[i].Attribute
|
|
if submittedCounts[attr] < storedCounts[attr] {
|
|
return "", model.NewAppError("mergeExpressionWithMaskedValues", "app.pap.save_policy.masked_condition_deleted", nil,
|
|
"cannot remove a rule condition that contains attribute values you do not hold", http.StatusForbidden)
|
|
}
|
|
}
|
|
|
|
// Match submitted conditions to stored ones by attribute (in order), merge hidden values.
|
|
storedByAttr := make(map[string][]model.Condition)
|
|
for _, cond := range storedAST.Conditions {
|
|
storedByAttr[cond.Attribute] = append(storedByAttr[cond.Attribute], cond)
|
|
}
|
|
|
|
matchCount := make(map[string]int)
|
|
var mergedConditions []model.Condition
|
|
|
|
for _, submitted := range submittedAST.Conditions {
|
|
storedList, found := storedByAttr[submitted.Attribute]
|
|
if !found {
|
|
mergedConditions = append(mergedConditions, submitted)
|
|
continue
|
|
}
|
|
|
|
matchIdx := matchCount[submitted.Attribute]
|
|
matchCount[submitted.Attribute]++
|
|
|
|
if matchIdx >= len(storedList) {
|
|
mergedConditions = append(mergedConditions, submitted)
|
|
continue
|
|
}
|
|
|
|
stored := storedList[matchIdx]
|
|
hiddenValues := a.getHiddenValues(rctxWithCaller, callerID, &stored, cpaGroupID, fieldsByName)
|
|
merged := mergeConditionValues(submitted, hiddenValues)
|
|
merged.Operator = stored.Operator
|
|
merged.AttributeType = stored.AttributeType
|
|
// Frontend emits "attr in []" as the placeholder for any fully-masked row
|
|
// regardless of the stored operator. After we restore the original operator,
|
|
// the value shape may not match (e.g., "==" with a []any value). Normalize
|
|
// scalar operators to a single string from the array.
|
|
//
|
|
// When the stored scalar value is hidden, always use hiddenValues[0] directly
|
|
// rather than taking arr[0] from the merged list. Without this guard a crafted
|
|
// submission of `in ["caller-visible"]` would pass validateConditionValues,
|
|
// land in mergeConditionValues as a []any, and arr[0] would be the attacker's
|
|
// value — silently overwriting the stored hidden value.
|
|
if isScalarOperator(merged.Operator) {
|
|
if len(hiddenValues) > 0 {
|
|
merged.Value = hiddenValues[0]
|
|
} else if arr, ok := merged.Value.([]any); ok {
|
|
if len(arr) == 0 {
|
|
merged.Value = nil
|
|
} else if s, ok := arr[0].(string); ok {
|
|
merged.Value = s
|
|
}
|
|
}
|
|
}
|
|
mergedConditions = append(mergedConditions, merged)
|
|
}
|
|
|
|
return buildCELFromConditions(mergedConditions), nil
|
|
}
|
|
|
|
// checkSelfInclusion verifies the caller satisfies all policy rules after their edit.
|
|
func (a *App) checkSelfInclusion(rctx request.CTX, policy *model.AccessControlPolicy, callerID string) *model.AppError {
|
|
for _, rule := range policy.Rules {
|
|
if rule.Expression == "" || rule.Expression == "true" {
|
|
continue
|
|
}
|
|
|
|
matches, appErr := a.ValidateExpressionAgainstRequester(rctx, rule.Expression, callerID)
|
|
if appErr != nil {
|
|
return appErr
|
|
}
|
|
if !matches {
|
|
return model.NewAppError("CreateOrUpdateAccessControlPolicy",
|
|
"app.pap.save_policy.self_exclusion", nil,
|
|
"You do not satisfy one or more conditions in this policy.", http.StatusForbidden)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *App) DeleteAccessControlPolicy(rctx request.CTX, id string) *model.AppError {
|
|
acs := a.Srv().ch.AccessControl
|
|
if acs == nil {
|
|
return model.NewAppError("DeleteAccessControlPolicy", "app.pap.delete_access_control_policy.app_error", nil, "Policy Administration Point is not initialized", http.StatusNotImplemented)
|
|
}
|
|
|
|
// Resolve the policy first so we know whether to broadcast a channel
|
|
// access control update after deletion (channel-type policies share the
|
|
// channel's ID, so we can use the policy ID as the channel ID).
|
|
policy, appErr := acs.GetPolicy(rctx, id)
|
|
if appErr != nil {
|
|
return appErr
|
|
}
|
|
|
|
// ABAC is gated at route registration; only check masking here.
|
|
if a.Config().FeatureFlags.AttributeValueMasking {
|
|
session := rctx.Session()
|
|
if session != nil {
|
|
callerID := session.UserId
|
|
if hasMasked, appErr := a.policyHasMaskedValuesForCaller(rctx, policy, callerID); appErr != nil {
|
|
return appErr
|
|
} else if hasMasked {
|
|
return model.NewAppError("DeleteAccessControlPolicy", "app.pap.delete_policy.masked_values", nil,
|
|
"policy contains attribute values you do not hold; you cannot delete this policy", http.StatusForbidden)
|
|
}
|
|
}
|
|
}
|
|
|
|
var affectedChannelIDs []string
|
|
if policy != nil && policy.Type != model.AccessControlPolicyTypeChannel {
|
|
affectedChannelIDs = a.channelPolicyIDsWithImport(rctx, id)
|
|
}
|
|
|
|
if appErr := acs.DeletePolicy(rctx, id); appErr != nil {
|
|
return appErr
|
|
}
|
|
|
|
if policy != nil && policy.Type == model.AccessControlPolicyTypeChannel {
|
|
a.publishChannelPolicyEnforcedUpdate(rctx, id)
|
|
} else if policy.Type == model.AccessControlPolicyTypeParent {
|
|
a.publishChannelPolicyEnforcedUpdatesForChannels(rctx, affectedChannelIDs)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *App) CheckExpression(rctx request.CTX, expression string) ([]model.CELExpressionError, *model.AppError) {
|
|
acs := a.Srv().ch.AccessControl
|
|
if acs == nil {
|
|
return nil, model.NewAppError("CheckExpression", "app.pap.check_expression.app_error", nil, "Policy Administration Point is not initialized", http.StatusNotImplemented)
|
|
}
|
|
|
|
errs, appErr := acs.CheckExpression(rctx, expression)
|
|
if appErr != nil {
|
|
return nil, model.NewAppError("CheckExpression", "app.pap.check_expression.app_error", nil, appErr.Error(), http.StatusInternalServerError)
|
|
}
|
|
|
|
return errs, nil
|
|
}
|
|
|
|
func (a *App) TestExpression(rctx request.CTX, expression string, opts model.SubjectSearchOptions) ([]*model.User, int64, *model.AppError) {
|
|
acs := a.Srv().ch.AccessControl
|
|
if acs == nil {
|
|
return nil, 0, model.NewAppError("TestExpression", "app.pap.check_expression.app_error", nil, "Policy Administration Point is not initialized", http.StatusNotImplemented)
|
|
}
|
|
|
|
res, count, err := acs.QueryUsersForExpression(rctx, expression, opts)
|
|
if err != nil {
|
|
return nil, 0, model.NewAppError("TestExpression", "app.pap.check_expression.app_error", nil, err.Error(), http.StatusInternalServerError)
|
|
}
|
|
|
|
return res, count, nil
|
|
}
|
|
|
|
// SimulateAccessControlPolicyForUsers proxies to the enterprise PDP
|
|
// service so the /cel/simulate_users handler can preview how a draft
|
|
// policy would resolve for an explicit set of users. The caller picks
|
|
// users (with optional per-user session attribute overrides); the
|
|
// response carries per-user, per-action decisions with blame attribution.
|
|
//
|
|
// Post-processing happens in two stages before the response leaves the
|
|
// server:
|
|
//
|
|
// 1. enrichBlameForDraftScope inspects every blame entry. Same-scope
|
|
// entries (this_rule / sibling_rule / sibling_saved against the
|
|
// draft, or system_permission entries whose blamed policy shares the
|
|
// draft's scope) gain the failing rule's CEL Expression so the picker
|
|
// can render an evaluation trace. system_permission entries that turn
|
|
// out to be at the draft's scope are reclassified to peer_policy.
|
|
// Truly upper-scoped entries (system_permission with a different
|
|
// scope, channel_policy) are deliberately left expression-less so
|
|
// the UI cannot leak the contents of a policy outside the editing
|
|
// scope.
|
|
// 2. filterResponseToEditingRuleScope (only when EvaluationScope ==
|
|
// "this_rule") is a defensive backstop that strips any non-editing-
|
|
// rule blame entries that may have leaked through despite the
|
|
// simulator restricting contributions to the editing rule. The
|
|
// simulator side does the heavy lifting (skipping sibling rules and
|
|
// system permission policies entirely); this filter drops anything
|
|
// that isn't a draft-side blame on the editing rule and flips
|
|
// orphaned denies back to allow.
|
|
//
|
|
// Returns NotImplemented when the access control service is unavailable
|
|
// (no enterprise license / ABAC disabled).
|
|
func (a *App) SimulateAccessControlPolicyForUsers(rctx request.CTX, params model.PolicySimulationByUsersParams) (*model.PolicySimulationResponse, *model.AppError) {
|
|
acs := a.Srv().ch.AccessControl
|
|
if acs == nil {
|
|
return nil, model.NewAppError("SimulateAccessControlPolicyForUsers", "app.pap.simulate.unavailable", nil, "Policy Administration Point is not initialized", http.StatusNotImplemented)
|
|
}
|
|
|
|
// The editor masks raw CEL literal values for callers who don't
|
|
// hold them on every GET / search response, replacing them with
|
|
// the "--------" sentinel. The frontend hands that masked policy
|
|
// right back to us when the admin clicks "Simulate access", so
|
|
// without re-injecting the stored hidden values the simulator
|
|
// would evaluate the sentinel as a literal — every condition
|
|
// would compare against "--------" and the verdicts would be
|
|
// meaningless.
|
|
//
|
|
// Reuse the same per-rule merge the save path uses to re-inject
|
|
// the stored hidden values so the simulator evaluates the real
|
|
// CEL. We deliberately do NOT run the save-side write-path value
|
|
// validation here: simulate doesn't persist anything, so
|
|
// rejecting submissions that carry forbidden literal values is a
|
|
// save-only invariant. The merge alone is what makes the
|
|
// simulator see the unmasked policy.
|
|
if a.Config().FeatureFlags.AttributeValueMasking {
|
|
if appErr := a.mergeStoredPolicyExpressions(rctx, params.Policy, rctx.Session().UserId); appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
}
|
|
|
|
resp, appErr := acs.SimulatePolicyForUsers(rctx, params)
|
|
if appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
|
|
if resp != nil {
|
|
enrichBlameForDraftScope(rctx, acs, params.Policy, resp)
|
|
if isThisRuleScope(params.EvaluationScope) {
|
|
filterResponseToEditingRuleScope(resp, params.RuleName)
|
|
}
|
|
|
|
// mergeStoredPolicyExpressions re-injected the stored hidden
|
|
// values so the simulator could evaluate the real CEL — and
|
|
// enrichBlameForDraftScope just copied those unmasked
|
|
// expressions into Blame.Expression / MergedRules / the
|
|
// evaluation tree. Re-mask every literal-bearing surface
|
|
// before the response leaves the server so the caller never
|
|
// sees a value they couldn't see via the policy GET path.
|
|
// Same flag gate as the merge above: either both run or
|
|
// neither does, so the response always matches the policy
|
|
// state that produced it.
|
|
if a.Config().FeatureFlags.AttributeValueMasking {
|
|
a.MaskSimulationPolicyLiteralsForCaller(rctx, resp, rctx.Session().UserId)
|
|
}
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
// ValidatePolicySimulationUsersInScope ensures every user listed for a delegated
|
|
// (non-system-admin) simulation belongs to the channel when channel_id is set,
|
|
// otherwise to the team when team_id is set. Call only after the caller has
|
|
// passed authorizeSimulatePolicy.
|
|
func (a *App) ValidatePolicySimulationUsersInScope(rctx request.CTX, teamID, channelID string, users []model.PolicySimulationUserOverride) *model.AppError {
|
|
if channelID != "" {
|
|
if !model.IsValidId(channelID) {
|
|
return model.NewAppError("ValidatePolicySimulationUsersInScope", "api.context.invalid_param.app_error", map[string]any{"Name": "channel_id"}, "", http.StatusBadRequest)
|
|
}
|
|
for _, u := range users {
|
|
if u.UserID == "" || !model.IsValidId(u.UserID) {
|
|
return model.NewAppError("ValidatePolicySimulationUsersInScope", "api.context.invalid_param.app_error", map[string]any{"Name": "user_id"}, "", http.StatusBadRequest)
|
|
}
|
|
if _, err := a.Srv().Store().Channel().GetMember(rctx, channelID, u.UserID); err != nil {
|
|
var nfErr *store.ErrNotFound
|
|
if errors.As(err, &nfErr) {
|
|
return model.NewAppError("ValidatePolicySimulationUsersInScope", "api.access_control_policy.simulate.users_out_of_scope.app_error", nil, "user_id="+u.UserID, http.StatusForbidden)
|
|
}
|
|
return model.NewAppError("ValidatePolicySimulationUsersInScope", "app.channel.get_member.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
if teamID != "" {
|
|
if !model.IsValidId(teamID) {
|
|
return model.NewAppError("ValidatePolicySimulationUsersInScope", "api.context.invalid_param.app_error", map[string]any{"Name": "team_id"}, "", http.StatusBadRequest)
|
|
}
|
|
for _, u := range users {
|
|
if u.UserID == "" || !model.IsValidId(u.UserID) {
|
|
return model.NewAppError("ValidatePolicySimulationUsersInScope", "api.context.invalid_param.app_error", map[string]any{"Name": "user_id"}, "", http.StatusBadRequest)
|
|
}
|
|
if _, appErr := a.GetTeamMember(rctx, teamID, u.UserID); appErr != nil {
|
|
if appErr.StatusCode == http.StatusNotFound {
|
|
return model.NewAppError("ValidatePolicySimulationUsersInScope", "api.access_control_policy.simulate.users_out_of_scope.app_error", nil, "user_id="+u.UserID, http.StatusForbidden)
|
|
}
|
|
return appErr
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// isThisRuleScope returns true when the simulator should run in
|
|
// "this rule only" mode. The empty string is included as a defensive
|
|
// belt-and-braces fallback for callers that bypass the api4 handler's
|
|
// normalisation (it forces "" → this_rule per the model docstring
|
|
// default). Direct App.SimulateAccessControlPolicyForUsers callers
|
|
// in tests / future RPC entry points may still hit this helper with
|
|
// a raw empty string; we treat it consistently with the documented
|
|
// model default rather than letting it silently fall through to
|
|
// "all" semantics.
|
|
func isThisRuleScope(scope string) bool {
|
|
return scope == "" || scope == model.PolicyEvaluationScopeThisRule
|
|
}
|
|
|
|
// userAttributesPathPrefix is the canonical CEL prefix the simulator
|
|
// records on leaf evaluation-tree nodes for user-attribute references
|
|
// (e.g. `user.attributes.Clearance`). The CPA field name is the
|
|
// suffix; we strip the prefix to match against the protected set
|
|
// indexed by field name.
|
|
const userAttributesPathPrefix = "user.attributes."
|
|
|
|
// RedactSimulationAttributesForCaller strips attribute values from a
|
|
// PolicySimulationResponse on every surface the picker exposes
|
|
// (top-level user/session Attributes maps AND the per-leaf
|
|
// ActualValue inside same-scope blame evaluation trees) when the
|
|
// caller is not a system admin.
|
|
//
|
|
// A field is treated as protected — and therefore redacted — when
|
|
// any of the following applies (channel and team admins are never a
|
|
// CPA field's source plugin, so the access_mode branches collapse
|
|
// to "not public" for these callers):
|
|
//
|
|
// - `visibility == "hidden"`: the field is hidden on the user
|
|
// profile page; the simulate UI must not be a side channel.
|
|
//
|
|
// - `access_mode == "source_only"`: the CPA value is reserved for
|
|
// the source plugin. Channel/team admins are never plugin
|
|
// callers, so the value is always inaccessible to them.
|
|
//
|
|
// - `access_mode == "shared_only"`: the underlying property
|
|
// service computes an intersection of the caller's and target's
|
|
// values on read. The simulator does NOT call the property
|
|
// service (it reads from AttributeView directly), so we
|
|
// conservatively redact these values rather than ship them
|
|
// unfiltered.
|
|
//
|
|
// System admins (passed via callerIsSystemAdmin=true) bypass the
|
|
// filter entirely; they always see every attribute the simulator
|
|
// recorded.
|
|
//
|
|
// On failure to look up the CPA fields we *strip every attribute map*
|
|
// and clear every evaluation tree's ActualValue, rather than leaking
|
|
// a value through a transient error — the fail-closed default mirrors
|
|
// how `BuildAccessControlSubject` treats a missing channel-role
|
|
// lookup.
|
|
func (a *App) RedactSimulationAttributesForCaller(rctx request.CTX, resp *model.PolicySimulationResponse, callerIsSystemAdmin bool) {
|
|
if resp == nil || callerIsSystemAdmin {
|
|
return
|
|
}
|
|
|
|
// Cheap-out when no result row carries any of the redactable
|
|
// surfaces (top-level Attributes maps or blame evaluation trees) —
|
|
// saves the CPA fetch on the common "deny chip only, no Decision
|
|
// Details panel" UX.
|
|
if !simulationHasRedactableAttributeData(resp) {
|
|
return
|
|
}
|
|
|
|
protected, err := a.protectedCPAFieldNamesForCaller(rctx)
|
|
if err != nil {
|
|
rctx.Logger().Warn(
|
|
"RedactSimulationAttributesForCaller: failed to load CPA fields; redacting every simulation attribute surface as a fail-closed default",
|
|
mlog.Err(err),
|
|
)
|
|
// Fail closed: drop every attribute snapshot AND every leaf
|
|
// `actual_value` rather than leak a protected field through a
|
|
// transient lookup failure.
|
|
clearAllSimulationAttributes(resp)
|
|
clearAllEvaluationTreeActualValues(resp)
|
|
return
|
|
}
|
|
if len(protected) == 0 {
|
|
return
|
|
}
|
|
|
|
stripProtectedAttributes(resp, protected)
|
|
redactProtectedEvaluationTreeActualValues(resp, protected)
|
|
}
|
|
|
|
// protectedCPAFieldNamesForCaller returns the set of CPA field names
|
|
// whose contents must be hidden from a non-system-admin caller. The
|
|
// set includes both `visibility: hidden` fields and any field whose
|
|
// `access_mode` is not public (source_only / shared_only). The
|
|
// simulator's AttributeView populates its per-user map keyed by
|
|
// `pf.Name` (see db/migrations/postgres/000137_update_attribute_view.up.sql),
|
|
// and the evaluation-tree walker likewise records `user.attributes.<name>`
|
|
// on each leaf — so matching by name is correct for both.
|
|
func (a *App) protectedCPAFieldNamesForCaller(rctx request.CTX) (map[string]struct{}, error) {
|
|
group, appErr := a.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
|
|
if appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
|
|
propertyFields, appErr := a.SearchPropertyFields(rctx, group.ID, model.PropertyFieldSearchOpts{
|
|
PerPage: model.AccessControlGroupFieldLimit + 5,
|
|
})
|
|
if appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
|
|
protected := map[string]struct{}{}
|
|
for _, pf := range propertyFields {
|
|
if pf == nil {
|
|
continue
|
|
}
|
|
f, err := model.NewCPAFieldFromPropertyField(pf)
|
|
if err != nil {
|
|
// Fail-closed: an unparseable field is treated as protected
|
|
// rather than leaked through the masking layer as public.
|
|
rctx.Logger().Warn("Failed to parse property field for CPA protection check; treating as protected",
|
|
mlog.String("field_name", pf.Name),
|
|
mlog.String("field_id", pf.ID),
|
|
mlog.Err(err),
|
|
)
|
|
protected[pf.Name] = struct{}{}
|
|
continue
|
|
}
|
|
if cpaFieldIsProtectedForChannelAdmin(f) {
|
|
protected[f.Name] = struct{}{}
|
|
}
|
|
}
|
|
return protected, nil
|
|
}
|
|
|
|
// cpaFieldIsProtectedForChannelAdmin reports whether a CPA field's
|
|
// value must be hidden from a non-system-admin caller. Pure helper
|
|
// so the protected-set construction and the per-leaf tree walker can
|
|
// share the same predicate.
|
|
func cpaFieldIsProtectedForChannelAdmin(f *model.CPAField) bool {
|
|
if f == nil {
|
|
return false
|
|
}
|
|
if f.Attrs.Visibility == model.CustomProfileAttributesVisibilityHidden {
|
|
return true
|
|
}
|
|
// access_mode "" defaults to public — only non-public values are
|
|
// protected. Channel/team admins are never the source plugin so
|
|
// both source_only and shared_only collapse to "inaccessible".
|
|
if f.Attrs.AccessMode != "" && f.Attrs.AccessMode != model.PropertyAccessModePublic {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// simulationHasRedactableAttributeData reports whether any result row
|
|
// carries a non-empty top-level `Attributes` map at the user OR
|
|
// session level, or any blame entry whose `EvaluationTree` (or
|
|
// per-rule subtree under MergedRules) might leak a leaf
|
|
// `ActualValue`. Used to short-circuit the redact pass when the
|
|
// response is purely "decision chips only" with no Decision Details
|
|
// data to redact.
|
|
func simulationHasRedactableAttributeData(resp *model.PolicySimulationResponse) bool {
|
|
if resp == nil {
|
|
return false
|
|
}
|
|
for i := range resp.Results {
|
|
r := &resp.Results[i]
|
|
if len(r.Attributes) > 0 {
|
|
return true
|
|
}
|
|
for j := range r.Decisions {
|
|
if decisionCarriesActualValue(r.Decisions[j]) {
|
|
return true
|
|
}
|
|
}
|
|
for j := range r.Sessions {
|
|
if len(r.Sessions[j].Attributes) > 0 {
|
|
return true
|
|
}
|
|
for k := range r.Sessions[j].Decisions {
|
|
if decisionCarriesActualValue(r.Sessions[j].Decisions[k]) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// decisionCarriesActualValue reports whether any blame entry on the
|
|
// decision has an evaluation tree (either at the top level or under a
|
|
// merged-rule entry) that could leak an `ActualValue`.
|
|
func decisionCarriesActualValue(dec model.PolicySimulationActionDecision) bool {
|
|
for i := range dec.Blame {
|
|
b := &dec.Blame[i]
|
|
if b.EvaluationTree != nil {
|
|
return true
|
|
}
|
|
for j := range b.MergedRules {
|
|
if b.MergedRules[j].EvaluationTree != nil {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// stripProtectedAttributes deletes any key in `protected` from every
|
|
// result row's user-level and per-session top-level Attributes maps in
|
|
// `resp`. Mutates `resp` in place; safe to call when `protected` is
|
|
// empty (no-op). This handles the top-level snapshot the Decision
|
|
// Details panel renders as a User/Session attributes table.
|
|
func stripProtectedAttributes(resp *model.PolicySimulationResponse, protected map[string]struct{}) {
|
|
if resp == nil || len(protected) == 0 {
|
|
return
|
|
}
|
|
for i := range resp.Results {
|
|
r := &resp.Results[i]
|
|
for name := range protected {
|
|
delete(r.Attributes, name)
|
|
}
|
|
for j := range r.Sessions {
|
|
for name := range protected {
|
|
delete(r.Sessions[j].Attributes, name)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// redactProtectedEvaluationTreeActualValues walks every blame entry's
|
|
// EvaluationTree (and the per-rule subtrees attached under
|
|
// MergedRules) on every result and session decision in `resp`. For
|
|
// each leaf node whose `Attribute` references a protected CPA field
|
|
// (path format `user.attributes.<name>`), the leaf's `ActualValue`
|
|
// is blanked.
|
|
//
|
|
// Why ActualValue and nothing else:
|
|
// - `Attribute` is the path; it already appears in the rule's
|
|
// `Expression`, which the channel admin can see.
|
|
// - `ExpectedValue` is the literal from the rule (e.g. `"il5"`),
|
|
// not the user's data — also already in `Expression`.
|
|
// - `ActualValue` is the only field that records the target user's
|
|
// concrete attribute value. That's the one we must redact.
|
|
func redactProtectedEvaluationTreeActualValues(resp *model.PolicySimulationResponse, protected map[string]struct{}) {
|
|
if resp == nil || len(protected) == 0 {
|
|
return
|
|
}
|
|
for i := range resp.Results {
|
|
r := &resp.Results[i]
|
|
for action, dec := range r.Decisions {
|
|
redactProtectedActualValuesInDecision(&dec, protected)
|
|
r.Decisions[action] = dec
|
|
}
|
|
for j := range r.Sessions {
|
|
for action, dec := range r.Sessions[j].Decisions {
|
|
redactProtectedActualValuesInDecision(&dec, protected)
|
|
r.Sessions[j].Decisions[action] = dec
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func redactProtectedActualValuesInDecision(dec *model.PolicySimulationActionDecision, protected map[string]struct{}) {
|
|
for i := range dec.Blame {
|
|
b := &dec.Blame[i]
|
|
if b.EvaluationTree != nil {
|
|
redactProtectedActualValuesInTree(b.EvaluationTree, protected)
|
|
}
|
|
for j := range b.MergedRules {
|
|
if b.MergedRules[j].EvaluationTree != nil {
|
|
redactProtectedActualValuesInTree(b.MergedRules[j].EvaluationTree, protected)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// redactProtectedActualValuesInTree recursively walks `node` and
|
|
// blanks the `ActualValue` on every leaf whose `Attribute` resolves
|
|
// to a CPA field in `protected`. Operates in place on the tree
|
|
// pointer the response shares with its parent blame entry.
|
|
func redactProtectedActualValuesInTree(node *model.PolicySimulationEvaluationNode, protected map[string]struct{}) {
|
|
if node == nil {
|
|
return
|
|
}
|
|
if isProtectedAttributePath(node.Attribute, protected) {
|
|
node.ActualValue = ""
|
|
}
|
|
for i := range node.Children {
|
|
redactProtectedActualValuesInTree(&node.Children[i], protected)
|
|
}
|
|
}
|
|
|
|
// isProtectedAttributePath returns true when `path` is the canonical
|
|
// CEL form `user.attributes.<name>` and `<name>` is in `protected`.
|
|
// Returns false for empty paths and for any path that doesn't carry
|
|
// the user-attribute prefix (other shapes — function-call leaves,
|
|
// constant comparisons — are not user data).
|
|
func isProtectedAttributePath(path string, protected map[string]struct{}) bool {
|
|
if path == "" || len(protected) == 0 {
|
|
return false
|
|
}
|
|
name, ok := strings.CutPrefix(path, userAttributesPathPrefix)
|
|
if !ok || name == "" {
|
|
return false
|
|
}
|
|
_, found := protected[name]
|
|
return found
|
|
}
|
|
|
|
// clearAllSimulationAttributes wipes every top-level user-level and
|
|
// per-session Attributes map in `resp`. Used as part of the fail-
|
|
// closed default when the CPA visibility lookup fails — a transient
|
|
// store error must not leak a hidden value to a channel admin via
|
|
// the simulator.
|
|
func clearAllSimulationAttributes(resp *model.PolicySimulationResponse) {
|
|
if resp == nil {
|
|
return
|
|
}
|
|
for i := range resp.Results {
|
|
r := &resp.Results[i]
|
|
r.Attributes = nil
|
|
for j := range r.Sessions {
|
|
r.Sessions[j].Attributes = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// clearAllEvaluationTreeActualValues wipes the `ActualValue` field on
|
|
// every leaf in every evaluation tree the response carries (top-level
|
|
// and per-merged-rule). Companion to `clearAllSimulationAttributes`
|
|
// for the fail-closed path: we don't know which fields are protected
|
|
// because the CPA lookup failed, so we redact every leaf rather than
|
|
// take the risk.
|
|
func clearAllEvaluationTreeActualValues(resp *model.PolicySimulationResponse) {
|
|
if resp == nil {
|
|
return
|
|
}
|
|
for i := range resp.Results {
|
|
r := &resp.Results[i]
|
|
for action, dec := range r.Decisions {
|
|
clearActualValuesInDecision(&dec)
|
|
r.Decisions[action] = dec
|
|
}
|
|
for j := range r.Sessions {
|
|
for action, dec := range r.Sessions[j].Decisions {
|
|
clearActualValuesInDecision(&dec)
|
|
r.Sessions[j].Decisions[action] = dec
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func clearActualValuesInDecision(dec *model.PolicySimulationActionDecision) {
|
|
for i := range dec.Blame {
|
|
b := &dec.Blame[i]
|
|
if b.EvaluationTree != nil {
|
|
clearActualValuesInTree(b.EvaluationTree)
|
|
}
|
|
for j := range b.MergedRules {
|
|
if b.MergedRules[j].EvaluationTree != nil {
|
|
clearActualValuesInTree(b.MergedRules[j].EvaluationTree)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func clearActualValuesInTree(node *model.PolicySimulationEvaluationNode) {
|
|
if node == nil {
|
|
return
|
|
}
|
|
node.ActualValue = ""
|
|
for i := range node.Children {
|
|
clearActualValuesInTree(&node.Children[i])
|
|
}
|
|
}
|
|
|
|
// enrichBlameForDraftScope walks the simulator response and:
|
|
// - copies the failing rule's expression into draft-side blame entries
|
|
// (this_rule / sibling_rule / sibling_saved) using params.Policy.Rules
|
|
// as the source — only if the simulator hasn't already populated it.
|
|
// - reclassifies system_permission blame entries whose blamed policy
|
|
// lives at the SAME scope as the draft (same Type and same Imports
|
|
// parent set) to peer_policy, populating the failing rule's
|
|
// expression in from the blamed policy's Rules when the simulator
|
|
// left it empty. acs.GetPolicy is consulted once per unique
|
|
// policy_id and cached for the request.
|
|
// - **defensively strips Expression and EvaluationTree from blame
|
|
// entries whose final source is truly upper-scoped**
|
|
// (system_permission, channel_policy). The simulator may attach
|
|
// these fields unconditionally for ergonomics; the privacy
|
|
// boundary is enforced here so the UI never receives the contents
|
|
// of a policy outside the editing scope.
|
|
func enrichBlameForDraftScope(rctx request.CTX, acs einterfaces.AccessControlServiceInterface, draft *model.AccessControlPolicy, resp *model.PolicySimulationResponse) {
|
|
if resp == nil || draft == nil {
|
|
return
|
|
}
|
|
draftRules := buildRulesIndex(draft)
|
|
cache := map[string]*model.AccessControlPolicy{}
|
|
|
|
enrichDecisions := func(decisions map[string]model.PolicySimulationActionDecision) {
|
|
for action, dec := range decisions {
|
|
for j := range dec.Blame {
|
|
enrichBlameEntry(rctx, acs, draft, draftRules, cache, &dec.Blame[j])
|
|
}
|
|
decisions[action] = dec
|
|
}
|
|
}
|
|
|
|
for i := range resp.Results {
|
|
enrichDecisions(resp.Results[i].Decisions)
|
|
for k := range resp.Results[i].Sessions {
|
|
enrichDecisions(resp.Results[i].Sessions[k].Decisions)
|
|
}
|
|
}
|
|
}
|
|
|
|
func enrichBlameEntry(rctx request.CTX, acs einterfaces.AccessControlServiceInterface, draft *model.AccessControlPolicy, draftRules map[string]string, cache map[string]*model.AccessControlPolicy, blame *model.PolicySimulationBlame) {
|
|
if blame == nil {
|
|
return
|
|
}
|
|
switch blame.Source {
|
|
case model.PolicySimulationBlameSourceThisRule,
|
|
model.PolicySimulationBlameSourceSiblingRule,
|
|
model.PolicySimulationBlameSourceSiblingSaved:
|
|
// Same-scope draft blame: backfill expression if the simulator
|
|
// didn't pre-populate it.
|
|
if blame.Expression == "" {
|
|
if expr, ok := draftRules[blame.RuleName]; ok {
|
|
blame.Expression = expr
|
|
}
|
|
}
|
|
case model.PolicySimulationBlameSourceSystemPermission:
|
|
// Peer-vs-upper distinction lives here: load the blamed
|
|
// policy, compare scope to the draft, and either reclassify
|
|
// (peer_policy with expression preserved/backfilled) or strip
|
|
// the leaked details.
|
|
if blame.PolicyID == "" {
|
|
stripUpperScopedFields(blame)
|
|
return
|
|
}
|
|
blamed, cached := cache[blame.PolicyID]
|
|
if !cached {
|
|
policy, appErr := acs.GetPolicy(rctx, blame.PolicyID)
|
|
if appErr != nil {
|
|
policy = nil
|
|
}
|
|
cache[blame.PolicyID] = policy
|
|
blamed = policy
|
|
}
|
|
if blamed == nil || !samePeerScope(draft, blamed) {
|
|
stripUpperScopedFields(blame)
|
|
return
|
|
}
|
|
blame.Source = model.PolicySimulationBlameSourcePeerPolicy
|
|
if blame.Expression == "" {
|
|
for _, r := range blamed.Rules {
|
|
if r.Name == blame.RuleName {
|
|
blame.Expression = r.Expression
|
|
break
|
|
}
|
|
}
|
|
}
|
|
case model.PolicySimulationBlameSourceChannelPolicy:
|
|
// channel_policy is always upper-scoped from a draft's view —
|
|
// the parent or an inherited resource policy. Strip
|
|
// expression / tree details so the UI keeps the chip opaque.
|
|
stripUpperScopedFields(blame)
|
|
}
|
|
}
|
|
|
|
// stripUpperScopedFields clears the fields that would leak the contents
|
|
// of an out-of-scope policy if the simulator attached them. Called
|
|
// whenever a blame entry's final source is determined to live above
|
|
// the editing scope.
|
|
//
|
|
// MergedRules is stripped alongside Expression / EvaluationTree:
|
|
// the per-rule list lets the picker number sub-rules of the
|
|
// contributing policy, which would amount to enumerating that
|
|
// policy's authored rules — exactly what the privacy boundary is
|
|
// supposed to hide. The simulator may have attached MergedRules
|
|
// unconditionally for ergonomics; we drop it here once the source is
|
|
// known to live above the editing scope.
|
|
func stripUpperScopedFields(blame *model.PolicySimulationBlame) {
|
|
blame.Expression = ""
|
|
blame.EvaluationTree = nil
|
|
blame.MergedRules = nil
|
|
}
|
|
|
|
// buildRulesIndex maps rule_name -> CEL expression for a policy. Rules
|
|
// without a name (legacy v0.3 membership rules) are skipped because the
|
|
// blame entries reference rules by name — anonymous rules would never
|
|
// match.
|
|
func buildRulesIndex(policy *model.AccessControlPolicy) map[string]string {
|
|
if policy == nil {
|
|
return nil
|
|
}
|
|
out := make(map[string]string, len(policy.Rules))
|
|
for _, r := range policy.Rules {
|
|
if r.Name == "" {
|
|
continue
|
|
}
|
|
out[r.Name] = r.Expression
|
|
}
|
|
return out
|
|
}
|
|
|
|
// samePeerScope reports whether two policies live at the same scope.
|
|
// Policies are peers when they share the same Type, the same Scope +
|
|
// ScopeID (so a team-scoped permission policy is never treated as a
|
|
// peer of a system-scoped one with the same imports), and the same
|
|
// parent imports set (order-insensitive). Two policies with no Imports
|
|
// (top-level system policies) count as peers of one another. A policy
|
|
// and its parent are NOT peers — the parent has a smaller / different
|
|
// imports set.
|
|
func samePeerScope(a, b *model.AccessControlPolicy) bool {
|
|
if a == nil || b == nil {
|
|
return false
|
|
}
|
|
if a.Type != b.Type {
|
|
return false
|
|
}
|
|
if a.Scope != b.Scope || a.ScopeID != b.ScopeID {
|
|
return false
|
|
}
|
|
return importsEqual(a.Imports, b.Imports)
|
|
}
|
|
|
|
func importsEqual(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
if len(a) == 0 {
|
|
return true
|
|
}
|
|
aa := append([]string(nil), a...)
|
|
bb := append([]string(nil), b...)
|
|
slices.Sort(aa)
|
|
slices.Sort(bb)
|
|
for i := range aa {
|
|
if aa[i] != bb[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// filterResponseToEditingRuleScope is the defensive post-process for the
|
|
// "this rule only" evaluation scope. The simulator already restricts
|
|
// contributions to just the editing rule (no sibling rules, no system
|
|
// permission policies, no peer policies), so in practice this function
|
|
// only runs over an already-clean response. It exists to backstop
|
|
// any blame entry that leaked through, drop anything that isn't a
|
|
// draft-side entry on the editing rule, and flip orphaned denies back
|
|
// to allow.
|
|
//
|
|
// editingRuleName is the rule the author is currently simulating; when
|
|
// non-empty, only this_rule blame entries that explicitly target that
|
|
// rule survive. When empty (e.g. an unnamed draft rule) the filter
|
|
// drops everything except this_rule, sibling_saved, and
|
|
// no_applicable_policy regardless of the rule_name field — sibling
|
|
// rules in the same policy are never kept in this mode.
|
|
func filterResponseToEditingRuleScope(resp *model.PolicySimulationResponse, editingRuleName string) {
|
|
for i := range resp.Results {
|
|
// System admins are subject to ABAC the same as any other
|
|
// user, BUT they don't carry the channel-level role tokens
|
|
// (channel_user / channel_guest / channel_admin) the
|
|
// simulator pairs rules against — they inherit them
|
|
// implicitly. The simulator returns a bare {decision: true}
|
|
// for sysadmin candidates without a this_rule blame, which
|
|
// looks identical to the "rule doesn't apply (role
|
|
// mismatch)" vacuous allow the filter relies on. Without a
|
|
// sysadmin carve-out the marker would mislabel sysadmin
|
|
// rows as "this rule doesn't apply" when in fact the rule
|
|
// does apply via role fallback — the sysadmin is allowed
|
|
// by the same rule the picker is testing. We pass the flag
|
|
// down to filterDecisionsToEditingRuleScope so it can skip
|
|
// the no_applicable_rule injection for those rows.
|
|
callerIsSystemAdmin := false
|
|
if u := resp.Results[i].User; u != nil {
|
|
callerIsSystemAdmin = u.IsSystemAdmin()
|
|
}
|
|
resp.Results[i].Decisions = filterDecisionsToEditingRuleScope(resp.Results[i].Decisions, editingRuleName, callerIsSystemAdmin)
|
|
for j := range resp.Results[i].Sessions {
|
|
resp.Results[i].Sessions[j].Decisions = filterDecisionsToEditingRuleScope(resp.Results[i].Sessions[j].Decisions, editingRuleName, callerIsSystemAdmin)
|
|
}
|
|
}
|
|
}
|
|
|
|
func filterDecisionsToEditingRuleScope(decisions map[string]model.PolicySimulationActionDecision, editingRuleName string, candidateIsSystemAdmin bool) map[string]model.PolicySimulationActionDecision {
|
|
if len(decisions) == 0 {
|
|
return decisions
|
|
}
|
|
for action, dec := range decisions {
|
|
filtered := filterBlameToEditingRuleScope(dec.Blame, editingRuleName)
|
|
|
|
switch {
|
|
case !dec.Decision && len(filtered) == 0:
|
|
// DENY with no editing-rule contribution at all (only
|
|
// upper-scoped / peer / sibling-rule denies, all of which
|
|
// were just filtered out). The editing rule is silent on
|
|
// this user, so we surface "doesn't apply" rather than
|
|
// the old flip-to-plain-allow — that read as "this rule
|
|
// alone would have allowed this user" which isn't true
|
|
// for a permission rule whose filter didn't grant.
|
|
//
|
|
// Outcome is left empty (not OutcomeAllow) to match the
|
|
// existing no_applicable_policy convention: the chip's
|
|
// hasBlame helper filters informational outcome=allow
|
|
// entries out, so a vacuous-allow synthetic must NOT set
|
|
// outcome=allow or the chip will skip it.
|
|
dec.Decision = true
|
|
dec.Blame = []model.PolicySimulationBlame{{
|
|
Source: model.PolicySimulationBlameSourceNoApplicableRule,
|
|
}}
|
|
case dec.Decision && !hasThisRuleAllow(filtered) && !hasNoApplicablePolicy(filtered) && !candidateIsSystemAdmin:
|
|
// ALLOW without the editing rule actively granting. This
|
|
// covers three real-world simulator outputs:
|
|
//
|
|
// 1. sibling_saved present — this rule denied, an
|
|
// OR-merged sibling allowed.
|
|
// 2. Bare {decision: true} with empty blame — the
|
|
// simulator emits a vacuous allow when the editing
|
|
// rule's role doesn't match the candidate's role
|
|
// (e.g. testing a channel_user rule against a guest
|
|
// user), or the rule's action set doesn't overlap.
|
|
// 3. Only upper-scoped allow blame survived the
|
|
// filter — same idea: the editing rule itself was
|
|
// silent on this user.
|
|
//
|
|
// In every case the editing rule didn't contribute a
|
|
// grant, so in "this rule only" view the chip should read
|
|
// "this rule doesn't apply". Append (don't replace) so
|
|
// any sibling_saved expression stays available for the
|
|
// Decision Details trace.
|
|
//
|
|
// Three carve-outs:
|
|
// - no_applicable_policy already attributes the verdict
|
|
// to the WHOLE policy being silent on this user;
|
|
// that's strictly more informative and we don't
|
|
// shadow it.
|
|
// - candidateIsSystemAdmin — sysadmins inherit every
|
|
// channel-level role implicitly, so a bare
|
|
// {decision: true} for a sysadmin candidate is a
|
|
// legitimate allow via role fallback, NOT a "rule
|
|
// doesn't apply" signal. The simulator just doesn't
|
|
// emit a this_rule blame entry for the fallback path.
|
|
// - this_rule allow + sibling_saved (handled by the
|
|
// hasThisRuleAllow guard above) — the rule did
|
|
// contribute, sibling is supplementary.
|
|
dec.Blame = append(filtered, model.PolicySimulationBlame{
|
|
Source: model.PolicySimulationBlameSourceNoApplicableRule,
|
|
})
|
|
default:
|
|
dec.Blame = filtered
|
|
}
|
|
|
|
decisions[action] = dec
|
|
}
|
|
return decisions
|
|
}
|
|
|
|
// hasThisRuleAllow reports whether any blame entry is an
|
|
// informational this_rule entry with outcome=allow — i.e. the
|
|
// editing rule itself granted the subject. When this is true we
|
|
// must NOT convert to no_applicable_rule: the rule did contribute,
|
|
// any sibling_saved entry alongside is just supplementary
|
|
// "another rule also allowed" context.
|
|
func hasThisRuleAllow(blames []model.PolicySimulationBlame) bool {
|
|
for _, b := range blames {
|
|
if b.Source == model.PolicySimulationBlameSourceThisRule && b.Outcome == model.PolicySimulationBlameOutcomeAllow {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// hasNoApplicablePolicy reports whether the simulator already
|
|
// marked the response with a no_applicable_policy synthetic blame
|
|
// — the policy as a whole doesn't govern this user. We use the
|
|
// same "outcome != allow" gate the chip's hasBlame helper uses so
|
|
// our detection lines up with what the picker will actually
|
|
// render; this prevents us from shadowing a wider
|
|
// "policy doesn't apply" verdict with a narrower
|
|
// "this rule doesn't apply" pill.
|
|
func hasNoApplicablePolicy(blames []model.PolicySimulationBlame) bool {
|
|
for _, b := range blames {
|
|
if b.Source == model.PolicySimulationBlameSourceNoApplicablePolicy && b.Outcome != model.PolicySimulationBlameOutcomeAllow {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// editingRuleBlameSources lists the blame sources that originate inside
|
|
// the editing rule itself (or are synthetic markers about how the rule
|
|
// applies). Anything else — peer_policy (same scope, different policy),
|
|
// system_permission, channel_policy, and even sibling_rule (same policy,
|
|
// different rule) — is dropped when the caller asks for "this rule only".
|
|
//
|
|
// no_applicable_rule is not listed here because it's emitted POST-filter
|
|
// by filterDecisionsToEditingRuleScope itself, not by the simulator.
|
|
// Listing it here would have no effect; the filter would never see one.
|
|
var editingRuleBlameSources = map[string]struct{}{
|
|
model.PolicySimulationBlameSourceThisRule: {},
|
|
model.PolicySimulationBlameSourceSiblingSaved: {},
|
|
model.PolicySimulationBlameSourceNoApplicablePolicy: {},
|
|
}
|
|
|
|
func filterBlameToEditingRuleScope(blame []model.PolicySimulationBlame, editingRuleName string) []model.PolicySimulationBlame {
|
|
if len(blame) == 0 {
|
|
return nil
|
|
}
|
|
out := blame[:0:0]
|
|
for _, b := range blame {
|
|
if _, ok := editingRuleBlameSources[b.Source]; !ok {
|
|
continue
|
|
}
|
|
// Defensive: when the editing rule's name is known, only keep
|
|
// blame entries that explicitly target it. sibling_saved is the
|
|
// deliberate exception — by definition it names the rescuing
|
|
// sibling, never the editing rule.
|
|
if editingRuleName != "" && b.RuleName != "" && b.RuleName != editingRuleName &&
|
|
b.Source != model.PolicySimulationBlameSourceSiblingSaved {
|
|
continue
|
|
}
|
|
out = append(out, b)
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (a *App) AssignAccessControlPolicyToChannels(rctx request.CTX, parentID string, channelIDs []string) ([]*model.AccessControlPolicy, *model.AppError) {
|
|
acs := a.Srv().ch.AccessControl
|
|
if acs == nil {
|
|
return nil, model.NewAppError("AssignAccessControlPolicyToChannels", "app.pap.assign_access_control_policy_to_channels.app_error", nil, "Policy Administration Point is not initialized", http.StatusNotImplemented)
|
|
}
|
|
|
|
policy, appErr := a.GetAccessControlPolicy(rctx, parentID)
|
|
if appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
|
|
if policy.Type != model.AccessControlPolicyTypeParent {
|
|
return nil, model.NewAppError("AssignAccessControlPolicyToChannels", "app.pap.assign_access_control_policy_to_channels.app_error", nil, "Policy is not of type parent", http.StatusBadRequest)
|
|
}
|
|
|
|
channels, err := a.GetChannels(rctx, channelIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
policies := make([]*model.AccessControlPolicy, 0, len(channelIDs))
|
|
for _, channel := range channels {
|
|
if appErr := a.ValidateChannelEligibilityForAccessControl(rctx, channel); appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
|
|
child, err := acs.GetPolicy(rctx, channel.Id)
|
|
if err != nil && err.StatusCode != http.StatusNotFound {
|
|
return nil, model.NewAppError("AssignAccessControlPolicyToChannels", "app.pap.assign_access_control_policy_to_channels.app_error", nil, err.Error(), http.StatusInternalServerError)
|
|
}
|
|
if child == nil {
|
|
child = &model.AccessControlPolicy{
|
|
ID: channel.Id,
|
|
Type: model.AccessControlPolicyTypeChannel,
|
|
Active: policy.Active,
|
|
CreateAt: model.GetMillis(),
|
|
Props: map[string]any{},
|
|
}
|
|
}
|
|
child.Version = model.AccessControlPolicyVersionV0_3
|
|
|
|
appErr := child.Inherit(policy)
|
|
if appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
|
|
child, appErr = acs.SavePolicy(rctx, child)
|
|
if appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
a.publishChannelPolicyEnforcedUpdate(rctx, child.ID)
|
|
policies = append(policies, child)
|
|
}
|
|
|
|
return policies, nil
|
|
}
|
|
|
|
func (a *App) UnassignPoliciesFromChannels(rctx request.CTX, policyID string, channelIDs []string) *model.AppError {
|
|
acs := a.Srv().ch.AccessControl
|
|
if acs == nil {
|
|
return model.NewAppError("UnassignPoliciesFromChannels", "app.pap.unassign_access_control_policy_from_channels.app_error", nil, "Policy Administration Point is not initialized", http.StatusNotImplemented)
|
|
}
|
|
|
|
cps, _, err := a.Srv().Store().AccessControlPolicy().SearchPolicies(rctx, model.AccessControlPolicySearch{
|
|
Type: model.AccessControlPolicyTypeChannel,
|
|
ParentID: policyID,
|
|
Limit: 1000,
|
|
})
|
|
if err != nil {
|
|
return model.NewAppError("UnassignPoliciesFromChannels", "app.pap.unassign_access_control_policy_from_channels.app_error", nil, err.Error(), http.StatusInternalServerError)
|
|
}
|
|
|
|
childPolicies := make(map[string]bool)
|
|
for _, p := range cps {
|
|
childPolicies[p.ID] = true
|
|
}
|
|
|
|
for _, channelID := range channelIDs {
|
|
if _, ok := childPolicies[channelID]; !ok {
|
|
mlog.Warn("Policy is not assigned to the parent policy", mlog.String("channel_id", channelID), mlog.String("parent_policy_id", policyID))
|
|
continue
|
|
}
|
|
|
|
child, appErr := acs.GetPolicy(rctx, channelID)
|
|
if appErr != nil {
|
|
return model.NewAppError("UnassignPoliciesFromChannels", "app.pap.unassign_access_control_policy_from_channels.app_error", nil, appErr.Error(), http.StatusInternalServerError)
|
|
}
|
|
|
|
child.Imports = slices.DeleteFunc(child.Imports, func(importID string) bool {
|
|
return importID == policyID
|
|
})
|
|
if len(child.Imports) == 0 && len(child.Rules) == 0 {
|
|
// If the policy has no imports and no rules, we can delete it
|
|
if err := acs.DeletePolicy(rctx, child.ID); err != nil {
|
|
return model.NewAppError("UnassignPoliciesFromChannels", "app.pap.unassign_access_control_policy_from_channels.app_error", nil, err.Error(), http.StatusInternalServerError)
|
|
}
|
|
// invalidate the channel cache and broadcast the policy change
|
|
a.publishChannelPolicyEnforcedUpdate(rctx, channelID)
|
|
continue
|
|
}
|
|
_, appErr = acs.SavePolicy(rctx, child)
|
|
if appErr != nil {
|
|
return model.NewAppError("UnassignPoliciesFromChannels", "app.pap.unassign_access_control_policy_from_channels.app_error", nil, appErr.Error(), http.StatusInternalServerError)
|
|
}
|
|
a.publishChannelPolicyEnforcedUpdate(rctx, channelID)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *App) SearchAccessControlPolicies(rctx request.CTX, opts model.AccessControlPolicySearch) ([]*model.AccessControlPolicy, int64, *model.AppError) {
|
|
acs := a.Srv().ch.AccessControl
|
|
if acs == nil {
|
|
return nil, 0, model.NewAppError("SearchAccessControlPolicies", "app.pap.search_access_control_policies.app_error", nil, "Policy Administration Point is not initialized", http.StatusNotImplemented)
|
|
}
|
|
|
|
policies, total, err := a.Srv().Store().AccessControlPolicy().SearchPolicies(rctx, opts)
|
|
if err != nil {
|
|
return nil, 0, model.NewAppError("SearchAccessControlPolicies", "app.pap.search_access_control_policies.app_error", nil, err.Error(), http.StatusInternalServerError)
|
|
}
|
|
|
|
for i, policy := range policies {
|
|
if policy.Type != model.AccessControlPolicyTypeParent {
|
|
continue
|
|
}
|
|
|
|
normlizedPolicy, appErr := acs.NormalizePolicy(rctx, policy)
|
|
if appErr != nil {
|
|
mlog.Error("Failed to normalize policy", mlog.String("policy_id", policy.ID), mlog.Err(appErr))
|
|
continue
|
|
}
|
|
policies[i] = normlizedPolicy
|
|
}
|
|
|
|
return policies, total, nil
|
|
}
|
|
|
|
func (a *App) GetAccessControlPolicyAttributes(rctx request.CTX, channelID string, action string) (map[string][]string, *model.AppError) {
|
|
acs := a.Srv().ch.AccessControl
|
|
if acs == nil {
|
|
return nil, model.NewAppError("GetChannelAccessControlAttributes", "app.pap.get_channel_access_control_attributes.app_error", nil, "Policy Administration Point is not initialized", http.StatusNotImplemented)
|
|
}
|
|
|
|
attributes, appErr := acs.GetPolicyRuleAttributes(rctx, channelID, action)
|
|
if appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
|
|
if len(attributes) == 0 {
|
|
return attributes, nil
|
|
}
|
|
|
|
// Strip source_only and shared_only fields: their values must not be
|
|
// exposed to channel members through the invite modal / members sidebar.
|
|
// Fail closed: if the CPA group or a field cannot be resolved, omit that
|
|
// field rather than leaking its values.
|
|
cpaGroup, appErr := a.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
|
|
if appErr != nil {
|
|
return map[string][]string{}, nil
|
|
}
|
|
|
|
for fieldName := range attributes {
|
|
// Read directly from the store so this security filter sees the raw
|
|
// access_mode, unaffected by property read hooks for the request caller.
|
|
field, fieldErr := a.Srv().Store().PropertyField().GetFieldByName(rctx.Context(), cpaGroup.ID, "", fieldName)
|
|
if fieldErr != nil {
|
|
delete(attributes, fieldName)
|
|
continue
|
|
}
|
|
switch field.GetAccessMode() {
|
|
case model.PropertyAccessModeSourceOnly, model.PropertyAccessModeSharedOnly:
|
|
delete(attributes, fieldName)
|
|
}
|
|
}
|
|
|
|
return attributes, nil
|
|
}
|
|
|
|
func (a *App) GetAccessControlFieldsAutocomplete(rctx request.CTX, after string, limit int, callerID string) ([]*model.PropertyField, *model.AppError) {
|
|
group, appErr := a.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
|
|
if appErr != nil {
|
|
return nil, model.NewAppError("GetAccessControlAutoComplete", "app.pap.get_access_control_auto_complete.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
|
|
}
|
|
|
|
// Use property app layer to enforce access control
|
|
rctxWithCaller := RequestContextWithCallerID(rctx, callerID)
|
|
fields, appErr := a.SearchPropertyFields(rctxWithCaller, group.ID, model.PropertyFieldSearchOpts{
|
|
Cursor: model.PropertyFieldSearchCursor{
|
|
PropertyFieldID: after,
|
|
CreateAt: 1,
|
|
},
|
|
PerPage: limit,
|
|
})
|
|
if appErr != nil {
|
|
return nil, model.NewAppError("GetAccessControlAutoComplete", "app.pap.get_access_control_auto_complete.app_error", nil, appErr.Error(), http.StatusInternalServerError)
|
|
}
|
|
|
|
return fields, nil
|
|
}
|
|
|
|
func (a *App) UpdateAccessControlPoliciesActive(rctx request.CTX, updates []model.AccessControlPolicyActiveUpdate) ([]*model.AccessControlPolicy, *model.AppError) {
|
|
acs := a.Srv().ch.AccessControl
|
|
if acs == nil {
|
|
return nil, model.NewAppError("ExpressionToVisualAST", "app.pap.update_access_control_policies_active.app_error", nil, "Policy Administration Point is not initialized", http.StatusNotImplemented)
|
|
}
|
|
|
|
policies, err := a.Srv().Store().AccessControlPolicy().SetActiveStatusMultiple(rctx, updates)
|
|
if err != nil {
|
|
return nil, model.NewAppError("UpdateAccessControlPoliciesActive", "app.pap.update_access_control_policies_active.app_error", nil, err.Error(), http.StatusInternalServerError)
|
|
}
|
|
return policies, nil
|
|
}
|
|
|
|
func (a *App) ExpressionToVisualAST(rctx request.CTX, expression string) (*model.VisualExpression, *model.AppError) {
|
|
acs := a.Srv().ch.AccessControl
|
|
if acs == nil {
|
|
return nil, model.NewAppError("ExpressionToVisualAST", "app.pap.expression_to_visual_ast.app_error", nil, "Policy Administration Point is not initialized", http.StatusNotImplemented)
|
|
}
|
|
|
|
visualAST, appErr := acs.ExpressionToVisualAST(rctx, expression)
|
|
if appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
|
|
return visualAST, nil
|
|
}
|
|
|
|
// publishChannelPolicyEnforcedForChannelPoliciesWithImport broadcasts
|
|
// channel_access_control_updated for every channel-type policy that lists
|
|
// importID in its imports. Call only after the imported policy (parent,
|
|
// permission, etc.) is persisted.
|
|
func (a *App) publishChannelPolicyEnforcedForChannelPoliciesWithImport(rctx request.CTX, importID string) {
|
|
a.publishChannelPolicyEnforcedUpdatesForChannels(rctx, a.channelPolicyIDsWithImport(rctx, importID))
|
|
}
|
|
|
|
func (a *App) publishChannelPolicyEnforcedUpdatesForChannels(rctx request.CTX, channelIDs []string) {
|
|
seen := make(map[string]struct{}, len(channelIDs))
|
|
for _, channelID := range channelIDs {
|
|
if channelID == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[channelID]; ok {
|
|
continue
|
|
}
|
|
seen[channelID] = struct{}{}
|
|
a.publishChannelPolicyEnforcedUpdate(rctx, channelID)
|
|
}
|
|
}
|
|
|
|
func (a *App) channelPolicyIDsWithImport(rctx request.CTX, importID string) []string {
|
|
channelIDs := []string{}
|
|
var cursor model.AccessControlPolicyCursor
|
|
for {
|
|
children, _, err := a.Srv().Store().AccessControlPolicy().SearchPolicies(rctx, model.AccessControlPolicySearch{
|
|
Type: model.AccessControlPolicyTypeChannel,
|
|
ParentID: importID,
|
|
Cursor: cursor,
|
|
Limit: accessControlChildPolicySearchLimit,
|
|
})
|
|
if err != nil {
|
|
rctx.Logger().Warn("Failed to list channel policies that import a policy; skipping channel access control fan-out",
|
|
mlog.String("imported_policy_id", importID),
|
|
mlog.Err(err),
|
|
)
|
|
return channelIDs
|
|
}
|
|
for _, child := range children {
|
|
channelIDs = append(channelIDs, child.ID)
|
|
}
|
|
if len(children) < accessControlChildPolicySearchLimit {
|
|
break
|
|
}
|
|
cursor.ID = children[len(children)-1].ID
|
|
}
|
|
return channelIDs
|
|
}
|
|
|
|
// HydrateChannelPolicyActions populates ch.PolicyActions for a single channel
|
|
// when ch.PolicyEnforced is true, by looking up the per-action union from
|
|
// the AccessControlPolicies table. It's a no-op for channels without an
|
|
// attached policy, so the cost on the common no-policy path is zero — only
|
|
// the cheap PolicyEnforced=false branch is taken.
|
|
//
|
|
// Errors from the underlying store are returned as AppErrors; callers
|
|
// should treat them as the channel having no actions (fail-closed) for any
|
|
// membership-dependent gate. Hydration leaves PolicyEnforced untouched so
|
|
// the "any AC policy attached" semantic remains available for consumers
|
|
// that need it (admin UI, useChannelSystemPolicies).
|
|
func (a *App) HydrateChannelPolicyActions(rctx request.CTX, ch *model.Channel) *model.AppError {
|
|
if ch == nil || !ch.PolicyEnforced {
|
|
return nil
|
|
}
|
|
actions, err := a.Srv().Store().AccessControlPolicy().GetActionsForPolicy(rctx, ch.Id)
|
|
if err != nil {
|
|
var nfErr *store.ErrNotFound
|
|
if errors.As(err, &nfErr) {
|
|
// Policy was deleted between the channel read and this lookup;
|
|
// the channel row's PolicyEnforced flag will be reconciled on
|
|
// the next write. Treat as "no actions" rather than failing.
|
|
ch.PolicyActions = map[string]bool{}
|
|
return nil
|
|
}
|
|
return model.NewAppError("HydrateChannelPolicyActions", "app.pap.hydrate_actions.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
ch.PolicyActions = actions
|
|
return nil
|
|
}
|
|
|
|
// HydrateChannelsPolicyActions does the same for a slice of channels, but
|
|
// batches the underlying store call for the subset of channels with
|
|
// PolicyEnforced=true. Channels with PolicyEnforced=false are left
|
|
// untouched and never reach the AccessControlPolicies table. Used by
|
|
// list endpoints to avoid an N+1 against the policy store.
|
|
func (a *App) HydrateChannelsPolicyActions(rctx request.CTX, channels []*model.Channel) *model.AppError {
|
|
if len(channels) == 0 {
|
|
return nil
|
|
}
|
|
var ids []string
|
|
for _, ch := range channels {
|
|
if ch == nil || !ch.PolicyEnforced {
|
|
continue
|
|
}
|
|
ids = append(ids, ch.Id)
|
|
}
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
actionsByID, err := a.Srv().Store().AccessControlPolicy().GetActionsForPolicies(rctx, ids)
|
|
if err != nil {
|
|
return model.NewAppError("HydrateChannelsPolicyActions", "app.pap.hydrate_actions.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
for _, ch := range channels {
|
|
if ch == nil || !ch.PolicyEnforced {
|
|
continue
|
|
}
|
|
if actions, ok := actionsByID[ch.Id]; ok {
|
|
ch.PolicyActions = actions
|
|
} else {
|
|
// Policy row missing for an enforced channel — same semantics
|
|
// as the single-channel ErrNotFound path: treat as empty rather
|
|
// than fail the whole batch.
|
|
ch.PolicyActions = map[string]bool{}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// publishChannelPolicyEnforcedUpdate invalidates the channel cache for the
|
|
// given channel ID and broadcasts a channel_access_control_updated websocket
|
|
// event so that connected clients can refresh their view of the channel's
|
|
// access control state (e.g. the policy_enforced flag and the set of
|
|
// attributes used by the policy). A dedicated event is used rather than
|
|
// channel_updated because this is fired on every policy mutation and clients
|
|
// only need to refresh access control state — not run the full
|
|
// channel_updated reducer/router pipeline.
|
|
func (a *App) publishChannelPolicyEnforcedUpdate(rctx request.CTX, channelID string) {
|
|
a.Srv().Store().Channel().InvalidateChannel(channelID)
|
|
|
|
channel, appErr := a.GetChannel(rctx, channelID)
|
|
if appErr != nil {
|
|
rctx.Logger().Warn("Failed to load channel after access control policy change",
|
|
mlog.String("channel_id", channelID),
|
|
mlog.Err(appErr),
|
|
)
|
|
return
|
|
}
|
|
|
|
// Ensure the broadcasted payload carries the freshly-hydrated action
|
|
// map so clients can react to action-set changes without an extra
|
|
// round-trip. GetChannel above already hydrates on cache miss, but
|
|
// re-hydrating here keeps the behavior consistent if a cache hit
|
|
// returned a channel without PolicyActions populated (e.g. a Phase 1
|
|
// rollout where caches predate the hydration seam).
|
|
if appErr := a.HydrateChannelPolicyActions(rctx, channel); appErr != nil {
|
|
rctx.Logger().Warn("Failed to hydrate policy actions before broadcast; clients will see policy_actions=nil",
|
|
mlog.String("channel_id", channelID),
|
|
mlog.Err(appErr),
|
|
)
|
|
}
|
|
|
|
channelJSON, jsonErr := json.Marshal(channel)
|
|
if jsonErr != nil {
|
|
rctx.Logger().Warn("Failed to marshal channel after access control policy change",
|
|
mlog.String("channel_id", channelID),
|
|
mlog.Err(jsonErr),
|
|
)
|
|
return
|
|
}
|
|
|
|
messageWs := model.NewWebSocketEvent(model.WebsocketEventChannelAccessControlUpdated, "", channel.Id, "", nil, "")
|
|
messageWs.Add("channel", string(channelJSON))
|
|
a.Publish(messageWs)
|
|
}
|
|
|
|
// ValidateChannelEligibilityForAccessControl checks that a channel is eligible for
|
|
// access control policy assignment: must be public or private (DM/GM excluded),
|
|
// not group-constrained, not shared, and not a team default channel (e.g. town-square).
|
|
func (a *App) ValidateChannelEligibilityForAccessControl(rctx request.CTX, channel *model.Channel) *model.AppError {
|
|
if channel.Type != model.ChannelTypePrivate && channel.Type != model.ChannelTypeOpen {
|
|
return model.NewAppError("ValidateChannelEligibilityForAccessControl",
|
|
"app.pap.access_control.channel_type_not_supported",
|
|
nil, "Policies can only be applied to public or private channels", http.StatusBadRequest)
|
|
}
|
|
|
|
if channel.IsGroupConstrained() {
|
|
return model.NewAppError("ValidateChannelEligibilityForAccessControl",
|
|
"app.pap.access_control.channel_group_constrained",
|
|
nil, "Channel is group constrained", http.StatusBadRequest)
|
|
}
|
|
|
|
if channel.IsShared() {
|
|
return model.NewAppError("ValidateChannelEligibilityForAccessControl",
|
|
"app.pap.access_control.channel_shared",
|
|
nil, "Channel is shared", http.StatusBadRequest)
|
|
}
|
|
|
|
if slices.Contains(a.DefaultChannelNames(rctx), channel.Name) {
|
|
return model.NewAppError("ValidateChannelEligibilityForAccessControl",
|
|
"app.pap.access_control.channel_default",
|
|
nil, "Channel is a team default channel", http.StatusBadRequest)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ValidateChannelAccessControlPermission validates if a user has permission to manage access control for a specific channel
|
|
func (a *App) ValidateChannelAccessControlPermission(rctx request.CTX, userID, channelID string) *model.AppError {
|
|
// Verify the channel exists
|
|
channel, appErr := a.GetChannel(rctx, channelID)
|
|
if appErr != nil {
|
|
return appErr
|
|
}
|
|
|
|
// Check if user has channel admin permission for the specific channel
|
|
if ok, _ := a.HasPermissionToChannel(rctx, userID, channelID, model.PermissionManageChannelAccessRules); !ok {
|
|
return model.NewAppError("ValidateChannelAccessControlPermission", "app.pap.access_control.insufficient_channel_permissions", nil, "user_id="+userID+" channel_id="+channelID, http.StatusForbidden)
|
|
}
|
|
|
|
if appErr := a.ValidateChannelEligibilityForAccessControl(rctx, channel); appErr != nil {
|
|
return appErr
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ValidateAccessControlPolicyPermission validates if a user has permission to manage a specific existing access control policy
|
|
func (a *App) ValidateAccessControlPolicyPermission(rctx request.CTX, userID, policyID string) *model.AppError {
|
|
return a.ValidateAccessControlPolicyPermissionWithOptions(rctx, userID, policyID, ValidateAccessControlPolicyPermissionOptions{})
|
|
}
|
|
|
|
type ValidateAccessControlPolicyPermissionOptions struct {
|
|
isReadOnly bool
|
|
channelID string
|
|
}
|
|
|
|
func (a *App) ValidateAccessControlPolicyPermissionWithOptions(rctx request.CTX, userID, policyID string, opts ValidateAccessControlPolicyPermissionOptions) *model.AppError {
|
|
// System admins can manage any policy
|
|
if a.HasPermissionTo(userID, model.PermissionManageSystem) {
|
|
return nil
|
|
}
|
|
|
|
// Get the policy to determine its type
|
|
policy, appErr := a.GetAccessControlPolicy(rctx, policyID)
|
|
if appErr != nil {
|
|
return appErr
|
|
}
|
|
|
|
// For read-only operations, allow access to system policies if they're applied to the specific channel
|
|
if opts.isReadOnly && policy.Type != model.AccessControlPolicyTypeChannel && opts.channelID != "" {
|
|
// Check if user has access to the channel
|
|
if ok, _ := a.HasPermissionToChannel(rctx, userID, opts.channelID, model.PermissionReadChannel); !ok {
|
|
return model.NewAppError("ValidateAccessControlPolicyPermissionWithOptions", "app.pap.access_control.insufficient_permissions", nil, "user_id="+userID+" channel_id="+opts.channelID, http.StatusForbidden)
|
|
}
|
|
|
|
// Check if this system policy is applied to the specific channel
|
|
if a.isSystemPolicyAppliedToChannel(rctx, policyID, opts.channelID) {
|
|
return nil // Allow read-only access
|
|
}
|
|
return model.NewAppError("ValidateAccessControlPolicyPermissionWithOptions", "app.pap.access_control.insufficient_permissions", nil, "user_id="+userID+" policy_type="+policy.Type+" channel_id="+opts.channelID, http.StatusForbidden)
|
|
}
|
|
|
|
// Non-system admins can only manage channel-type policies (for non-read-only operations)
|
|
if policy.Type != model.AccessControlPolicyTypeChannel {
|
|
return model.NewAppError("ValidateAccessControlPolicyPermissionWithOptions", "app.pap.access_control.insufficient_permissions", nil, "user_id="+userID+" policy_type="+policy.Type, http.StatusForbidden)
|
|
}
|
|
|
|
// For channel-type policies, validate channel-specific permission (policy ID equals channel ID)
|
|
return a.ValidateChannelAccessControlPermission(rctx, userID, policyID)
|
|
}
|
|
|
|
// ValidateAccessControlPolicyPermissionWithMode validates access control policy permissions with read-only mode option
|
|
func (a *App) ValidateAccessControlPolicyPermissionWithMode(rctx request.CTX, userID, policyID string, isReadOnly bool) *model.AppError {
|
|
return a.ValidateAccessControlPolicyPermissionWithOptions(rctx, userID, policyID, ValidateAccessControlPolicyPermissionOptions{
|
|
isReadOnly: isReadOnly,
|
|
})
|
|
}
|
|
|
|
// ValidateAccessControlPolicyPermissionWithChannelContext validates access control policy permissions with channel context
|
|
func (a *App) ValidateAccessControlPolicyPermissionWithChannelContext(rctx request.CTX, userID, policyID string, isReadOnly bool, channelID string) *model.AppError {
|
|
return a.ValidateAccessControlPolicyPermissionWithOptions(rctx, userID, policyID, ValidateAccessControlPolicyPermissionOptions{
|
|
isReadOnly: isReadOnly,
|
|
channelID: channelID,
|
|
})
|
|
}
|
|
|
|
// isSystemPolicyAppliedToChannel checks if a system policy is applied to a specific channel
|
|
func (a *App) isSystemPolicyAppliedToChannel(rctx request.CTX, policyID, channelID string) bool {
|
|
// Get the channel's policy (channel ID = policy ID for channel policies)
|
|
channelPolicy, err := a.GetAccessControlPolicy(rctx, channelID)
|
|
if err != nil {
|
|
return false // Channel doesn't have a policy
|
|
}
|
|
|
|
// Check if the channel policy imports this system policy
|
|
if channelPolicy.Imports != nil {
|
|
return slices.Contains(channelPolicy.Imports, policyID)
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// ValidateChannelAccessControlPolicyCreation validates if a user can create a channel-specific access control policy
|
|
func (a *App) ValidateChannelAccessControlPolicyCreation(rctx request.CTX, userID string, policy *model.AccessControlPolicy) *model.AppError {
|
|
// System admins can create any type of policy
|
|
if a.HasPermissionTo(userID, model.PermissionManageSystem) {
|
|
return nil
|
|
}
|
|
|
|
// Non-system admins can only create channel-type policies
|
|
if policy.Type != model.AccessControlPolicyTypeChannel {
|
|
return model.NewAppError("ValidateChannelAccessControlPolicyCreation", "app.access_control.insufficient_permissions", nil, "user_id="+userID+" policy_type="+policy.Type, http.StatusForbidden)
|
|
}
|
|
|
|
// For channel-type policies, validate channel-specific permission (policy ID equals channel ID)
|
|
return a.ValidateChannelAccessControlPermission(rctx, userID, policy.ID)
|
|
}
|
|
|
|
// TestExpressionWithChannelContext tests expressions for channel admins with attribute validation
|
|
// Channel admins can only see users that match expressions they themselves would match
|
|
func (a *App) TestExpressionWithChannelContext(rctx request.CTX, expression string, opts model.SubjectSearchOptions) ([]*model.User, int64, *model.AppError) {
|
|
// Get the current user (channel admin)
|
|
session := rctx.Session()
|
|
if session == nil {
|
|
return nil, 0, model.NewAppError("TestExpressionWithChannelContext", "api.context.session_expired.app_error", nil, "", http.StatusUnauthorized)
|
|
}
|
|
|
|
currentUserID := session.UserId
|
|
|
|
// SECURITY: First check if the channel admin themselves matches this expression
|
|
// If they don't match, they shouldn't be able to see users who do
|
|
adminMatches, appErr := a.ValidateExpressionAgainstRequester(rctx, expression, currentUserID)
|
|
if appErr != nil {
|
|
return nil, 0, appErr
|
|
}
|
|
|
|
if !adminMatches {
|
|
// Channel admin doesn't match the expression, so return empty results
|
|
return []*model.User{}, 0, nil
|
|
}
|
|
|
|
// If the channel admin matches the expression, run it against all users
|
|
acs := a.Srv().ch.AccessControl
|
|
if acs == nil {
|
|
return nil, 0, model.NewAppError("TestExpressionWithChannelContext", "app.pap.check_expression.app_error", nil, "Policy Administration Point is not initialized", http.StatusNotImplemented)
|
|
}
|
|
|
|
return a.TestExpression(rctx, expression, opts)
|
|
}
|
|
|
|
// ValidateExpressionAgainstRequester validates an expression directly against a specific user
|
|
func (a *App) ValidateExpressionAgainstRequester(rctx request.CTX, expression string, requesterID string) (bool, *model.AppError) {
|
|
// Self-exclusion validation should work with any attribute
|
|
// Channel admins should be able to validate any expression they're testing
|
|
|
|
// Use access control service to evaluate expression
|
|
acs := a.Srv().ch.AccessControl
|
|
if acs == nil {
|
|
return false, model.NewAppError("ValidateExpressionAgainstRequester", "app.pap.check_expression.app_error", nil, "Policy Administration Point is not initialized", http.StatusNotImplemented)
|
|
}
|
|
|
|
// Search only for the specific requester user ID
|
|
users, _, appErr := acs.QueryUsersForExpression(rctx, expression, model.SubjectSearchOptions{
|
|
SubjectID: requesterID, // Only check this specific user
|
|
Limit: 1, // Maximum 1 result expected
|
|
})
|
|
if appErr != nil {
|
|
return false, appErr
|
|
}
|
|
if len(users) == 1 && users[0].Id == requesterID {
|
|
return true, nil
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// BuildAccessControlSubject creates a fully populated Subject with user attributes and
|
|
// scoped roles for use in AccessEvaluation calls. It also ensures the materialized
|
|
// attribute view is refreshed periodically (at most once per attributeViewRefreshInterval).
|
|
//
|
|
// channelID is optional: when non-empty, the channel-scoped role for the user is resolved
|
|
// from ChannelMember and appended to Subject.ScopedRoles so v0.4 channel resource policy
|
|
// permission rules can match (channel_guest / channel_user / channel_admin). When empty,
|
|
// only the system-scoped role is populated.
|
|
func (a *App) BuildAccessControlSubject(rctx request.CTX, userID string, roles string, channelID string) (*model.Subject, *model.AppError) {
|
|
a.refreshAttributeViewIfStale(rctx)
|
|
|
|
group, err := a.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
|
|
if err != nil {
|
|
return nil, model.NewAppError("BuildAccessControlSubject", "app.access_control.build_subject.group_id.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
subject, storeErr := a.Srv().Store().Attributes().GetSubject(rctx, userID, group.ID)
|
|
if storeErr != nil {
|
|
var nfErr *store.ErrNotFound
|
|
if errors.As(storeErr, &nfErr) {
|
|
subject = &model.Subject{
|
|
ID: userID,
|
|
Type: "user",
|
|
Attributes: map[string]any{},
|
|
}
|
|
} else {
|
|
rctx.Logger().Warn("Failed to get subject for access control subject",
|
|
mlog.String("user_id", userID),
|
|
mlog.String("roles", roles),
|
|
mlog.Err(storeErr),
|
|
)
|
|
return nil, model.NewAppError("BuildAccessControlSubject", "app.access_control.build_subject.get_subject.app_error", nil, "", http.StatusInternalServerError).Wrap(storeErr)
|
|
}
|
|
}
|
|
|
|
subject.Role = roles
|
|
subject.SetScopedRole(model.AccessControlSubjectScopeSystem, ResolveSystemRole(roles))
|
|
if channelID != "" {
|
|
channelRole, appErr := a.GetSubjectChannelRole(rctx, userID, channelID)
|
|
if appErr != nil {
|
|
// Fail closed: a transient channel-member lookup failure must
|
|
// not silently produce a subject without a channel-scoped
|
|
// role — the resource lane evaluator would then evaluate
|
|
// against an empty role and let the user through any
|
|
// channel-role-targeted rules. Propagate the error so the
|
|
// caller treats the build as a denial.
|
|
rctx.Logger().Warn("Failed to resolve channel-scoped role for ABAC subject; aborting subject build",
|
|
mlog.String("user_id", userID),
|
|
mlog.String("channel_id", channelID),
|
|
mlog.Err(appErr),
|
|
)
|
|
return nil, appErr
|
|
}
|
|
if channelRole != "" {
|
|
subject.SetScopedRole(model.AccessControlSubjectScopeChannel, channelRole)
|
|
}
|
|
}
|
|
|
|
return subject, nil
|
|
}
|
|
|
|
// GetSubjectChannelRole returns the channel-scoped role identifier
|
|
// (channel_admin / channel_user / channel_guest) for the given user in
|
|
// the given channel.
|
|
//
|
|
// Resolution order:
|
|
// 1. Look up ChannelMember; map SchemeAdmin → channel_admin, SchemeUser → channel_user,
|
|
// SchemeGuest → channel_guest.
|
|
// 2. Inspect the Roles tokens on the channel member for the channel role names.
|
|
//
|
|
// Returns ("", nil) when no channel role can be determined — either
|
|
// because the user is not a member of the channel, or because the
|
|
// ChannelMember row exists but is in an inconsistent shape (no scheme
|
|
// flag set and no recognised channel-role token in Roles). Callers
|
|
// (e.g. attachChannelScopedRole, BuildAccessControlSubject) gate on the
|
|
// empty string and skip the channel scope rather than evaluating against
|
|
// a fabricated role. Inconsistent-row cases are logged at WARN with the
|
|
// row's flags and Roles for operator triage.
|
|
func (a *App) GetSubjectChannelRole(rctx request.CTX, userID, channelID string) (string, *model.AppError) {
|
|
cm, err := a.Srv().Store().Channel().GetMember(rctx, channelID, userID)
|
|
if err != nil {
|
|
var nfErr *store.ErrNotFound
|
|
if errors.As(err, &nfErr) {
|
|
// Not a member: return an empty role and let the caller
|
|
// decide what "no resource role" means for them. We used
|
|
// to fabricate a role from the user's system roles here,
|
|
// but that synthesised channel-scope information from
|
|
// data the user has no actual channel membership behind —
|
|
// callers (e.g. attachChannelScopedRole in file.go) now
|
|
// gate on the empty string and skip the channel scope
|
|
// rather than evaluating against a guess.
|
|
return "", nil
|
|
}
|
|
return "", model.NewAppError("GetSubjectChannelRole", "app.access_control.get_channel_role.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
switch {
|
|
case cm.SchemeAdmin:
|
|
return model.ChannelAdminRoleId, nil
|
|
case cm.SchemeGuest:
|
|
return model.ChannelGuestRoleId, nil
|
|
case cm.SchemeUser:
|
|
return model.ChannelUserRoleId, nil
|
|
}
|
|
|
|
// Inspect the Roles tokens deterministically rather than returning
|
|
// whichever recognised token appears first in the space-separated
|
|
// string. The legacy first-match-wins behaviour silently downgraded
|
|
// a channel admin whose Roles happened to list
|
|
// `channel_user channel_admin` (in either order, depending on how
|
|
// the row was migrated).
|
|
//
|
|
// channel_admin is checked first because admin and user tokens
|
|
// STACK on legacy rows — a promoted member carries both. Picking
|
|
// admin when present matches the stacked-token reality.
|
|
//
|
|
// channel_guest is a separate lane: it represents an external
|
|
// guest account, NOT a lower rung of the admin/user hierarchy.
|
|
// In healthy data it never co-occurs with channel_user /
|
|
// channel_admin (the SchemeGuest switch case above handles the
|
|
// modern path), so checking it after the stacked-pair tokens is
|
|
// purely defensive — only reached when SchemeGuest wasn't set
|
|
// and `channel_guest` is the sole recognised token in the row.
|
|
tokens := strings.Fields(cm.Roles)
|
|
if slices.Contains(tokens, model.ChannelAdminRoleId) {
|
|
return model.ChannelAdminRoleId, nil
|
|
}
|
|
if slices.Contains(tokens, model.ChannelUserRoleId) {
|
|
return model.ChannelUserRoleId, nil
|
|
}
|
|
if slices.Contains(tokens, model.ChannelGuestRoleId) {
|
|
return model.ChannelGuestRoleId, nil
|
|
}
|
|
|
|
// ChannelMember row exists but neither the scheme flags nor the
|
|
// Roles tokens identify a recognised channel role. This shouldn't
|
|
// happen on healthy data — schemes set SchemeUser by default, and
|
|
// pre-scheme rows still carry channel_user / channel_admin /
|
|
// channel_guest tokens. We used to fall back to guessing from the
|
|
// user's system roles here, but that fabricated channel-scope
|
|
// information from system-scope data and silently masked the
|
|
// underlying inconsistency. Returning "" makes the caller skip the
|
|
// channel scope (same as the not-a-member path) and the WARN log
|
|
// surfaces the row state so operators can investigate.
|
|
rctx.Logger().Warn(
|
|
"Channel member exists but channel role could not be resolved; treating as no channel scope",
|
|
mlog.String("user_id", userID),
|
|
mlog.String("channel_id", channelID),
|
|
mlog.String("roles", cm.Roles),
|
|
mlog.Bool("scheme_admin", cm.SchemeAdmin),
|
|
mlog.Bool("scheme_user", cm.SchemeUser),
|
|
mlog.Bool("scheme_guest", cm.SchemeGuest),
|
|
)
|
|
return "", nil
|
|
}
|
|
|
|
// ResolveSystemRole returns the highest-precedence base system role token
|
|
// from a space-separated roles string. The check order is deterministic:
|
|
// system_admin > system_guest > system_user. Custom/admin-managed roles
|
|
// without a recognised base default to system_user so the permission-policy
|
|
// lane is never silently skipped.
|
|
func ResolveSystemRole(roles string) string {
|
|
tokens := strings.Fields(roles)
|
|
if slices.Contains(tokens, model.SystemAdminRoleId) {
|
|
return model.SystemAdminRoleId
|
|
}
|
|
if slices.Contains(tokens, model.SystemGuestRoleId) {
|
|
return model.SystemGuestRoleId
|
|
}
|
|
if slices.Contains(tokens, model.SystemUserRoleId) {
|
|
return model.SystemUserRoleId
|
|
}
|
|
return model.SystemUserRoleId
|
|
}
|
|
|
|
// refreshAttributeViewIfStale refreshes the materialized AttributeView if the last
|
|
// refresh was more than attributeViewRefreshInterval ago. The refresh is non-blocking:
|
|
// if another goroutine is already refreshing, this call returns immediately.
|
|
func (a *App) refreshAttributeViewIfStale(rctx request.CTX) {
|
|
ch := a.Srv().Channels()
|
|
|
|
if !ch.attributeViewRefreshMut.TryLock() {
|
|
return
|
|
}
|
|
defer ch.attributeViewRefreshMut.Unlock()
|
|
|
|
if time.Since(ch.attributeViewRefreshLast) < attributeViewRefreshInterval {
|
|
return
|
|
}
|
|
|
|
if err := a.Srv().Store().Attributes().RefreshAttributes(); err != nil {
|
|
rctx.Logger().Warn("Failed to refresh attribute materialized view", mlog.Err(err))
|
|
return
|
|
}
|
|
|
|
ch.attributeViewRefreshLast = time.Now()
|
|
}
|