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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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<Operand> 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<Seg> leftSegs = toSegments(left);
List<Seg> rightSegs = toSegments(right);

List<Predicate> leftPrefixOfRight = checkPrefixConditions(leftSegs, rightSegs);
List<Predicate> rightPrefixOfLeft = checkPrefixConditions(rightSegs, leftSegs);

java.util.List<List<Predicate>> 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<Predicate> 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<Predicate> 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<String> strictPrefixes = getStrictPrefixes(constant.segments(), delimiter);

java.util.List<Predicate> 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<Operand> 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<String> 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<Operand> 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<Operand> 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<Seg> 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<String> 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<Seg> 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<Predicate> checkPrefixConditions(List<Seg> shorter, List<Seg> longer) {
if (shorter.size() > longer.size()) {
return null;
}
java.util.List<Predicate> 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<Seg> segs) {
return segs.stream().anyMatch(s -> s instanceof Seg.FieldSeg);
}

private static boolean isPrefix(List<String> shorter, List<String> 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<String> getStrictPrefixes(List<String> segments, String delimiter) {
if (segments.size() <= 1) {
return List.of();
}
java.util.List<String> 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<String> splitLiteral(String raw, String delimiter) {
return List.of(raw.split(java.util.regex.Pattern.quote(delimiter), -1));
}
}

// -- Scope --
Expand Down Expand Up @@ -860,6 +1126,25 @@ private static AttributeMapping walkRelationChain(AttributeMapping.Relation rel,
*/
record RelationChain(List<AttributeMapping.Relation> 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<String> 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<Seg> 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<String, AttributeMapping> mapper, String cerbosVar) {
AttributeMapping direct = mapper.get(cerbosVar);
if (direct instanceof AttributeMapping.Relation rel) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, AttributeMapping> 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)
Expand Down Expand Up @@ -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");
Expand All @@ -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");
Expand All @@ -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");
Expand Down Expand Up @@ -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));
}
}
}
Loading