yuqi1129 commented on code in PR #13478:
URL: https://github.com/apache/gravitino/pull/13478#discussion_r4228177977


##########
core/src/main/java/org/apache/gravitino/meta/PolicyEntity.java:
##########
@@ -199,6 +202,13 @@ private void validatePolicy() {
         policyType.name(),
         content().getClass().getName());
     content().validate();
+    if (content() instanceof RowFilterContent) {
+      RestrictedRegoExpressionParserFacade.parseRowFilter(

Review Comment:
   `validatePolicy()` runs from `Builder.build()`, which `POConverters` (around 
line 1561) also uses when loading a policy from the store. So every read 
re-parses the expression, and if the grammar is ever tightened (a bug fix, or a 
new restriction), previously stored policies throw on load. That breaks 
get/list, and also delete, since delete loads the policy first. Could the Rego 
validation move to the write path (`PolicyManager` create/alter), so that 
`build()` only performs structural checks?



##########
core/src/main/java/org/apache/gravitino/policy/expression/RestrictedRegoProgram.java:
##########
@@ -0,0 +1,415 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.policy.expression;
+
+import com.google.common.base.Preconditions;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import org.apache.gravitino.policy.expression.CanonicalExpression.Operation;
+import org.apache.gravitino.policy.expression.CanonicalExpression.Operator;
+
+/** A parsed, unresolved program in the {@code restricted-rego-v1} source 
dialect. */
+public interface RestrictedRegoProgram {
+
+  /**
+   * Returns the rule type declared by this program.
+   *
+   * @return rule type
+   */
+  RuleType ruleType();
+
+  /**
+   * Validates the rule and every expression it contains.
+   *
+   * @throws IllegalArgumentException if the rule is malformed
+   */
+  void validate() throws IllegalArgumentException;
+
+  /** Rule heads supported by {@code restricted-rego-v1}. */
+  enum RuleType {
+    /** A row-filter rule. */
+    FILTER,
+    /** A column-mask rule. */
+    MASK
+  }
+
+  /** Logical column-mask actions supported by the first restricted profile. */
+  enum MaskAction {
+    /** Mask alphanumeric characters. */
+    MASK_ALPHANUM("mask-alphanum"),
+    /** Replace the value with the action's fixed value. */
+    MASK_TO_FIXED_VALUE("mask-to-fixed-value"),
+    /** Replace the value with null. */
+    REPLACE_WITH_NULL("replace-with-null"),
+    /** Preserve only the first four characters. */
+    SHOW_FIRST_4("show-first-4"),
+    /** Preserve only the last four characters. */
+    SHOW_LAST_4("show-last-4"),
+    /** Truncate a temporal value to its year. */
+    TRUNCATE_TO_YEAR("truncate-to-year"),
+    /** Truncate a temporal value to its month. */
+    TRUNCATE_TO_MONTH("truncate-to-month"),
+    /** Apply a global SHA-256 mask. */
+    SHA_256_GLOBAL("sha-256-global"),
+    /** Apply a query-local SHA-256 mask. */
+    SHA_256_QUERY_LOCAL("sha-256-query-local");
+
+    private final String value;
+
+    MaskAction(String value) {
+      this.value = value;
+    }
+
+    /**
+     * Parses a mask-action value.
+     *
+     * @param value source action value
+     * @return parsed action
+     * @throws IllegalArgumentException if the action is not allowlisted
+     */
+    public static MaskAction fromValue(String value) {
+      Preconditions.checkArgument(value != null && !value.isEmpty(), "mask 
action cannot be empty");
+      for (MaskAction action : values()) {
+        if (action.value.equals(value)) {
+          return action;
+        }
+      }
+      throw new IllegalArgumentException("Unsupported restricted-rego-v1 mask 
action: " + value);
+    }
+
+    /**
+     * Returns the source action value.
+     *
+     * @return source action value
+     */
+    public String value() {
+      return value;
+    }
+  }
+
+  /** One guarded result in a conditional row-filter rule. */
+  final class FilterBranch {
+    private final CanonicalExpression result;
+    private final CanonicalExpression condition;
+
+    FilterBranch(CanonicalExpression result, CanonicalExpression condition) {
+      this.result = result;
+      this.condition = condition;
+    }
+
+    /**
+     * Returns the Boolean result selected by this branch.
+     *
+     * @return unresolved Boolean result
+     */
+    public CanonicalExpression result() {
+      return result;
+    }
+
+    /**
+     * Returns this branch's Boolean condition.
+     *
+     * @return unresolved Boolean condition
+     */
+    public CanonicalExpression condition() {
+      return condition;
+    }
+
+    /**
+     * Validates the branch.
+     *
+     * @throws IllegalArgumentException if its result or condition is not 
Boolean
+     */
+    public void validate() throws IllegalArgumentException {
+      Preconditions.checkArgument(result != null, "filter branch result cannot 
be null");
+      Preconditions.checkArgument(condition != null, "filter branch condition 
cannot be null");
+      result.validate();
+      condition.validate();
+      Preconditions.checkArgument(
+          ExpressionValidation.isPredicate(result), "filter branch result must 
be Boolean");
+      Preconditions.checkArgument(
+          ExpressionValidation.isPredicate(condition), "filter branch 
condition must be Boolean");
+    }
+
+    @Override
+    public boolean equals(Object other) {
+      if (!(other instanceof FilterBranch)) {
+        return false;
+      }
+      FilterBranch that = (FilterBranch) other;
+      return Objects.equals(result, that.result) && Objects.equals(condition, 
that.condition);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(result, condition);
+    }
+
+    @Override
+    public String toString() {
+      return "FilterBranch{" + "result=" + result + ", condition=" + condition 
+ '}';
+    }
+  }
+
+  /** A complete {@code filter := ...} row-filter rule. */
+  final class RowFilter implements RestrictedRegoProgram {
+    private final List<FilterBranch> branches;
+    private final CanonicalExpression fallback;
+
+    RowFilter(List<FilterBranch> branches, CanonicalExpression fallback) {
+      this.branches =
+          branches == null ? null : Collections.unmodifiableList(new 
ArrayList<>(branches));
+      this.fallback = fallback;
+    }
+
+    @Override
+    public RuleType ruleType() {
+      return RuleType.FILTER;
+    }
+
+    /**
+     * Returns the guarded results in source order.
+     *
+     * @return immutable branches, or an empty list for an unconditional rule
+     */
+    public List<FilterBranch> branches() {
+      return branches;
+    }
+
+    /**
+     * Returns the unconditional result or final {@code else} result.
+     *
+     * @return unresolved Boolean fallback
+     */
+    public CanonicalExpression fallback() {
+      return fallback;
+    }
+
+    /**
+     * Returns whether this rule contains at least one conditional branch.
+     *
+     * @return {@code true} for a conditional rule
+     */
+    public boolean isConditional() {
+      return branches != null && !branches.isEmpty();
+    }
+
+    /**
+     * Lowers the ordered branches to one Boolean predicate with first-match 
semantics.
+     *
+     * <p>The returned predicate remains unresolved. Context and column 
binding can be applied to it
+     * by a later resolver.
+     *
+     * @return lowered unresolved predicate
+     */
+    public CanonicalExpression lower() {

Review Comment:
   Each branch adds `OR -> AND -> NOT -> condition` around the remaining 
branches, so the lowered predicate is about 3 levels deeper per branch. The 
design caps the resolved predicate at depth 8. For example, a filter with 3 
branches whose conditions use `col(...)` (which context binding does not fold 
away) passes save-time validation, but the lowered result would have depth 9 
and fail closed at enforcement time. Should save-time validation also check the 
depth of `lower()`?



##########
core/src/main/java/org/apache/gravitino/policy/expression/RestrictedRegoExpressionParserFacade.java:
##########
@@ -0,0 +1,749 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.policy.expression;
+
+import com.google.common.base.Preconditions;
+import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import org.apache.commons.lang3.StringUtils;
+import 
org.apache.gravitino.policy.expression.CanonicalExpression.GroupMembership;
+import org.apache.gravitino.policy.expression.CanonicalExpression.Literal;
+import org.apache.gravitino.policy.expression.CanonicalExpression.LiteralArray;
+import org.apache.gravitino.policy.expression.CanonicalExpression.LiteralType;
+import org.apache.gravitino.policy.expression.CanonicalExpression.Operation;
+import org.apache.gravitino.policy.expression.CanonicalExpression.Operator;
+import org.apache.gravitino.policy.expression.CanonicalExpression.SessionUser;
+import org.apache.gravitino.policy.expression.RestrictedRegoProgram.ColumnMask;
+import 
org.apache.gravitino.policy.expression.RestrictedRegoProgram.FilterBranch;
+import org.apache.gravitino.policy.expression.RestrictedRegoProgram.MaskAction;
+import org.apache.gravitino.policy.expression.RestrictedRegoProgram.MaskBranch;
+import org.apache.gravitino.policy.expression.RestrictedRegoProgram.RowFilter;
+
+/** Parses and validates complete programs in the {@code restricted-rego-v1} 
source dialect. */
+public final class RestrictedRegoExpressionParserFacade {
+
+  private static final int MAX_SOURCE_BYTES = 16 * 1024;
+  private static final int MAX_SOURCE_DEPTH = 8;
+  private static final int MAX_AST_NODES = 256;
+  private static final int MAX_STRING_BYTES = 4 * 1024;
+  private static final int MAX_ARRAY_ELEMENTS = 256;
+
+  private RestrictedRegoExpressionParserFacade() {}
+
+  /**
+   * Parses and validates one complete row-filter or column-mask program.
+   *
+   * @param source program in {@code restricted-rego-v1}
+   * @return validated unresolved program
+   * @throws IllegalArgumentException if syntax or semantics are invalid
+   */
+  public static RestrictedRegoProgram parse(String source) {
+    Preconditions.checkArgument(
+        StringUtils.isNotBlank(source), "restricted-rego-v1 program cannot be 
blank");
+    Preconditions.checkArgument(
+        source.getBytes(StandardCharsets.UTF_8).length <= MAX_SOURCE_BYTES,
+        "restricted-rego-v1 source must not exceed %s UTF-8 bytes",
+        MAX_SOURCE_BYTES);
+
+    RestrictedRegoProgram program = new SourceParser(source).parseProgram();
+    validateProgram(program);
+    return program;
+  }
+
+  /**
+   * Parses and validates one complete {@code filter := ...} program.
+   *
+   * @param source row-filter program in {@code restricted-rego-v1}
+   * @return validated unresolved row-filter rule
+   * @throws IllegalArgumentException if syntax or semantics are invalid or 
the rule head is not
+   *     {@code filter}
+   */
+  public static RowFilter parseRowFilter(String source) {
+    RestrictedRegoProgram program = parse(source);
+    Preconditions.checkArgument(
+        program instanceof RowFilter, "row-filter program must declare a 
filter rule");
+    return (RowFilter) program;
+  }
+
+  /**
+   * Parses and validates one complete {@code mask := action(...)} program.
+   *
+   * @param source column-mask program in {@code restricted-rego-v1}
+   * @return validated unresolved column-mask rule
+   * @throws IllegalArgumentException if syntax or semantics are invalid or 
the rule head is not
+   *     {@code mask}
+   */
+  public static ColumnMask parseColumnMask(String source) {
+    RestrictedRegoProgram program = parse(source);
+    Preconditions.checkArgument(
+        program instanceof ColumnMask, "column-mask program must declare a 
mask rule");
+    return (ColumnMask) program;
+  }
+
+  private static void validateProgram(RestrictedRegoProgram program) {
+    program.validate();
+    List<CanonicalExpression> expressions = expressions(program);
+    int nodeCount = 0;
+    for (CanonicalExpression expression : expressions) {
+      Preconditions.checkArgument(
+          expression.depth() <= MAX_SOURCE_DEPTH,
+          "restricted-rego-v1 source depth must not exceed %s",
+          MAX_SOURCE_DEPTH);
+      nodeCount += countNodes(expression);
+    }
+    Preconditions.checkArgument(
+        nodeCount <= MAX_AST_NODES,
+        "restricted-rego-v1 AST must not exceed %s nodes",
+        MAX_AST_NODES);
+  }
+
+  private static List<CanonicalExpression> expressions(RestrictedRegoProgram 
program) {
+    List<CanonicalExpression> expressions = new ArrayList<>();
+    if (program instanceof RowFilter) {
+      RowFilter filter = (RowFilter) program;
+      for (FilterBranch branch : filter.branches()) {
+        expressions.add(branch.result());
+        expressions.add(branch.condition());
+      }
+      expressions.add(filter.fallback());
+    } else {
+      ColumnMask mask = (ColumnMask) program;
+      for (MaskBranch branch : mask.branches()) {
+        expressions.add(branch.condition());
+      }
+    }
+    return expressions;
+  }
+
+  private static int countNodes(CanonicalExpression expression) {
+    if (!(expression instanceof Operation)) {
+      return 1;
+    }
+
+    Operation operation = (Operation) expression;
+    int count = 1;
+    if (operation.left() != null) {
+      count += countNodes(operation.left());
+    }
+    if (operation.right() != null) {
+      count += countNodes(operation.right());
+    }
+    if (operation.operand() != null) {
+      count += countNodes(operation.operand());
+    }
+    if (operation.operands() != null) {
+      for (CanonicalExpression child : operation.operands()) {
+        count += countNodes(child);
+      }
+    }
+    return count;
+  }
+
+  private static final class SourceParser {
+    private static final int MAX_PARENTHESIS_NESTING = 256;
+
+    private final Lexer lexer;
+    private Token current;
+    private int parenthesisNesting;
+
+    private SourceParser(String source) {
+      this.lexer = new Lexer(source);
+      this.current = lexer.nextToken();
+    }
+
+    private RestrictedRegoProgram parseProgram() {
+      RestrictedRegoProgram program;
+      if (check(TokenType.FILTER)) {
+        program = parseRowFilter();
+      } else if (check(TokenType.MASK)) {
+        program = parseColumnMask();
+      } else {
+        throw error("expected filter or mask rule");
+      }
+      expect(TokenType.EOF, "expected end of program");
+      return program;
+    }
+
+    private RowFilter parseRowFilter() {
+      expect(TokenType.FILTER, "expected filter");
+      expect(TokenType.ASSIGN, "expected := after filter");
+      CanonicalExpression result = parseExpression();
+      if (!match(TokenType.IF)) {
+        return new RowFilter(new ArrayList<>(), result);
+      }
+
+      List<FilterBranch> branches = new ArrayList<>();
+      branches.add(new FilterBranch(result, parseExpression()));
+      while (true) {
+        expect(TokenType.ELSE, "expected final else branch");
+        expect(TokenType.ASSIGN, "expected := after else");
+        CanonicalExpression nextResult = parseExpression();
+        if (!match(TokenType.IF)) {
+          return new RowFilter(branches, nextResult);
+        }
+        branches.add(new FilterBranch(nextResult, parseExpression()));
+      }
+    }
+
+    private ColumnMask parseColumnMask() {
+      expect(TokenType.MASK, "expected mask");
+      expect(TokenType.ASSIGN, "expected := after mask");
+      MaskAction action = parseMaskAction();
+      if (!match(TokenType.IF)) {
+        return new ColumnMask(new ArrayList<>(), action);
+      }
+
+      List<MaskBranch> branches = new ArrayList<>();
+      branches.add(new MaskBranch(action, parseExpression()));
+      while (true) {
+        expect(TokenType.ELSE, "expected final else branch");
+        expect(TokenType.ASSIGN, "expected := after else");
+        MaskAction nextAction = parseMaskAction();
+        if (!match(TokenType.IF)) {
+          return new ColumnMask(branches, nextAction);
+        }
+        branches.add(new MaskBranch(nextAction, parseExpression()));
+      }
+    }
+
+    private MaskAction parseMaskAction() {
+      expect(TokenType.ACTION, "expected action");
+      expect(TokenType.LPAREN, "expected ( after action");
+      String action = decodeString(expect(TokenType.STRING, "expected mask 
action string").text);
+      expect(TokenType.RPAREN, "expected ) after mask action");
+      return MaskAction.fromValue(action);
+    }
+
+    private CanonicalExpression parseExpression() {
+      return parseOrExpression();
+    }
+
+    private CanonicalExpression parseOrExpression() {

Review Comment:
   Each `or` (and `and`) wraps the previous result in a new binary `Operation`, 
so `a or b or ... or i` (9 comparisons) is rejected with `source depth must not 
exceed 8`, while 8 comparisons are accepted. Since `operands` is already n-ary, 
could we flatten operators of the same kind here (and in `lower()`)? It would 
also help to clarify what "operation depth" means in the design doc.



##########
core/src/main/java/org/apache/gravitino/policy/expression/RestrictedRegoExpressionParserFacade.java:
##########
@@ -0,0 +1,749 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.policy.expression;
+
+import com.google.common.base.Preconditions;
+import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import org.apache.commons.lang3.StringUtils;
+import 
org.apache.gravitino.policy.expression.CanonicalExpression.GroupMembership;
+import org.apache.gravitino.policy.expression.CanonicalExpression.Literal;
+import org.apache.gravitino.policy.expression.CanonicalExpression.LiteralArray;
+import org.apache.gravitino.policy.expression.CanonicalExpression.LiteralType;
+import org.apache.gravitino.policy.expression.CanonicalExpression.Operation;
+import org.apache.gravitino.policy.expression.CanonicalExpression.Operator;
+import org.apache.gravitino.policy.expression.CanonicalExpression.SessionUser;
+import org.apache.gravitino.policy.expression.RestrictedRegoProgram.ColumnMask;
+import 
org.apache.gravitino.policy.expression.RestrictedRegoProgram.FilterBranch;
+import org.apache.gravitino.policy.expression.RestrictedRegoProgram.MaskAction;
+import org.apache.gravitino.policy.expression.RestrictedRegoProgram.MaskBranch;
+import org.apache.gravitino.policy.expression.RestrictedRegoProgram.RowFilter;
+
+/** Parses and validates complete programs in the {@code restricted-rego-v1} 
source dialect. */
+public final class RestrictedRegoExpressionParserFacade {
+
+  private static final int MAX_SOURCE_BYTES = 16 * 1024;
+  private static final int MAX_SOURCE_DEPTH = 8;
+  private static final int MAX_AST_NODES = 256;
+  private static final int MAX_STRING_BYTES = 4 * 1024;
+  private static final int MAX_ARRAY_ELEMENTS = 256;
+
+  private RestrictedRegoExpressionParserFacade() {}
+
+  /**
+   * Parses and validates one complete row-filter or column-mask program.
+   *
+   * @param source program in {@code restricted-rego-v1}
+   * @return validated unresolved program
+   * @throws IllegalArgumentException if syntax or semantics are invalid
+   */
+  public static RestrictedRegoProgram parse(String source) {
+    Preconditions.checkArgument(
+        StringUtils.isNotBlank(source), "restricted-rego-v1 program cannot be 
blank");
+    Preconditions.checkArgument(
+        source.getBytes(StandardCharsets.UTF_8).length <= MAX_SOURCE_BYTES,
+        "restricted-rego-v1 source must not exceed %s UTF-8 bytes",
+        MAX_SOURCE_BYTES);
+
+    RestrictedRegoProgram program = new SourceParser(source).parseProgram();
+    validateProgram(program);
+    return program;
+  }
+
+  /**
+   * Parses and validates one complete {@code filter := ...} program.
+   *
+   * @param source row-filter program in {@code restricted-rego-v1}
+   * @return validated unresolved row-filter rule
+   * @throws IllegalArgumentException if syntax or semantics are invalid or 
the rule head is not
+   *     {@code filter}
+   */
+  public static RowFilter parseRowFilter(String source) {
+    RestrictedRegoProgram program = parse(source);
+    Preconditions.checkArgument(
+        program instanceof RowFilter, "row-filter program must declare a 
filter rule");
+    return (RowFilter) program;
+  }
+
+  /**
+   * Parses and validates one complete {@code mask := action(...)} program.
+   *
+   * @param source column-mask program in {@code restricted-rego-v1}
+   * @return validated unresolved column-mask rule
+   * @throws IllegalArgumentException if syntax or semantics are invalid or 
the rule head is not
+   *     {@code mask}
+   */
+  public static ColumnMask parseColumnMask(String source) {
+    RestrictedRegoProgram program = parse(source);
+    Preconditions.checkArgument(
+        program instanceof ColumnMask, "column-mask program must declare a 
mask rule");
+    return (ColumnMask) program;
+  }
+
+  private static void validateProgram(RestrictedRegoProgram program) {
+    program.validate();
+    List<CanonicalExpression> expressions = expressions(program);
+    int nodeCount = 0;
+    for (CanonicalExpression expression : expressions) {
+      Preconditions.checkArgument(
+          expression.depth() <= MAX_SOURCE_DEPTH,
+          "restricted-rego-v1 source depth must not exceed %s",
+          MAX_SOURCE_DEPTH);
+      nodeCount += countNodes(expression);
+    }
+    Preconditions.checkArgument(
+        nodeCount <= MAX_AST_NODES,
+        "restricted-rego-v1 AST must not exceed %s nodes",
+        MAX_AST_NODES);
+  }
+
+  private static List<CanonicalExpression> expressions(RestrictedRegoProgram 
program) {
+    List<CanonicalExpression> expressions = new ArrayList<>();
+    if (program instanceof RowFilter) {
+      RowFilter filter = (RowFilter) program;
+      for (FilterBranch branch : filter.branches()) {
+        expressions.add(branch.result());
+        expressions.add(branch.condition());
+      }
+      expressions.add(filter.fallback());
+    } else {
+      ColumnMask mask = (ColumnMask) program;
+      for (MaskBranch branch : mask.branches()) {
+        expressions.add(branch.condition());
+      }
+    }
+    return expressions;
+  }
+
+  private static int countNodes(CanonicalExpression expression) {
+    if (!(expression instanceof Operation)) {
+      return 1;
+    }
+
+    Operation operation = (Operation) expression;
+    int count = 1;
+    if (operation.left() != null) {
+      count += countNodes(operation.left());
+    }
+    if (operation.right() != null) {
+      count += countNodes(operation.right());
+    }
+    if (operation.operand() != null) {
+      count += countNodes(operation.operand());
+    }
+    if (operation.operands() != null) {
+      for (CanonicalExpression child : operation.operands()) {
+        count += countNodes(child);
+      }
+    }
+    return count;
+  }
+
+  private static final class SourceParser {
+    private static final int MAX_PARENTHESIS_NESTING = 256;
+
+    private final Lexer lexer;
+    private Token current;
+    private int parenthesisNesting;
+
+    private SourceParser(String source) {
+      this.lexer = new Lexer(source);
+      this.current = lexer.nextToken();
+    }
+
+    private RestrictedRegoProgram parseProgram() {
+      RestrictedRegoProgram program;
+      if (check(TokenType.FILTER)) {
+        program = parseRowFilter();
+      } else if (check(TokenType.MASK)) {
+        program = parseColumnMask();
+      } else {
+        throw error("expected filter or mask rule");
+      }
+      expect(TokenType.EOF, "expected end of program");
+      return program;
+    }
+
+    private RowFilter parseRowFilter() {
+      expect(TokenType.FILTER, "expected filter");
+      expect(TokenType.ASSIGN, "expected := after filter");
+      CanonicalExpression result = parseExpression();
+      if (!match(TokenType.IF)) {
+        return new RowFilter(new ArrayList<>(), result);
+      }
+
+      List<FilterBranch> branches = new ArrayList<>();
+      branches.add(new FilterBranch(result, parseExpression()));
+      while (true) {
+        expect(TokenType.ELSE, "expected final else branch");
+        expect(TokenType.ASSIGN, "expected := after else");
+        CanonicalExpression nextResult = parseExpression();
+        if (!match(TokenType.IF)) {
+          return new RowFilter(branches, nextResult);
+        }
+        branches.add(new FilterBranch(nextResult, parseExpression()));
+      }
+    }
+
+    private ColumnMask parseColumnMask() {
+      expect(TokenType.MASK, "expected mask");
+      expect(TokenType.ASSIGN, "expected := after mask");
+      MaskAction action = parseMaskAction();
+      if (!match(TokenType.IF)) {
+        return new ColumnMask(new ArrayList<>(), action);
+      }
+
+      List<MaskBranch> branches = new ArrayList<>();
+      branches.add(new MaskBranch(action, parseExpression()));
+      while (true) {
+        expect(TokenType.ELSE, "expected final else branch");
+        expect(TokenType.ASSIGN, "expected := after else");
+        MaskAction nextAction = parseMaskAction();
+        if (!match(TokenType.IF)) {
+          return new ColumnMask(branches, nextAction);
+        }
+        branches.add(new MaskBranch(nextAction, parseExpression()));
+      }
+    }
+
+    private MaskAction parseMaskAction() {
+      expect(TokenType.ACTION, "expected action");
+      expect(TokenType.LPAREN, "expected ( after action");
+      String action = decodeString(expect(TokenType.STRING, "expected mask 
action string").text);
+      expect(TokenType.RPAREN, "expected ) after mask action");
+      return MaskAction.fromValue(action);
+    }
+
+    private CanonicalExpression parseExpression() {
+      return parseOrExpression();
+    }
+
+    private CanonicalExpression parseOrExpression() {
+      CanonicalExpression result = parseAndExpression();
+      while (match(TokenType.OR)) {
+        Preconditions.checkArgument(
+            ExpressionValidation.isPredicate(result), "or operands must be 
boolean predicates");
+        CanonicalExpression operand = parseAndExpression();
+        Preconditions.checkArgument(
+            ExpressionValidation.isPredicate(operand), "or operands must be 
boolean predicates");
+        result = new Operation(Operator.OR, null, null, null, 
Arrays.asList(result, operand));
+      }
+      return result;
+    }
+
+    private CanonicalExpression parseAndExpression() {
+      CanonicalExpression result = parseNotExpression();
+      while (match(TokenType.AND)) {
+        Preconditions.checkArgument(
+            ExpressionValidation.isPredicate(result), "and operands must be 
boolean predicates");
+        CanonicalExpression operand = parseNotExpression();
+        Preconditions.checkArgument(
+            ExpressionValidation.isPredicate(operand), "and operands must be 
boolean predicates");
+        result = new Operation(Operator.AND, null, null, null, 
Arrays.asList(result, operand));
+      }
+      return result;
+    }
+
+    private CanonicalExpression parseNotExpression() {
+      int notCount = 0;
+      while (match(TokenType.NOT)) {
+        notCount++;
+      }
+
+      CanonicalExpression result = parseComparisonExpression();
+      for (int index = 0; index < notCount; index++) {
+        Preconditions.checkArgument(
+            ExpressionValidation.isPredicate(result), "not operand must be a 
boolean predicate");
+        result = new Operation(Operator.NOT, null, null, result, null);
+      }
+      return result;
+    }
+
+    private CanonicalExpression parseComparisonExpression() {
+      CanonicalExpression left = parsePrimary();
+      if (!isComparisonOperator(current.type)) {
+        return left;
+      }
+
+      Operator operator = Operator.fromValue(advance().text);
+      return new Operation(operator, left, parsePrimary(), null, null);
+    }
+
+    private CanonicalExpression parsePrimary() {
+      if (match(TokenType.COL)) {
+        expect(TokenType.LPAREN, "expected ( after col");
+        String column = decodeString(expect(TokenType.STRING, "expected column 
name string").text);
+        expect(TokenType.RPAREN, "expected ) after column name");
+        return new CanonicalExpression.Column(column);
+      }
+      if (match(TokenType.SESSION_USER)) {
+        expect(TokenType.LPAREN, "expected ( after session_user");
+        expect(TokenType.RPAREN, "expected ) after session_user(");
+        return new SessionUser();
+      }
+      if (match(TokenType.IS_GROUP_MEMBER)) {
+        expect(TokenType.LPAREN, "expected ( after is_group_member");
+        String group = decodeString(expect(TokenType.STRING, "expected group 
name string").text);
+        expect(TokenType.RPAREN, "expected ) after group name");
+        return new GroupMembership(group);
+      }
+      if (isLiteral(current.type)) {
+        return parseLiteral();
+      }
+      if (match(TokenType.LBRACKET)) {
+        return parseLiteralArray();
+      }
+      if (match(TokenType.LPAREN)) {
+        parenthesisNesting++;
+        Preconditions.checkArgument(
+            parenthesisNesting <= MAX_PARENTHESIS_NESTING,
+            "restricted-rego-v1 parenthesis nesting must not exceed %s",
+            MAX_PARENTHESIS_NESTING);
+        CanonicalExpression expression = parseExpression();
+        expect(TokenType.RPAREN, "expected ) after expression");
+        parenthesisNesting--;
+        return expression;
+      }
+      throw error("expected expression");
+    }
+
+    private Literal parseLiteral() {
+      Token token = advance();
+      switch (token.type) {
+        case STRING:
+          return new Literal(LiteralType.STRING, decodeString(token.text));
+        case NUMBER:
+          BigDecimal value = new BigDecimal(token.text);
+          return new Literal(
+              LiteralType.NUMBER, value, token.text.startsWith("-") && 
value.signum() == 0);
+        case TRUE:
+          return new Literal(LiteralType.BOOLEAN, true);
+        case FALSE:
+          return new Literal(LiteralType.BOOLEAN, false);
+        case NULL:
+          return new Literal(LiteralType.NULL, null);
+        default:
+          throw error(token, "expected literal");
+      }
+    }
+
+    private LiteralArray parseLiteralArray() {
+      List<Literal> values = new ArrayList<>();
+      LiteralType elementType = null;
+      do {
+        Preconditions.checkArgument(
+            values.size() < MAX_ARRAY_ELEMENTS,
+            "array literal must not exceed %s elements",
+            MAX_ARRAY_ELEMENTS);
+        Preconditions.checkArgument(isLiteral(current.type), "array literal 
cannot be empty");
+        Literal literal = parseLiteral();
+        Preconditions.checkArgument(
+            literal.literalType() != LiteralType.NULL, "array literals cannot 
contain null");
+        Preconditions.checkArgument(
+            elementType == null || elementType == literal.literalType(),
+            "array literals must have one homogeneous source type");
+        elementType = literal.literalType();
+        values.add(literal);
+      } while (match(TokenType.COMMA));
+      expect(TokenType.RBRACKET, "expected ] after array literal");
+      return new LiteralArray(elementType, values);
+    }
+
+    private boolean match(TokenType type) {
+      if (!check(type)) {
+        return false;
+      }
+      advance();
+      return true;
+    }
+
+    private boolean check(TokenType type) {
+      return current.type == type;
+    }
+
+    private Token expect(TokenType type, String message) {
+      if (!check(type)) {
+        throw error(message);
+      }
+      return advance();
+    }
+
+    private Token advance() {
+      Token previous = current;
+      current = lexer.nextToken();
+      return previous;
+    }
+
+    private IllegalArgumentException error(String message) {
+      return error(current, message);
+    }
+
+    private static IllegalArgumentException error(Token token, String message) 
{
+      return new IllegalArgumentException(
+          String.format(
+              "Invalid restricted-rego-v1 program at line %s, column %s: %s",
+              token.line, token.column, message));
+    }
+
+    private static boolean isComparisonOperator(TokenType type) {
+      return type == TokenType.EQ
+          || type == TokenType.NEQ
+          || type == TokenType.LT
+          || type == TokenType.LTE
+          || type == TokenType.GT
+          || type == TokenType.GTE
+          || type == TokenType.IN;
+    }
+
+    private static boolean isLiteral(TokenType type) {
+      return type == TokenType.STRING
+          || type == TokenType.NUMBER
+          || type == TokenType.TRUE
+          || type == TokenType.FALSE
+          || type == TokenType.NULL;
+    }
+  }
+
+  private enum TokenType {
+    FILTER,
+    MASK,
+    IF,
+    ELSE,
+    OR,
+    AND,
+    NOT,
+    IN,
+    COL,
+    SESSION_USER,
+    IS_GROUP_MEMBER,
+    ACTION,
+    TRUE,
+    FALSE,
+    NULL,
+    ASSIGN,
+    EQ,
+    NEQ,
+    LTE,
+    LT,
+    GTE,
+    GT,
+    LPAREN,
+    RPAREN,
+    LBRACKET,
+    RBRACKET,
+    COMMA,
+    NUMBER,
+    STRING,
+    EOF
+  }
+
+  private static final class Token {
+    private final TokenType type;
+    private final String text;
+    private final int line;
+    private final int column;
+
+    private Token(TokenType type, String text, int line, int column) {
+      this.type = type;
+      this.text = text;
+      this.line = line;
+      this.column = column;
+    }
+  }
+
+  private static final class Lexer {
+    private final String source;
+    private int offset;
+    private int line = 1;
+    private int column;

Review Comment:
   Nit: `line` starts at 1 but `column` starts at 0, so `filter := @` reports 
column 10 for a character at the 11th position. Also, errors from `keywordType` 
(`Unsupported identifier ...`) and the inline `Preconditions` checks in the 
parser carry no position, and inputs such as `01` or `a == 1 == true` only 
report `expected end of program`. More specific messages would make these much 
easier for users to fix.



##########
core/src/main/java/org/apache/gravitino/policy/expression/CanonicalExpression.java:
##########
@@ -0,0 +1,888 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.policy.expression;
+
+import com.google.common.base.Preconditions;
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+
+/**
+ * A node in an unresolved {@code restricted-rego-v1} expression tree.
+ *
+ * <p>The tree contains only the source constructs allowlisted by the 
read-restriction contract.
+ * Column and context references remain symbolic until the effective-policy 
resolver binds them to a
+ * table schema and request context.
+ */
+public interface CanonicalExpression {
+
+  /**
+   * Validates this expression node and all of its children.
+   *
+   * @throws IllegalArgumentException if the expression is malformed or 
outside the allowlisted
+   *     source profile
+   */
+  void validate() throws IllegalArgumentException;
+
+  /**
+   * Returns the source operation depth defined by {@code restricted-rego-v1}.
+   *
+   * @return expression depth
+   */
+  int depth();
+
+  /** Operators supported by {@code restricted-rego-v1}. */
+  enum Operator {
+    /** Equality comparison. */
+    EQ("eq"),
+    /** Inequality comparison. */
+    NEQ("neq"),
+    /** Less-than comparison. */
+    LT("lt"),
+    /** Less-than-or-equal comparison. */
+    LTE("lte"),
+    /** Greater-than comparison. */
+    GT("gt"),
+    /** Greater-than-or-equal comparison. */
+    GTE("gte"),
+    /** Literal-array membership comparison. */
+    IN("in"),
+    /** Boolean conjunction. */
+    AND("and"),
+    /** Boolean disjunction. */
+    OR("or"),
+    /** Boolean negation. */
+    NOT("not");
+
+    private final String value;
+
+    Operator(String value) {
+      this.value = value;
+    }
+
+    /**
+     * Parses a source or canonical operator value.
+     *
+     * @param value operator value
+     * @return parsed operator
+     */
+    public static Operator fromValue(String value) {
+      Preconditions.checkArgument(value != null && !value.isEmpty(), "operator 
cannot be empty");
+      switch (value) {
+        case "==":
+          return EQ;
+        case "!=":
+          return NEQ;
+        case "<":
+          return LT;
+        case "<=":
+          return LTE;
+        case ">":
+          return GT;
+        case ">=":
+          return GTE;
+        default:
+          for (Operator operator : values()) {
+            if (operator.value.equals(value)) {
+              return operator;
+            }
+          }
+          throw new IllegalArgumentException("Unsupported restricted-rego-v1 
operator: " + value);
+      }
+    }
+
+    /**
+     * Returns the canonical operator value.
+     *
+     * @return canonical operator value
+     */
+    public String value() {
+      return value;
+    }
+  }
+
+  /** Source literal types supported by {@code restricted-rego-v1}. */
+  enum LiteralType {
+    /** String literal. */
+    STRING("string"),
+    /** Exact base-10 numeric literal. */
+    NUMBER("number"),
+    /** Boolean literal. */
+    BOOLEAN("boolean"),
+    /** Null literal. */
+    NULL("null");
+
+    private final String value;
+
+    LiteralType(String value) {
+      this.value = value;
+    }
+
+    /**
+     * Parses a literal type value.
+     *
+     * @param value literal type value
+     * @return parsed literal type
+     */
+    public static LiteralType fromValue(String value) {
+      Preconditions.checkArgument(
+          value != null && !value.isEmpty(), "literal type cannot be empty");
+      return LiteralType.valueOf(value.toUpperCase(Locale.ROOT));
+    }
+
+    /**
+     * Returns the literal type value.
+     *
+     * @return literal type value
+     */
+    public String value() {
+      return value;
+    }
+  }
+
+  /** An allowlisted operation node. */
+  final class Operation implements CanonicalExpression {
+    private final Operator op;
+
+    private final CanonicalExpression left;
+
+    private final CanonicalExpression right;
+
+    private final CanonicalExpression operand;
+
+    private final List<CanonicalExpression> operands;
+
+    private Operation() {

Review Comment:
   Nit: these private no-arg constructors, along with the constant `type = 
"literal"` field and its check in `Literal`, look like leftovers from a Jackson 
DTO. Nothing here is (de)serialized, so they can probably be removed.



##########
core/src/main/java/org/apache/gravitino/policy/expression/CanonicalExpression.java:
##########
@@ -0,0 +1,888 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.policy.expression;
+
+import com.google.common.base.Preconditions;
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+
+/**
+ * A node in an unresolved {@code restricted-rego-v1} expression tree.
+ *
+ * <p>The tree contains only the source constructs allowlisted by the 
read-restriction contract.
+ * Column and context references remain symbolic until the effective-policy 
resolver binds them to a
+ * table schema and request context.
+ */
+public interface CanonicalExpression {
+
+  /**
+   * Validates this expression node and all of its children.
+   *
+   * @throws IllegalArgumentException if the expression is malformed or 
outside the allowlisted
+   *     source profile
+   */
+  void validate() throws IllegalArgumentException;
+
+  /**
+   * Returns the source operation depth defined by {@code restricted-rego-v1}.
+   *
+   * @return expression depth
+   */
+  int depth();
+
+  /** Operators supported by {@code restricted-rego-v1}. */
+  enum Operator {
+    /** Equality comparison. */
+    EQ("eq"),
+    /** Inequality comparison. */
+    NEQ("neq"),
+    /** Less-than comparison. */
+    LT("lt"),
+    /** Less-than-or-equal comparison. */
+    LTE("lte"),
+    /** Greater-than comparison. */
+    GT("gt"),
+    /** Greater-than-or-equal comparison. */
+    GTE("gte"),
+    /** Literal-array membership comparison. */
+    IN("in"),
+    /** Boolean conjunction. */
+    AND("and"),
+    /** Boolean disjunction. */
+    OR("or"),
+    /** Boolean negation. */
+    NOT("not");
+
+    private final String value;
+
+    Operator(String value) {
+      this.value = value;
+    }
+
+    /**
+     * Parses a source or canonical operator value.
+     *
+     * @param value operator value
+     * @return parsed operator
+     */
+    public static Operator fromValue(String value) {
+      Preconditions.checkArgument(value != null && !value.isEmpty(), "operator 
cannot be empty");
+      switch (value) {
+        case "==":
+          return EQ;
+        case "!=":
+          return NEQ;
+        case "<":
+          return LT;
+        case "<=":
+          return LTE;
+        case ">":
+          return GT;
+        case ">=":
+          return GTE;
+        default:
+          for (Operator operator : values()) {
+            if (operator.value.equals(value)) {
+              return operator;
+            }
+          }
+          throw new IllegalArgumentException("Unsupported restricted-rego-v1 
operator: " + value);
+      }
+    }
+
+    /**
+     * Returns the canonical operator value.
+     *
+     * @return canonical operator value
+     */
+    public String value() {
+      return value;
+    }
+  }
+
+  /** Source literal types supported by {@code restricted-rego-v1}. */
+  enum LiteralType {
+    /** String literal. */
+    STRING("string"),
+    /** Exact base-10 numeric literal. */
+    NUMBER("number"),
+    /** Boolean literal. */
+    BOOLEAN("boolean"),
+    /** Null literal. */
+    NULL("null");
+
+    private final String value;
+
+    LiteralType(String value) {
+      this.value = value;
+    }
+
+    /**
+     * Parses a literal type value.
+     *
+     * @param value literal type value
+     * @return parsed literal type
+     */
+    public static LiteralType fromValue(String value) {
+      Preconditions.checkArgument(
+          value != null && !value.isEmpty(), "literal type cannot be empty");
+      return LiteralType.valueOf(value.toUpperCase(Locale.ROOT));
+    }
+
+    /**
+     * Returns the literal type value.
+     *
+     * @return literal type value
+     */
+    public String value() {
+      return value;
+    }
+  }
+
+  /** An allowlisted operation node. */
+  final class Operation implements CanonicalExpression {

Review Comment:
   `Operation` represents three different shapes (comparison, `not`, and n-ary 
`and`/`or`) using five nullable fields, so `validate()`, `depth()`, 
`isContextOnly()` and `countNodes()` all have to rediscover the shape through 
null checks, and invalid combinations can be represented. Separate 
`Comparison(op, left, right)`, `Not(operand)` and `Logical(op, operands)` nodes 
would remove most of those checks. Relatedly, the same predicate checks are 
done inline in the parser and again in `validate()`. Could the parser build 
only the tree, with semantic validation done in one pass?



##########
core/src/main/java/org/apache/gravitino/policy/expression/RestrictedRegoExpressionParserFacade.java:
##########
@@ -0,0 +1,749 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.policy.expression;
+
+import com.google.common.base.Preconditions;
+import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import org.apache.commons.lang3.StringUtils;
+import 
org.apache.gravitino.policy.expression.CanonicalExpression.GroupMembership;
+import org.apache.gravitino.policy.expression.CanonicalExpression.Literal;
+import org.apache.gravitino.policy.expression.CanonicalExpression.LiteralArray;
+import org.apache.gravitino.policy.expression.CanonicalExpression.LiteralType;
+import org.apache.gravitino.policy.expression.CanonicalExpression.Operation;
+import org.apache.gravitino.policy.expression.CanonicalExpression.Operator;
+import org.apache.gravitino.policy.expression.CanonicalExpression.SessionUser;
+import org.apache.gravitino.policy.expression.RestrictedRegoProgram.ColumnMask;
+import 
org.apache.gravitino.policy.expression.RestrictedRegoProgram.FilterBranch;
+import org.apache.gravitino.policy.expression.RestrictedRegoProgram.MaskAction;
+import org.apache.gravitino.policy.expression.RestrictedRegoProgram.MaskBranch;
+import org.apache.gravitino.policy.expression.RestrictedRegoProgram.RowFilter;
+
+/** Parses and validates complete programs in the {@code restricted-rego-v1} 
source dialect. */
+public final class RestrictedRegoExpressionParserFacade {
+
+  private static final int MAX_SOURCE_BYTES = 16 * 1024;
+  private static final int MAX_SOURCE_DEPTH = 8;
+  private static final int MAX_AST_NODES = 256;
+  private static final int MAX_STRING_BYTES = 4 * 1024;
+  private static final int MAX_ARRAY_ELEMENTS = 256;
+
+  private RestrictedRegoExpressionParserFacade() {}
+
+  /**
+   * Parses and validates one complete row-filter or column-mask program.
+   *
+   * @param source program in {@code restricted-rego-v1}
+   * @return validated unresolved program
+   * @throws IllegalArgumentException if syntax or semantics are invalid
+   */
+  public static RestrictedRegoProgram parse(String source) {
+    Preconditions.checkArgument(
+        StringUtils.isNotBlank(source), "restricted-rego-v1 program cannot be 
blank");
+    Preconditions.checkArgument(
+        source.getBytes(StandardCharsets.UTF_8).length <= MAX_SOURCE_BYTES,
+        "restricted-rego-v1 source must not exceed %s UTF-8 bytes",
+        MAX_SOURCE_BYTES);
+
+    RestrictedRegoProgram program = new SourceParser(source).parseProgram();
+    validateProgram(program);
+    return program;
+  }
+
+  /**
+   * Parses and validates one complete {@code filter := ...} program.
+   *
+   * @param source row-filter program in {@code restricted-rego-v1}
+   * @return validated unresolved row-filter rule
+   * @throws IllegalArgumentException if syntax or semantics are invalid or 
the rule head is not
+   *     {@code filter}
+   */
+  public static RowFilter parseRowFilter(String source) {
+    RestrictedRegoProgram program = parse(source);
+    Preconditions.checkArgument(
+        program instanceof RowFilter, "row-filter program must declare a 
filter rule");
+    return (RowFilter) program;
+  }
+
+  /**
+   * Parses and validates one complete {@code mask := action(...)} program.
+   *
+   * @param source column-mask program in {@code restricted-rego-v1}
+   * @return validated unresolved column-mask rule
+   * @throws IllegalArgumentException if syntax or semantics are invalid or 
the rule head is not
+   *     {@code mask}
+   */
+  public static ColumnMask parseColumnMask(String source) {
+    RestrictedRegoProgram program = parse(source);
+    Preconditions.checkArgument(
+        program instanceof ColumnMask, "column-mask program must declare a 
mask rule");
+    return (ColumnMask) program;
+  }
+
+  private static void validateProgram(RestrictedRegoProgram program) {
+    program.validate();
+    List<CanonicalExpression> expressions = expressions(program);
+    int nodeCount = 0;
+    for (CanonicalExpression expression : expressions) {
+      Preconditions.checkArgument(
+          expression.depth() <= MAX_SOURCE_DEPTH,
+          "restricted-rego-v1 source depth must not exceed %s",
+          MAX_SOURCE_DEPTH);
+      nodeCount += countNodes(expression);
+    }
+    Preconditions.checkArgument(
+        nodeCount <= MAX_AST_NODES,
+        "restricted-rego-v1 AST must not exceed %s nodes",
+        MAX_AST_NODES);
+  }
+
+  private static List<CanonicalExpression> expressions(RestrictedRegoProgram 
program) {
+    List<CanonicalExpression> expressions = new ArrayList<>();
+    if (program instanceof RowFilter) {
+      RowFilter filter = (RowFilter) program;
+      for (FilterBranch branch : filter.branches()) {
+        expressions.add(branch.result());
+        expressions.add(branch.condition());
+      }
+      expressions.add(filter.fallback());
+    } else {
+      ColumnMask mask = (ColumnMask) program;
+      for (MaskBranch branch : mask.branches()) {
+        expressions.add(branch.condition());
+      }
+    }
+    return expressions;
+  }
+
+  private static int countNodes(CanonicalExpression expression) {
+    if (!(expression instanceof Operation)) {
+      return 1;
+    }
+
+    Operation operation = (Operation) expression;
+    int count = 1;
+    if (operation.left() != null) {
+      count += countNodes(operation.left());
+    }
+    if (operation.right() != null) {
+      count += countNodes(operation.right());
+    }
+    if (operation.operand() != null) {
+      count += countNodes(operation.operand());
+    }
+    if (operation.operands() != null) {
+      for (CanonicalExpression child : operation.operands()) {
+        count += countNodes(child);
+      }
+    }
+    return count;
+  }
+
+  private static final class SourceParser {
+    private static final int MAX_PARENTHESIS_NESTING = 256;
+
+    private final Lexer lexer;
+    private Token current;
+    private int parenthesisNesting;
+
+    private SourceParser(String source) {
+      this.lexer = new Lexer(source);
+      this.current = lexer.nextToken();
+    }
+
+    private RestrictedRegoProgram parseProgram() {
+      RestrictedRegoProgram program;
+      if (check(TokenType.FILTER)) {
+        program = parseRowFilter();
+      } else if (check(TokenType.MASK)) {
+        program = parseColumnMask();
+      } else {
+        throw error("expected filter or mask rule");
+      }
+      expect(TokenType.EOF, "expected end of program");
+      return program;
+    }
+
+    private RowFilter parseRowFilter() {
+      expect(TokenType.FILTER, "expected filter");
+      expect(TokenType.ASSIGN, "expected := after filter");
+      CanonicalExpression result = parseExpression();
+      if (!match(TokenType.IF)) {
+        return new RowFilter(new ArrayList<>(), result);
+      }
+
+      List<FilterBranch> branches = new ArrayList<>();
+      branches.add(new FilterBranch(result, parseExpression()));
+      while (true) {
+        expect(TokenType.ELSE, "expected final else branch");
+        expect(TokenType.ASSIGN, "expected := after else");
+        CanonicalExpression nextResult = parseExpression();
+        if (!match(TokenType.IF)) {
+          return new RowFilter(branches, nextResult);
+        }
+        branches.add(new FilterBranch(nextResult, parseExpression()));
+      }
+    }
+
+    private ColumnMask parseColumnMask() {
+      expect(TokenType.MASK, "expected mask");
+      expect(TokenType.ASSIGN, "expected := after mask");
+      MaskAction action = parseMaskAction();
+      if (!match(TokenType.IF)) {
+        return new ColumnMask(new ArrayList<>(), action);
+      }
+
+      List<MaskBranch> branches = new ArrayList<>();
+      branches.add(new MaskBranch(action, parseExpression()));
+      while (true) {
+        expect(TokenType.ELSE, "expected final else branch");
+        expect(TokenType.ASSIGN, "expected := after else");
+        MaskAction nextAction = parseMaskAction();
+        if (!match(TokenType.IF)) {
+          return new ColumnMask(branches, nextAction);
+        }
+        branches.add(new MaskBranch(nextAction, parseExpression()));
+      }
+    }
+
+    private MaskAction parseMaskAction() {
+      expect(TokenType.ACTION, "expected action");
+      expect(TokenType.LPAREN, "expected ( after action");
+      String action = decodeString(expect(TokenType.STRING, "expected mask 
action string").text);
+      expect(TokenType.RPAREN, "expected ) after mask action");
+      return MaskAction.fromValue(action);
+    }
+
+    private CanonicalExpression parseExpression() {
+      return parseOrExpression();
+    }
+
+    private CanonicalExpression parseOrExpression() {
+      CanonicalExpression result = parseAndExpression();
+      while (match(TokenType.OR)) {
+        Preconditions.checkArgument(
+            ExpressionValidation.isPredicate(result), "or operands must be 
boolean predicates");
+        CanonicalExpression operand = parseAndExpression();
+        Preconditions.checkArgument(
+            ExpressionValidation.isPredicate(operand), "or operands must be 
boolean predicates");
+        result = new Operation(Operator.OR, null, null, null, 
Arrays.asList(result, operand));
+      }
+      return result;
+    }
+
+    private CanonicalExpression parseAndExpression() {
+      CanonicalExpression result = parseNotExpression();
+      while (match(TokenType.AND)) {
+        Preconditions.checkArgument(
+            ExpressionValidation.isPredicate(result), "and operands must be 
boolean predicates");
+        CanonicalExpression operand = parseNotExpression();
+        Preconditions.checkArgument(
+            ExpressionValidation.isPredicate(operand), "and operands must be 
boolean predicates");
+        result = new Operation(Operator.AND, null, null, null, 
Arrays.asList(result, operand));
+      }
+      return result;
+    }
+
+    private CanonicalExpression parseNotExpression() {
+      int notCount = 0;
+      while (match(TokenType.NOT)) {
+        notCount++;
+      }
+
+      CanonicalExpression result = parseComparisonExpression();
+      for (int index = 0; index < notCount; index++) {
+        Preconditions.checkArgument(
+            ExpressionValidation.isPredicate(result), "not operand must be a 
boolean predicate");
+        result = new Operation(Operator.NOT, null, null, result, null);
+      }
+      return result;
+    }
+
+    private CanonicalExpression parseComparisonExpression() {
+      CanonicalExpression left = parsePrimary();
+      if (!isComparisonOperator(current.type)) {
+        return left;
+      }
+
+      Operator operator = Operator.fromValue(advance().text);
+      return new Operation(operator, left, parsePrimary(), null, null);
+    }
+
+    private CanonicalExpression parsePrimary() {
+      if (match(TokenType.COL)) {
+        expect(TokenType.LPAREN, "expected ( after col");
+        String column = decodeString(expect(TokenType.STRING, "expected column 
name string").text);
+        expect(TokenType.RPAREN, "expected ) after column name");
+        return new CanonicalExpression.Column(column);
+      }
+      if (match(TokenType.SESSION_USER)) {
+        expect(TokenType.LPAREN, "expected ( after session_user");
+        expect(TokenType.RPAREN, "expected ) after session_user(");
+        return new SessionUser();
+      }
+      if (match(TokenType.IS_GROUP_MEMBER)) {
+        expect(TokenType.LPAREN, "expected ( after is_group_member");
+        String group = decodeString(expect(TokenType.STRING, "expected group 
name string").text);
+        expect(TokenType.RPAREN, "expected ) after group name");
+        return new GroupMembership(group);
+      }
+      if (isLiteral(current.type)) {
+        return parseLiteral();
+      }
+      if (match(TokenType.LBRACKET)) {
+        return parseLiteralArray();
+      }
+      if (match(TokenType.LPAREN)) {
+        parenthesisNesting++;
+        Preconditions.checkArgument(
+            parenthesisNesting <= MAX_PARENTHESIS_NESTING,
+            "restricted-rego-v1 parenthesis nesting must not exceed %s",
+            MAX_PARENTHESIS_NESTING);
+        CanonicalExpression expression = parseExpression();
+        expect(TokenType.RPAREN, "expected ) after expression");
+        parenthesisNesting--;
+        return expression;
+      }
+      throw error("expected expression");
+    }
+
+    private Literal parseLiteral() {
+      Token token = advance();
+      switch (token.type) {
+        case STRING:
+          return new Literal(LiteralType.STRING, decodeString(token.text));
+        case NUMBER:
+          BigDecimal value = new BigDecimal(token.text);
+          return new Literal(
+              LiteralType.NUMBER, value, token.text.startsWith("-") && 
value.signum() == 0);
+        case TRUE:
+          return new Literal(LiteralType.BOOLEAN, true);
+        case FALSE:
+          return new Literal(LiteralType.BOOLEAN, false);
+        case NULL:
+          return new Literal(LiteralType.NULL, null);
+        default:
+          throw error(token, "expected literal");
+      }
+    }
+
+    private LiteralArray parseLiteralArray() {
+      List<Literal> values = new ArrayList<>();
+      LiteralType elementType = null;
+      do {
+        Preconditions.checkArgument(
+            values.size() < MAX_ARRAY_ELEMENTS,
+            "array literal must not exceed %s elements",
+            MAX_ARRAY_ELEMENTS);
+        Preconditions.checkArgument(isLiteral(current.type), "array literal 
cannot be empty");
+        Literal literal = parseLiteral();
+        Preconditions.checkArgument(
+            literal.literalType() != LiteralType.NULL, "array literals cannot 
contain null");
+        Preconditions.checkArgument(
+            elementType == null || elementType == literal.literalType(),
+            "array literals must have one homogeneous source type");
+        elementType = literal.literalType();
+        values.add(literal);
+      } while (match(TokenType.COMMA));
+      expect(TokenType.RBRACKET, "expected ] after array literal");
+      return new LiteralArray(elementType, values);
+    }
+
+    private boolean match(TokenType type) {
+      if (!check(type)) {
+        return false;
+      }
+      advance();
+      return true;
+    }
+
+    private boolean check(TokenType type) {
+      return current.type == type;
+    }
+
+    private Token expect(TokenType type, String message) {
+      if (!check(type)) {
+        throw error(message);
+      }
+      return advance();
+    }
+
+    private Token advance() {
+      Token previous = current;
+      current = lexer.nextToken();
+      return previous;
+    }
+
+    private IllegalArgumentException error(String message) {
+      return error(current, message);
+    }
+
+    private static IllegalArgumentException error(Token token, String message) 
{
+      return new IllegalArgumentException(
+          String.format(
+              "Invalid restricted-rego-v1 program at line %s, column %s: %s",
+              token.line, token.column, message));
+    }
+
+    private static boolean isComparisonOperator(TokenType type) {
+      return type == TokenType.EQ
+          || type == TokenType.NEQ
+          || type == TokenType.LT
+          || type == TokenType.LTE
+          || type == TokenType.GT
+          || type == TokenType.GTE
+          || type == TokenType.IN;
+    }
+
+    private static boolean isLiteral(TokenType type) {
+      return type == TokenType.STRING
+          || type == TokenType.NUMBER
+          || type == TokenType.TRUE
+          || type == TokenType.FALSE
+          || type == TokenType.NULL;
+    }
+  }
+
+  private enum TokenType {
+    FILTER,
+    MASK,
+    IF,
+    ELSE,
+    OR,
+    AND,
+    NOT,
+    IN,
+    COL,
+    SESSION_USER,
+    IS_GROUP_MEMBER,
+    ACTION,
+    TRUE,
+    FALSE,
+    NULL,
+    ASSIGN,
+    EQ,
+    NEQ,
+    LTE,
+    LT,
+    GTE,
+    GT,
+    LPAREN,
+    RPAREN,
+    LBRACKET,
+    RBRACKET,
+    COMMA,
+    NUMBER,
+    STRING,
+    EOF
+  }
+
+  private static final class Token {
+    private final TokenType type;
+    private final String text;
+    private final int line;
+    private final int column;
+
+    private Token(TokenType type, String text, int line, int column) {
+      this.type = type;
+      this.text = text;
+      this.line = line;
+      this.column = column;
+    }
+  }
+
+  private static final class Lexer {
+    private final String source;
+    private int offset;
+    private int line = 1;
+    private int column;
+
+    private Lexer(String source) {
+      this.source = source;
+    }
+
+    private Token nextToken() {
+      skipWhitespace();
+      int tokenOffset = offset;
+      int tokenLine = line;
+      int tokenColumn = column;
+      if (atEnd()) {
+        return new Token(TokenType.EOF, "", tokenLine, tokenColumn);
+      }
+
+      char current = peek();
+      if (current == '"') {
+        scanString(tokenLine, tokenColumn);
+        return token(TokenType.STRING, tokenOffset, tokenLine, tokenColumn);
+      }
+      if (isIdentifierStart(current)) {
+        do {
+          advance();
+        } while (!atEnd() && isIdentifierPart(peek()));
+        String text = source.substring(tokenOffset, offset);
+        return new Token(keywordType(text), text, tokenLine, tokenColumn);
+      }
+      if (isDigit(current) || (current == '-' && isDigit(peekNext()))) {
+        scanNumber();
+        if (!atEnd() && isIdentifierStart(peek())) {
+          throw lexicalError(
+              tokenLine, tokenColumn, "numeric literal must be separated from 
identifiers");
+        }
+        return token(TokenType.NUMBER, tokenOffset, tokenLine, tokenColumn);
+      }
+
+      advance();
+      switch (current) {
+        case ':':
+          return symbol(TokenType.ASSIGN, '=', tokenOffset, tokenLine, 
tokenColumn);
+        case '=':
+          return symbol(TokenType.EQ, '=', tokenOffset, tokenLine, 
tokenColumn);
+        case '!':
+          return symbol(TokenType.NEQ, '=', tokenOffset, tokenLine, 
tokenColumn);
+        case '<':
+          return optionalEquals(TokenType.LT, TokenType.LTE, tokenOffset, 
tokenLine, tokenColumn);
+        case '>':
+          return optionalEquals(TokenType.GT, TokenType.GTE, tokenOffset, 
tokenLine, tokenColumn);
+        case '(':
+          return token(TokenType.LPAREN, tokenOffset, tokenLine, tokenColumn);
+        case ')':
+          return token(TokenType.RPAREN, tokenOffset, tokenLine, tokenColumn);
+        case '[':
+          return token(TokenType.LBRACKET, tokenOffset, tokenLine, 
tokenColumn);
+        case ']':
+          return token(TokenType.RBRACKET, tokenOffset, tokenLine, 
tokenColumn);
+        case ',':
+          return token(TokenType.COMMA, tokenOffset, tokenLine, tokenColumn);
+        default:
+          throw lexicalError(tokenLine, tokenColumn, "unsupported character " 
+ current);
+      }
+    }
+
+    private void scanString(int tokenLine, int tokenColumn) {
+      advance();
+      while (!atEnd()) {
+        char current = advance();
+        if (current == '"') {
+          return;
+        }
+        if (current < 0x20) {
+          throw lexicalError(tokenLine, tokenColumn, "unescaped control 
character in string");
+        }
+        if (current != '\\') {
+          continue;
+        }
+        if (atEnd()) {
+          break;
+        }
+        char escaped = advance();
+        if (escaped == 'u') {
+          for (int index = 0; index < 4; index++) {
+            if (atEnd() || Character.digit(advance(), 16) < 0) {
+              throw lexicalError(tokenLine, tokenColumn, "invalid Unicode 
escape in string");
+            }
+          }
+        } else if ("\"\\/bfnrt".indexOf(escaped) < 0) {
+          throw lexicalError(tokenLine, tokenColumn, "unsupported string 
escape");
+        }
+      }
+      throw lexicalError(tokenLine, tokenColumn, "unterminated string");
+    }
+
+    private void scanNumber() {
+      if (peek() == '-') {
+        advance();
+      }
+      if (peek() == '0') {
+        advance();
+      } else {
+        while (!atEnd() && isDigit(peek())) {
+          advance();
+        }
+      }
+      if (!atEnd() && peek() == '.' && isDigit(peekNext())) {
+        advance();
+        while (!atEnd() && isDigit(peek())) {
+          advance();
+        }
+      }
+    }
+
+    private Token symbol(
+        TokenType type, char second, int tokenOffset, int tokenLine, int 
tokenColumn) {
+      if (atEnd() || peek() != second) {
+        throw lexicalError(tokenLine, tokenColumn, "unsupported operator");
+      }
+      advance();
+      return token(type, tokenOffset, tokenLine, tokenColumn);
+    }
+
+    private Token optionalEquals(
+        TokenType single, TokenType withEquals, int tokenOffset, int 
tokenLine, int tokenColumn) {
+      TokenType type = single;
+      if (!atEnd() && peek() == '=') {
+        advance();
+        type = withEquals;
+      }
+      return token(type, tokenOffset, tokenLine, tokenColumn);
+    }
+
+    private Token token(TokenType type, int tokenOffset, int tokenLine, int 
tokenColumn) {
+      return new Token(type, source.substring(tokenOffset, offset), tokenLine, 
tokenColumn);
+    }
+
+    private void skipWhitespace() {
+      while (!atEnd()) {
+        char current = peek();
+        if (current != ' ' && current != '\t' && current != '\r' && current != 
'\n') {
+          return;
+        }
+        advance();
+      }
+    }
+
+    private char advance() {
+      char current = source.charAt(offset++);
+      if (current == '\n') {
+        line++;
+        column = 0;
+      } else {
+        column++;
+      }
+      return current;
+    }
+
+    private char peek() {
+      return source.charAt(offset);
+    }
+
+    private char peekNext() {
+      return offset + 1 < source.length() ? source.charAt(offset + 1) : '\0';
+    }
+
+    private boolean atEnd() {
+      return offset >= source.length();
+    }
+
+    private static TokenType keywordType(String text) {
+      switch (text) {
+        case "filter":
+          return TokenType.FILTER;
+        case "mask":
+          return TokenType.MASK;
+        case "if":
+          return TokenType.IF;
+        case "else":
+          return TokenType.ELSE;
+        case "or":
+          return TokenType.OR;
+        case "and":
+          return TokenType.AND;
+        case "not":
+          return TokenType.NOT;
+        case "in":
+          return TokenType.IN;
+        case "col":
+          return TokenType.COL;
+        case "session_user":
+          return TokenType.SESSION_USER;
+        case "is_group_member":
+          return TokenType.IS_GROUP_MEMBER;
+        case "action":
+          return TokenType.ACTION;
+        case "true":
+          return TokenType.TRUE;
+        case "false":
+          return TokenType.FALSE;
+        case "null":
+          return TokenType.NULL;
+        default:
+          throw new IllegalArgumentException(
+              "Unsupported identifier in restricted-rego-v1: " + text);

Review Comment:
   Nit: this error is the only lexer error that does not use 
`lexicalError(line, column, ...)`, so a typo such as `filter := region == "US"` 
gives no position.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to