roryqi commented on code in PR #13478: URL: https://github.com/apache/gravitino/pull/13478#discussion_r4229836915
########## 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) { Review Comment: Fixed in c98a40c8d4. I renamed the stored text to `canonicalName` and replaced `fromValue` with `fromSourceToken`, which accepts only source tokens such as `==` and maps them explicitly to `EQ`. This separates source spelling from the canonical AST name `eq`. ########## 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: Fixed in c98a40c8d4. I removed the unused private no-arg constructors and all constant node-type fields/checks. I also removed the unused literal-type string conversion state left from the DTO-shaped model. ########## 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: Fixed in c98a40c8d4. Save-time validation now lowers every row filter and checks the lowered predicate against the same depth-8 limit. I added a boundary test where source depth is valid but the lowered predicate reaches depth 9 and is rejected. ########## 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: Fixed in c98a40c8d4. Semantic parsing was removed from `PolicyEntity.build()`. `PolicyManager` now validates row-filter and column-mask expressions only on create and content-update paths. The tests also verify that a structurally valid entity with previously stored expression text can still be built without re-parsing it. ########## 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: Fixed in c98a40c8d4. `keywordType` now receives the token start line/column and reports unsupported identifiers through `lexicalError`, so `filter := region == "US"` identifies line 1, column 11. ########## 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: Fixed in c98a40c8d4. Consecutive `and`/`or` expressions are now represented by one n-ary `Logical` node, and `RowFilter.lower()` emits a flat disjunction of conjunctions. The design doc now defines operation depth and states that parentheses do not add depth. ########## 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: Fixed in c98a40c8d4. `Operation` was split into `Comparison`, `Not`, and n-ary `Logical` nodes. The parser now builds syntax trees without duplicating predicate/operand semantic checks; `program.validate()` performs those checks in one validation 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; Review Comment: Fixed in c98a40c8d4. Columns are now 1-based, identifier and string/action errors retain token positions, leading-zero numbers are rejected explicitly, and a second comparison operator reports `chained comparisons are not supported`. The new tests cover these cases. -- 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]
