Skip to content

Decompose subject mapping operators into comparison, quantifier, and modifier axes #3335

@jrschumacher

Description

@jrschumacher

Problem

The current SubjectMappingOperatorEnum conflates three concerns into a single enum:

Current operator String comparison Quantifier Negation
IN exact match any-of no
NOT_IN exact match none-of yes
IN_CONTAINS substring any-of no

This creates several issues:

  1. Missing operators — there's no STARTS_WITH or ENDS_WITH, so there's no safe way to match email domains. IN_CONTAINS with @acme.com would also match user@acme.com.badactor.ru.
  2. Enum sprawl — adding STARTS_WITH, ENDS_WITH, and their NOT_ and CASE_INSENSITIVE_ variants would require 20+ enum values.
  3. No quantifier control — there's no way to express "the entity must have ALL of these values" without chaining multiple AND'd single-value conditions.

Proposal

Decompose the condition into three orthogonal axes:

1. Comparison operator (how to compare two strings)

enum ConditionComparisonOperator {
  CONDITION_COMPARISON_OPERATOR_EQUALS = 0;
  CONDITION_COMPARISON_OPERATOR_CONTAINS = 1;
  CONDITION_COMPARISON_OPERATOR_STARTS_WITH = 2;
  CONDITION_COMPARISON_OPERATOR_ENDS_WITH = 3;
}

2. Quantifier (how to aggregate across value lists)

enum ConditionQuantifier {
  CONDITION_QUANTIFIER_ANY = 0;   // at least one match (current IN behavior)
  CONDITION_QUANTIFIER_ALL = 1;   // every expected value is matched
  CONDITION_QUANTIFIER_NONE = 2;  // no matches (current NOT_IN behavior)
}

3. Case sensitivity modifier (bool flag)

bool case_insensitive = 7;

Updated Condition message

message Condition {
  string subject_external_selector_value = 1;
  repeated string subject_external_values = 2;

  // New decomposed fields
  ConditionComparisonOperator comparison = 5;
  ConditionQuantifier quantifier = 6;
  bool case_insensitive = 7;

  // Deprecated — old conflated operator, kept for backward compat
  SubjectMappingOperatorEnum operator = 3 [deprecated = true];
}

Combination examples

comparison quantifier case_insensitive Plain English Old equivalent
EQUALS ANY false value matches any in list IN
EQUALS NONE false value matches none in list NOT_IN
CONTAINS ANY false value contains any substring IN_CONTAINS
ENDS_WITH ANY true value ends with (case-insensitive) — new —
STARTS_WITH ALL false value starts with every prefix — new —
EQUALS ALL false entity has all of these values — new —

4 comparisons × 3 quantifiers × 2 case modes = 24 combinations from 3 enums + 1 bool, instead of 24 separate enum values.

Migration

Normalize the old operator field early in the Go evaluation path:

func normalizeCondition(c *Condition) {
    if c.Comparison != 0 || c.Quantifier != 0 {
        return // already using new fields
    }
    switch c.Operator {
    case IN:
        c.Comparison = EQUALS; c.Quantifier = ANY
    case NOT_IN:
        c.Comparison = EQUALS; c.Quantifier = NONE
    case IN_CONTAINS:
        c.Comparison = CONTAINS; c.Quantifier = ANY
    }
}

Old payloads continue to work. New payloads use the decomposed fields.

Scope

In scope

  • Proto changes to Condition message and new enums
  • Go evaluation logic in subject_mapping_builtin.go
  • Normalization/backward compatibility for old SubjectMappingOperatorEnum values
  • Tests for all new comparison × quantifier combinations
  • Documentation updates

Explicitly out of scope (for now)

  • REGEX comparison — performance risk in policy hot path, auditability concerns. Can be added as a comparison operator later without structural changes.
  • GLOB/LIKESTARTS_WITH + ENDS_WITH + CONTAINS cover the practical cases.
  • ALL_EXTRACTED quantifier ("every value the user has must be in this allowed list") — rare use case, can add as a fourth quantifier later if needed.

Security motivation

The immediate driver is that IN_CONTAINS is unsafe for domain matching — @acme.com matches user@acme.com.badactor.ru. ENDS_WITH is the correct operator for this use case, and the decomposed model makes it available without ad-hoc enum additions.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions