diff --git a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java index 8f642010..5fe063d8 100644 --- a/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java +++ b/spring-data/src/main/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapter.java @@ -132,6 +132,9 @@ private Predicate traverseExpression(PlanResourcesFilter.Expression expression, case "hasIntersection" -> handleHasIntersection(operands, scope); case "isSet" -> handleIsSet(operands, scope); case "in" -> handleIn(operands, scope); + case "overlaps" -> handleOverlaps(operands, scope); + case "ancestorOf" -> handleAncestorDescendant(operands, scope, true); + case "descendentOf" -> handleAncestorDescendant(operands, scope, false); default -> { Predicate sizePred = trySizeComparison(op, operands, scope); if (sizePred != null) { @@ -682,6 +685,269 @@ private Predicate existsSubquery(Scope scope, AttributeMapping.Relation rel, Sub sub.where(body); return cb.exists(sub); } + + // -- hierarchy operators (overlaps / ancestorOf / descendentOf) -- + // + // A Cerbos hierarchy is a delimited path (e.g. "a:b:c"). Both sides of a hierarchy + // operator are wrapped in a `hierarchy(...)` expression that resolves to one of: + // - a constant string split into segments, + // - a single field whose column holds the whole delimited string, or + // - a `list(...)` of segments, each a constant or a field. + // The translations mirror the Prisma adapter so behaviour is consistent across adapters. + + private Predicate handleOverlaps(List operands, Scope scope) { + Hierarchy[] both = extractHierarchyOperands("overlaps", operands, scope); + Hierarchy left = both[0]; + Hierarchy right = both[1]; + + if (left instanceof Hierarchy.FieldRef || right instanceof Hierarchy.FieldRef) { + return handleFieldOverlaps(left, right); + } + + List leftSegs = toSegments(left); + List rightSegs = toSegments(right); + + List leftPrefixOfRight = checkPrefixConditions(leftSegs, rightSegs); + List rightPrefixOfLeft = checkPrefixConditions(rightSegs, leftSegs); + + java.util.List> valid = new java.util.ArrayList<>(); + if (leftPrefixOfRight != null) valid.add(leftPrefixOfRight); + if (rightPrefixOfLeft != null) valid.add(rightPrefixOfLeft); + + if (valid.isEmpty()) { + // Neither side can be a prefix of the other. If a field is involved the overlap is + // simply never satisfiable (always-false); two incompatible constants are a planner bug. + boolean hasField = containsFieldSegment(leftSegs) || containsFieldSegment(rightSegs); + if (hasField) { + return cb.disjunction(); + } + throw new IllegalArgumentException("Cannot determine hierarchy overlap: no field references found"); + } + // An empty condition list means every compared segment was a matching constant — overlap + // holds unconditionally. + for (List c : valid) { + if (c.isEmpty()) { + return cb.conjunction(); + } + } + // Both directions (equal-length hierarchies) compare the same segment pairs, so either + // condition set is equivalent; use the first. + List chosen = valid.get(0); + return chosen.size() == 1 ? chosen.get(0) : cb.and(chosen.toArray(Predicate[]::new)); + } + + private Predicate handleFieldOverlaps(Hierarchy left, Hierarchy right) { + if (left instanceof Hierarchy.FieldRef && right instanceof Hierarchy.FieldRef) { + throw new IllegalArgumentException("overlaps: cannot compare two field-reference hierarchies"); + } + Hierarchy.FieldRef field = (left instanceof Hierarchy.FieldRef f) ? f : (Hierarchy.FieldRef) right; + Hierarchy other = (left instanceof Hierarchy.FieldRef) ? right : left; + if (!(other instanceof Hierarchy.Constant constant)) { + throw new IllegalArgumentException( + "overlaps: segmented hierarchies with field hierarchies are not supported"); + } + + String delimiter = field.delimiter(); + String otherRaw = String.join(delimiter, constant.segments()); + List strictPrefixes = getStrictPrefixes(constant.segments(), delimiter); + + java.util.List conditions = new java.util.ArrayList<>(); + // field is an ancestor of the constant... + if (!strictPrefixes.isEmpty()) { + conditions.add(field.path().in(strictPrefixes)); + } + // ...or equal to it... + conditions.add(cb.equal(field.path(), otherRaw)); + // ...or a descendant of it. + conditions.add(startsWithLiteral(field.path(), otherRaw + delimiter)); + + return conditions.size() == 1 ? conditions.get(0) : cb.or(conditions.toArray(Predicate[]::new)); + } + + private Predicate handleAncestorDescendant(List operands, Scope scope, boolean isAncestor) { + String opName = isAncestor ? "ancestorOf" : "descendentOf"; + Hierarchy[] both = extractHierarchyOperands(opName, operands, scope); + // ancestorOf(A, B) ⇔ A is a strict prefix of B; descendentOf(A, B) ⇔ B is a strict prefix of A. + Hierarchy ancestor = isAncestor ? both[0] : both[1]; + Hierarchy descendant = isAncestor ? both[1] : both[0]; + + if (ancestor instanceof Hierarchy.Constant a && descendant instanceof Hierarchy.FieldRef d) { + String prefix = String.join(d.delimiter(), a.segments()) + d.delimiter(); + return startsWithLiteral(d.path(), prefix); + } + if (ancestor instanceof Hierarchy.FieldRef a && descendant instanceof Hierarchy.Constant d) { + List prefixes = getStrictPrefixes(d.segments(), a.delimiter()); + if (prefixes.isEmpty()) { + return cb.disjunction(); + } + if (prefixes.size() == 1) { + return cb.equal(a.path(), prefixes.get(0)); + } + return a.path().in(prefixes); + } + if (ancestor instanceof Hierarchy.Constant a && descendant instanceof Hierarchy.Constant d) { + if (d.segments().size() > a.segments().size() + && isPrefix(a.segments(), d.segments())) { + return cb.conjunction(); + } + throw new IllegalArgumentException( + opName + ": constant operands do not satisfy the " + (isAncestor ? "ancestor" : "descendant") + + " relationship"); + } + throw new IllegalArgumentException(opName + ": unsupported hierarchy operand combination"); + } + + private Hierarchy[] extractHierarchyOperands(String opName, List operands, Scope scope) { + if (operands.size() != 2) { + throw new IllegalArgumentException(opName + " requires exactly 2 operands"); + } + return new Hierarchy[]{ + normalizeHierarchy(resolveHierarchy(opName, operands.get(0), scope)), + normalizeHierarchy(resolveHierarchy(opName, operands.get(1), scope)), + }; + } + + private Hierarchy resolveHierarchy(String opName, Operand operand, Scope scope) { + if (operand.getNodeCase() != Operand.NodeCase.EXPRESSION + || !"hierarchy".equals(operand.getExpression().getOperator())) { + throw new IllegalArgumentException(opName + " requires hierarchy(...) operands"); + } + List ops = operand.getExpression().getOperandsList(); + if (ops.size() == 2) { + Operand strOp = ops.get(0); + Operand delimOp = ops.get(1); + if (delimOp.getNodeCase() != Operand.NodeCase.VALUE) { + throw new IllegalArgumentException("hierarchy delimiter must be a value"); + } + String delimiter = String.valueOf(protoValueToJava(delimOp.getValue())); + if (strOp.getNodeCase() == Operand.NodeCase.VALUE) { + String raw = String.valueOf(protoValueToJava(strOp.getValue())); + return new Hierarchy.Constant(splitLiteral(raw, delimiter), delimiter); + } + if (strOp.getNodeCase() == Operand.NodeCase.VARIABLE) { + return new Hierarchy.FieldRef(scope.resolvePath(strOp.getVariable()), delimiter); + } + throw new IllegalArgumentException("hierarchy(string, delimiter) requires a value or field operand"); + } + if (ops.size() == 1) { + Operand inner = ops.get(0); + return switch (inner.getNodeCase()) { + case VALUE -> new Hierarchy.Constant( + splitLiteral(String.valueOf(protoValueToJava(inner.getValue())), "."), "."); + case VARIABLE -> new Hierarchy.FieldRef(scope.resolvePath(inner.getVariable()), "."); + case EXPRESSION -> { + if (!"list".equals(inner.getExpression().getOperator())) { + throw new IllegalArgumentException("hierarchy requires a value, field, or list operand"); + } + java.util.List segs = new java.util.ArrayList<>(); + for (Operand seg : inner.getExpression().getOperandsList()) { + switch (seg.getNodeCase()) { + case VALUE -> segs.add(new Seg.Const(String.valueOf(protoValueToJava(seg.getValue())))); + case VARIABLE -> segs.add(new Seg.FieldSeg(scope.resolvePath(seg.getVariable()))); + default -> throw new IllegalArgumentException( + "hierarchy list segment must be a value or field, got " + seg.getNodeCase()); + } + } + yield new Hierarchy.Segmented(segs); + } + default -> throw new IllegalArgumentException( + "hierarchy requires a value, field, or list operand, got " + inner.getNodeCase()); + }; + } + throw new IllegalArgumentException("hierarchy requires 1 or 2 operands"); + } + + /** Collapse an all-constant segmented hierarchy to a plain Constant (default delimiter). */ + private Hierarchy normalizeHierarchy(Hierarchy h) { + if (!(h instanceof Hierarchy.Segmented seg)) { + return h; + } + java.util.List values = new java.util.ArrayList<>(); + for (Seg s : seg.segments()) { + if (s instanceof Seg.Const c) { + values.add(c.value()); + } else { + return h; + } + } + return new Hierarchy.Constant(values, "."); + } + + private List toSegments(Hierarchy h) { + if (h instanceof Hierarchy.Constant c) { + return c.segments().stream().map(s -> (Seg) new Seg.Const(s)).toList(); + } + if (h instanceof Hierarchy.Segmented s) { + return s.segments(); + } + throw new IllegalArgumentException("Cannot enumerate segments of a field-reference hierarchy"); + } + + /** + * If {@code shorter} is a prefix of {@code longer}, return the predicates that must hold for + * the field segments to line up (an empty list = unconditionally true). Returns {@code null} + * if {@code shorter} cannot be a prefix of {@code longer}. + */ + private List checkPrefixConditions(List shorter, List longer) { + if (shorter.size() > longer.size()) { + return null; + } + java.util.List conditions = new java.util.ArrayList<>(); + for (int i = 0; i < shorter.size(); i++) { + Seg s = shorter.get(i); + Seg l = longer.get(i); + if (s instanceof Seg.Const sc && l instanceof Seg.Const lc) { + if (!sc.value().equals(lc.value())) { + return null; + } + } else if (s instanceof Seg.FieldSeg sf && l instanceof Seg.Const lc) { + conditions.add(cb.equal(sf.path(), lc.value())); + } else if (s instanceof Seg.Const sc && l instanceof Seg.FieldSeg lf) { + conditions.add(cb.equal(lf.path(), sc.value())); + } else { + throw new IllegalArgumentException( + "Cannot compare two field references in a hierarchy overlap"); + } + } + return conditions; + } + + private Predicate startsWithLiteral(Path path, String prefix) { + return cb.like(path.as(String.class), escapeLike(prefix) + "%", '\\'); + } + + private static boolean containsFieldSegment(List segs) { + return segs.stream().anyMatch(s -> s instanceof Seg.FieldSeg); + } + + private static boolean isPrefix(List shorter, List longer) { + for (int i = 0; i < shorter.size(); i++) { + if (!shorter.get(i).equals(longer.get(i))) { + return false; + } + } + return true; + } + + /** All proper (strict) ancestor prefixes of a segment list, joined with {@code delimiter}. */ + private static List getStrictPrefixes(List segments, String delimiter) { + if (segments.size() <= 1) { + return List.of(); + } + java.util.List prefixes = new java.util.ArrayList<>(); + String current = segments.get(0); + prefixes.add(current); + for (int i = 1; i < segments.size() - 1; i++) { + current = current + delimiter + segments.get(i); + prefixes.add(current); + } + return prefixes; + } + + /** Split on a literal delimiter (not a regex), keeping trailing empty segments. */ + private static List splitLiteral(String raw, String delimiter) { + return List.of(raw.split(java.util.regex.Pattern.quote(delimiter), -1)); + } } // -- Scope -- @@ -860,6 +1126,25 @@ private static AttributeMapping walkRelationChain(AttributeMapping.Relation rel, */ record RelationChain(List relations, AttributeMapping.Field tail) {} + /** A resolved {@code hierarchy(...)} operand: a constant path, a whole-column field, or a list of segments. */ + private sealed interface Hierarchy permits Hierarchy.Constant, Hierarchy.FieldRef, Hierarchy.Segmented { + /** A literal delimited path split into segments. */ + record Constant(List segments, String delimiter) implements Hierarchy {} + + /** A single column holding the whole delimited string. */ + record FieldRef(Path path, String delimiter) implements Hierarchy {} + + /** A {@code list(...)} of segments, each a constant or a field. */ + record Segmented(List segments) implements Hierarchy {} + } + + /** One segment of a {@link Hierarchy.Segmented}: either a literal value or a field reference. */ + private sealed interface Seg permits Seg.Const, Seg.FieldSeg { + record Const(String value) implements Seg {} + + record FieldSeg(Path path) implements Seg {} + } + private static RelationChain resolveRelationChain(Map mapper, String cerbosVar) { AttributeMapping direct = mapper.get(cerbosVar); if (direct instanceof AttributeMapping.Relation rel) { diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java index 26b95b7b..b89c8d50 100644 --- a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataIntegrationTest.java @@ -162,6 +162,12 @@ public String inspect(String sql) { } } + // Hierarchy policies reference R.id (the resource id) and R.attr.scope (a delimited path column). + private static final Map HIERARCHY_MAP = Map.ofEntries( + Map.entry("request.resource.id", AttributeMapping.field("id")), + Map.entry("request.resource.attr.scope", AttributeMapping.field("scope")) + ); + private static GenericContainer createCerbosContainer() { GenericContainer container = new GenericContainer<>("ghcr.io/cerbos/cerbos:latest") .withExposedPorts(3593) @@ -270,6 +276,7 @@ private static void seedData() { r1.setaNumber(1); r1.setaOptionalString("hello"); r1.setCreatedBy("user1"); + r1.setScope("a.b.c"); r1.setOwnedBy(new java.util.ArrayList<>(List.of("user1", "user2"))); r1.setTagNames(new java.util.ArrayList<>(List.of("public", "featured"))); r1.addTag("tag1", "public"); @@ -293,6 +300,7 @@ private static void seedData() { r2.setaString("amIAString?"); r2.setaNumber(2); r2.setCreatedBy("user2"); + r2.setScope("a.x"); r2.setOwnedBy(new java.util.ArrayList<>(List.of("user2"))); r2.setTagNames(new java.util.ArrayList<>(List.of("private"))); r2.addTag("tag3", "private"); @@ -316,6 +324,7 @@ private static void seedData() { r3.setaNumber(3); r3.setaOptionalString("world"); r3.setCreatedBy("user3"); + r3.setScope("a.b"); r3.setOwnedBy(new java.util.ArrayList<>(List.of("user1"))); r3.setTagNames(new java.util.ArrayList<>(List.of("public"))); r3.addTag("tag1", "public"); @@ -1151,4 +1160,43 @@ void sizeOfFilterThrows() { assertActionThrows("filter-count-gt", NESTED_FIELD_MAP, "size()"); } } + + @Nested + class HierarchyOperators { + + // Resource scopes seeded above: r1="a.b.c", r2="a.x", r3="a.b". + + @Test + void overlaps() { + // Policy: hierarchy(P.attr.context, ":").overlaps(hierarchy(["projects", R.id])) + // With context "projects:1" the segments are ["projects","1"] and the field segment is + // R.id, so the overlap reduces to id == "1". + Principal principal = Principal.newInstance("user1", "USER") + .withAttribute("context", AttributeValue.stringValue("projects:1")); + assertEquals(List.of("1"), runWithPrincipalAndMapping( + principal, "hierarchy-overlaps", HIERARCHY_MAP)); + } + + @Test + void ancestorOf() { + // Policy: hierarchy(P.attr.scope).ancestorOf(hierarchy(R.attr.scope)) + // P.attr.scope "a.b" must be a strict prefix of R.attr.scope → scope LIKE 'a.b.%'. + // Only r1 ("a.b.c") is a strict descendant; r3 ("a.b") is equal (not strict). + Principal principal = Principal.newInstance("user1", "USER") + .withAttribute("scope", AttributeValue.stringValue("a.b")); + assertEquals(List.of("1"), runWithPrincipalAndMapping( + principal, "hierarchy-ancestor-of", HIERARCHY_MAP)); + } + + @Test + void descendentOf() { + // Policy: hierarchy(R.attr.scope).descendentOf(hierarchy(P.attr.scope)) + // R.attr.scope must be a strict descendant of P.attr.scope "a.b" — same result as + // ancestorOf above (the relation is symmetric across the two operators). + Principal principal = Principal.newInstance("user1", "USER") + .withAttribute("scope", AttributeValue.stringValue("a.b")); + assertEquals(List.of("1"), runWithPrincipalAndMapping( + principal, "hierarchy-descendent-of", HIERARCHY_MAP)); + } + } } diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java index f4696ca9..804c177a 100644 --- a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/SpringDataQueryPlanAdapterTest.java @@ -830,6 +830,125 @@ void sizeOfFilterThrows() { } } + @Nested + class HierarchyOperators { + + // Helpers: a hierarchy(...) wrapper and a list(...) of segments. + private Operand hierarchy(Operand inner, String delimiter) { + return exprOp("hierarchy", inner, sval(delimiter)); + } + + private Operand hierarchy(Operand inner) { + return exprOp("hierarchy", inner); + } + + private Operand segList(Operand... segs) { + return exprOp("list", segs); + } + + @Test + void ancestorOfConstantPrefixOfField() { + // ancestorOf(hierarchy("a:b", ":"), hierarchy(field, ":")) → field LIKE 'a:b:%' + Operand cond = exprOp("ancestorOf", + hierarchy(sval("a:b"), ":"), + hierarchy(var("request.resource.attr.aString"), ":")); + assertEquals(0, runCount(cond)); + } + + @Test + void ancestorOfFieldPrefixOfConstant() { + // ancestorOf(hierarchy(field, ":"), hierarchy("a:b:c", ":")) → field IN ('a', 'a:b') + Operand cond = exprOp("ancestorOf", + hierarchy(var("request.resource.attr.aString"), ":"), + hierarchy(sval("a:b:c"), ":")); + assertEquals(0, runCount(cond)); + } + + @Test + void descendentOfFieldUnderConstant() { + // descendentOf(hierarchy(field, ":"), hierarchy("a:b", ":")) → field LIKE 'a:b:%' + Operand cond = exprOp("descendentOf", + hierarchy(var("request.resource.attr.aString"), ":"), + hierarchy(sval("a:b"), ":")); + assertEquals(0, runCount(cond)); + } + + @Test + void overlapsFieldHierarchyWithConstant() { + // overlaps(hierarchy(field, ":"), hierarchy("a:b", ":")) + // → field IN ('a') OR field = 'a:b' OR field LIKE 'a:b:%' + Operand cond = exprOp("overlaps", + hierarchy(var("request.resource.attr.aString"), ":"), + hierarchy(sval("a:b"), ":")); + assertEquals(0, runCount(cond)); + } + + @Test + void overlapsSegmentedWithField() { + // The policy shape: hierarchy("projects:123", ":").overlaps(hierarchy(["projects", R.id])) + // → segment-wise: const "projects" matches, then field == "123" + Operand cond = exprOp("overlaps", + hierarchy(sval("projects:123"), ":"), + hierarchy(segList(sval("projects"), var("request.resource.attr.aString")))); + assertEquals(0, runCount(cond)); + } + + @Test + void overlapsConstantsMatchingPrefixIsAlwaysTrue() { + // overlaps("a", "a:b") — "a" is a prefix of "a:b", all constant → unconditionally true. + Operand cond = exprOp("overlaps", + hierarchy(sval("a"), ":"), + hierarchy(sval("a:b"), ":")); + assertEquals(0, runCount(cond)); + } + + @Test + void ancestorOfConstantsSatisfied() { + // ancestorOf("a", "a:b") — satisfied by constants alone → unconditionally true. + Operand cond = exprOp("ancestorOf", + hierarchy(sval("a"), ":"), + hierarchy(sval("a:b"), ":")); + assertEquals(0, runCount(cond)); + } + + @Test + void overlapsIncompatibleConstantsWithoutFieldThrows() { + // overlaps("a:b", "x:y") — no prefix relationship and no field to constrain → planner bug. + assertConditionThrows( + exprOp("overlaps", + hierarchy(sval("a:b"), ":"), + hierarchy(sval("x:y"), ":")), + "Cannot determine hierarchy overlap"); + } + + @Test + void ancestorOfConstantsNotSatisfiedThrows() { + assertConditionThrows( + exprOp("ancestorOf", + hierarchy(sval("x"), ":"), + hierarchy(sval("a:b"), ":")), + "ancestorOf", "do not satisfy"); + } + + @Test + void nonHierarchyOperandThrows() { + assertConditionThrows( + exprOp("overlaps", + var("request.resource.attr.aString"), + sval("a:b")), + "overlaps", "hierarchy(...) operands"); + } + + @Test + void twoFieldHierarchiesInOverlapThrows() { + assertConditionThrows( + exprOp("overlaps", + hierarchy(var("request.resource.attr.aString"), ":"), + hierarchy(var("request.resource.attr.createdBy"), ":")), + "two field-reference hierarchies"); + } + } + @Test void operatorOverrideIsUsed() { Operand cond = exprOp("eq", var("request.resource.attr.aString"), sval("foo")); diff --git a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/ResourceEntity.java b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/ResourceEntity.java index d1c029b1..25ddcfd6 100644 --- a/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/ResourceEntity.java +++ b/spring-data/src/test/java/dev/cerbos/queryplan/springdata/testmodel/ResourceEntity.java @@ -43,6 +43,9 @@ public class ResourceEntity { @Column(name = "created_by") private String createdBy; + @Column(name = "scope") + private String scope; + @ElementCollection @CollectionTable(name = "resource_owned_by", joinColumns = @JoinColumn(name = "resource_id")) @Column(name = "owner") @@ -89,6 +92,8 @@ public ResourceEntity(String id) { public void setaOptionalString(String aOptionalString) { this.aOptionalString = aOptionalString; } public String getCreatedBy() { return createdBy; } public void setCreatedBy(String createdBy) { this.createdBy = createdBy; } + public String getScope() { return scope; } + public void setScope(String scope) { this.scope = scope; } public List getOwnedBy() { return ownedBy; } public void setOwnedBy(List ownedBy) { this.ownedBy = ownedBy; } public List getTagNames() { return tagNames; }