Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 139 additions & 48 deletions server/channels/app/access_control.go

Large diffs are not rendered by default.

18 changes: 11 additions & 7 deletions server/channels/app/access_control_masking.go
Original file line number Diff line number Diff line change
Expand Up @@ -624,7 +624,6 @@ func celValueLiteral(value any) string {
const maskedTokenValue = "--------"

// validatePolicyExpressionValues checks that all submitted literal values are held by the caller.
// Returns the same generic error for every rejection to prevent value enumeration.
func (a *App) validatePolicyExpressionValues(rctx request.CTX, policy *model.AccessControlPolicy, callerID string) *model.AppError {
cpaGroup, appErr := a.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
if appErr != nil {
Expand Down Expand Up @@ -775,9 +774,7 @@ func (a *App) GetMaskedExpression(rctx request.CTX, expression string, callerID
// maskConditionValuesWithToken replaces non-held values with the masked token in place,
// preserving expression structure so the visual AST endpoint can still parse it.
// fieldsByName is pre-fetched by the caller to avoid N+1 lookups; a missing entry
// is treated as fail-closed (whole value masked).
// maskConditionValuesWithToken replaces non-held values with the masked token in place.
// Returns true if any value was masked.
// is treated as fail-closed (whole value masked). Returns true if any value was masked.
func (a *App) maskConditionValuesWithToken(rctx request.CTX, callerID string, condition *model.Condition, cpaGroupID string, fieldsByName map[string]*model.PropertyField) bool {
if condition.ValueType == model.AttrValue {
return false
Expand Down Expand Up @@ -1215,8 +1212,15 @@ func clearEvaluationTreeLiterals(node *model.PolicySimulationEvaluationNode) {
}
}

// maskFailClosedSentinel is the CEL expression written into a response rule when masking
// cannot safely produce a redacted version (parse failure or CPA group unavailable).
// "false" is used because it is deny-all if ever evaluated literally, matching the
// fail-closed intent. This value only ever appears in API responses — the stored DB
// expression is never overwritten by this path.
const maskFailClosedSentinel = "false"

// MaskPolicyExpressions masks non-held literal values in all policy rule expressions, in place.
// Fails closed (sets a rule to "true") if its expression cannot be parsed or masked.
// Fails closed (sets a rule to maskFailClosedSentinel) if its expression cannot be parsed or masked.
func (a *App) MaskPolicyExpressions(rctx request.CTX, policy *model.AccessControlPolicy, callerID string) {
cpaGroup, appErr := a.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
if appErr != nil {
Expand All @@ -1227,7 +1231,7 @@ func (a *App) MaskPolicyExpressions(rctx request.CTX, policy *model.AccessContro
if rule.Expression == "" || rule.Expression == "true" {
continue
}
policy.Rules[i].Expression = "true"
policy.Rules[i].Expression = maskFailClosedSentinel
}
return
}
Expand All @@ -1245,7 +1249,7 @@ func (a *App) MaskPolicyExpressions(rctx request.CTX, policy *model.AccessContro
}
ast, appErr := a.ExpressionToVisualAST(rctx, rule.Expression)
if appErr != nil {
policy.Rules[i].Expression = "true" // fail closed
policy.Rules[i].Expression = maskFailClosedSentinel // fail closed: deny-all sentinel, response-only
continue
}
asts[i] = ast
Expand Down
8 changes: 3 additions & 5 deletions server/channels/app/access_control_masking_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -833,11 +833,9 @@ func TestMaskSimulationPolicyLiteralsForCaller_GuardClauses(t *testing.T) {
})

t.Run("empty callerID is a no-op", func(t *testing.T) {
// A session-less caller reaching this code path would be a
// caller-context bug; the function must refuse rather than
// run with an empty caller (which the property service would
// resolve to "no holdings" and therefore mask everything,
// effectively a stealthy DoS).
// The API layer rejects empty callerID before reaching this function.
// Pinned to ensure MaskSimulationPolicyLiteralsForCaller is safe to call
// with an empty callerID (no panic, no masking applied).
resp := &model.PolicySimulationResponse{
Results: []model.PolicySimulationUserResult{{
Decisions: map[string]model.PolicySimulationActionDecision{
Expand Down
Loading
Loading