@cerbos/orm-prisma 3.2.1 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -3
- package/lib/index.d.ts +29 -2
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +1792 -150
- package/lib/index.js.map +1 -1
- package/package.json +24 -11
- package/src/adversarial.test.ts +586 -0
- package/src/generated/prisma/client.ts +3 -1
- package/src/generated/prisma/commonInputTypes.ts +26 -0
- package/src/generated/prisma/internal/class.ts +22 -10
- package/src/generated/prisma/internal/prismaNamespace.ts +89 -19
- package/src/generated/prisma/internal/prismaNamespaceBrowser.ts +2 -1
- package/src/generated/prisma/models/Category.ts +6 -1
- package/src/generated/prisma/models/Label.ts +6 -1
- package/src/generated/prisma/models/NestedResource.ts +6 -1
- package/src/generated/prisma/models/NextLevelNestedResource.ts +6 -1
- package/src/generated/prisma/models/Resource.ts +67 -2
- package/src/generated/prisma/models/SubCategory.ts +6 -1
- package/src/generated/prisma/models/Tag.ts +6 -1
- package/src/generated/prisma/models/User.ts +6 -1
- package/src/generated/prisma-adversarial/browser.ts +44 -0
- package/src/generated/prisma-adversarial/client.ts +66 -0
- package/src/generated/prisma-adversarial/commonInputTypes.ts +352 -0
- package/src/generated/prisma-adversarial/enums.ts +15 -0
- package/src/generated/prisma-adversarial/internal/class.ts +244 -0
- package/src/generated/prisma-adversarial/internal/prismaNamespace.ts +1156 -0
- package/src/generated/prisma-adversarial/internal/prismaNamespaceBrowser.ts +140 -0
- package/src/generated/prisma-adversarial/models/AdversarialCategory.ts +1401 -0
- package/src/generated/prisma-adversarial/models/AdversarialLabel.ts +1272 -0
- package/src/generated/prisma-adversarial/models/AdversarialResource.ts +1665 -0
- package/src/generated/prisma-adversarial/models/AdversarialSubCategory.ts +1401 -0
- package/src/generated/prisma-adversarial/models/AdversarialTag.ts +1307 -0
- package/src/generated/prisma-adversarial/models.ts +16 -0
- package/src/generated/prisma-adversarial-v6/client.d.ts +1 -0
- package/src/generated/prisma-adversarial-v6/default.d.ts +1 -0
- package/src/generated/prisma-adversarial-v6/edge.d.ts +1 -0
- package/src/generated/prisma-adversarial-v6/index.d.ts +8640 -0
- package/src/generated/prisma-adversarial-v6/runtime/index-browser.d.ts +370 -0
- package/src/generated/prisma-adversarial-v6/runtime/library.d.ts +3982 -0
- package/src/generated/prisma-adversarial-v6/wasm.d.ts +1 -0
- package/src/generated/prisma-v6/index.d.ts +90 -3
- package/src/index.test.ts +3611 -241
- package/src/index.ts +2544 -174
- package/src/test-setup.adversarial.v6.ts +5 -0
- package/src/test-setup.adversarial.v7.ts +12 -0
package/src/index.test.ts
CHANGED
|
@@ -176,6 +176,7 @@ const fixtureResources: Prisma.ResourceCreateInput[] = [
|
|
|
176
176
|
aNumber: 1,
|
|
177
177
|
aString: "string",
|
|
178
178
|
aOptionalString: "optionalString",
|
|
179
|
+
aOptionalBool: true,
|
|
179
180
|
createdBy: {
|
|
180
181
|
connect: {
|
|
181
182
|
id: "user1",
|
|
@@ -207,6 +208,7 @@ const fixtureResources: Prisma.ResourceCreateInput[] = [
|
|
|
207
208
|
aBool: false,
|
|
208
209
|
aNumber: 2,
|
|
209
210
|
aString: "string2",
|
|
211
|
+
aOptionalBool: false,
|
|
210
212
|
createdBy: {
|
|
211
213
|
connect: {
|
|
212
214
|
id: "user2",
|
|
@@ -450,6 +452,109 @@ describe("Field Operations", () => {
|
|
|
450
452
|
);
|
|
451
453
|
});
|
|
452
454
|
|
|
455
|
+
test("conditional - eq - bool false", async () => {
|
|
456
|
+
const queryPlan = await cerbos.planResources({
|
|
457
|
+
principal: { id: "user1", roles: ["USER"] },
|
|
458
|
+
resource: { kind: "resource" },
|
|
459
|
+
action: "equal-bool-false",
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
|
|
463
|
+
expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual(
|
|
464
|
+
{
|
|
465
|
+
operator: "eq",
|
|
466
|
+
operands: [{ name: "request.resource.attr.aBool" }, { value: false }],
|
|
467
|
+
}
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
const result = queryPlanToPrisma({
|
|
471
|
+
queryPlan,
|
|
472
|
+
mapper: {
|
|
473
|
+
"request.resource.attr.aBool": { field: "aBool" },
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
expect(result).toStrictEqual({
|
|
478
|
+
kind: PlanKind.CONDITIONAL,
|
|
479
|
+
filters: { aBool: { equals: false } },
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
if (result.kind !== PlanKind.CONDITIONAL) {
|
|
483
|
+
throw new Error("Expected CONDITIONAL result");
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const query = await prisma.resource.findMany({
|
|
487
|
+
where: { ...result.filters },
|
|
488
|
+
});
|
|
489
|
+
expect(query.map((r) => r.id)).toEqual(
|
|
490
|
+
fixtureResources.filter((a) => a.aBool === false).map((r) => r.id)
|
|
491
|
+
);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test("conditional - eq - field to field", async () => {
|
|
495
|
+
// TODO: field-to-field comparison support is a follow-up — Prisma adapter
|
|
496
|
+
// currently only handles field-to-value. This test pins the plan shape and
|
|
497
|
+
// asserts the adapter throws so behaviour is locked in.
|
|
498
|
+
const queryPlan = await cerbos.planResources({
|
|
499
|
+
principal: { id: "user1", roles: ["USER"] },
|
|
500
|
+
resource: { kind: "resource" },
|
|
501
|
+
action: "equal-field-to-field",
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
|
|
505
|
+
expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual(
|
|
506
|
+
{
|
|
507
|
+
operator: "eq",
|
|
508
|
+
operands: [
|
|
509
|
+
{ name: "request.resource.attr.aString" },
|
|
510
|
+
{ name: "request.resource.attr.id" },
|
|
511
|
+
],
|
|
512
|
+
}
|
|
513
|
+
);
|
|
514
|
+
|
|
515
|
+
// Without the model name there is no container for a Prisma field reference.
|
|
516
|
+
expect(() =>
|
|
517
|
+
queryPlanToPrisma({
|
|
518
|
+
queryPlan,
|
|
519
|
+
mapper: {
|
|
520
|
+
"request.resource.attr.aString": { field: "aString" },
|
|
521
|
+
"request.resource.attr.id": { field: "id" },
|
|
522
|
+
},
|
|
523
|
+
})
|
|
524
|
+
).toThrow(
|
|
525
|
+
"Field-to-field comparison requires the `model` option (the Prisma model name) to build a field reference"
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
// With the model name, the comparison compiles to a Prisma field reference.
|
|
529
|
+
const result = queryPlanToPrisma({
|
|
530
|
+
queryPlan,
|
|
531
|
+
mapper: {
|
|
532
|
+
"request.resource.attr.aString": { field: "aString" },
|
|
533
|
+
"request.resource.attr.id": { field: "id" },
|
|
534
|
+
},
|
|
535
|
+
model: "Resource",
|
|
536
|
+
});
|
|
537
|
+
expect(result).toStrictEqual({
|
|
538
|
+
kind: PlanKind.CONDITIONAL,
|
|
539
|
+
filters: {
|
|
540
|
+
aString: { equals: { _ref: "id", _container: "Resource" } },
|
|
541
|
+
},
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
if (result.kind !== PlanKind.CONDITIONAL) {
|
|
545
|
+
throw new Error("Expected CONDITIONAL result");
|
|
546
|
+
}
|
|
547
|
+
const query = await prisma.resource.findMany({
|
|
548
|
+
where: { ...result.filters },
|
|
549
|
+
});
|
|
550
|
+
expect(query.map((r) => r.id).sort()).toEqual(
|
|
551
|
+
fixtureResources
|
|
552
|
+
.filter((r) => r.aString === r.id)
|
|
553
|
+
.map((r) => r.id)
|
|
554
|
+
.sort()
|
|
555
|
+
);
|
|
556
|
+
});
|
|
557
|
+
|
|
453
558
|
test("conditional - ne", async () => {
|
|
454
559
|
const queryPlan = await cerbos.planResources({
|
|
455
560
|
principal: { id: "user1", roles: ["USER"] },
|
|
@@ -1363,6 +1468,51 @@ describe("Field Operations", () => {
|
|
|
1363
1468
|
fixtureResources.filter((a) => a.aOptionalString).map((r) => r.id)
|
|
1364
1469
|
);
|
|
1365
1470
|
});
|
|
1471
|
+
|
|
1472
|
+
test("conditional - isNotSet", async () => {
|
|
1473
|
+
const queryPlan = await cerbos.planResources({
|
|
1474
|
+
principal: { id: "user1", roles: ["USER"] },
|
|
1475
|
+
resource: { kind: "resource" },
|
|
1476
|
+
action: "is-not-set",
|
|
1477
|
+
});
|
|
1478
|
+
|
|
1479
|
+
expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
|
|
1480
|
+
expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual(
|
|
1481
|
+
{
|
|
1482
|
+
operator: "eq",
|
|
1483
|
+
operands: [
|
|
1484
|
+
{ name: "request.resource.attr.aOptionalString" },
|
|
1485
|
+
{ value: null },
|
|
1486
|
+
],
|
|
1487
|
+
}
|
|
1488
|
+
);
|
|
1489
|
+
|
|
1490
|
+
const result = queryPlanToPrisma({
|
|
1491
|
+
queryPlan,
|
|
1492
|
+
mapper: {
|
|
1493
|
+
"request.resource.attr.aOptionalString": { field: "aOptionalString" },
|
|
1494
|
+
},
|
|
1495
|
+
});
|
|
1496
|
+
|
|
1497
|
+
expect(result).toStrictEqual({
|
|
1498
|
+
kind: PlanKind.CONDITIONAL,
|
|
1499
|
+
filters: { aOptionalString: { equals: null } },
|
|
1500
|
+
});
|
|
1501
|
+
|
|
1502
|
+
if (result.kind !== PlanKind.CONDITIONAL) {
|
|
1503
|
+
throw new Error("Expected CONDITIONAL result");
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
const query = await prisma.resource.findMany({
|
|
1507
|
+
where: { ...result.filters },
|
|
1508
|
+
});
|
|
1509
|
+
expect(query.map((r) => r.id).sort()).toEqual(
|
|
1510
|
+
fixtureResources
|
|
1511
|
+
.filter((a) => a.aOptionalString == null)
|
|
1512
|
+
.map((r) => r.id)
|
|
1513
|
+
.sort()
|
|
1514
|
+
);
|
|
1515
|
+
});
|
|
1366
1516
|
});
|
|
1367
1517
|
});
|
|
1368
1518
|
|
|
@@ -1417,6 +1567,53 @@ describe("Collection Operations", () => {
|
|
|
1417
1567
|
);
|
|
1418
1568
|
});
|
|
1419
1569
|
|
|
1570
|
+
test("conditional - in - number list", async () => {
|
|
1571
|
+
const queryPlan = await cerbos.planResources({
|
|
1572
|
+
principal: { id: "user1", roles: ["USER"] },
|
|
1573
|
+
resource: { kind: "resource" },
|
|
1574
|
+
action: "in-number",
|
|
1575
|
+
});
|
|
1576
|
+
|
|
1577
|
+
expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
|
|
1578
|
+
expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual(
|
|
1579
|
+
{
|
|
1580
|
+
operator: "in",
|
|
1581
|
+
operands: [
|
|
1582
|
+
{ name: "request.resource.attr.aNumber" },
|
|
1583
|
+
{ value: [1, 2, 3] },
|
|
1584
|
+
],
|
|
1585
|
+
}
|
|
1586
|
+
);
|
|
1587
|
+
|
|
1588
|
+
const result = queryPlanToPrisma({
|
|
1589
|
+
queryPlan,
|
|
1590
|
+
mapper: {
|
|
1591
|
+
"request.resource.attr.aNumber": { field: "aNumber" },
|
|
1592
|
+
},
|
|
1593
|
+
});
|
|
1594
|
+
|
|
1595
|
+
expect(result).toStrictEqual({
|
|
1596
|
+
kind: PlanKind.CONDITIONAL,
|
|
1597
|
+
filters: {
|
|
1598
|
+
aNumber: { in: [1, 2, 3] },
|
|
1599
|
+
},
|
|
1600
|
+
});
|
|
1601
|
+
|
|
1602
|
+
if (result.kind !== PlanKind.CONDITIONAL) {
|
|
1603
|
+
throw new Error("Expected CONDITIONAL result");
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
const query = await prisma.resource.findMany({
|
|
1607
|
+
where: { ...result.filters },
|
|
1608
|
+
});
|
|
1609
|
+
expect(query.map((r) => r.id).sort()).toEqual(
|
|
1610
|
+
fixtureResources
|
|
1611
|
+
.filter((r) => [1, 2, 3].includes(r.aNumber as number))
|
|
1612
|
+
.map((r) => r.id)
|
|
1613
|
+
.sort()
|
|
1614
|
+
);
|
|
1615
|
+
});
|
|
1616
|
+
|
|
1420
1617
|
test("conditional - in - scalar value", async () => {
|
|
1421
1618
|
const queryPlan = createConditionalPlan({
|
|
1422
1619
|
operator: "in",
|
|
@@ -1999,62 +2196,24 @@ describe("Collection Operations", () => {
|
|
|
1999
2196
|
}
|
|
2000
2197
|
);
|
|
2001
2198
|
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
expect(result).toStrictEqual({
|
|
2015
|
-
kind: PlanKind.CONDITIONAL,
|
|
2016
|
-
filters: {
|
|
2017
|
-
tags: {
|
|
2018
|
-
some: {
|
|
2019
|
-
name: { equals: "public" },
|
|
2020
|
-
},
|
|
2021
|
-
},
|
|
2022
|
-
AND: [
|
|
2023
|
-
{
|
|
2024
|
-
tags: {
|
|
2025
|
-
every: {
|
|
2026
|
-
OR: [
|
|
2027
|
-
{ name: { equals: "public" } },
|
|
2028
|
-
{ NOT: { name: { equals: "public" } } },
|
|
2029
|
-
],
|
|
2030
|
-
},
|
|
2199
|
+
// exists_one means EXACTLY one matching element, which requires counting matches —
|
|
2200
|
+
// inexpressible in Prisma's where shape. The previous translation silently degraded
|
|
2201
|
+
// to a plain exists (its `every: OR[P, NOT P]` clause was a tautology), returning
|
|
2202
|
+
// wrong rows whenever a resource had two or more matches; it now fails loudly.
|
|
2203
|
+
expect(() =>
|
|
2204
|
+
queryPlanToPrisma({
|
|
2205
|
+
queryPlan,
|
|
2206
|
+
mapper: {
|
|
2207
|
+
"request.resource.attr.tags": {
|
|
2208
|
+
relation: {
|
|
2209
|
+
name: "tags",
|
|
2210
|
+
type: "many",
|
|
2031
2211
|
},
|
|
2032
2212
|
},
|
|
2033
|
-
|
|
2034
|
-
}
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
if (result.kind !== PlanKind.CONDITIONAL) {
|
|
2038
|
-
throw new Error("Expected CONDITIONAL result");
|
|
2039
|
-
}
|
|
2040
|
-
|
|
2041
|
-
const query = await prisma.resource.findMany({
|
|
2042
|
-
where: { ...result.filters },
|
|
2043
|
-
});
|
|
2044
|
-
|
|
2045
|
-
expect(query.map((r) => r.id)).toEqual(
|
|
2046
|
-
fixtureResources
|
|
2047
|
-
.filter(
|
|
2048
|
-
(a) =>
|
|
2049
|
-
Array.isArray(a.tags?.connect) &&
|
|
2050
|
-
a.tags?.connect
|
|
2051
|
-
.map((t) => {
|
|
2052
|
-
return fixtureTags.find((f) => f.id === t.id);
|
|
2053
|
-
})
|
|
2054
|
-
.filter((t) => t?.id === "tag1" && t?.name === "public")
|
|
2055
|
-
.length > 0
|
|
2056
|
-
)
|
|
2057
|
-
.map((r) => r.id)
|
|
2213
|
+
},
|
|
2214
|
+
})
|
|
2215
|
+
).toThrow(
|
|
2216
|
+
"exists_one requires counting matching elements, which Prisma where-filters cannot express"
|
|
2058
2217
|
);
|
|
2059
2218
|
});
|
|
2060
2219
|
|
|
@@ -2276,32 +2435,231 @@ describe("Collection Operations", () => {
|
|
|
2276
2435
|
.map((r) => r.id)
|
|
2277
2436
|
);
|
|
2278
2437
|
});
|
|
2279
|
-
});
|
|
2280
|
-
});
|
|
2281
|
-
|
|
2282
|
-
// Relations
|
|
2283
|
-
describe("Relations", () => {
|
|
2284
|
-
describe("Simple Relations", () => {
|
|
2285
|
-
describe("One-to-One", () => {
|
|
2286
|
-
test("conditional - relation is", async () => {
|
|
2287
|
-
const queryPlan = await cerbos.planResources({
|
|
2288
|
-
principal: { id: "user1", roles: ["USER"] },
|
|
2289
|
-
resource: { kind: "resource" },
|
|
2290
|
-
action: "relation-is",
|
|
2291
|
-
});
|
|
2292
|
-
|
|
2293
|
-
expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
|
|
2294
2438
|
|
|
2295
|
-
|
|
2296
|
-
|
|
2439
|
+
test("conditional - all with multi-clause lambda body (all-nested)", async () => {
|
|
2440
|
+
const queryPlan = await cerbos.planResources({
|
|
2441
|
+
principal: { id: "user1", roles: ["USER"] },
|
|
2442
|
+
resource: { kind: "resource" },
|
|
2443
|
+
action: "all-nested",
|
|
2444
|
+
});
|
|
2297
2445
|
|
|
2298
|
-
|
|
2299
|
-
|
|
2446
|
+
expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
|
|
2447
|
+
expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual(
|
|
2448
|
+
{
|
|
2449
|
+
operator: "all",
|
|
2300
2450
|
operands: [
|
|
2301
|
-
{ name: "request.resource.attr.
|
|
2302
|
-
{
|
|
2303
|
-
|
|
2304
|
-
|
|
2451
|
+
{ name: "request.resource.attr.tags" },
|
|
2452
|
+
{
|
|
2453
|
+
operator: "lambda",
|
|
2454
|
+
operands: [
|
|
2455
|
+
{
|
|
2456
|
+
operator: "and",
|
|
2457
|
+
operands: [
|
|
2458
|
+
{
|
|
2459
|
+
operator: "eq",
|
|
2460
|
+
operands: [
|
|
2461
|
+
{ name: "tag.name" },
|
|
2462
|
+
{ value: "public" },
|
|
2463
|
+
],
|
|
2464
|
+
},
|
|
2465
|
+
{
|
|
2466
|
+
operator: "ne",
|
|
2467
|
+
operands: [
|
|
2468
|
+
{ name: "tag.id" },
|
|
2469
|
+
{ value: "tag1" },
|
|
2470
|
+
],
|
|
2471
|
+
},
|
|
2472
|
+
],
|
|
2473
|
+
},
|
|
2474
|
+
{ name: "tag" },
|
|
2475
|
+
],
|
|
2476
|
+
},
|
|
2477
|
+
],
|
|
2478
|
+
}
|
|
2479
|
+
);
|
|
2480
|
+
|
|
2481
|
+
const result = queryPlanToPrisma({
|
|
2482
|
+
queryPlan,
|
|
2483
|
+
mapper: {
|
|
2484
|
+
"request.resource.attr.tags": {
|
|
2485
|
+
relation: {
|
|
2486
|
+
name: "tags",
|
|
2487
|
+
type: "many",
|
|
2488
|
+
},
|
|
2489
|
+
},
|
|
2490
|
+
},
|
|
2491
|
+
});
|
|
2492
|
+
|
|
2493
|
+
expect(result).toStrictEqual({
|
|
2494
|
+
kind: PlanKind.CONDITIONAL,
|
|
2495
|
+
filters: {
|
|
2496
|
+
tags: {
|
|
2497
|
+
every: {
|
|
2498
|
+
AND: [
|
|
2499
|
+
{ name: { equals: "public" } },
|
|
2500
|
+
{ id: { not: "tag1" } },
|
|
2501
|
+
],
|
|
2502
|
+
},
|
|
2503
|
+
},
|
|
2504
|
+
},
|
|
2505
|
+
});
|
|
2506
|
+
|
|
2507
|
+
if (result.kind !== PlanKind.CONDITIONAL) {
|
|
2508
|
+
throw new Error("Expected CONDITIONAL result");
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
const query = await prisma.resource.findMany({
|
|
2512
|
+
where: { ...result.filters },
|
|
2513
|
+
});
|
|
2514
|
+
|
|
2515
|
+
expect(query.map((r) => r.id).sort()).toEqual(
|
|
2516
|
+
fixtureResources
|
|
2517
|
+
.filter((r) => {
|
|
2518
|
+
const connected = Array.isArray(r.tags?.connect)
|
|
2519
|
+
? r.tags!.connect.map((c) =>
|
|
2520
|
+
fixtureTags.find((ft) => ft.id === c.id)
|
|
2521
|
+
)
|
|
2522
|
+
: [];
|
|
2523
|
+
return connected.every(
|
|
2524
|
+
(t) => t?.name === "public" && t?.id !== "tag1"
|
|
2525
|
+
);
|
|
2526
|
+
})
|
|
2527
|
+
.map((r) => r.id)
|
|
2528
|
+
.sort()
|
|
2529
|
+
);
|
|
2530
|
+
});
|
|
2531
|
+
|
|
2532
|
+
test("conditional - map() compared to literal list throws (map-compared)", async () => {
|
|
2533
|
+
// prisma does not support comparing `map(...)` directly to a list
|
|
2534
|
+
// literal — `hasIntersection(map(...), [...])` is the supported form.
|
|
2535
|
+
const queryPlan = await cerbos.planResources({
|
|
2536
|
+
principal: { id: "user1", roles: ["USER"] },
|
|
2537
|
+
resource: { kind: "resource" },
|
|
2538
|
+
action: "map-compared",
|
|
2539
|
+
});
|
|
2540
|
+
|
|
2541
|
+
expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
|
|
2542
|
+
expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual(
|
|
2543
|
+
{
|
|
2544
|
+
operator: "eq",
|
|
2545
|
+
operands: [
|
|
2546
|
+
{
|
|
2547
|
+
operator: "map",
|
|
2548
|
+
operands: [
|
|
2549
|
+
{ name: "request.resource.attr.tags" },
|
|
2550
|
+
{
|
|
2551
|
+
operator: "lambda",
|
|
2552
|
+
operands: [{ name: "t.id" }, { name: "t" }],
|
|
2553
|
+
},
|
|
2554
|
+
],
|
|
2555
|
+
},
|
|
2556
|
+
{ value: ["tag1", "tag2"] },
|
|
2557
|
+
],
|
|
2558
|
+
}
|
|
2559
|
+
);
|
|
2560
|
+
|
|
2561
|
+
expect(() =>
|
|
2562
|
+
queryPlanToPrisma({
|
|
2563
|
+
queryPlan,
|
|
2564
|
+
mapper: {
|
|
2565
|
+
"request.resource.attr.tags": {
|
|
2566
|
+
relation: {
|
|
2567
|
+
name: "tags",
|
|
2568
|
+
type: "many",
|
|
2569
|
+
fields: {
|
|
2570
|
+
id: { field: "id" },
|
|
2571
|
+
},
|
|
2572
|
+
},
|
|
2573
|
+
},
|
|
2574
|
+
},
|
|
2575
|
+
})
|
|
2576
|
+
).toThrow(/map\(\.\.\.\) to a value is not supported/);
|
|
2577
|
+
});
|
|
2578
|
+
|
|
2579
|
+
test("conditional - size(filter(...)) > 0 throws (filter-count-gt)", async () => {
|
|
2580
|
+
// TODO(#232): prisma adapter supports size(R.attr.collection) > N but
|
|
2581
|
+
// does not yet unwrap size(filter(R.attr.collection, lambda)) > N.
|
|
2582
|
+
// Locks in current behavior.
|
|
2583
|
+
const queryPlan = await cerbos.planResources({
|
|
2584
|
+
principal: { id: "user1", roles: ["USER"] },
|
|
2585
|
+
resource: { kind: "resource" },
|
|
2586
|
+
action: "filter-count-gt",
|
|
2587
|
+
});
|
|
2588
|
+
|
|
2589
|
+
expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
|
|
2590
|
+
expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual(
|
|
2591
|
+
{
|
|
2592
|
+
operator: "gt",
|
|
2593
|
+
operands: [
|
|
2594
|
+
{
|
|
2595
|
+
operator: "size",
|
|
2596
|
+
operands: [
|
|
2597
|
+
{
|
|
2598
|
+
operator: "filter",
|
|
2599
|
+
operands: [
|
|
2600
|
+
{ name: "request.resource.attr.tags" },
|
|
2601
|
+
{
|
|
2602
|
+
operator: "lambda",
|
|
2603
|
+
operands: [
|
|
2604
|
+
{
|
|
2605
|
+
operator: "eq",
|
|
2606
|
+
operands: [
|
|
2607
|
+
{ name: "t.name" },
|
|
2608
|
+
{ value: "public" },
|
|
2609
|
+
],
|
|
2610
|
+
},
|
|
2611
|
+
{ name: "t" },
|
|
2612
|
+
],
|
|
2613
|
+
},
|
|
2614
|
+
],
|
|
2615
|
+
},
|
|
2616
|
+
],
|
|
2617
|
+
},
|
|
2618
|
+
{ value: 0 },
|
|
2619
|
+
],
|
|
2620
|
+
}
|
|
2621
|
+
);
|
|
2622
|
+
|
|
2623
|
+
expect(() =>
|
|
2624
|
+
queryPlanToPrisma({
|
|
2625
|
+
queryPlan,
|
|
2626
|
+
mapper: {
|
|
2627
|
+
"request.resource.attr.tags": {
|
|
2628
|
+
relation: {
|
|
2629
|
+
name: "tags",
|
|
2630
|
+
type: "many",
|
|
2631
|
+
},
|
|
2632
|
+
},
|
|
2633
|
+
},
|
|
2634
|
+
})
|
|
2635
|
+
).toThrow();
|
|
2636
|
+
});
|
|
2637
|
+
});
|
|
2638
|
+
});
|
|
2639
|
+
|
|
2640
|
+
// Relations
|
|
2641
|
+
describe("Relations", () => {
|
|
2642
|
+
describe("Simple Relations", () => {
|
|
2643
|
+
describe("One-to-One", () => {
|
|
2644
|
+
test("conditional - relation is", async () => {
|
|
2645
|
+
const queryPlan = await cerbos.planResources({
|
|
2646
|
+
principal: { id: "user1", roles: ["USER"] },
|
|
2647
|
+
resource: { kind: "resource" },
|
|
2648
|
+
action: "relation-is",
|
|
2649
|
+
});
|
|
2650
|
+
|
|
2651
|
+
expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
|
|
2652
|
+
|
|
2653
|
+
const conditions = (queryPlan as PlanResourcesConditionalResponse)
|
|
2654
|
+
.condition;
|
|
2655
|
+
|
|
2656
|
+
expect(conditions).toEqual({
|
|
2657
|
+
operator: "eq",
|
|
2658
|
+
operands: [
|
|
2659
|
+
{ name: "request.resource.attr.createdBy" },
|
|
2660
|
+
{ value: "user1" },
|
|
2661
|
+
],
|
|
2662
|
+
});
|
|
2305
2663
|
|
|
2306
2664
|
const result = queryPlanToPrisma({
|
|
2307
2665
|
queryPlan,
|
|
@@ -4100,30 +4458,42 @@ describe("Complex Operations", () => {
|
|
|
4100
4458
|
);
|
|
4101
4459
|
});
|
|
4102
4460
|
|
|
4103
|
-
test("conditional -
|
|
4461
|
+
test("conditional - or - leaf and exists", async () => {
|
|
4104
4462
|
const queryPlan = await cerbos.planResources({
|
|
4105
4463
|
principal: { id: "user1", roles: ["USER"] },
|
|
4106
4464
|
resource: { kind: "resource" },
|
|
4107
|
-
action: "
|
|
4465
|
+
action: "or-leaf-exists",
|
|
4108
4466
|
});
|
|
4109
4467
|
|
|
4110
4468
|
expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
|
|
4111
4469
|
expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual(
|
|
4112
4470
|
{
|
|
4113
|
-
operator: "
|
|
4471
|
+
operator: "or",
|
|
4114
4472
|
operands: [
|
|
4115
4473
|
{
|
|
4116
|
-
operator: "
|
|
4474
|
+
operator: "eq",
|
|
4117
4475
|
operands: [
|
|
4118
|
-
{ name: "request.resource.attr.
|
|
4119
|
-
{ value:
|
|
4476
|
+
{ name: "request.resource.attr.aBool" },
|
|
4477
|
+
{ value: true },
|
|
4120
4478
|
],
|
|
4121
4479
|
},
|
|
4122
4480
|
{
|
|
4123
|
-
operator: "
|
|
4481
|
+
operator: "exists",
|
|
4124
4482
|
operands: [
|
|
4125
|
-
{ name: "request.resource.attr.
|
|
4126
|
-
{
|
|
4483
|
+
{ name: "request.resource.attr.tags" },
|
|
4484
|
+
{
|
|
4485
|
+
operator: "lambda",
|
|
4486
|
+
operands: [
|
|
4487
|
+
{
|
|
4488
|
+
operator: "eq",
|
|
4489
|
+
operands: [
|
|
4490
|
+
{ name: "t.name" },
|
|
4491
|
+
{ value: "public" },
|
|
4492
|
+
],
|
|
4493
|
+
},
|
|
4494
|
+
{ name: "t" },
|
|
4495
|
+
],
|
|
4496
|
+
},
|
|
4127
4497
|
],
|
|
4128
4498
|
},
|
|
4129
4499
|
],
|
|
@@ -4133,10 +4503,11 @@ describe("Complex Operations", () => {
|
|
|
4133
4503
|
const result = queryPlanToPrisma({
|
|
4134
4504
|
queryPlan,
|
|
4135
4505
|
mapper: {
|
|
4136
|
-
"request.resource.attr.
|
|
4506
|
+
"request.resource.attr.aBool": { field: "aBool" },
|
|
4507
|
+
"request.resource.attr.tags": {
|
|
4137
4508
|
relation: {
|
|
4138
|
-
name: "
|
|
4139
|
-
type: "
|
|
4509
|
+
name: "tags",
|
|
4510
|
+
type: "many",
|
|
4140
4511
|
},
|
|
4141
4512
|
},
|
|
4142
4513
|
},
|
|
@@ -4145,21 +4516,9 @@ describe("Complex Operations", () => {
|
|
|
4145
4516
|
expect(result).toStrictEqual({
|
|
4146
4517
|
kind: PlanKind.CONDITIONAL,
|
|
4147
4518
|
filters: {
|
|
4148
|
-
|
|
4149
|
-
{
|
|
4150
|
-
|
|
4151
|
-
is: {
|
|
4152
|
-
aNumber: { gt: 1 },
|
|
4153
|
-
},
|
|
4154
|
-
},
|
|
4155
|
-
},
|
|
4156
|
-
{
|
|
4157
|
-
nested: {
|
|
4158
|
-
is: {
|
|
4159
|
-
aNumber: { lt: 3 },
|
|
4160
|
-
},
|
|
4161
|
-
},
|
|
4162
|
-
},
|
|
4519
|
+
OR: [
|
|
4520
|
+
{ aBool: { equals: true } },
|
|
4521
|
+
{ tags: { some: { name: { equals: "public" } } } },
|
|
4163
4522
|
],
|
|
4164
4523
|
},
|
|
4165
4524
|
});
|
|
@@ -4171,43 +4530,131 @@ describe("Complex Operations", () => {
|
|
|
4171
4530
|
const query = await prisma.resource.findMany({
|
|
4172
4531
|
where: { ...result.filters },
|
|
4173
4532
|
});
|
|
4174
|
-
|
|
4175
|
-
expect(query.map((r) => r.id)).toEqual(
|
|
4533
|
+
expect(query.map((r) => r.id).sort()).toEqual(
|
|
4176
4534
|
fixtureResources
|
|
4177
4535
|
.filter((r) => {
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
4183
|
-
|
|
4184
|
-
|
|
4536
|
+
if (r.aBool) return true;
|
|
4537
|
+
const tagRefs = Array.isArray(r.tags?.connect)
|
|
4538
|
+
? r.tags.connect
|
|
4539
|
+
: [];
|
|
4540
|
+
return tagRefs.some((tagRef) => {
|
|
4541
|
+
const tag = fixtureTags.find((t) => t.id === tagRef.id);
|
|
4542
|
+
return tag?.name === "public";
|
|
4543
|
+
});
|
|
4185
4544
|
})
|
|
4186
4545
|
.map((r) => r.id)
|
|
4546
|
+
.sort()
|
|
4187
4547
|
);
|
|
4188
4548
|
});
|
|
4189
4549
|
|
|
4190
|
-
test("conditional - relation multiple
|
|
4550
|
+
test("conditional - relation multiple all", async () => {
|
|
4191
4551
|
const queryPlan = await cerbos.planResources({
|
|
4192
4552
|
principal: { id: "user1", roles: ["USER"] },
|
|
4193
4553
|
resource: { kind: "resource" },
|
|
4194
|
-
action: "relation-multiple-
|
|
4554
|
+
action: "relation-multiple-all",
|
|
4195
4555
|
});
|
|
4196
4556
|
|
|
4197
4557
|
expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
|
|
4198
4558
|
expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual(
|
|
4199
4559
|
{
|
|
4200
|
-
operator: "
|
|
4560
|
+
operator: "and",
|
|
4201
4561
|
operands: [
|
|
4202
4562
|
{
|
|
4203
|
-
operator: "
|
|
4563
|
+
operator: "gt",
|
|
4204
4564
|
operands: [
|
|
4205
|
-
{ name: "request.resource.attr.
|
|
4206
|
-
{ value:
|
|
4565
|
+
{ name: "request.resource.attr.nested.aNumber" },
|
|
4566
|
+
{ value: 1 },
|
|
4207
4567
|
],
|
|
4208
4568
|
},
|
|
4209
4569
|
{
|
|
4210
|
-
operator: "
|
|
4570
|
+
operator: "lt",
|
|
4571
|
+
operands: [
|
|
4572
|
+
{ name: "request.resource.attr.nested.aNumber" },
|
|
4573
|
+
{ value: 3 },
|
|
4574
|
+
],
|
|
4575
|
+
},
|
|
4576
|
+
],
|
|
4577
|
+
}
|
|
4578
|
+
);
|
|
4579
|
+
|
|
4580
|
+
const result = queryPlanToPrisma({
|
|
4581
|
+
queryPlan,
|
|
4582
|
+
mapper: {
|
|
4583
|
+
"request.resource.attr.nested": {
|
|
4584
|
+
relation: {
|
|
4585
|
+
name: "nested",
|
|
4586
|
+
type: "one",
|
|
4587
|
+
},
|
|
4588
|
+
},
|
|
4589
|
+
},
|
|
4590
|
+
});
|
|
4591
|
+
|
|
4592
|
+
expect(result).toStrictEqual({
|
|
4593
|
+
kind: PlanKind.CONDITIONAL,
|
|
4594
|
+
filters: {
|
|
4595
|
+
AND: [
|
|
4596
|
+
{
|
|
4597
|
+
nested: {
|
|
4598
|
+
is: {
|
|
4599
|
+
aNumber: { gt: 1 },
|
|
4600
|
+
},
|
|
4601
|
+
},
|
|
4602
|
+
},
|
|
4603
|
+
{
|
|
4604
|
+
nested: {
|
|
4605
|
+
is: {
|
|
4606
|
+
aNumber: { lt: 3 },
|
|
4607
|
+
},
|
|
4608
|
+
},
|
|
4609
|
+
},
|
|
4610
|
+
],
|
|
4611
|
+
},
|
|
4612
|
+
});
|
|
4613
|
+
|
|
4614
|
+
if (result.kind !== PlanKind.CONDITIONAL) {
|
|
4615
|
+
throw new Error("Expected CONDITIONAL result");
|
|
4616
|
+
}
|
|
4617
|
+
|
|
4618
|
+
const query = await prisma.resource.findMany({
|
|
4619
|
+
where: { ...result.filters },
|
|
4620
|
+
});
|
|
4621
|
+
|
|
4622
|
+
expect(query.map((r) => r.id)).toEqual(
|
|
4623
|
+
fixtureResources
|
|
4624
|
+
.filter((r) => {
|
|
4625
|
+
const nestedResource = fixtureNestedResources.find(
|
|
4626
|
+
(n) => n.id === r.nested.connect?.id
|
|
4627
|
+
);
|
|
4628
|
+
return (
|
|
4629
|
+
(nestedResource?.aNumber ?? 0) > 1 &&
|
|
4630
|
+
(nestedResource?.aNumber ?? 0) < 3
|
|
4631
|
+
);
|
|
4632
|
+
})
|
|
4633
|
+
.map((r) => r.id)
|
|
4634
|
+
);
|
|
4635
|
+
});
|
|
4636
|
+
|
|
4637
|
+
test("conditional - relation multiple or", async () => {
|
|
4638
|
+
const queryPlan = await cerbos.planResources({
|
|
4639
|
+
principal: { id: "user1", roles: ["USER"] },
|
|
4640
|
+
resource: { kind: "resource" },
|
|
4641
|
+
action: "relation-multiple-or",
|
|
4642
|
+
});
|
|
4643
|
+
|
|
4644
|
+
expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
|
|
4645
|
+
expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual(
|
|
4646
|
+
{
|
|
4647
|
+
operator: "or",
|
|
4648
|
+
operands: [
|
|
4649
|
+
{
|
|
4650
|
+
operator: "eq",
|
|
4651
|
+
operands: [
|
|
4652
|
+
{ name: "request.resource.attr.createdBy" },
|
|
4653
|
+
{ value: "user1" },
|
|
4654
|
+
],
|
|
4655
|
+
},
|
|
4656
|
+
{
|
|
4657
|
+
operator: "in",
|
|
4211
4658
|
operands: [
|
|
4212
4659
|
{ value: "user1" },
|
|
4213
4660
|
{ name: "request.resource.attr.ownedBy" },
|
|
@@ -4394,54 +4841,58 @@ describe("Complex Operations", () => {
|
|
|
4394
4841
|
});
|
|
4395
4842
|
});
|
|
4396
4843
|
|
|
4397
|
-
describe("
|
|
4398
|
-
test("conditional -
|
|
4844
|
+
describe("Negation Operations (DeMorgan)", () => {
|
|
4845
|
+
test("conditional - not-and", async () => {
|
|
4399
4846
|
const queryPlan = await cerbos.planResources({
|
|
4400
4847
|
principal: { id: "user1", roles: ["USER"] },
|
|
4401
4848
|
resource: { kind: "resource" },
|
|
4402
|
-
action: "
|
|
4849
|
+
action: "not-and",
|
|
4403
4850
|
});
|
|
4404
4851
|
|
|
4852
|
+
expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
|
|
4853
|
+
expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual(
|
|
4854
|
+
{
|
|
4855
|
+
operator: "not",
|
|
4856
|
+
operands: [
|
|
4857
|
+
{
|
|
4858
|
+
operator: "and",
|
|
4859
|
+
operands: [
|
|
4860
|
+
{
|
|
4861
|
+
operator: "eq",
|
|
4862
|
+
operands: [
|
|
4863
|
+
{ name: "request.resource.attr.aBool" },
|
|
4864
|
+
{ value: true },
|
|
4865
|
+
],
|
|
4866
|
+
},
|
|
4867
|
+
{
|
|
4868
|
+
operator: "ne",
|
|
4869
|
+
operands: [
|
|
4870
|
+
{ name: "request.resource.attr.aString" },
|
|
4871
|
+
{ value: "string" },
|
|
4872
|
+
],
|
|
4873
|
+
},
|
|
4874
|
+
],
|
|
4875
|
+
},
|
|
4876
|
+
],
|
|
4877
|
+
}
|
|
4878
|
+
);
|
|
4879
|
+
|
|
4405
4880
|
const result = queryPlanToPrisma({
|
|
4406
4881
|
queryPlan,
|
|
4407
4882
|
mapper: {
|
|
4408
|
-
"request.resource.attr.
|
|
4409
|
-
|
|
4410
|
-
name: "categories",
|
|
4411
|
-
type: "many",
|
|
4412
|
-
fields: {
|
|
4413
|
-
name: { field: "name" },
|
|
4414
|
-
subCategories: {
|
|
4415
|
-
relation: {
|
|
4416
|
-
name: "subCategories",
|
|
4417
|
-
type: "many",
|
|
4418
|
-
fields: {
|
|
4419
|
-
name: { field: "name" },
|
|
4420
|
-
},
|
|
4421
|
-
},
|
|
4422
|
-
},
|
|
4423
|
-
},
|
|
4424
|
-
},
|
|
4425
|
-
},
|
|
4883
|
+
"request.resource.attr.aBool": { field: "aBool" },
|
|
4884
|
+
"request.resource.attr.aString": { field: "aString" },
|
|
4426
4885
|
},
|
|
4427
4886
|
});
|
|
4428
4887
|
|
|
4429
4888
|
expect(result).toStrictEqual({
|
|
4430
4889
|
kind: PlanKind.CONDITIONAL,
|
|
4431
4890
|
filters: {
|
|
4432
|
-
|
|
4433
|
-
|
|
4434
|
-
|
|
4435
|
-
|
|
4436
|
-
|
|
4437
|
-
subCategories: {
|
|
4438
|
-
some: {
|
|
4439
|
-
name: { equals: "finance" },
|
|
4440
|
-
},
|
|
4441
|
-
},
|
|
4442
|
-
},
|
|
4443
|
-
],
|
|
4444
|
-
},
|
|
4891
|
+
NOT: {
|
|
4892
|
+
AND: [
|
|
4893
|
+
{ aBool: { equals: true } },
|
|
4894
|
+
{ aString: { not: "string" } },
|
|
4895
|
+
],
|
|
4445
4896
|
},
|
|
4446
4897
|
},
|
|
4447
4898
|
});
|
|
@@ -4453,120 +4904,293 @@ describe("Complex Operations", () => {
|
|
|
4453
4904
|
const query = await prisma.resource.findMany({
|
|
4454
4905
|
where: { ...result.filters },
|
|
4455
4906
|
});
|
|
4456
|
-
|
|
4457
4907
|
expect(query.map((r) => r.id)).toEqual(
|
|
4458
4908
|
fixtureResources
|
|
4459
|
-
.filter((r) =>
|
|
4460
|
-
const categoryConnections = r.categories
|
|
4461
|
-
?.connect as Prisma.CategoryWhereUniqueInput[];
|
|
4462
|
-
return categoryConnections?.some((categoryConnection) => {
|
|
4463
|
-
const category = fixtureCategories.find(
|
|
4464
|
-
(fc) => fc.id === categoryConnection.id
|
|
4465
|
-
);
|
|
4466
|
-
return (
|
|
4467
|
-
category?.name === "business" &&
|
|
4468
|
-
(
|
|
4469
|
-
category?.subCategories
|
|
4470
|
-
?.connect as Prisma.SubCategoryWhereUniqueInput[]
|
|
4471
|
-
)?.some(
|
|
4472
|
-
(sc) =>
|
|
4473
|
-
fixtureSubCategories.find((fsc) => fsc.id === sc.id)
|
|
4474
|
-
?.name === "finance"
|
|
4475
|
-
)
|
|
4476
|
-
);
|
|
4477
|
-
});
|
|
4478
|
-
})
|
|
4909
|
+
.filter((r) => !(r.aBool && r.aString != "string"))
|
|
4479
4910
|
.map((r) => r.id)
|
|
4480
4911
|
);
|
|
4481
4912
|
});
|
|
4482
4913
|
|
|
4483
|
-
test("
|
|
4914
|
+
test("conditional - not-or", async () => {
|
|
4484
4915
|
const queryPlan = await cerbos.planResources({
|
|
4485
4916
|
principal: { id: "user1", roles: ["USER"] },
|
|
4486
4917
|
resource: { kind: "resource" },
|
|
4487
|
-
action: "
|
|
4918
|
+
action: "not-or",
|
|
4488
4919
|
});
|
|
4489
4920
|
|
|
4921
|
+
expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
|
|
4922
|
+
expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual(
|
|
4923
|
+
{
|
|
4924
|
+
operator: "not",
|
|
4925
|
+
operands: [
|
|
4926
|
+
{
|
|
4927
|
+
operator: "or",
|
|
4928
|
+
operands: [
|
|
4929
|
+
{
|
|
4930
|
+
operator: "eq",
|
|
4931
|
+
operands: [
|
|
4932
|
+
{ name: "request.resource.attr.aBool" },
|
|
4933
|
+
{ value: true },
|
|
4934
|
+
],
|
|
4935
|
+
},
|
|
4936
|
+
{
|
|
4937
|
+
operator: "ne",
|
|
4938
|
+
operands: [
|
|
4939
|
+
{ name: "request.resource.attr.aString" },
|
|
4940
|
+
{ value: "string" },
|
|
4941
|
+
],
|
|
4942
|
+
},
|
|
4943
|
+
],
|
|
4944
|
+
},
|
|
4945
|
+
],
|
|
4946
|
+
}
|
|
4947
|
+
);
|
|
4948
|
+
|
|
4490
4949
|
const result = queryPlanToPrisma({
|
|
4491
4950
|
queryPlan,
|
|
4492
4951
|
mapper: {
|
|
4493
|
-
"request.resource.attr.
|
|
4494
|
-
|
|
4495
|
-
name: "nested",
|
|
4496
|
-
type: "one",
|
|
4497
|
-
fields: {
|
|
4498
|
-
aOptionalString: { field: "aOptionalString" },
|
|
4499
|
-
},
|
|
4500
|
-
},
|
|
4501
|
-
},
|
|
4952
|
+
"request.resource.attr.aBool": { field: "aBool" },
|
|
4953
|
+
"request.resource.attr.aString": { field: "aString" },
|
|
4502
4954
|
},
|
|
4503
4955
|
});
|
|
4504
4956
|
|
|
4505
4957
|
expect(result).toStrictEqual({
|
|
4506
4958
|
kind: PlanKind.CONDITIONAL,
|
|
4507
4959
|
filters: {
|
|
4508
|
-
|
|
4509
|
-
|
|
4510
|
-
|
|
4511
|
-
|
|
4960
|
+
NOT: {
|
|
4961
|
+
OR: [
|
|
4962
|
+
{ aBool: { equals: true } },
|
|
4963
|
+
{ aString: { not: "string" } },
|
|
4964
|
+
],
|
|
4512
4965
|
},
|
|
4513
4966
|
},
|
|
4514
4967
|
});
|
|
4968
|
+
|
|
4969
|
+
if (result.kind !== PlanKind.CONDITIONAL) {
|
|
4970
|
+
throw new Error("Expected CONDITIONAL result");
|
|
4971
|
+
}
|
|
4972
|
+
|
|
4973
|
+
const query = await prisma.resource.findMany({
|
|
4974
|
+
where: { ...result.filters },
|
|
4975
|
+
});
|
|
4976
|
+
expect(query.map((r) => r.id)).toEqual(
|
|
4977
|
+
fixtureResources
|
|
4978
|
+
.filter((r) => !(r.aBool || r.aString != "string"))
|
|
4979
|
+
.map((r) => r.id)
|
|
4980
|
+
);
|
|
4515
4981
|
});
|
|
4516
4982
|
|
|
4517
|
-
test("
|
|
4983
|
+
test("conditional - not-gt", async () => {
|
|
4518
4984
|
const queryPlan = await cerbos.planResources({
|
|
4519
4985
|
principal: { id: "user1", roles: ["USER"] },
|
|
4520
4986
|
resource: { kind: "resource" },
|
|
4521
|
-
action: "
|
|
4987
|
+
action: "not-gt",
|
|
4522
4988
|
});
|
|
4523
4989
|
|
|
4990
|
+
expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
|
|
4991
|
+
expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual(
|
|
4992
|
+
{
|
|
4993
|
+
operator: "not",
|
|
4994
|
+
operands: [
|
|
4995
|
+
{
|
|
4996
|
+
operator: "gt",
|
|
4997
|
+
operands: [
|
|
4998
|
+
{ name: "request.resource.attr.aNumber" },
|
|
4999
|
+
{ value: 1 },
|
|
5000
|
+
],
|
|
5001
|
+
},
|
|
5002
|
+
],
|
|
5003
|
+
}
|
|
5004
|
+
);
|
|
5005
|
+
|
|
4524
5006
|
const result = queryPlanToPrisma({
|
|
4525
5007
|
queryPlan,
|
|
4526
5008
|
mapper: {
|
|
4527
|
-
"request.resource.attr.
|
|
4528
|
-
relation: {
|
|
4529
|
-
name: "categories",
|
|
4530
|
-
type: "many",
|
|
4531
|
-
fields: {
|
|
4532
|
-
subCategories: {
|
|
4533
|
-
relation: {
|
|
4534
|
-
name: "subCategories",
|
|
4535
|
-
type: "many",
|
|
4536
|
-
fields: {
|
|
4537
|
-
name: { field: "name" },
|
|
4538
|
-
},
|
|
4539
|
-
},
|
|
4540
|
-
},
|
|
4541
|
-
},
|
|
4542
|
-
},
|
|
4543
|
-
},
|
|
5009
|
+
"request.resource.attr.aNumber": { field: "aNumber" },
|
|
4544
5010
|
},
|
|
4545
5011
|
});
|
|
4546
5012
|
|
|
4547
5013
|
expect(result).toStrictEqual({
|
|
4548
5014
|
kind: PlanKind.CONDITIONAL,
|
|
4549
5015
|
filters: {
|
|
4550
|
-
|
|
4551
|
-
|
|
4552
|
-
|
|
4553
|
-
|
|
4554
|
-
|
|
4555
|
-
|
|
4556
|
-
|
|
5016
|
+
NOT: { aNumber: { gt: 1 } },
|
|
5017
|
+
},
|
|
5018
|
+
});
|
|
5019
|
+
|
|
5020
|
+
if (result.kind !== PlanKind.CONDITIONAL) {
|
|
5021
|
+
throw new Error("Expected CONDITIONAL result");
|
|
5022
|
+
}
|
|
5023
|
+
|
|
5024
|
+
const query = await prisma.resource.findMany({
|
|
5025
|
+
where: { ...result.filters },
|
|
5026
|
+
});
|
|
5027
|
+
expect(query.map((r) => r.id)).toEqual(
|
|
5028
|
+
fixtureResources
|
|
5029
|
+
.filter((r) => !(r.aNumber > 1))
|
|
5030
|
+
.map((r) => r.id)
|
|
5031
|
+
);
|
|
5032
|
+
});
|
|
5033
|
+
|
|
5034
|
+
test("conditional - not-lt", async () => {
|
|
5035
|
+
const queryPlan = await cerbos.planResources({
|
|
5036
|
+
principal: { id: "user1", roles: ["USER"] },
|
|
5037
|
+
resource: { kind: "resource" },
|
|
5038
|
+
action: "not-lt",
|
|
5039
|
+
});
|
|
5040
|
+
|
|
5041
|
+
expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
|
|
5042
|
+
expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual(
|
|
5043
|
+
{
|
|
5044
|
+
operator: "not",
|
|
5045
|
+
operands: [
|
|
5046
|
+
{
|
|
5047
|
+
operator: "lt",
|
|
5048
|
+
operands: [
|
|
5049
|
+
{ name: "request.resource.attr.aNumber" },
|
|
5050
|
+
{ value: 2 },
|
|
5051
|
+
],
|
|
4557
5052
|
},
|
|
4558
|
-
|
|
5053
|
+
],
|
|
5054
|
+
}
|
|
5055
|
+
);
|
|
5056
|
+
|
|
5057
|
+
const result = queryPlanToPrisma({
|
|
5058
|
+
queryPlan,
|
|
5059
|
+
mapper: {
|
|
5060
|
+
"request.resource.attr.aNumber": { field: "aNumber" },
|
|
5061
|
+
},
|
|
5062
|
+
});
|
|
5063
|
+
|
|
5064
|
+
expect(result).toStrictEqual({
|
|
5065
|
+
kind: PlanKind.CONDITIONAL,
|
|
5066
|
+
filters: {
|
|
5067
|
+
NOT: { aNumber: { lt: 2 } },
|
|
5068
|
+
},
|
|
5069
|
+
});
|
|
5070
|
+
|
|
5071
|
+
if (result.kind !== PlanKind.CONDITIONAL) {
|
|
5072
|
+
throw new Error("Expected CONDITIONAL result");
|
|
5073
|
+
}
|
|
5074
|
+
|
|
5075
|
+
const query = await prisma.resource.findMany({
|
|
5076
|
+
where: { ...result.filters },
|
|
5077
|
+
});
|
|
5078
|
+
expect(query.map((r) => r.id)).toEqual(
|
|
5079
|
+
fixtureResources
|
|
5080
|
+
.filter((r) => !(r.aNumber < 2))
|
|
5081
|
+
.map((r) => r.id)
|
|
5082
|
+
);
|
|
5083
|
+
});
|
|
5084
|
+
|
|
5085
|
+
test("conditional - not-contains", async () => {
|
|
5086
|
+
const queryPlan = await cerbos.planResources({
|
|
5087
|
+
principal: { id: "user1", roles: ["USER"] },
|
|
5088
|
+
resource: { kind: "resource" },
|
|
5089
|
+
action: "not-contains",
|
|
5090
|
+
});
|
|
5091
|
+
|
|
5092
|
+
expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
|
|
5093
|
+
expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual(
|
|
5094
|
+
{
|
|
5095
|
+
operator: "not",
|
|
5096
|
+
operands: [
|
|
5097
|
+
{
|
|
5098
|
+
operator: "contains",
|
|
5099
|
+
operands: [
|
|
5100
|
+
{ name: "request.resource.attr.aString" },
|
|
5101
|
+
{ value: "str" },
|
|
5102
|
+
],
|
|
5103
|
+
},
|
|
5104
|
+
],
|
|
5105
|
+
}
|
|
5106
|
+
);
|
|
5107
|
+
|
|
5108
|
+
const result = queryPlanToPrisma({
|
|
5109
|
+
queryPlan,
|
|
5110
|
+
mapper: {
|
|
5111
|
+
"request.resource.attr.aString": { field: "aString" },
|
|
5112
|
+
},
|
|
5113
|
+
});
|
|
5114
|
+
|
|
5115
|
+
expect(result).toStrictEqual({
|
|
5116
|
+
kind: PlanKind.CONDITIONAL,
|
|
5117
|
+
filters: {
|
|
5118
|
+
NOT: { aString: { contains: "str" } },
|
|
5119
|
+
},
|
|
5120
|
+
});
|
|
5121
|
+
|
|
5122
|
+
if (result.kind !== PlanKind.CONDITIONAL) {
|
|
5123
|
+
throw new Error("Expected CONDITIONAL result");
|
|
5124
|
+
}
|
|
5125
|
+
|
|
5126
|
+
const query = await prisma.resource.findMany({
|
|
5127
|
+
where: { ...result.filters },
|
|
5128
|
+
});
|
|
5129
|
+
expect(query.map((r) => r.id)).toEqual(
|
|
5130
|
+
fixtureResources
|
|
5131
|
+
.filter((r) => !r.aString.includes("str"))
|
|
5132
|
+
.map((r) => r.id)
|
|
5133
|
+
);
|
|
5134
|
+
});
|
|
5135
|
+
|
|
5136
|
+
test("conditional - not-starts-with", async () => {
|
|
5137
|
+
const queryPlan = await cerbos.planResources({
|
|
5138
|
+
principal: { id: "user1", roles: ["USER"] },
|
|
5139
|
+
resource: { kind: "resource" },
|
|
5140
|
+
action: "not-starts-with",
|
|
5141
|
+
});
|
|
5142
|
+
|
|
5143
|
+
expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
|
|
5144
|
+
expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual(
|
|
5145
|
+
{
|
|
5146
|
+
operator: "not",
|
|
5147
|
+
operands: [
|
|
5148
|
+
{
|
|
5149
|
+
operator: "startsWith",
|
|
5150
|
+
operands: [
|
|
5151
|
+
{ name: "request.resource.attr.aString" },
|
|
5152
|
+
{ value: "str" },
|
|
5153
|
+
],
|
|
5154
|
+
},
|
|
5155
|
+
],
|
|
5156
|
+
}
|
|
5157
|
+
);
|
|
5158
|
+
|
|
5159
|
+
const result = queryPlanToPrisma({
|
|
5160
|
+
queryPlan,
|
|
5161
|
+
mapper: {
|
|
5162
|
+
"request.resource.attr.aString": { field: "aString" },
|
|
5163
|
+
},
|
|
5164
|
+
});
|
|
5165
|
+
|
|
5166
|
+
expect(result).toStrictEqual({
|
|
5167
|
+
kind: PlanKind.CONDITIONAL,
|
|
5168
|
+
filters: {
|
|
5169
|
+
NOT: { aString: { startsWith: "str" } },
|
|
4559
5170
|
},
|
|
4560
5171
|
});
|
|
5172
|
+
|
|
5173
|
+
if (result.kind !== PlanKind.CONDITIONAL) {
|
|
5174
|
+
throw new Error("Expected CONDITIONAL result");
|
|
5175
|
+
}
|
|
5176
|
+
|
|
5177
|
+
const query = await prisma.resource.findMany({
|
|
5178
|
+
where: { ...result.filters },
|
|
5179
|
+
});
|
|
5180
|
+
expect(query.map((r) => r.id)).toEqual(
|
|
5181
|
+
fixtureResources
|
|
5182
|
+
.filter((r) => !r.aString.startsWith("str"))
|
|
5183
|
+
.map((r) => r.id)
|
|
5184
|
+
);
|
|
4561
5185
|
});
|
|
4562
5186
|
});
|
|
4563
5187
|
|
|
4564
|
-
describe("
|
|
4565
|
-
test("
|
|
5188
|
+
describe("Collection Operations", () => {
|
|
5189
|
+
test("conditional - exists on nested collection", async () => {
|
|
4566
5190
|
const queryPlan = await cerbos.planResources({
|
|
4567
5191
|
principal: { id: "user1", roles: ["USER"] },
|
|
4568
5192
|
resource: { kind: "resource" },
|
|
4569
|
-
action: "
|
|
5193
|
+
action: "exists-nested-collection",
|
|
4570
5194
|
});
|
|
4571
5195
|
|
|
4572
5196
|
const result = queryPlanToPrisma({
|
|
@@ -4577,20 +5201,13 @@ describe("Complex Operations", () => {
|
|
|
4577
5201
|
name: "categories",
|
|
4578
5202
|
type: "many",
|
|
4579
5203
|
fields: {
|
|
5204
|
+
name: { field: "name" },
|
|
4580
5205
|
subCategories: {
|
|
4581
5206
|
relation: {
|
|
4582
5207
|
name: "subCategories",
|
|
4583
5208
|
type: "many",
|
|
4584
5209
|
fields: {
|
|
4585
|
-
|
|
4586
|
-
relation: {
|
|
4587
|
-
name: "labels",
|
|
4588
|
-
type: "many",
|
|
4589
|
-
fields: {
|
|
4590
|
-
name: { field: "name" },
|
|
4591
|
-
},
|
|
4592
|
-
},
|
|
4593
|
-
},
|
|
5210
|
+
name: { field: "name" },
|
|
4594
5211
|
},
|
|
4595
5212
|
},
|
|
4596
5213
|
},
|
|
@@ -4605,26 +5222,94 @@ describe("Complex Operations", () => {
|
|
|
4605
5222
|
filters: {
|
|
4606
5223
|
categories: {
|
|
4607
5224
|
some: {
|
|
4608
|
-
|
|
4609
|
-
|
|
4610
|
-
|
|
5225
|
+
AND: [
|
|
5226
|
+
{ name: { equals: "business" } },
|
|
5227
|
+
{
|
|
5228
|
+
subCategories: {
|
|
4611
5229
|
some: {
|
|
4612
|
-
name: { equals: "
|
|
5230
|
+
name: { equals: "finance" },
|
|
4613
5231
|
},
|
|
4614
5232
|
},
|
|
4615
5233
|
},
|
|
5234
|
+
],
|
|
5235
|
+
},
|
|
5236
|
+
},
|
|
5237
|
+
},
|
|
5238
|
+
});
|
|
5239
|
+
|
|
5240
|
+
if (result.kind !== PlanKind.CONDITIONAL) {
|
|
5241
|
+
throw new Error("Expected CONDITIONAL result");
|
|
5242
|
+
}
|
|
5243
|
+
|
|
5244
|
+
const query = await prisma.resource.findMany({
|
|
5245
|
+
where: { ...result.filters },
|
|
5246
|
+
});
|
|
5247
|
+
|
|
5248
|
+
expect(query.map((r) => r.id)).toEqual(
|
|
5249
|
+
fixtureResources
|
|
5250
|
+
.filter((r) => {
|
|
5251
|
+
const categoryConnections = r.categories
|
|
5252
|
+
?.connect as Prisma.CategoryWhereUniqueInput[];
|
|
5253
|
+
return categoryConnections?.some((categoryConnection) => {
|
|
5254
|
+
const category = fixtureCategories.find(
|
|
5255
|
+
(fc) => fc.id === categoryConnection.id
|
|
5256
|
+
);
|
|
5257
|
+
return (
|
|
5258
|
+
category?.name === "business" &&
|
|
5259
|
+
(
|
|
5260
|
+
category?.subCategories
|
|
5261
|
+
?.connect as Prisma.SubCategoryWhereUniqueInput[]
|
|
5262
|
+
)?.some(
|
|
5263
|
+
(sc) =>
|
|
5264
|
+
fixtureSubCategories.find((fsc) => fsc.id === sc.id)
|
|
5265
|
+
?.name === "finance"
|
|
5266
|
+
)
|
|
5267
|
+
);
|
|
5268
|
+
});
|
|
5269
|
+
})
|
|
5270
|
+
.map((r) => r.id)
|
|
5271
|
+
);
|
|
5272
|
+
});
|
|
5273
|
+
|
|
5274
|
+
test("isSet with nested relation", async () => {
|
|
5275
|
+
const queryPlan = await cerbos.planResources({
|
|
5276
|
+
principal: { id: "user1", roles: ["USER"] },
|
|
5277
|
+
resource: { kind: "resource" },
|
|
5278
|
+
action: "is-set-nested",
|
|
5279
|
+
});
|
|
5280
|
+
|
|
5281
|
+
const result = queryPlanToPrisma({
|
|
5282
|
+
queryPlan,
|
|
5283
|
+
mapper: {
|
|
5284
|
+
"request.resource.attr.nested.aOptionalString": {
|
|
5285
|
+
relation: {
|
|
5286
|
+
name: "nested",
|
|
5287
|
+
type: "one",
|
|
5288
|
+
fields: {
|
|
5289
|
+
aOptionalString: { field: "aOptionalString" },
|
|
4616
5290
|
},
|
|
4617
5291
|
},
|
|
4618
5292
|
},
|
|
4619
5293
|
},
|
|
4620
5294
|
});
|
|
5295
|
+
|
|
5296
|
+
expect(result).toStrictEqual({
|
|
5297
|
+
kind: PlanKind.CONDITIONAL,
|
|
5298
|
+
filters: {
|
|
5299
|
+
nested: {
|
|
5300
|
+
is: {
|
|
5301
|
+
aOptionalString: { not: null },
|
|
5302
|
+
},
|
|
5303
|
+
},
|
|
5304
|
+
},
|
|
5305
|
+
});
|
|
4621
5306
|
});
|
|
4622
5307
|
|
|
4623
|
-
test("
|
|
5308
|
+
test("hasIntersection with nested collection", async () => {
|
|
4624
5309
|
const queryPlan = await cerbos.planResources({
|
|
4625
5310
|
principal: { id: "user1", roles: ["USER"] },
|
|
4626
5311
|
resource: { kind: "resource" },
|
|
4627
|
-
action: "
|
|
5312
|
+
action: "has-intersection-nested",
|
|
4628
5313
|
});
|
|
4629
5314
|
|
|
4630
5315
|
const result = queryPlanToPrisma({
|
|
@@ -4666,13 +5351,119 @@ describe("Complex Operations", () => {
|
|
|
4666
5351
|
});
|
|
4667
5352
|
});
|
|
4668
5353
|
});
|
|
4669
|
-
});
|
|
4670
5354
|
|
|
4671
|
-
|
|
4672
|
-
|
|
4673
|
-
|
|
4674
|
-
|
|
4675
|
-
|
|
5355
|
+
describe("Deep Nesting", () => {
|
|
5356
|
+
test("filter on deeply nested relation", async () => {
|
|
5357
|
+
const queryPlan = await cerbos.planResources({
|
|
5358
|
+
principal: { id: "user1", roles: ["USER"] },
|
|
5359
|
+
resource: { kind: "resource" },
|
|
5360
|
+
action: "filter-deeply-nested",
|
|
5361
|
+
});
|
|
5362
|
+
|
|
5363
|
+
const result = queryPlanToPrisma({
|
|
5364
|
+
queryPlan,
|
|
5365
|
+
mapper: {
|
|
5366
|
+
"request.resource.attr.categories": {
|
|
5367
|
+
relation: {
|
|
5368
|
+
name: "categories",
|
|
5369
|
+
type: "many",
|
|
5370
|
+
fields: {
|
|
5371
|
+
subCategories: {
|
|
5372
|
+
relation: {
|
|
5373
|
+
name: "subCategories",
|
|
5374
|
+
type: "many",
|
|
5375
|
+
fields: {
|
|
5376
|
+
labels: {
|
|
5377
|
+
relation: {
|
|
5378
|
+
name: "labels",
|
|
5379
|
+
type: "many",
|
|
5380
|
+
fields: {
|
|
5381
|
+
name: { field: "name" },
|
|
5382
|
+
},
|
|
5383
|
+
},
|
|
5384
|
+
},
|
|
5385
|
+
},
|
|
5386
|
+
},
|
|
5387
|
+
},
|
|
5388
|
+
},
|
|
5389
|
+
},
|
|
5390
|
+
},
|
|
5391
|
+
},
|
|
5392
|
+
});
|
|
5393
|
+
|
|
5394
|
+
expect(result).toStrictEqual({
|
|
5395
|
+
kind: PlanKind.CONDITIONAL,
|
|
5396
|
+
filters: {
|
|
5397
|
+
categories: {
|
|
5398
|
+
some: {
|
|
5399
|
+
subCategories: {
|
|
5400
|
+
some: {
|
|
5401
|
+
labels: {
|
|
5402
|
+
some: {
|
|
5403
|
+
name: { equals: "important" },
|
|
5404
|
+
},
|
|
5405
|
+
},
|
|
5406
|
+
},
|
|
5407
|
+
},
|
|
5408
|
+
},
|
|
5409
|
+
},
|
|
5410
|
+
},
|
|
5411
|
+
});
|
|
5412
|
+
});
|
|
5413
|
+
|
|
5414
|
+
test("map on deeply nested relation", async () => {
|
|
5415
|
+
const queryPlan = await cerbos.planResources({
|
|
5416
|
+
principal: { id: "user1", roles: ["USER"] },
|
|
5417
|
+
resource: { kind: "resource" },
|
|
5418
|
+
action: "map-deeply-nested",
|
|
5419
|
+
});
|
|
5420
|
+
|
|
5421
|
+
const result = queryPlanToPrisma({
|
|
5422
|
+
queryPlan,
|
|
5423
|
+
mapper: {
|
|
5424
|
+
"request.resource.attr.categories": {
|
|
5425
|
+
relation: {
|
|
5426
|
+
name: "categories",
|
|
5427
|
+
type: "many",
|
|
5428
|
+
fields: {
|
|
5429
|
+
subCategories: {
|
|
5430
|
+
relation: {
|
|
5431
|
+
name: "subCategories",
|
|
5432
|
+
type: "many",
|
|
5433
|
+
fields: {
|
|
5434
|
+
name: { field: "name" },
|
|
5435
|
+
},
|
|
5436
|
+
},
|
|
5437
|
+
},
|
|
5438
|
+
},
|
|
5439
|
+
},
|
|
5440
|
+
},
|
|
5441
|
+
},
|
|
5442
|
+
});
|
|
5443
|
+
|
|
5444
|
+
expect(result).toStrictEqual({
|
|
5445
|
+
kind: PlanKind.CONDITIONAL,
|
|
5446
|
+
filters: {
|
|
5447
|
+
categories: {
|
|
5448
|
+
some: {
|
|
5449
|
+
subCategories: {
|
|
5450
|
+
some: {
|
|
5451
|
+
name: { in: ["finance", "tech"] },
|
|
5452
|
+
},
|
|
5453
|
+
},
|
|
5454
|
+
},
|
|
5455
|
+
},
|
|
5456
|
+
},
|
|
5457
|
+
});
|
|
5458
|
+
});
|
|
5459
|
+
});
|
|
5460
|
+
});
|
|
5461
|
+
|
|
5462
|
+
// Error Handling
|
|
5463
|
+
describe("Error Cases", () => {
|
|
5464
|
+
test("throws error for invalid query plan", () => {
|
|
5465
|
+
const invalidQueryPlan = {
|
|
5466
|
+
kind: "INVALID_KIND" as PlanKind,
|
|
4676
5467
|
};
|
|
4677
5468
|
|
|
4678
5469
|
expect(() =>
|
|
@@ -5513,3 +6304,2582 @@ describe("Return Types", () => {
|
|
|
5513
6304
|
expect(conditional.kind).toBe(PlanKind.CONDITIONAL);
|
|
5514
6305
|
});
|
|
5515
6306
|
});
|
|
6307
|
+
|
|
6308
|
+
// Issue #156: add operator (string concatenation)
|
|
6309
|
+
describe("Add Operator", () => {
|
|
6310
|
+
test("add with two values (constant folding)", () => {
|
|
6311
|
+
// #given eq(field, add("prefix:", "suffix"))
|
|
6312
|
+
const plan = createConditionalPlan({
|
|
6313
|
+
operator: "eq",
|
|
6314
|
+
operands: [
|
|
6315
|
+
{ name: "request.resource.attr.aString" },
|
|
6316
|
+
{
|
|
6317
|
+
operator: "add",
|
|
6318
|
+
operands: [{ value: "prefix:" }, { value: "suffix" }],
|
|
6319
|
+
},
|
|
6320
|
+
],
|
|
6321
|
+
});
|
|
6322
|
+
|
|
6323
|
+
// #when
|
|
6324
|
+
const result = queryPlanToPrisma({
|
|
6325
|
+
queryPlan: plan,
|
|
6326
|
+
mapper: {
|
|
6327
|
+
"request.resource.attr.aString": { field: "aString" },
|
|
6328
|
+
},
|
|
6329
|
+
});
|
|
6330
|
+
|
|
6331
|
+
// #then constant fold: "prefix:" + "suffix" = "prefix:suffix"
|
|
6332
|
+
expect(result).toEqual({
|
|
6333
|
+
kind: PlanKind.CONDITIONAL,
|
|
6334
|
+
filters: { aString: { equals: "prefix:suffix" } },
|
|
6335
|
+
});
|
|
6336
|
+
});
|
|
6337
|
+
|
|
6338
|
+
test("add with value prefix and field reference in eq", () => {
|
|
6339
|
+
// #given eq("projects:123", add("projects:", R.attr.id))
|
|
6340
|
+
// This is the exact plan Cerbos produces for:
|
|
6341
|
+
// P.attr.context == "projects:" + request.resource.attr.id
|
|
6342
|
+
const plan = createConditionalPlan({
|
|
6343
|
+
operator: "eq",
|
|
6344
|
+
operands: [
|
|
6345
|
+
{ value: "projects:123" },
|
|
6346
|
+
{
|
|
6347
|
+
operator: "add",
|
|
6348
|
+
operands: [
|
|
6349
|
+
{ value: "projects:" },
|
|
6350
|
+
{ name: "request.resource.attr.id" },
|
|
6351
|
+
],
|
|
6352
|
+
},
|
|
6353
|
+
],
|
|
6354
|
+
});
|
|
6355
|
+
|
|
6356
|
+
// #when
|
|
6357
|
+
const result = queryPlanToPrisma({
|
|
6358
|
+
queryPlan: plan,
|
|
6359
|
+
mapper: {
|
|
6360
|
+
"request.resource.attr.id": { field: "id" },
|
|
6361
|
+
},
|
|
6362
|
+
});
|
|
6363
|
+
|
|
6364
|
+
// #then solve: "projects:123" == "projects:" + id → id == "123"
|
|
6365
|
+
expect(result).toEqual({
|
|
6366
|
+
kind: PlanKind.CONDITIONAL,
|
|
6367
|
+
filters: { id: { equals: "123" } },
|
|
6368
|
+
});
|
|
6369
|
+
});
|
|
6370
|
+
|
|
6371
|
+
test("add with field reference and value suffix in eq", () => {
|
|
6372
|
+
// #given eq("hello_world", add(R.attr.aString, "_world"))
|
|
6373
|
+
const plan = createConditionalPlan({
|
|
6374
|
+
operator: "eq",
|
|
6375
|
+
operands: [
|
|
6376
|
+
{ value: "hello_world" },
|
|
6377
|
+
{
|
|
6378
|
+
operator: "add",
|
|
6379
|
+
operands: [
|
|
6380
|
+
{ name: "request.resource.attr.aString" },
|
|
6381
|
+
{ value: "_world" },
|
|
6382
|
+
],
|
|
6383
|
+
},
|
|
6384
|
+
],
|
|
6385
|
+
});
|
|
6386
|
+
|
|
6387
|
+
// #when
|
|
6388
|
+
const result = queryPlanToPrisma({
|
|
6389
|
+
queryPlan: plan,
|
|
6390
|
+
mapper: {
|
|
6391
|
+
"request.resource.attr.aString": { field: "aString" },
|
|
6392
|
+
},
|
|
6393
|
+
});
|
|
6394
|
+
|
|
6395
|
+
// #then solve: "hello_world" == aString + "_world" → aString == "hello"
|
|
6396
|
+
expect(result).toEqual({
|
|
6397
|
+
kind: PlanKind.CONDITIONAL,
|
|
6398
|
+
filters: { aString: { equals: "hello" } },
|
|
6399
|
+
});
|
|
6400
|
+
});
|
|
6401
|
+
|
|
6402
|
+
test("add with value prefix and field reference in ne", () => {
|
|
6403
|
+
// #given ne("projects:123", add("projects:", R.attr.id))
|
|
6404
|
+
const plan = createConditionalPlan({
|
|
6405
|
+
operator: "ne",
|
|
6406
|
+
operands: [
|
|
6407
|
+
{ value: "projects:123" },
|
|
6408
|
+
{
|
|
6409
|
+
operator: "add",
|
|
6410
|
+
operands: [
|
|
6411
|
+
{ value: "projects:" },
|
|
6412
|
+
{ name: "request.resource.attr.id" },
|
|
6413
|
+
],
|
|
6414
|
+
},
|
|
6415
|
+
],
|
|
6416
|
+
});
|
|
6417
|
+
|
|
6418
|
+
// #when
|
|
6419
|
+
const result = queryPlanToPrisma({
|
|
6420
|
+
queryPlan: plan,
|
|
6421
|
+
mapper: {
|
|
6422
|
+
"request.resource.attr.id": { field: "id" },
|
|
6423
|
+
},
|
|
6424
|
+
});
|
|
6425
|
+
|
|
6426
|
+
// #then solve: "projects:123" != "projects:" + id → id != "123"
|
|
6427
|
+
expect(result).toEqual({
|
|
6428
|
+
kind: PlanKind.CONDITIONAL,
|
|
6429
|
+
filters: { id: { not: "123" } },
|
|
6430
|
+
});
|
|
6431
|
+
});
|
|
6432
|
+
|
|
6433
|
+
test("add with prefix mismatch yields impossible filter", () => {
|
|
6434
|
+
// #given eq("other:123", add("projects:", R.attr.id))
|
|
6435
|
+
// "other:123" doesn't start with "projects:", so no value of id can match
|
|
6436
|
+
const plan = createConditionalPlan({
|
|
6437
|
+
operator: "eq",
|
|
6438
|
+
operands: [
|
|
6439
|
+
{ value: "other:123" },
|
|
6440
|
+
{
|
|
6441
|
+
operator: "add",
|
|
6442
|
+
operands: [
|
|
6443
|
+
{ value: "projects:" },
|
|
6444
|
+
{ name: "request.resource.attr.id" },
|
|
6445
|
+
],
|
|
6446
|
+
},
|
|
6447
|
+
],
|
|
6448
|
+
});
|
|
6449
|
+
|
|
6450
|
+
// #when
|
|
6451
|
+
const result = queryPlanToPrisma({
|
|
6452
|
+
queryPlan: plan,
|
|
6453
|
+
mapper: {
|
|
6454
|
+
"request.resource.attr.id": { field: "id" },
|
|
6455
|
+
},
|
|
6456
|
+
});
|
|
6457
|
+
|
|
6458
|
+
// #then impossible: produces a filter that matches nothing
|
|
6459
|
+
// Use an impossible equality that Prisma won't match
|
|
6460
|
+
expect(result).toEqual({
|
|
6461
|
+
kind: PlanKind.CONDITIONAL,
|
|
6462
|
+
filters: {
|
|
6463
|
+
id: { in: [] },
|
|
6464
|
+
},
|
|
6465
|
+
});
|
|
6466
|
+
});
|
|
6467
|
+
|
|
6468
|
+
test("add with relation mapping", () => {
|
|
6469
|
+
// #given eq("projects:123", add("projects:", R.attr.nested.id))
|
|
6470
|
+
const plan = createConditionalPlan({
|
|
6471
|
+
operator: "eq",
|
|
6472
|
+
operands: [
|
|
6473
|
+
{ value: "projects:123" },
|
|
6474
|
+
{
|
|
6475
|
+
operator: "add",
|
|
6476
|
+
operands: [
|
|
6477
|
+
{ value: "projects:" },
|
|
6478
|
+
{ name: "request.resource.attr.nested.id" },
|
|
6479
|
+
],
|
|
6480
|
+
},
|
|
6481
|
+
],
|
|
6482
|
+
});
|
|
6483
|
+
|
|
6484
|
+
// #when
|
|
6485
|
+
const result = queryPlanToPrisma({
|
|
6486
|
+
queryPlan: plan,
|
|
6487
|
+
mapper: {
|
|
6488
|
+
"request.resource.attr.nested": {
|
|
6489
|
+
relation: {
|
|
6490
|
+
name: "nested",
|
|
6491
|
+
type: "one",
|
|
6492
|
+
field: "id",
|
|
6493
|
+
},
|
|
6494
|
+
},
|
|
6495
|
+
},
|
|
6496
|
+
});
|
|
6497
|
+
|
|
6498
|
+
// #then
|
|
6499
|
+
expect(result).toEqual({
|
|
6500
|
+
kind: PlanKind.CONDITIONAL,
|
|
6501
|
+
filters: { nested: { is: { id: { equals: "123" } } } },
|
|
6502
|
+
});
|
|
6503
|
+
});
|
|
6504
|
+
|
|
6505
|
+
test("add via cerbos integration", async () => {
|
|
6506
|
+
const queryPlan = await cerbos.planResources({
|
|
6507
|
+
principal: {
|
|
6508
|
+
id: "user1",
|
|
6509
|
+
roles: ["USER"],
|
|
6510
|
+
attr: { context: "projects:123" },
|
|
6511
|
+
},
|
|
6512
|
+
resource: { kind: "resource" },
|
|
6513
|
+
action: "string-concat-principal",
|
|
6514
|
+
});
|
|
6515
|
+
|
|
6516
|
+
expect(queryPlan.kind).toBe(PlanKind.CONDITIONAL);
|
|
6517
|
+
|
|
6518
|
+
const result = queryPlanToPrisma({
|
|
6519
|
+
queryPlan,
|
|
6520
|
+
mapper: {
|
|
6521
|
+
"request.resource.attr.id": { field: "id" },
|
|
6522
|
+
},
|
|
6523
|
+
});
|
|
6524
|
+
|
|
6525
|
+
expect(result).toEqual({
|
|
6526
|
+
kind: PlanKind.CONDITIONAL,
|
|
6527
|
+
filters: { id: { equals: "123" } },
|
|
6528
|
+
});
|
|
6529
|
+
});
|
|
6530
|
+
|
|
6531
|
+
test("add with numeric values", () => {
|
|
6532
|
+
// #given add with numbers: eq(10, add(3, R.attr.aNumber))
|
|
6533
|
+
const plan = createConditionalPlan({
|
|
6534
|
+
operator: "eq",
|
|
6535
|
+
operands: [
|
|
6536
|
+
{ value: 10 },
|
|
6537
|
+
{
|
|
6538
|
+
operator: "add",
|
|
6539
|
+
operands: [
|
|
6540
|
+
{ value: 3 },
|
|
6541
|
+
{ name: "request.resource.attr.aNumber" },
|
|
6542
|
+
],
|
|
6543
|
+
},
|
|
6544
|
+
],
|
|
6545
|
+
});
|
|
6546
|
+
|
|
6547
|
+
// #when
|
|
6548
|
+
const result = queryPlanToPrisma({
|
|
6549
|
+
queryPlan: plan,
|
|
6550
|
+
mapper: {
|
|
6551
|
+
"request.resource.attr.aNumber": { field: "aNumber" },
|
|
6552
|
+
},
|
|
6553
|
+
});
|
|
6554
|
+
|
|
6555
|
+
// #then solve: 10 == 3 + aNumber → aNumber == 7
|
|
6556
|
+
expect(result).toEqual({
|
|
6557
|
+
kind: PlanKind.CONDITIONAL,
|
|
6558
|
+
filters: { aNumber: { equals: 7 } },
|
|
6559
|
+
});
|
|
6560
|
+
});
|
|
6561
|
+
});
|
|
6562
|
+
|
|
6563
|
+
// Coverage for additional CEL operators emitted by the planner. Linear arithmetic with a
|
|
6564
|
+
// constant side is solved to a plain column comparison (Prisma's where shape cannot express
|
|
6565
|
+
// computed columns); genuinely inexpressible shapes (mod, regex, list indexing, arithmetic
|
|
6566
|
+
// on both sides) throw the adapter's "Unsupported operator: <op>" error so callers can
|
|
6567
|
+
// detect them.
|
|
6568
|
+
describe("Arithmetic Operators", () => {
|
|
6569
|
+
test("arith-add (gt with field+value add) solves for the column", async () => {
|
|
6570
|
+
const queryPlan = await cerbos.planResources({
|
|
6571
|
+
principal: { id: "user1", roles: ["USER"] },
|
|
6572
|
+
resource: { kind: "resource" },
|
|
6573
|
+
action: "arith-add",
|
|
6574
|
+
});
|
|
6575
|
+
|
|
6576
|
+
expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
|
|
6577
|
+
expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual({
|
|
6578
|
+
operator: "gt",
|
|
6579
|
+
operands: [
|
|
6580
|
+
{
|
|
6581
|
+
operator: "add",
|
|
6582
|
+
operands: [
|
|
6583
|
+
{ name: "request.resource.attr.aNumber" },
|
|
6584
|
+
{ value: 1 },
|
|
6585
|
+
],
|
|
6586
|
+
},
|
|
6587
|
+
{ value: 2 },
|
|
6588
|
+
],
|
|
6589
|
+
});
|
|
6590
|
+
|
|
6591
|
+
// aNumber + 1 > 2 ⇔ aNumber > 1
|
|
6592
|
+
const result = queryPlanToPrisma({
|
|
6593
|
+
queryPlan,
|
|
6594
|
+
mapper: {
|
|
6595
|
+
"request.resource.attr.aNumber": { field: "aNumber" },
|
|
6596
|
+
},
|
|
6597
|
+
});
|
|
6598
|
+
expect(result).toStrictEqual({
|
|
6599
|
+
kind: PlanKind.CONDITIONAL,
|
|
6600
|
+
filters: { aNumber: { gt: 1 } },
|
|
6601
|
+
});
|
|
6602
|
+
|
|
6603
|
+
if (result.kind !== PlanKind.CONDITIONAL) {
|
|
6604
|
+
throw new Error("Expected CONDITIONAL result");
|
|
6605
|
+
}
|
|
6606
|
+
const query = await prisma.resource.findMany({
|
|
6607
|
+
where: { ...result.filters },
|
|
6608
|
+
});
|
|
6609
|
+
expect(query.map((r) => r.id).sort()).toEqual(
|
|
6610
|
+
fixtureResources
|
|
6611
|
+
.filter((r) => r.aNumber + 1 > 2)
|
|
6612
|
+
.map((r) => r.id)
|
|
6613
|
+
.sort()
|
|
6614
|
+
);
|
|
6615
|
+
});
|
|
6616
|
+
|
|
6617
|
+
test("arith-sub solves for the column", async () => {
|
|
6618
|
+
const queryPlan = await cerbos.planResources({
|
|
6619
|
+
principal: { id: "user1", roles: ["USER"] },
|
|
6620
|
+
resource: { kind: "resource" },
|
|
6621
|
+
action: "arith-sub",
|
|
6622
|
+
});
|
|
6623
|
+
|
|
6624
|
+
expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
|
|
6625
|
+
expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual({
|
|
6626
|
+
operator: "lt",
|
|
6627
|
+
operands: [
|
|
6628
|
+
{
|
|
6629
|
+
operator: "sub",
|
|
6630
|
+
operands: [
|
|
6631
|
+
{ name: "request.resource.attr.aNumber" },
|
|
6632
|
+
{ value: 1 },
|
|
6633
|
+
],
|
|
6634
|
+
},
|
|
6635
|
+
{ value: 2 },
|
|
6636
|
+
],
|
|
6637
|
+
});
|
|
6638
|
+
|
|
6639
|
+
// aNumber - 1 < 2 ⇔ aNumber < 3
|
|
6640
|
+
const result = queryPlanToPrisma({
|
|
6641
|
+
queryPlan,
|
|
6642
|
+
mapper: {
|
|
6643
|
+
"request.resource.attr.aNumber": { field: "aNumber" },
|
|
6644
|
+
},
|
|
6645
|
+
});
|
|
6646
|
+
expect(result).toStrictEqual({
|
|
6647
|
+
kind: PlanKind.CONDITIONAL,
|
|
6648
|
+
filters: { aNumber: { lt: 3 } },
|
|
6649
|
+
});
|
|
6650
|
+
|
|
6651
|
+
if (result.kind !== PlanKind.CONDITIONAL) {
|
|
6652
|
+
throw new Error("Expected CONDITIONAL result");
|
|
6653
|
+
}
|
|
6654
|
+
const query = await prisma.resource.findMany({
|
|
6655
|
+
where: { ...result.filters },
|
|
6656
|
+
});
|
|
6657
|
+
expect(query.map((r) => r.id).sort()).toEqual(
|
|
6658
|
+
fixtureResources
|
|
6659
|
+
.filter((r) => r.aNumber - 1 < 2)
|
|
6660
|
+
.map((r) => r.id)
|
|
6661
|
+
.sort()
|
|
6662
|
+
);
|
|
6663
|
+
});
|
|
6664
|
+
|
|
6665
|
+
test("arith-mult solves for the column", async () => {
|
|
6666
|
+
const queryPlan = await cerbos.planResources({
|
|
6667
|
+
principal: { id: "user1", roles: ["USER"] },
|
|
6668
|
+
resource: { kind: "resource" },
|
|
6669
|
+
action: "arith-mult",
|
|
6670
|
+
});
|
|
6671
|
+
|
|
6672
|
+
expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
|
|
6673
|
+
expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual({
|
|
6674
|
+
operator: "gt",
|
|
6675
|
+
operands: [
|
|
6676
|
+
{
|
|
6677
|
+
operator: "mult",
|
|
6678
|
+
operands: [
|
|
6679
|
+
{ name: "request.resource.attr.aNumber" },
|
|
6680
|
+
{ value: 2 },
|
|
6681
|
+
],
|
|
6682
|
+
},
|
|
6683
|
+
{ value: 2 },
|
|
6684
|
+
],
|
|
6685
|
+
});
|
|
6686
|
+
|
|
6687
|
+
// aNumber * 2 > 2 ⇔ aNumber > 1
|
|
6688
|
+
const result = queryPlanToPrisma({
|
|
6689
|
+
queryPlan,
|
|
6690
|
+
mapper: {
|
|
6691
|
+
"request.resource.attr.aNumber": { field: "aNumber" },
|
|
6692
|
+
},
|
|
6693
|
+
});
|
|
6694
|
+
expect(result).toStrictEqual({
|
|
6695
|
+
kind: PlanKind.CONDITIONAL,
|
|
6696
|
+
filters: { aNumber: { gt: 1 } },
|
|
6697
|
+
});
|
|
6698
|
+
|
|
6699
|
+
if (result.kind !== PlanKind.CONDITIONAL) {
|
|
6700
|
+
throw new Error("Expected CONDITIONAL result");
|
|
6701
|
+
}
|
|
6702
|
+
const query = await prisma.resource.findMany({
|
|
6703
|
+
where: { ...result.filters },
|
|
6704
|
+
});
|
|
6705
|
+
expect(query.map((r) => r.id).sort()).toEqual(
|
|
6706
|
+
fixtureResources
|
|
6707
|
+
.filter((r) => r.aNumber * 2 > 2)
|
|
6708
|
+
.map((r) => r.id)
|
|
6709
|
+
.sort()
|
|
6710
|
+
);
|
|
6711
|
+
});
|
|
6712
|
+
|
|
6713
|
+
test("arith-div solves for the column", async () => {
|
|
6714
|
+
const queryPlan = await cerbos.planResources({
|
|
6715
|
+
principal: { id: "user1", roles: ["USER"] },
|
|
6716
|
+
resource: { kind: "resource" },
|
|
6717
|
+
action: "arith-div",
|
|
6718
|
+
});
|
|
6719
|
+
|
|
6720
|
+
expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
|
|
6721
|
+
expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual({
|
|
6722
|
+
operator: "gt",
|
|
6723
|
+
operands: [
|
|
6724
|
+
{
|
|
6725
|
+
operator: "div",
|
|
6726
|
+
operands: [
|
|
6727
|
+
{ name: "request.resource.attr.aNumber" },
|
|
6728
|
+
{ value: 2 },
|
|
6729
|
+
],
|
|
6730
|
+
},
|
|
6731
|
+
{ value: 0 },
|
|
6732
|
+
],
|
|
6733
|
+
});
|
|
6734
|
+
|
|
6735
|
+
// aNumber / 2 > 0 ⇔ aNumber > 0
|
|
6736
|
+
const result = queryPlanToPrisma({
|
|
6737
|
+
queryPlan,
|
|
6738
|
+
mapper: {
|
|
6739
|
+
"request.resource.attr.aNumber": { field: "aNumber" },
|
|
6740
|
+
},
|
|
6741
|
+
});
|
|
6742
|
+
expect(result).toStrictEqual({
|
|
6743
|
+
kind: PlanKind.CONDITIONAL,
|
|
6744
|
+
filters: { aNumber: { gt: 0 } },
|
|
6745
|
+
});
|
|
6746
|
+
|
|
6747
|
+
if (result.kind !== PlanKind.CONDITIONAL) {
|
|
6748
|
+
throw new Error("Expected CONDITIONAL result");
|
|
6749
|
+
}
|
|
6750
|
+
const query = await prisma.resource.findMany({
|
|
6751
|
+
where: { ...result.filters },
|
|
6752
|
+
});
|
|
6753
|
+
expect(query.map((r) => r.id).sort()).toEqual(
|
|
6754
|
+
fixtureResources
|
|
6755
|
+
.filter((r) => r.aNumber / 2 > 0)
|
|
6756
|
+
.map((r) => r.id)
|
|
6757
|
+
.sort()
|
|
6758
|
+
);
|
|
6759
|
+
});
|
|
6760
|
+
|
|
6761
|
+
test("arith-mod is unsupported", async () => {
|
|
6762
|
+
const queryPlan = await cerbos.planResources({
|
|
6763
|
+
principal: { id: "user1", roles: ["USER"] },
|
|
6764
|
+
resource: { kind: "resource" },
|
|
6765
|
+
action: "arith-mod",
|
|
6766
|
+
});
|
|
6767
|
+
|
|
6768
|
+
expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
|
|
6769
|
+
expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual({
|
|
6770
|
+
operator: "eq",
|
|
6771
|
+
operands: [
|
|
6772
|
+
{
|
|
6773
|
+
operator: "mod",
|
|
6774
|
+
operands: [
|
|
6775
|
+
{
|
|
6776
|
+
operator: "int",
|
|
6777
|
+
operands: [{ name: "request.resource.attr.aNumber" }],
|
|
6778
|
+
},
|
|
6779
|
+
{ value: 2 },
|
|
6780
|
+
],
|
|
6781
|
+
},
|
|
6782
|
+
{ value: 0 },
|
|
6783
|
+
],
|
|
6784
|
+
});
|
|
6785
|
+
|
|
6786
|
+
expect(() =>
|
|
6787
|
+
queryPlanToPrisma({
|
|
6788
|
+
queryPlan,
|
|
6789
|
+
mapper: {
|
|
6790
|
+
"request.resource.attr.aNumber": { field: "aNumber" },
|
|
6791
|
+
},
|
|
6792
|
+
})
|
|
6793
|
+
).toThrow("Unsupported operator: mod");
|
|
6794
|
+
});
|
|
6795
|
+
});
|
|
6796
|
+
|
|
6797
|
+
describe("Regex Operator", () => {
|
|
6798
|
+
test("matches-regex is unsupported", async () => {
|
|
6799
|
+
const queryPlan = await cerbos.planResources({
|
|
6800
|
+
principal: { id: "user1", roles: ["USER"] },
|
|
6801
|
+
resource: { kind: "resource" },
|
|
6802
|
+
action: "matches-regex",
|
|
6803
|
+
});
|
|
6804
|
+
|
|
6805
|
+
expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
|
|
6806
|
+
expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual({
|
|
6807
|
+
operator: "matches",
|
|
6808
|
+
operands: [
|
|
6809
|
+
{ name: "request.resource.attr.aString" },
|
|
6810
|
+
{ value: "^str.*" },
|
|
6811
|
+
],
|
|
6812
|
+
});
|
|
6813
|
+
|
|
6814
|
+
expect(() =>
|
|
6815
|
+
queryPlanToPrisma({
|
|
6816
|
+
queryPlan,
|
|
6817
|
+
mapper: {
|
|
6818
|
+
"request.resource.attr.aString": { field: "aString" },
|
|
6819
|
+
},
|
|
6820
|
+
})
|
|
6821
|
+
).toThrow("Unsupported operator: matches");
|
|
6822
|
+
});
|
|
6823
|
+
});
|
|
6824
|
+
|
|
6825
|
+
describe("List Index Operator", () => {
|
|
6826
|
+
test("index-list is unsupported", async () => {
|
|
6827
|
+
const queryPlan = await cerbos.planResources({
|
|
6828
|
+
principal: { id: "user1", roles: ["USER"] },
|
|
6829
|
+
resource: { kind: "resource" },
|
|
6830
|
+
action: "index-list",
|
|
6831
|
+
});
|
|
6832
|
+
|
|
6833
|
+
expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
|
|
6834
|
+
expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual({
|
|
6835
|
+
operator: "eq",
|
|
6836
|
+
operands: [
|
|
6837
|
+
{
|
|
6838
|
+
operator: "index",
|
|
6839
|
+
operands: [
|
|
6840
|
+
{ name: "request.resource.attr.ownedBy" },
|
|
6841
|
+
{ value: 0 },
|
|
6842
|
+
],
|
|
6843
|
+
},
|
|
6844
|
+
{ value: "user1" },
|
|
6845
|
+
],
|
|
6846
|
+
});
|
|
6847
|
+
|
|
6848
|
+
expect(() =>
|
|
6849
|
+
queryPlanToPrisma({
|
|
6850
|
+
queryPlan,
|
|
6851
|
+
mapper: {
|
|
6852
|
+
"request.resource.attr.ownedBy": {
|
|
6853
|
+
relation: { name: "ownedBy", type: "many", field: "id" },
|
|
6854
|
+
},
|
|
6855
|
+
},
|
|
6856
|
+
})
|
|
6857
|
+
).toThrow("Unsupported operator: index");
|
|
6858
|
+
});
|
|
6859
|
+
});
|
|
6860
|
+
|
|
6861
|
+
describe("Type Conversion Operators", () => {
|
|
6862
|
+
test("convert-string is unsupported", async () => {
|
|
6863
|
+
const queryPlan = await cerbos.planResources({
|
|
6864
|
+
principal: { id: "user1", roles: ["USER"] },
|
|
6865
|
+
resource: { kind: "resource" },
|
|
6866
|
+
action: "convert-string",
|
|
6867
|
+
});
|
|
6868
|
+
|
|
6869
|
+
expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
|
|
6870
|
+
expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual({
|
|
6871
|
+
operator: "eq",
|
|
6872
|
+
operands: [
|
|
6873
|
+
{
|
|
6874
|
+
operator: "string",
|
|
6875
|
+
operands: [{ name: "request.resource.attr.aNumber" }],
|
|
6876
|
+
},
|
|
6877
|
+
{ value: "1" },
|
|
6878
|
+
],
|
|
6879
|
+
});
|
|
6880
|
+
|
|
6881
|
+
expect(() =>
|
|
6882
|
+
queryPlanToPrisma({
|
|
6883
|
+
queryPlan,
|
|
6884
|
+
mapper: {
|
|
6885
|
+
"request.resource.attr.aNumber": { field: "aNumber" },
|
|
6886
|
+
},
|
|
6887
|
+
})
|
|
6888
|
+
).toThrow("Unsupported operator: string");
|
|
6889
|
+
});
|
|
6890
|
+
|
|
6891
|
+
test("convert-double is unsupported", async () => {
|
|
6892
|
+
const queryPlan = await cerbos.planResources({
|
|
6893
|
+
principal: { id: "user1", roles: ["USER"] },
|
|
6894
|
+
resource: { kind: "resource" },
|
|
6895
|
+
action: "convert-double",
|
|
6896
|
+
});
|
|
6897
|
+
|
|
6898
|
+
expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
|
|
6899
|
+
expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual({
|
|
6900
|
+
operator: "gt",
|
|
6901
|
+
operands: [
|
|
6902
|
+
{
|
|
6903
|
+
operator: "double",
|
|
6904
|
+
operands: [{ name: "request.resource.attr.aNumber" }],
|
|
6905
|
+
},
|
|
6906
|
+
{ value: 1.5 },
|
|
6907
|
+
],
|
|
6908
|
+
});
|
|
6909
|
+
|
|
6910
|
+
expect(() =>
|
|
6911
|
+
queryPlanToPrisma({
|
|
6912
|
+
queryPlan,
|
|
6913
|
+
mapper: {
|
|
6914
|
+
"request.resource.attr.aNumber": { field: "aNumber" },
|
|
6915
|
+
},
|
|
6916
|
+
})
|
|
6917
|
+
).toThrow("Unsupported operator: double");
|
|
6918
|
+
});
|
|
6919
|
+
|
|
6920
|
+
test("convert-int is unsupported", async () => {
|
|
6921
|
+
const queryPlan = await cerbos.planResources({
|
|
6922
|
+
principal: { id: "user1", roles: ["USER"] },
|
|
6923
|
+
resource: { kind: "resource" },
|
|
6924
|
+
action: "convert-int",
|
|
6925
|
+
});
|
|
6926
|
+
|
|
6927
|
+
expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
|
|
6928
|
+
expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual({
|
|
6929
|
+
operator: "gt",
|
|
6930
|
+
operands: [
|
|
6931
|
+
{
|
|
6932
|
+
operator: "int",
|
|
6933
|
+
operands: [{ name: "request.resource.attr.aString" }],
|
|
6934
|
+
},
|
|
6935
|
+
{ value: 0 },
|
|
6936
|
+
],
|
|
6937
|
+
});
|
|
6938
|
+
|
|
6939
|
+
expect(() =>
|
|
6940
|
+
queryPlanToPrisma({
|
|
6941
|
+
queryPlan,
|
|
6942
|
+
mapper: {
|
|
6943
|
+
"request.resource.attr.aString": { field: "aString" },
|
|
6944
|
+
},
|
|
6945
|
+
})
|
|
6946
|
+
).toThrow("Unsupported operator: int");
|
|
6947
|
+
});
|
|
6948
|
+
});
|
|
6949
|
+
|
|
6950
|
+
describe("Ternary Operator", () => {
|
|
6951
|
+
const mapper = {
|
|
6952
|
+
"request.resource.attr.aBool": { field: "aBool" },
|
|
6953
|
+
"request.resource.attr.aNumber": { field: "aNumber" },
|
|
6954
|
+
"request.resource.attr.aString": { field: "aString" },
|
|
6955
|
+
"request.resource.attr.aOptionalString": { field: "aOptionalString" },
|
|
6956
|
+
"request.resource.attr.aOptionalBool": { field: "aOptionalBool" },
|
|
6957
|
+
};
|
|
6958
|
+
|
|
6959
|
+
async function matchingResourceIds(
|
|
6960
|
+
queryPlan: PlanResourcesResponse
|
|
6961
|
+
): Promise<string[]> {
|
|
6962
|
+
const result = queryPlanToPrisma({ queryPlan, mapper });
|
|
6963
|
+
if (result.kind !== PlanKind.CONDITIONAL) {
|
|
6964
|
+
throw new Error("Expected CONDITIONAL result");
|
|
6965
|
+
}
|
|
6966
|
+
const resources = await prisma.resource.findMany({
|
|
6967
|
+
where: { ...result.filters },
|
|
6968
|
+
});
|
|
6969
|
+
return resources.map((resource) => resource.id).sort();
|
|
6970
|
+
}
|
|
6971
|
+
|
|
6972
|
+
test("rewrites a comparison-wrapped ternary from the PDP", async () => {
|
|
6973
|
+
const queryPlan = await cerbos.planResources({
|
|
6974
|
+
principal: { id: "user1", roles: ["USER"] },
|
|
6975
|
+
resource: { kind: "resource" },
|
|
6976
|
+
action: "ternary",
|
|
6977
|
+
});
|
|
6978
|
+
|
|
6979
|
+
expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
|
|
6980
|
+
expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual({
|
|
6981
|
+
operator: "gt",
|
|
6982
|
+
operands: [
|
|
6983
|
+
{
|
|
6984
|
+
operator: "if",
|
|
6985
|
+
operands: [
|
|
6986
|
+
{ name: "request.resource.attr.aBool" },
|
|
6987
|
+
{ name: "request.resource.attr.aNumber" },
|
|
6988
|
+
{ value: 0 },
|
|
6989
|
+
],
|
|
6990
|
+
},
|
|
6991
|
+
{ value: 0 },
|
|
6992
|
+
],
|
|
6993
|
+
});
|
|
6994
|
+
|
|
6995
|
+
expect(queryPlanToPrisma({ queryPlan, mapper })).toStrictEqual({
|
|
6996
|
+
kind: PlanKind.CONDITIONAL,
|
|
6997
|
+
filters: {
|
|
6998
|
+
OR: [
|
|
6999
|
+
{
|
|
7000
|
+
AND: [
|
|
7001
|
+
{ aBool: { equals: true } },
|
|
7002
|
+
{ aNumber: { gt: 0 } },
|
|
7003
|
+
],
|
|
7004
|
+
},
|
|
7005
|
+
{
|
|
7006
|
+
AND: [
|
|
7007
|
+
{ aBool: { equals: true } },
|
|
7008
|
+
{ aBool: { equals: false } },
|
|
7009
|
+
],
|
|
7010
|
+
},
|
|
7011
|
+
],
|
|
7012
|
+
},
|
|
7013
|
+
});
|
|
7014
|
+
expect(await matchingResourceIds(queryPlan)).toEqual(["resource1"]);
|
|
7015
|
+
});
|
|
7016
|
+
|
|
7017
|
+
test("rewrites a ternary used directly as a boolean predicate", async () => {
|
|
7018
|
+
const queryPlan = createConditionalPlan({
|
|
7019
|
+
operator: "if",
|
|
7020
|
+
operands: [
|
|
7021
|
+
{ name: "request.resource.attr.aBool" },
|
|
7022
|
+
{
|
|
7023
|
+
operator: "gt",
|
|
7024
|
+
operands: [
|
|
7025
|
+
{ name: "request.resource.attr.aNumber" },
|
|
7026
|
+
{ value: 0 },
|
|
7027
|
+
],
|
|
7028
|
+
},
|
|
7029
|
+
{
|
|
7030
|
+
operator: "eq",
|
|
7031
|
+
operands: [
|
|
7032
|
+
{ name: "request.resource.attr.aString" },
|
|
7033
|
+
{ value: "string3" },
|
|
7034
|
+
],
|
|
7035
|
+
},
|
|
7036
|
+
],
|
|
7037
|
+
});
|
|
7038
|
+
|
|
7039
|
+
expect(await matchingResourceIds(queryPlan)).toEqual([
|
|
7040
|
+
"resource1",
|
|
7041
|
+
"resource3",
|
|
7042
|
+
]);
|
|
7043
|
+
});
|
|
7044
|
+
|
|
7045
|
+
test("recursively rewrites nested ternary branches", async () => {
|
|
7046
|
+
const queryPlan = createConditionalPlan({
|
|
7047
|
+
operator: "gt",
|
|
7048
|
+
operands: [
|
|
7049
|
+
{
|
|
7050
|
+
operator: "if",
|
|
7051
|
+
operands: [
|
|
7052
|
+
{ name: "request.resource.attr.aBool" },
|
|
7053
|
+
{
|
|
7054
|
+
operator: "if",
|
|
7055
|
+
operands: [
|
|
7056
|
+
{
|
|
7057
|
+
operator: "eq",
|
|
7058
|
+
operands: [
|
|
7059
|
+
{ name: "request.resource.attr.aString" },
|
|
7060
|
+
{ value: "string" },
|
|
7061
|
+
],
|
|
7062
|
+
},
|
|
7063
|
+
{ name: "request.resource.attr.aNumber" },
|
|
7064
|
+
{ value: 0 },
|
|
7065
|
+
],
|
|
7066
|
+
},
|
|
7067
|
+
{ value: 0 },
|
|
7068
|
+
],
|
|
7069
|
+
},
|
|
7070
|
+
{ value: 0 },
|
|
7071
|
+
],
|
|
7072
|
+
});
|
|
7073
|
+
|
|
7074
|
+
expect(await matchingResourceIds(queryPlan)).toEqual(["resource1"]);
|
|
7075
|
+
});
|
|
7076
|
+
|
|
7077
|
+
test("mirrors a value-first comparison around a ternary", async () => {
|
|
7078
|
+
const queryPlan = createConditionalPlan({
|
|
7079
|
+
operator: "lt",
|
|
7080
|
+
operands: [
|
|
7081
|
+
{ value: 0 },
|
|
7082
|
+
{
|
|
7083
|
+
operator: "if",
|
|
7084
|
+
operands: [
|
|
7085
|
+
{ name: "request.resource.attr.aBool" },
|
|
7086
|
+
{ name: "request.resource.attr.aNumber" },
|
|
7087
|
+
{ value: -1 },
|
|
7088
|
+
],
|
|
7089
|
+
},
|
|
7090
|
+
],
|
|
7091
|
+
});
|
|
7092
|
+
|
|
7093
|
+
const result = queryPlanToPrisma({ queryPlan, mapper });
|
|
7094
|
+
expect(result).toStrictEqual({
|
|
7095
|
+
kind: PlanKind.CONDITIONAL,
|
|
7096
|
+
filters: {
|
|
7097
|
+
OR: [
|
|
7098
|
+
{
|
|
7099
|
+
AND: [
|
|
7100
|
+
{ aBool: { equals: true } },
|
|
7101
|
+
{ aNumber: { gt: 0 } },
|
|
7102
|
+
],
|
|
7103
|
+
},
|
|
7104
|
+
{
|
|
7105
|
+
AND: [
|
|
7106
|
+
{ aBool: { equals: true } },
|
|
7107
|
+
{ aBool: { equals: false } },
|
|
7108
|
+
],
|
|
7109
|
+
},
|
|
7110
|
+
],
|
|
7111
|
+
},
|
|
7112
|
+
});
|
|
7113
|
+
expect(await matchingResourceIds(queryPlan)).toEqual(["resource1"]);
|
|
7114
|
+
});
|
|
7115
|
+
|
|
7116
|
+
test("keeps a nullable condition unknown under outer negation", async () => {
|
|
7117
|
+
const queryPlan = createConditionalPlan({
|
|
7118
|
+
operator: "not",
|
|
7119
|
+
operands: [
|
|
7120
|
+
{
|
|
7121
|
+
operator: "if",
|
|
7122
|
+
operands: [
|
|
7123
|
+
{ name: "request.resource.attr.aOptionalBool" },
|
|
7124
|
+
{ value: false },
|
|
7125
|
+
{ value: false },
|
|
7126
|
+
],
|
|
7127
|
+
},
|
|
7128
|
+
],
|
|
7129
|
+
});
|
|
7130
|
+
|
|
7131
|
+
expect(await matchingResourceIds(queryPlan)).toEqual([
|
|
7132
|
+
"resource1",
|
|
7133
|
+
"resource2",
|
|
7134
|
+
]);
|
|
7135
|
+
});
|
|
7136
|
+
|
|
7137
|
+
test("negates a relation-backed boolean condition as a whole", () => {
|
|
7138
|
+
const queryPlan = createConditionalPlan({
|
|
7139
|
+
operator: "if",
|
|
7140
|
+
operands: [
|
|
7141
|
+
{ name: "request.resource.attr.ownedBy.aBool" },
|
|
7142
|
+
{ value: false },
|
|
7143
|
+
{ value: true },
|
|
7144
|
+
],
|
|
7145
|
+
});
|
|
7146
|
+
|
|
7147
|
+
expect(
|
|
7148
|
+
queryPlanToPrisma({
|
|
7149
|
+
queryPlan,
|
|
7150
|
+
mapper: {
|
|
7151
|
+
"request.resource.attr.ownedBy": {
|
|
7152
|
+
relation: {
|
|
7153
|
+
name: "ownedBy",
|
|
7154
|
+
type: "many",
|
|
7155
|
+
fields: { aBool: { field: "aBool" } },
|
|
7156
|
+
},
|
|
7157
|
+
},
|
|
7158
|
+
},
|
|
7159
|
+
})
|
|
7160
|
+
).toStrictEqual({
|
|
7161
|
+
kind: PlanKind.CONDITIONAL,
|
|
7162
|
+
filters: {
|
|
7163
|
+
OR: [
|
|
7164
|
+
{
|
|
7165
|
+
NOT: {
|
|
7166
|
+
ownedBy: {
|
|
7167
|
+
some: { aBool: { equals: true } },
|
|
7168
|
+
},
|
|
7169
|
+
},
|
|
7170
|
+
},
|
|
7171
|
+
{
|
|
7172
|
+
AND: [
|
|
7173
|
+
{
|
|
7174
|
+
ownedBy: {
|
|
7175
|
+
some: { aBool: { equals: true } },
|
|
7176
|
+
},
|
|
7177
|
+
},
|
|
7178
|
+
{
|
|
7179
|
+
NOT: {
|
|
7180
|
+
ownedBy: {
|
|
7181
|
+
some: { aBool: { equals: true } },
|
|
7182
|
+
},
|
|
7183
|
+
},
|
|
7184
|
+
},
|
|
7185
|
+
],
|
|
7186
|
+
},
|
|
7187
|
+
],
|
|
7188
|
+
},
|
|
7189
|
+
});
|
|
7190
|
+
});
|
|
7191
|
+
|
|
7192
|
+
test("rejects malformed and non-boolean ternary conditions", () => {
|
|
7193
|
+
expect(() =>
|
|
7194
|
+
queryPlanToPrisma({
|
|
7195
|
+
queryPlan: createConditionalPlan({
|
|
7196
|
+
operator: "if",
|
|
7197
|
+
operands: [
|
|
7198
|
+
{ name: "request.resource.attr.aBool" },
|
|
7199
|
+
{ value: true },
|
|
7200
|
+
],
|
|
7201
|
+
}),
|
|
7202
|
+
mapper,
|
|
7203
|
+
})
|
|
7204
|
+
).toThrow(
|
|
7205
|
+
"if (ternary) requires exactly 3 operands (condition, then, else), got 2"
|
|
7206
|
+
);
|
|
7207
|
+
|
|
7208
|
+
expect(() =>
|
|
7209
|
+
queryPlanToPrisma({
|
|
7210
|
+
queryPlan: createConditionalPlan({
|
|
7211
|
+
operator: "if",
|
|
7212
|
+
operands: [{ value: null }, { value: true }, { value: false }],
|
|
7213
|
+
}),
|
|
7214
|
+
mapper,
|
|
7215
|
+
})
|
|
7216
|
+
).toThrow("if (ternary) condition must be a boolean expression");
|
|
7217
|
+
|
|
7218
|
+
expect(() =>
|
|
7219
|
+
queryPlanToPrisma({
|
|
7220
|
+
queryPlan: createConditionalPlan({
|
|
7221
|
+
operator: "eq",
|
|
7222
|
+
operands: [
|
|
7223
|
+
{
|
|
7224
|
+
operator: "if",
|
|
7225
|
+
operands: [
|
|
7226
|
+
{ name: "request.resource.attr.aBool" },
|
|
7227
|
+
{ value: true },
|
|
7228
|
+
{ value: false },
|
|
7229
|
+
],
|
|
7230
|
+
},
|
|
7231
|
+
{ value: true },
|
|
7232
|
+
{ value: false },
|
|
7233
|
+
],
|
|
7234
|
+
}),
|
|
7235
|
+
mapper,
|
|
7236
|
+
})
|
|
7237
|
+
).toThrow("eq with a ternary requires exactly 2 operands, got 3");
|
|
7238
|
+
});
|
|
7239
|
+
|
|
7240
|
+
test("handles planner-normalized constant conditions", async () => {
|
|
7241
|
+
const bareTrue = createConditionalPlan({
|
|
7242
|
+
operator: "if",
|
|
7243
|
+
operands: [{ value: true }, { value: true }, { value: false }],
|
|
7244
|
+
});
|
|
7245
|
+
const bareFalse = createConditionalPlan({
|
|
7246
|
+
operator: "if",
|
|
7247
|
+
operands: [{ value: true }, { value: false }, { value: true }],
|
|
7248
|
+
});
|
|
7249
|
+
const comparisonFalse = createConditionalPlan({
|
|
7250
|
+
operator: "gt",
|
|
7251
|
+
operands: [
|
|
7252
|
+
{
|
|
7253
|
+
operator: "if",
|
|
7254
|
+
operands: [{ value: true }, { value: 0 }, { value: 1 }],
|
|
7255
|
+
},
|
|
7256
|
+
{ value: 0 },
|
|
7257
|
+
],
|
|
7258
|
+
});
|
|
7259
|
+
|
|
7260
|
+
expect(await matchingResourceIds(bareTrue)).toEqual([
|
|
7261
|
+
"resource1",
|
|
7262
|
+
"resource2",
|
|
7263
|
+
"resource3",
|
|
7264
|
+
]);
|
|
7265
|
+
expect(() => queryPlanToPrisma({ queryPlan: bareFalse, mapper })).toThrow(
|
|
7266
|
+
"A constant-false conditional predicate must be folded by the Cerbos planner"
|
|
7267
|
+
);
|
|
7268
|
+
expect(() =>
|
|
7269
|
+
queryPlanToPrisma({ queryPlan: comparisonFalse, mapper })
|
|
7270
|
+
).toThrow(
|
|
7271
|
+
"A constant-false conditional predicate must be folded by the Cerbos planner"
|
|
7272
|
+
);
|
|
7273
|
+
});
|
|
7274
|
+
|
|
7275
|
+
test("orders folded strings by Unicode code point", async () => {
|
|
7276
|
+
const queryPlan = createConditionalPlan({
|
|
7277
|
+
operator: "lt",
|
|
7278
|
+
operands: [
|
|
7279
|
+
{
|
|
7280
|
+
operator: "if",
|
|
7281
|
+
operands: [
|
|
7282
|
+
{ name: "request.resource.attr.aBool" },
|
|
7283
|
+
{ value: "\u{10000}" },
|
|
7284
|
+
{ value: "\u{10000}" },
|
|
7285
|
+
],
|
|
7286
|
+
},
|
|
7287
|
+
{ value: "\uE000" },
|
|
7288
|
+
],
|
|
7289
|
+
});
|
|
7290
|
+
|
|
7291
|
+
expect(await matchingResourceIds(queryPlan)).toEqual([]);
|
|
7292
|
+
});
|
|
7293
|
+
});
|
|
7294
|
+
|
|
7295
|
+
describe("Size on String", () => {
|
|
7296
|
+
test("string-size is unsupported (size() on a scalar string has no relation mapping)", async () => {
|
|
7297
|
+
const queryPlan = await cerbos.planResources({
|
|
7298
|
+
principal: { id: "user1", roles: ["USER"] },
|
|
7299
|
+
resource: { kind: "resource" },
|
|
7300
|
+
action: "string-size",
|
|
7301
|
+
});
|
|
7302
|
+
|
|
7303
|
+
expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
|
|
7304
|
+
expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual({
|
|
7305
|
+
operator: "gt",
|
|
7306
|
+
operands: [
|
|
7307
|
+
{
|
|
7308
|
+
operator: "size",
|
|
7309
|
+
operands: [{ name: "request.resource.attr.aString" }],
|
|
7310
|
+
},
|
|
7311
|
+
{ value: 0 },
|
|
7312
|
+
],
|
|
7313
|
+
});
|
|
7314
|
+
|
|
7315
|
+
// size(scalar_string) cannot be expressed in Prisma's where shape and the
|
|
7316
|
+
// adapter's size handler requires a relation mapping. A scalar string
|
|
7317
|
+
// field is not a relation, so this throws.
|
|
7318
|
+
expect(() =>
|
|
7319
|
+
queryPlanToPrisma({
|
|
7320
|
+
queryPlan,
|
|
7321
|
+
mapper: {
|
|
7322
|
+
"request.resource.attr.aString": { field: "aString" },
|
|
7323
|
+
},
|
|
7324
|
+
})
|
|
7325
|
+
).toThrow("size operator requires a relation mapping");
|
|
7326
|
+
});
|
|
7327
|
+
});
|
|
7328
|
+
|
|
7329
|
+
describe("Empty Collection", () => {
|
|
7330
|
+
test("empty-collection (size(relation) == 0) maps to relation.none filter", async () => {
|
|
7331
|
+
const queryPlan = await cerbos.planResources({
|
|
7332
|
+
principal: { id: "user1", roles: ["USER"] },
|
|
7333
|
+
resource: { kind: "resource" },
|
|
7334
|
+
action: "empty-collection",
|
|
7335
|
+
});
|
|
7336
|
+
|
|
7337
|
+
expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
|
|
7338
|
+
expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual({
|
|
7339
|
+
operator: "eq",
|
|
7340
|
+
operands: [
|
|
7341
|
+
{
|
|
7342
|
+
operator: "size",
|
|
7343
|
+
operands: [{ name: "request.resource.attr.tags" }],
|
|
7344
|
+
},
|
|
7345
|
+
{ value: 0 },
|
|
7346
|
+
],
|
|
7347
|
+
});
|
|
7348
|
+
|
|
7349
|
+
const result = queryPlanToPrisma({
|
|
7350
|
+
queryPlan,
|
|
7351
|
+
mapper: {
|
|
7352
|
+
"request.resource.attr.tags": {
|
|
7353
|
+
relation: { name: "tags", type: "many" },
|
|
7354
|
+
},
|
|
7355
|
+
},
|
|
7356
|
+
});
|
|
7357
|
+
|
|
7358
|
+
expect(result).toStrictEqual({
|
|
7359
|
+
kind: PlanKind.CONDITIONAL,
|
|
7360
|
+
filters: { tags: { none: {} } },
|
|
7361
|
+
});
|
|
7362
|
+
|
|
7363
|
+
if (result.kind !== PlanKind.CONDITIONAL) {
|
|
7364
|
+
throw new Error("Expected CONDITIONAL result");
|
|
7365
|
+
}
|
|
7366
|
+
|
|
7367
|
+
const query = await prisma.resource.findMany({
|
|
7368
|
+
where: { ...result.filters },
|
|
7369
|
+
});
|
|
7370
|
+
|
|
7371
|
+
expect(query.map((r) => r.id)).toEqual(
|
|
7372
|
+
fixtureResources
|
|
7373
|
+
.filter((r) => {
|
|
7374
|
+
if (!r.tags?.connect) return true;
|
|
7375
|
+
return (r.tags.connect as { id: string }[]).length === 0;
|
|
7376
|
+
})
|
|
7377
|
+
.map((r) => r.id)
|
|
7378
|
+
);
|
|
7379
|
+
});
|
|
7380
|
+
});
|
|
7381
|
+
|
|
7382
|
+
// Issue #155: hierarchy overlaps operator
|
|
7383
|
+
// overlaps = one hierarchy is a prefix of the other (segment-wise comparison)
|
|
7384
|
+
describe("Hierarchy Overlaps", () => {
|
|
7385
|
+
test("overlaps - equal length hierarchies, field must match", () => {
|
|
7386
|
+
// #given hierarchy("projects:123", ":").overlaps(hierarchy(["projects", R.id]))
|
|
7387
|
+
// Left segments: ["projects", "123"], Right segments: ["projects", R.id]
|
|
7388
|
+
// Same length: prefix check both directions requires all segments match
|
|
7389
|
+
// segments[0]: "projects" == "projects" ✓
|
|
7390
|
+
// segments[1]: "123" == R.id → R.id == "123"
|
|
7391
|
+
const plan = createConditionalPlan({
|
|
7392
|
+
operator: "overlaps",
|
|
7393
|
+
operands: [
|
|
7394
|
+
{
|
|
7395
|
+
operator: "hierarchy",
|
|
7396
|
+
operands: [{ value: "projects:123" }, { value: ":" }],
|
|
7397
|
+
},
|
|
7398
|
+
{
|
|
7399
|
+
operator: "hierarchy",
|
|
7400
|
+
operands: [
|
|
7401
|
+
{
|
|
7402
|
+
operator: "list",
|
|
7403
|
+
operands: [
|
|
7404
|
+
{ value: "projects" },
|
|
7405
|
+
{ name: "request.resource.id" },
|
|
7406
|
+
],
|
|
7407
|
+
},
|
|
7408
|
+
],
|
|
7409
|
+
},
|
|
7410
|
+
],
|
|
7411
|
+
});
|
|
7412
|
+
|
|
7413
|
+
// #when
|
|
7414
|
+
const result = queryPlanToPrisma({
|
|
7415
|
+
queryPlan: plan,
|
|
7416
|
+
mapper: {
|
|
7417
|
+
"request.resource.id": { field: "id" },
|
|
7418
|
+
},
|
|
7419
|
+
});
|
|
7420
|
+
|
|
7421
|
+
// #then
|
|
7422
|
+
expect(result).toEqual({
|
|
7423
|
+
kind: PlanKind.CONDITIONAL,
|
|
7424
|
+
filters: { id: { equals: "123" } },
|
|
7425
|
+
});
|
|
7426
|
+
});
|
|
7427
|
+
|
|
7428
|
+
test("overlaps - left shorter than right, left is always prefix", () => {
|
|
7429
|
+
// #given hierarchy("admin", ":").overlaps(hierarchy(["admin", R.attr.scope]))
|
|
7430
|
+
// Left segments: ["admin"] (length 1), Right segments: ["admin", R.attr.scope] (length 2)
|
|
7431
|
+
// Left prefix of right: "admin" == "admin" ✓ → always true
|
|
7432
|
+
const plan = createConditionalPlan({
|
|
7433
|
+
operator: "overlaps",
|
|
7434
|
+
operands: [
|
|
7435
|
+
{
|
|
7436
|
+
operator: "hierarchy",
|
|
7437
|
+
operands: [{ value: "admin" }, { value: ":" }],
|
|
7438
|
+
},
|
|
7439
|
+
{
|
|
7440
|
+
operator: "hierarchy",
|
|
7441
|
+
operands: [
|
|
7442
|
+
{
|
|
7443
|
+
operator: "list",
|
|
7444
|
+
operands: [
|
|
7445
|
+
{ value: "admin" },
|
|
7446
|
+
{ name: "request.resource.attr.scope" },
|
|
7447
|
+
],
|
|
7448
|
+
},
|
|
7449
|
+
],
|
|
7450
|
+
},
|
|
7451
|
+
],
|
|
7452
|
+
});
|
|
7453
|
+
|
|
7454
|
+
// #when
|
|
7455
|
+
const result = queryPlanToPrisma({
|
|
7456
|
+
queryPlan: plan,
|
|
7457
|
+
mapper: {
|
|
7458
|
+
"request.resource.attr.scope": { field: "scope" },
|
|
7459
|
+
},
|
|
7460
|
+
});
|
|
7461
|
+
|
|
7462
|
+
// #then always true
|
|
7463
|
+
expect(result).toEqual({
|
|
7464
|
+
kind: PlanKind.CONDITIONAL,
|
|
7465
|
+
filters: {},
|
|
7466
|
+
});
|
|
7467
|
+
});
|
|
7468
|
+
|
|
7469
|
+
test("overlaps - left longer, right is prefix when field matches", () => {
|
|
7470
|
+
// #given hierarchy("dept:engineering:frontend", ":").overlaps(hierarchy(["dept", R.attr.department]))
|
|
7471
|
+
// Left segments: ["dept", "engineering", "frontend"] (3)
|
|
7472
|
+
// Right segments: ["dept", R.attr.department] (2)
|
|
7473
|
+
// Left prefix of right: len 3 > len 2 → impossible
|
|
7474
|
+
// Right prefix of left: "dept" == "dept" ✓, R.attr.department == "engineering"
|
|
7475
|
+
const plan = createConditionalPlan({
|
|
7476
|
+
operator: "overlaps",
|
|
7477
|
+
operands: [
|
|
7478
|
+
{
|
|
7479
|
+
operator: "hierarchy",
|
|
7480
|
+
operands: [
|
|
7481
|
+
{ value: "dept:engineering:frontend" },
|
|
7482
|
+
{ value: ":" },
|
|
7483
|
+
],
|
|
7484
|
+
},
|
|
7485
|
+
{
|
|
7486
|
+
operator: "hierarchy",
|
|
7487
|
+
operands: [
|
|
7488
|
+
{
|
|
7489
|
+
operator: "list",
|
|
7490
|
+
operands: [
|
|
7491
|
+
{ value: "dept" },
|
|
7492
|
+
{ name: "request.resource.attr.department" },
|
|
7493
|
+
],
|
|
7494
|
+
},
|
|
7495
|
+
],
|
|
7496
|
+
},
|
|
7497
|
+
],
|
|
7498
|
+
});
|
|
7499
|
+
|
|
7500
|
+
// #when
|
|
7501
|
+
const result = queryPlanToPrisma({
|
|
7502
|
+
queryPlan: plan,
|
|
7503
|
+
mapper: {
|
|
7504
|
+
"request.resource.attr.department": { field: "department" },
|
|
7505
|
+
},
|
|
7506
|
+
});
|
|
7507
|
+
|
|
7508
|
+
// #then
|
|
7509
|
+
expect(result).toEqual({
|
|
7510
|
+
kind: PlanKind.CONDITIONAL,
|
|
7511
|
+
filters: { department: { equals: "engineering" } },
|
|
7512
|
+
});
|
|
7513
|
+
});
|
|
7514
|
+
|
|
7515
|
+
test("overlaps - different roots, impossible", () => {
|
|
7516
|
+
// #given hierarchy("org:acme:team1", ":").overlaps(hierarchy(["company", R.attr.team]))
|
|
7517
|
+
// Left: ["org", "acme", "team1"], Right: ["company", R.attr.team]
|
|
7518
|
+
// Both directions fail at segment[0]: "org" ≠ "company"
|
|
7519
|
+
const plan = createConditionalPlan({
|
|
7520
|
+
operator: "overlaps",
|
|
7521
|
+
operands: [
|
|
7522
|
+
{
|
|
7523
|
+
operator: "hierarchy",
|
|
7524
|
+
operands: [{ value: "org:acme:team1" }, { value: ":" }],
|
|
7525
|
+
},
|
|
7526
|
+
{
|
|
7527
|
+
operator: "hierarchy",
|
|
7528
|
+
operands: [
|
|
7529
|
+
{
|
|
7530
|
+
operator: "list",
|
|
7531
|
+
operands: [
|
|
7532
|
+
{ value: "company" },
|
|
7533
|
+
{ name: "request.resource.attr.team" },
|
|
7534
|
+
],
|
|
7535
|
+
},
|
|
7536
|
+
],
|
|
7537
|
+
},
|
|
7538
|
+
],
|
|
7539
|
+
});
|
|
7540
|
+
|
|
7541
|
+
// #when
|
|
7542
|
+
const result = queryPlanToPrisma({
|
|
7543
|
+
queryPlan: plan,
|
|
7544
|
+
mapper: {
|
|
7545
|
+
"request.resource.attr.team": { field: "team" },
|
|
7546
|
+
},
|
|
7547
|
+
});
|
|
7548
|
+
|
|
7549
|
+
// #then impossible
|
|
7550
|
+
expect(result).toEqual({
|
|
7551
|
+
kind: PlanKind.CONDITIONAL,
|
|
7552
|
+
filters: {
|
|
7553
|
+
team: { in: [] },
|
|
7554
|
+
},
|
|
7555
|
+
});
|
|
7556
|
+
});
|
|
7557
|
+
|
|
7558
|
+
test("overlaps - equal length with field in middle, field must match", () => {
|
|
7559
|
+
// #given hierarchy("region:us:west", ":").overlaps(hierarchy(["region", "us", R.attr.zone]))
|
|
7560
|
+
// Left: ["region", "us", "west"], Right: ["region", "us", R.attr.zone]
|
|
7561
|
+
// Same length, prefix check: "region"=="region" ✓, "us"=="us" ✓, "west"==R.attr.zone
|
|
7562
|
+
const plan = createConditionalPlan({
|
|
7563
|
+
operator: "overlaps",
|
|
7564
|
+
operands: [
|
|
7565
|
+
{
|
|
7566
|
+
operator: "hierarchy",
|
|
7567
|
+
operands: [{ value: "region:us:west" }, { value: ":" }],
|
|
7568
|
+
},
|
|
7569
|
+
{
|
|
7570
|
+
operator: "hierarchy",
|
|
7571
|
+
operands: [
|
|
7572
|
+
{
|
|
7573
|
+
operator: "list",
|
|
7574
|
+
operands: [
|
|
7575
|
+
{ value: "region" },
|
|
7576
|
+
{ value: "us" },
|
|
7577
|
+
{ name: "request.resource.attr.zone" },
|
|
7578
|
+
],
|
|
7579
|
+
},
|
|
7580
|
+
],
|
|
7581
|
+
},
|
|
7582
|
+
],
|
|
7583
|
+
});
|
|
7584
|
+
|
|
7585
|
+
// #when
|
|
7586
|
+
const result = queryPlanToPrisma({
|
|
7587
|
+
queryPlan: plan,
|
|
7588
|
+
mapper: {
|
|
7589
|
+
"request.resource.attr.zone": { field: "zone" },
|
|
7590
|
+
},
|
|
7591
|
+
});
|
|
7592
|
+
|
|
7593
|
+
// #then
|
|
7594
|
+
expect(result).toEqual({
|
|
7595
|
+
kind: PlanKind.CONDITIONAL,
|
|
7596
|
+
filters: { zone: { equals: "west" } },
|
|
7597
|
+
});
|
|
7598
|
+
});
|
|
7599
|
+
|
|
7600
|
+
test("overlaps - single-field right hierarchy as prefix of left", () => {
|
|
7601
|
+
// #given hierarchy("team:alpha", ":").overlaps(hierarchy([R.attr.group]))
|
|
7602
|
+
// Left: ["team", "alpha"] (2), Right: [R.attr.group] (1)
|
|
7603
|
+
// Left prefix of right: len 2 > len 1 → impossible
|
|
7604
|
+
// Right prefix of left: R.attr.group == "team" (first segment)
|
|
7605
|
+
const plan = createConditionalPlan({
|
|
7606
|
+
operator: "overlaps",
|
|
7607
|
+
operands: [
|
|
7608
|
+
{
|
|
7609
|
+
operator: "hierarchy",
|
|
7610
|
+
operands: [{ value: "team:alpha" }, { value: ":" }],
|
|
7611
|
+
},
|
|
7612
|
+
{
|
|
7613
|
+
operator: "hierarchy",
|
|
7614
|
+
operands: [
|
|
7615
|
+
{
|
|
7616
|
+
operator: "list",
|
|
7617
|
+
operands: [{ name: "request.resource.attr.group" }],
|
|
7618
|
+
},
|
|
7619
|
+
],
|
|
7620
|
+
},
|
|
7621
|
+
],
|
|
7622
|
+
});
|
|
7623
|
+
|
|
7624
|
+
// #when
|
|
7625
|
+
const result = queryPlanToPrisma({
|
|
7626
|
+
queryPlan: plan,
|
|
7627
|
+
mapper: {
|
|
7628
|
+
"request.resource.attr.group": { field: "group" },
|
|
7629
|
+
},
|
|
7630
|
+
});
|
|
7631
|
+
|
|
7632
|
+
// #then
|
|
7633
|
+
expect(result).toEqual({
|
|
7634
|
+
kind: PlanKind.CONDITIONAL,
|
|
7635
|
+
filters: { group: { equals: "team" } },
|
|
7636
|
+
});
|
|
7637
|
+
});
|
|
7638
|
+
|
|
7639
|
+
test("overlaps - hierarchy(dotted string) without delimiter", () => {
|
|
7640
|
+
// #given hierarchy("a.b.c").overlaps(hierarchy(["a", "b", R.attr.zone]))
|
|
7641
|
+
// hierarchy("a.b.c") uses default "." delimiter → ["a", "b", "c"]
|
|
7642
|
+
const plan = createConditionalPlan({
|
|
7643
|
+
operator: "overlaps",
|
|
7644
|
+
operands: [
|
|
7645
|
+
{
|
|
7646
|
+
operator: "hierarchy",
|
|
7647
|
+
operands: [{ value: "a.b.c" }],
|
|
7648
|
+
},
|
|
7649
|
+
{
|
|
7650
|
+
operator: "hierarchy",
|
|
7651
|
+
operands: [
|
|
7652
|
+
{
|
|
7653
|
+
operator: "list",
|
|
7654
|
+
operands: [
|
|
7655
|
+
{ value: "a" },
|
|
7656
|
+
{ value: "b" },
|
|
7657
|
+
{ name: "request.resource.attr.zone" },
|
|
7658
|
+
],
|
|
7659
|
+
},
|
|
7660
|
+
],
|
|
7661
|
+
},
|
|
7662
|
+
],
|
|
7663
|
+
});
|
|
7664
|
+
|
|
7665
|
+
// #when
|
|
7666
|
+
const result = queryPlanToPrisma({
|
|
7667
|
+
queryPlan: plan,
|
|
7668
|
+
mapper: { "request.resource.attr.zone": { field: "zone" } },
|
|
7669
|
+
});
|
|
7670
|
+
|
|
7671
|
+
// #then
|
|
7672
|
+
expect(result).toEqual({
|
|
7673
|
+
kind: PlanKind.CONDITIONAL,
|
|
7674
|
+
filters: { zone: { equals: "c" } },
|
|
7675
|
+
});
|
|
7676
|
+
});
|
|
7677
|
+
|
|
7678
|
+
test("overlaps via cerbos integration", async () => {
|
|
7679
|
+
const queryPlan = await cerbos.planResources({
|
|
7680
|
+
principal: {
|
|
7681
|
+
id: "user1",
|
|
7682
|
+
roles: ["USER"],
|
|
7683
|
+
attr: { context: "projects:123" },
|
|
7684
|
+
},
|
|
7685
|
+
resource: { kind: "resource" },
|
|
7686
|
+
action: "hierarchy-overlaps",
|
|
7687
|
+
});
|
|
7688
|
+
|
|
7689
|
+
expect(queryPlan.kind).toBe(PlanKind.CONDITIONAL);
|
|
7690
|
+
|
|
7691
|
+
const result = queryPlanToPrisma({
|
|
7692
|
+
queryPlan,
|
|
7693
|
+
mapper: {
|
|
7694
|
+
"request.resource.id": { field: "id" },
|
|
7695
|
+
},
|
|
7696
|
+
});
|
|
7697
|
+
|
|
7698
|
+
// hierarchy("projects:123", ":") segments = ["projects", "123"]
|
|
7699
|
+
// hierarchy(["projects", R.id]) segments = ["projects", R.id]
|
|
7700
|
+
// Same length, segments[1]: "123" == R.id → R.id == "123"
|
|
7701
|
+
expect(result).toEqual({
|
|
7702
|
+
kind: PlanKind.CONDITIONAL,
|
|
7703
|
+
filters: { id: { equals: "123" } },
|
|
7704
|
+
});
|
|
7705
|
+
});
|
|
7706
|
+
});
|
|
7707
|
+
|
|
7708
|
+
const hierarchyMapper = {
|
|
7709
|
+
"request.resource.attr.scope": { field: "scope" },
|
|
7710
|
+
"request.resource.attr.path": { field: "path" },
|
|
7711
|
+
};
|
|
7712
|
+
|
|
7713
|
+
// ancestorOf = first hierarchy is a strict prefix of second
|
|
7714
|
+
// hierarchy("a.b").ancestorOf(hierarchy("a.b.c.d")) == true
|
|
7715
|
+
describe("Hierarchy ancestorOf", () => {
|
|
7716
|
+
test("ancestorOf - constant is ancestor, field is descendant", () => {
|
|
7717
|
+
// #given hierarchy("a.b.c").ancestorOf(hierarchy(R.attr.scope))
|
|
7718
|
+
const plan = createConditionalPlan({
|
|
7719
|
+
operator: "ancestorOf",
|
|
7720
|
+
operands: [
|
|
7721
|
+
{
|
|
7722
|
+
operator: "hierarchy",
|
|
7723
|
+
operands: [{ value: "a.b.c" }],
|
|
7724
|
+
},
|
|
7725
|
+
{
|
|
7726
|
+
operator: "hierarchy",
|
|
7727
|
+
operands: [{ name: "request.resource.attr.scope" }],
|
|
7728
|
+
},
|
|
7729
|
+
],
|
|
7730
|
+
});
|
|
7731
|
+
|
|
7732
|
+
// #when
|
|
7733
|
+
const result = queryPlanToPrisma({ queryPlan: plan, mapper: hierarchyMapper });
|
|
7734
|
+
|
|
7735
|
+
// #then - R.attr.scope must start with "a.b.c." (ancestor means strict prefix)
|
|
7736
|
+
expect(result).toEqual({
|
|
7737
|
+
kind: PlanKind.CONDITIONAL,
|
|
7738
|
+
filters: { scope: { startsWith: "a.b.c." } },
|
|
7739
|
+
});
|
|
7740
|
+
});
|
|
7741
|
+
|
|
7742
|
+
test("ancestorOf - field is ancestor of constant", () => {
|
|
7743
|
+
// #given hierarchy(R.attr.scope).ancestorOf(hierarchy("a.b.c.d"))
|
|
7744
|
+
// R.attr.scope must be a prefix of "a.b.c.d"
|
|
7745
|
+
// Possible prefixes: "a", "a.b", "a.b.c"
|
|
7746
|
+
const plan = createConditionalPlan({
|
|
7747
|
+
operator: "ancestorOf",
|
|
7748
|
+
operands: [
|
|
7749
|
+
{
|
|
7750
|
+
operator: "hierarchy",
|
|
7751
|
+
operands: [{ name: "request.resource.attr.scope" }],
|
|
7752
|
+
},
|
|
7753
|
+
{
|
|
7754
|
+
operator: "hierarchy",
|
|
7755
|
+
operands: [{ value: "a.b.c.d" }],
|
|
7756
|
+
},
|
|
7757
|
+
],
|
|
7758
|
+
});
|
|
7759
|
+
|
|
7760
|
+
// #when
|
|
7761
|
+
const result = queryPlanToPrisma({ queryPlan: plan, mapper: hierarchyMapper });
|
|
7762
|
+
|
|
7763
|
+
// #then - scope must be one of the strict prefixes of "a.b.c.d"
|
|
7764
|
+
expect(result).toEqual({
|
|
7765
|
+
kind: PlanKind.CONDITIONAL,
|
|
7766
|
+
filters: { scope: { in: ["a", "a.b", "a.b.c"] } },
|
|
7767
|
+
});
|
|
7768
|
+
});
|
|
7769
|
+
|
|
7770
|
+
test("ancestorOf - single segment constant ancestor", () => {
|
|
7771
|
+
// #given hierarchy("root").ancestorOf(hierarchy(R.attr.scope))
|
|
7772
|
+
const plan = createConditionalPlan({
|
|
7773
|
+
operator: "ancestorOf",
|
|
7774
|
+
operands: [
|
|
7775
|
+
{
|
|
7776
|
+
operator: "hierarchy",
|
|
7777
|
+
operands: [{ value: "root" }],
|
|
7778
|
+
},
|
|
7779
|
+
{
|
|
7780
|
+
operator: "hierarchy",
|
|
7781
|
+
operands: [{ name: "request.resource.attr.scope" }],
|
|
7782
|
+
},
|
|
7783
|
+
],
|
|
7784
|
+
});
|
|
7785
|
+
|
|
7786
|
+
// #when
|
|
7787
|
+
const result = queryPlanToPrisma({ queryPlan: plan, mapper: hierarchyMapper });
|
|
7788
|
+
|
|
7789
|
+
// #then
|
|
7790
|
+
expect(result).toEqual({
|
|
7791
|
+
kind: PlanKind.CONDITIONAL,
|
|
7792
|
+
filters: { scope: { startsWith: "root." } },
|
|
7793
|
+
});
|
|
7794
|
+
});
|
|
7795
|
+
|
|
7796
|
+
test("ancestorOf - with custom delimiter", () => {
|
|
7797
|
+
// #given hierarchy(P.attr.scope, ":").ancestorOf(hierarchy(R.attr.path, ":"))
|
|
7798
|
+
// P.attr.scope is resolved at plan time → constant "org:team"
|
|
7799
|
+
const plan = createConditionalPlan({
|
|
7800
|
+
operator: "ancestorOf",
|
|
7801
|
+
operands: [
|
|
7802
|
+
{
|
|
7803
|
+
operator: "hierarchy",
|
|
7804
|
+
operands: [{ value: "org:team" }, { value: ":" }],
|
|
7805
|
+
},
|
|
7806
|
+
{
|
|
7807
|
+
operator: "hierarchy",
|
|
7808
|
+
operands: [{ name: "request.resource.attr.path" }, { value: ":" }],
|
|
7809
|
+
},
|
|
7810
|
+
],
|
|
7811
|
+
});
|
|
7812
|
+
|
|
7813
|
+
// #when
|
|
7814
|
+
const result = queryPlanToPrisma({ queryPlan: plan, mapper: hierarchyMapper });
|
|
7815
|
+
|
|
7816
|
+
// #then - R.attr.path must start with "org:team:" (using the ":" delimiter)
|
|
7817
|
+
expect(result).toEqual({
|
|
7818
|
+
kind: PlanKind.CONDITIONAL,
|
|
7819
|
+
filters: { path: { startsWith: "org:team:" } },
|
|
7820
|
+
});
|
|
7821
|
+
});
|
|
7822
|
+
|
|
7823
|
+
test("ancestorOf via cerbos integration", async () => {
|
|
7824
|
+
const plan = await cerbos.planResources({
|
|
7825
|
+
principal: {
|
|
7826
|
+
id: "user1",
|
|
7827
|
+
roles: ["USER"],
|
|
7828
|
+
attr: { scope: "a.b.c" },
|
|
7829
|
+
},
|
|
7830
|
+
resource: { kind: "resource" },
|
|
7831
|
+
action: "hierarchy-ancestor-of",
|
|
7832
|
+
});
|
|
7833
|
+
|
|
7834
|
+
const result = queryPlanToPrisma({
|
|
7835
|
+
queryPlan: plan,
|
|
7836
|
+
mapper: {
|
|
7837
|
+
"request.resource.attr.scope": { field: "scope" },
|
|
7838
|
+
},
|
|
7839
|
+
});
|
|
7840
|
+
|
|
7841
|
+
// hierarchy("a.b.c").ancestorOf(hierarchy(R.attr.scope))
|
|
7842
|
+
// "a.b.c" is ancestor of R.attr.scope → R.attr.scope starts with "a.b.c."
|
|
7843
|
+
expect(result).toEqual({
|
|
7844
|
+
kind: PlanKind.CONDITIONAL,
|
|
7845
|
+
filters: { scope: { startsWith: "a.b.c." } },
|
|
7846
|
+
});
|
|
7847
|
+
});
|
|
7848
|
+
});
|
|
7849
|
+
|
|
7850
|
+
// descendentOf = mirror of ancestorOf
|
|
7851
|
+
// hierarchy("a.b.c.d").descendentOf(hierarchy("a.b")) == true
|
|
7852
|
+
describe("Hierarchy descendentOf", () => {
|
|
7853
|
+
test("descendentOf - field is descendant of constant", () => {
|
|
7854
|
+
// #given hierarchy(R.attr.scope).descendentOf(hierarchy("a.b"))
|
|
7855
|
+
// R.attr.scope must be a descendant of "a.b" → "a.b" is a prefix of R.attr.scope
|
|
7856
|
+
const plan = createConditionalPlan({
|
|
7857
|
+
operator: "descendentOf",
|
|
7858
|
+
operands: [
|
|
7859
|
+
{
|
|
7860
|
+
operator: "hierarchy",
|
|
7861
|
+
operands: [{ name: "request.resource.attr.scope" }],
|
|
7862
|
+
},
|
|
7863
|
+
{
|
|
7864
|
+
operator: "hierarchy",
|
|
7865
|
+
operands: [{ value: "a.b" }],
|
|
7866
|
+
},
|
|
7867
|
+
],
|
|
7868
|
+
});
|
|
7869
|
+
|
|
7870
|
+
// #when
|
|
7871
|
+
const result = queryPlanToPrisma({ queryPlan: plan, mapper: hierarchyMapper });
|
|
7872
|
+
|
|
7873
|
+
// #then - scope must start with "a.b." (descendant = strict, must be longer)
|
|
7874
|
+
expect(result).toEqual({
|
|
7875
|
+
kind: PlanKind.CONDITIONAL,
|
|
7876
|
+
filters: { scope: { startsWith: "a.b." } },
|
|
7877
|
+
});
|
|
7878
|
+
});
|
|
7879
|
+
|
|
7880
|
+
test("descendentOf - constant is descendant of field", () => {
|
|
7881
|
+
// #given hierarchy("a.b.c.d").descendentOf(hierarchy(R.attr.scope))
|
|
7882
|
+
// "a.b.c.d" descends from R.attr.scope → R.attr.scope is a prefix of "a.b.c.d"
|
|
7883
|
+
const plan = createConditionalPlan({
|
|
7884
|
+
operator: "descendentOf",
|
|
7885
|
+
operands: [
|
|
7886
|
+
{
|
|
7887
|
+
operator: "hierarchy",
|
|
7888
|
+
operands: [{ value: "a.b.c.d" }],
|
|
7889
|
+
},
|
|
7890
|
+
{
|
|
7891
|
+
operator: "hierarchy",
|
|
7892
|
+
operands: [{ name: "request.resource.attr.scope" }],
|
|
7893
|
+
},
|
|
7894
|
+
],
|
|
7895
|
+
});
|
|
7896
|
+
|
|
7897
|
+
// #when
|
|
7898
|
+
const result = queryPlanToPrisma({ queryPlan: plan, mapper: hierarchyMapper });
|
|
7899
|
+
|
|
7900
|
+
// #then - scope must be one of the strict prefixes of "a.b.c.d"
|
|
7901
|
+
expect(result).toEqual({
|
|
7902
|
+
kind: PlanKind.CONDITIONAL,
|
|
7903
|
+
filters: { scope: { in: ["a", "a.b", "a.b.c"] } },
|
|
7904
|
+
});
|
|
7905
|
+
});
|
|
7906
|
+
|
|
7907
|
+
test("descendentOf via cerbos integration", async () => {
|
|
7908
|
+
const plan = await cerbos.planResources({
|
|
7909
|
+
principal: {
|
|
7910
|
+
id: "user1",
|
|
7911
|
+
roles: ["USER"],
|
|
7912
|
+
attr: { scope: "a.b.c" },
|
|
7913
|
+
},
|
|
7914
|
+
resource: { kind: "resource" },
|
|
7915
|
+
action: "hierarchy-descendent-of",
|
|
7916
|
+
});
|
|
7917
|
+
|
|
7918
|
+
const result = queryPlanToPrisma({
|
|
7919
|
+
queryPlan: plan,
|
|
7920
|
+
mapper: {
|
|
7921
|
+
"request.resource.attr.scope": { field: "scope" },
|
|
7922
|
+
},
|
|
7923
|
+
});
|
|
7924
|
+
|
|
7925
|
+
// hierarchy(R.attr.scope).descendentOf(hierarchy("a.b.c"))
|
|
7926
|
+
// R.attr.scope descends from "a.b.c" → R.attr.scope starts with "a.b.c."
|
|
7927
|
+
expect(result).toEqual({
|
|
7928
|
+
kind: PlanKind.CONDITIONAL,
|
|
7929
|
+
filters: { scope: { startsWith: "a.b.c." } },
|
|
7930
|
+
});
|
|
7931
|
+
});
|
|
7932
|
+
});
|
|
7933
|
+
|
|
7934
|
+
// Every hierarchy relationship that narrows a column with a prefix emits a Prisma
|
|
7935
|
+
// `startsWith`, which compiles to `LIKE` with no `ESCAPE` clause: `%` and `_` in the
|
|
7936
|
+
// constant hierarchy would match as wildcards and admit rows the PDP denies. `[` is unsafe
|
|
7937
|
+
// even where an `ESCAPE` clause exists, because SQL Server opens a character class on it.
|
|
7938
|
+
// Both prefix-emitting paths (ancestorOf/descendentOf and overlaps) must throw.
|
|
7939
|
+
describe("Hierarchy prefix LIKE metacharacters", () => {
|
|
7940
|
+
const likeMetacharacterError =
|
|
7941
|
+
"Cannot translate hierarchy prefix matching with LIKE metacharacters (%, _ or [): " +
|
|
7942
|
+
"Prisma emits LIKE without an ESCAPE clause, and [ opens a character class on SQL Server even with one";
|
|
7943
|
+
|
|
7944
|
+
const metacharacterHierarchies: [string, string][] = [
|
|
7945
|
+
["percent", "50%:a"],
|
|
7946
|
+
["underscore", "50_:a"],
|
|
7947
|
+
["bracket", "[env]:prod"],
|
|
7948
|
+
];
|
|
7949
|
+
|
|
7950
|
+
function hierarchyOf(
|
|
7951
|
+
operand: PlanExpressionOperand,
|
|
7952
|
+
delimiter?: string
|
|
7953
|
+
): PlanExpressionOperand {
|
|
7954
|
+
return {
|
|
7955
|
+
operator: "hierarchy",
|
|
7956
|
+
operands: delimiter === undefined ? [operand] : [operand, { value: delimiter }],
|
|
7957
|
+
};
|
|
7958
|
+
}
|
|
7959
|
+
|
|
7960
|
+
const scopeHierarchy = (delimiter?: string): PlanExpressionOperand =>
|
|
7961
|
+
hierarchyOf({ name: "request.resource.attr.scope" }, delimiter);
|
|
7962
|
+
const constantHierarchy = (
|
|
7963
|
+
raw: string,
|
|
7964
|
+
delimiter?: string
|
|
7965
|
+
): PlanExpressionOperand => hierarchyOf({ value: raw }, delimiter);
|
|
7966
|
+
|
|
7967
|
+
const translate = (condition: PlanExpressionOperand): QueryPlanToPrismaResult =>
|
|
7968
|
+
queryPlanToPrisma({
|
|
7969
|
+
queryPlan: createConditionalPlan(condition),
|
|
7970
|
+
mapper: hierarchyMapper,
|
|
7971
|
+
});
|
|
7972
|
+
|
|
7973
|
+
describe.each(metacharacterHierarchies)(
|
|
7974
|
+
"constant hierarchy containing a %s",
|
|
7975
|
+
(_name, raw) => {
|
|
7976
|
+
test("overlaps - field-first throws", () => {
|
|
7977
|
+
expect(() =>
|
|
7978
|
+
translate({
|
|
7979
|
+
operator: "overlaps",
|
|
7980
|
+
operands: [scopeHierarchy(":"), constantHierarchy(raw, ":")],
|
|
7981
|
+
})
|
|
7982
|
+
).toThrow(likeMetacharacterError);
|
|
7983
|
+
});
|
|
7984
|
+
|
|
7985
|
+
test("overlaps - constant-first throws", () => {
|
|
7986
|
+
expect(() =>
|
|
7987
|
+
translate({
|
|
7988
|
+
operator: "overlaps",
|
|
7989
|
+
operands: [constantHierarchy(raw, ":"), scopeHierarchy(":")],
|
|
7990
|
+
})
|
|
7991
|
+
).toThrow(likeMetacharacterError);
|
|
7992
|
+
});
|
|
7993
|
+
|
|
7994
|
+
test("ancestorOf - constant ancestor of field descendant throws", () => {
|
|
7995
|
+
expect(() =>
|
|
7996
|
+
translate({
|
|
7997
|
+
operator: "ancestorOf",
|
|
7998
|
+
operands: [constantHierarchy(raw, ":"), scopeHierarchy(":")],
|
|
7999
|
+
})
|
|
8000
|
+
).toThrow(likeMetacharacterError);
|
|
8001
|
+
});
|
|
8002
|
+
|
|
8003
|
+
test("descendentOf - field descendant of constant ancestor throws", () => {
|
|
8004
|
+
expect(() =>
|
|
8005
|
+
translate({
|
|
8006
|
+
operator: "descendentOf",
|
|
8007
|
+
operands: [scopeHierarchy(":"), constantHierarchy(raw, ":")],
|
|
8008
|
+
})
|
|
8009
|
+
).toThrow(likeMetacharacterError);
|
|
8010
|
+
});
|
|
8011
|
+
}
|
|
8012
|
+
);
|
|
8013
|
+
|
|
8014
|
+
// The guard must not widen into a blanket rejection of hierarchy prefixes: pin the exact
|
|
8015
|
+
// filter emitted for a metacharacter-free hierarchy.
|
|
8016
|
+
describe("metacharacter-free hierarchy still translates", () => {
|
|
8017
|
+
const overlapsFilter = {
|
|
8018
|
+
OR: [
|
|
8019
|
+
{ scope: { in: ["dept"] } },
|
|
8020
|
+
{ scope: { equals: "dept.eng" } },
|
|
8021
|
+
{ scope: { startsWith: "dept.eng." } },
|
|
8022
|
+
],
|
|
8023
|
+
};
|
|
8024
|
+
|
|
8025
|
+
test("overlaps - field-first", () => {
|
|
8026
|
+
expect(
|
|
8027
|
+
translate({
|
|
8028
|
+
operator: "overlaps",
|
|
8029
|
+
operands: [scopeHierarchy(), constantHierarchy("dept.eng")],
|
|
8030
|
+
})
|
|
8031
|
+
).toEqual({ kind: PlanKind.CONDITIONAL, filters: overlapsFilter });
|
|
8032
|
+
});
|
|
8033
|
+
|
|
8034
|
+
test("overlaps - constant-first", () => {
|
|
8035
|
+
expect(
|
|
8036
|
+
translate({
|
|
8037
|
+
operator: "overlaps",
|
|
8038
|
+
operands: [constantHierarchy("dept.eng"), scopeHierarchy()],
|
|
8039
|
+
})
|
|
8040
|
+
).toEqual({ kind: PlanKind.CONDITIONAL, filters: overlapsFilter });
|
|
8041
|
+
});
|
|
8042
|
+
|
|
8043
|
+
test("ancestorOf - constant ancestor of field descendant", () => {
|
|
8044
|
+
expect(
|
|
8045
|
+
translate({
|
|
8046
|
+
operator: "ancestorOf",
|
|
8047
|
+
operands: [constantHierarchy("dept.eng"), scopeHierarchy()],
|
|
8048
|
+
})
|
|
8049
|
+
).toEqual({
|
|
8050
|
+
kind: PlanKind.CONDITIONAL,
|
|
8051
|
+
filters: { scope: { startsWith: "dept.eng." } },
|
|
8052
|
+
});
|
|
8053
|
+
});
|
|
8054
|
+
|
|
8055
|
+
test("descendentOf - field descendant of constant ancestor", () => {
|
|
8056
|
+
expect(
|
|
8057
|
+
translate({
|
|
8058
|
+
operator: "descendentOf",
|
|
8059
|
+
operands: [scopeHierarchy(), constantHierarchy("dept.eng")],
|
|
8060
|
+
})
|
|
8061
|
+
).toEqual({
|
|
8062
|
+
kind: PlanKind.CONDITIONAL,
|
|
8063
|
+
filters: { scope: { startsWith: "dept.eng." } },
|
|
8064
|
+
});
|
|
8065
|
+
});
|
|
8066
|
+
});
|
|
8067
|
+
});
|
|
8068
|
+
|
|
8069
|
+
// The planner preserves policy source order, so a constant can be the FIRST operand of a
|
|
8070
|
+
// comparison (`1 < R.attr.aNumber`). Directional operators must be mirrored when the value
|
|
8071
|
+
// precedes the field, or the filter is silently inverted. See #256.
|
|
8072
|
+
describe("Value-First Operand Order", () => {
|
|
8073
|
+
test("conditional - lt with value first (1 < aNumber means aNumber > 1)", async () => {
|
|
8074
|
+
const queryPlan = await cerbos.planResources({
|
|
8075
|
+
principal: { id: "user1", roles: ["USER"] },
|
|
8076
|
+
resource: { kind: "resource" },
|
|
8077
|
+
action: "value-first-lt",
|
|
8078
|
+
});
|
|
8079
|
+
|
|
8080
|
+
expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
|
|
8081
|
+
expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual({
|
|
8082
|
+
operator: "lt",
|
|
8083
|
+
operands: [{ value: 1 }, { name: "request.resource.attr.aNumber" }],
|
|
8084
|
+
});
|
|
8085
|
+
|
|
8086
|
+
const result = queryPlanToPrisma({
|
|
8087
|
+
queryPlan,
|
|
8088
|
+
mapper: {
|
|
8089
|
+
"request.resource.attr.aNumber": { field: "aNumber" },
|
|
8090
|
+
},
|
|
8091
|
+
});
|
|
8092
|
+
|
|
8093
|
+
expect(result).toStrictEqual({
|
|
8094
|
+
kind: PlanKind.CONDITIONAL,
|
|
8095
|
+
filters: { aNumber: { gt: 1 } },
|
|
8096
|
+
});
|
|
8097
|
+
|
|
8098
|
+
if (result.kind !== PlanKind.CONDITIONAL) {
|
|
8099
|
+
throw new Error("Expected CONDITIONAL result");
|
|
8100
|
+
}
|
|
8101
|
+
|
|
8102
|
+
const query = await prisma.resource.findMany({
|
|
8103
|
+
where: { ...result.filters },
|
|
8104
|
+
});
|
|
8105
|
+
expect(query.map((r) => r.id).sort()).toEqual(
|
|
8106
|
+
fixtureResources
|
|
8107
|
+
.filter((r) => r.aNumber > 1)
|
|
8108
|
+
.map((r) => r.id)
|
|
8109
|
+
.sort()
|
|
8110
|
+
);
|
|
8111
|
+
});
|
|
8112
|
+
|
|
8113
|
+
test("conditional - size with value first (0 < size(ownedBy) means non-empty)", async () => {
|
|
8114
|
+
const queryPlan = await cerbos.planResources({
|
|
8115
|
+
principal: { id: "user1", roles: ["USER"] },
|
|
8116
|
+
resource: { kind: "resource" },
|
|
8117
|
+
action: "value-first-size",
|
|
8118
|
+
});
|
|
8119
|
+
|
|
8120
|
+
expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
|
|
8121
|
+
|
|
8122
|
+
const result = queryPlanToPrisma({
|
|
8123
|
+
queryPlan,
|
|
8124
|
+
mapper: {
|
|
8125
|
+
"request.resource.attr.ownedBy": {
|
|
8126
|
+
relation: { name: "ownedBy", type: "many", field: "id" },
|
|
8127
|
+
},
|
|
8128
|
+
},
|
|
8129
|
+
});
|
|
8130
|
+
|
|
8131
|
+
expect(result).toStrictEqual({
|
|
8132
|
+
kind: PlanKind.CONDITIONAL,
|
|
8133
|
+
filters: { ownedBy: { some: {} } },
|
|
8134
|
+
});
|
|
8135
|
+
|
|
8136
|
+
if (result.kind !== PlanKind.CONDITIONAL) {
|
|
8137
|
+
throw new Error("Expected CONDITIONAL result");
|
|
8138
|
+
}
|
|
8139
|
+
|
|
8140
|
+
const query = await prisma.resource.findMany({
|
|
8141
|
+
where: { ...result.filters },
|
|
8142
|
+
});
|
|
8143
|
+
expect(query.map((r) => r.id).sort()).toEqual(
|
|
8144
|
+
fixtureResources
|
|
8145
|
+
.filter(
|
|
8146
|
+
(r) =>
|
|
8147
|
+
Array.isArray(r.ownedBy?.connect) &&
|
|
8148
|
+
(r.ownedBy.connect as { id: string }[]).length > 0
|
|
8149
|
+
)
|
|
8150
|
+
.map((r) => r.id)
|
|
8151
|
+
.sort()
|
|
8152
|
+
);
|
|
8153
|
+
});
|
|
8154
|
+
|
|
8155
|
+
test("conditional - hasIntersection with value first (constant list is operand 0)", async () => {
|
|
8156
|
+
const queryPlan = await cerbos.planResources({
|
|
8157
|
+
principal: { id: "user1", roles: ["USER"] },
|
|
8158
|
+
resource: { kind: "resource" },
|
|
8159
|
+
action: "value-first-intersect",
|
|
8160
|
+
});
|
|
8161
|
+
|
|
8162
|
+
expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
|
|
8163
|
+
expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual({
|
|
8164
|
+
operator: "hasIntersection",
|
|
8165
|
+
operands: [
|
|
8166
|
+
{ value: ["user1", "userX"] },
|
|
8167
|
+
{ name: "request.resource.attr.ownedBy" },
|
|
8168
|
+
],
|
|
8169
|
+
});
|
|
8170
|
+
|
|
8171
|
+
const result = queryPlanToPrisma({
|
|
8172
|
+
queryPlan,
|
|
8173
|
+
mapper: {
|
|
8174
|
+
"request.resource.attr.ownedBy": {
|
|
8175
|
+
relation: { name: "ownedBy", type: "many", field: "id" },
|
|
8176
|
+
},
|
|
8177
|
+
},
|
|
8178
|
+
});
|
|
8179
|
+
|
|
8180
|
+
expect(result).toStrictEqual({
|
|
8181
|
+
kind: PlanKind.CONDITIONAL,
|
|
8182
|
+
filters: {
|
|
8183
|
+
ownedBy: { some: { id: { in: ["user1", "userX"] } } },
|
|
8184
|
+
},
|
|
8185
|
+
});
|
|
8186
|
+
|
|
8187
|
+
if (result.kind !== PlanKind.CONDITIONAL) {
|
|
8188
|
+
throw new Error("Expected CONDITIONAL result");
|
|
8189
|
+
}
|
|
8190
|
+
|
|
8191
|
+
const query = await prisma.resource.findMany({
|
|
8192
|
+
where: { ...result.filters },
|
|
8193
|
+
});
|
|
8194
|
+
expect(query.map((r) => r.id).sort()).toEqual(
|
|
8195
|
+
fixtureResources
|
|
8196
|
+
.filter(
|
|
8197
|
+
(r) =>
|
|
8198
|
+
Array.isArray(r.ownedBy?.connect) &&
|
|
8199
|
+
(r.ownedBy.connect as { id: string }[]).some((o) =>
|
|
8200
|
+
["user1", "userX"].includes(o.id)
|
|
8201
|
+
)
|
|
8202
|
+
)
|
|
8203
|
+
.map((r) => r.id)
|
|
8204
|
+
.sort()
|
|
8205
|
+
);
|
|
8206
|
+
});
|
|
8207
|
+
});
|
|
8208
|
+
|
|
8209
|
+
describe("Known-Value Collections (planner unroll cliff)", () => {
|
|
8210
|
+
// The planner unrolls exists/all over a known collection (e.g. a folded
|
|
8211
|
+
// principal attribute) into an or/and chain at <= 10 elements
|
|
8212
|
+
// (cerbos/cerbos#2570, #2817) and emits the lambda with a literal value-list
|
|
8213
|
+
// collection above that. These tests straddle the 10-item cliff so both wire
|
|
8214
|
+
// shapes stay exercised regardless of the PDP version behind the sidecar.
|
|
8215
|
+
describe("live plans across the 10-item threshold", () => {
|
|
8216
|
+
const buildTeams = (size: number): string[] => {
|
|
8217
|
+
const teams = ["string", "string3"];
|
|
8218
|
+
while (teams.length < size) {
|
|
8219
|
+
teams.push(`filler-${teams.length}`);
|
|
8220
|
+
}
|
|
8221
|
+
return teams;
|
|
8222
|
+
};
|
|
8223
|
+
|
|
8224
|
+
// Supported PDPs are >= 0.54, where both macros unroll at <= 10 elements and
|
|
8225
|
+
// ship the value-list lambda above that. Pin the wire shape so each leg
|
|
8226
|
+
// provably exercises its side of the cliff — if a future planner moves the
|
|
8227
|
+
// threshold, this fails loudly instead of silently testing one shape only.
|
|
8228
|
+
const expectShape = (
|
|
8229
|
+
queryPlan: PlanResourcesResponse,
|
|
8230
|
+
size: number,
|
|
8231
|
+
unrolledOperator: string,
|
|
8232
|
+
macroOperator: string
|
|
8233
|
+
): void => {
|
|
8234
|
+
const condition = (queryPlan as PlanResourcesConditionalResponse)
|
|
8235
|
+
.condition;
|
|
8236
|
+
expect((condition as PlanExpression).operator).toEqual(
|
|
8237
|
+
size <= 10 ? unrolledOperator : macroOperator
|
|
8238
|
+
);
|
|
8239
|
+
};
|
|
8240
|
+
|
|
8241
|
+
describe.each([9, 10, 11])("with %i-element principal collection", (size) => {
|
|
8242
|
+
test("conditional - principal-exists", async () => {
|
|
8243
|
+
const teams = buildTeams(size);
|
|
8244
|
+
const queryPlan = await cerbos.planResources({
|
|
8245
|
+
principal: { id: "user1", roles: ["USER"], attr: { teams } },
|
|
8246
|
+
resource: { kind: "resource" },
|
|
8247
|
+
action: "principal-exists",
|
|
8248
|
+
});
|
|
8249
|
+
|
|
8250
|
+
expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
|
|
8251
|
+
expectShape(queryPlan, size, "or", "exists");
|
|
8252
|
+
|
|
8253
|
+
const result = queryPlanToPrisma({
|
|
8254
|
+
queryPlan,
|
|
8255
|
+
mapper: {
|
|
8256
|
+
"request.resource.attr.aString": { field: "aString" },
|
|
8257
|
+
},
|
|
8258
|
+
});
|
|
8259
|
+
|
|
8260
|
+
if (result.kind !== PlanKind.CONDITIONAL) {
|
|
8261
|
+
throw new Error("Expected CONDITIONAL result");
|
|
8262
|
+
}
|
|
8263
|
+
|
|
8264
|
+
const query = await prisma.resource.findMany({
|
|
8265
|
+
where: { ...result.filters },
|
|
8266
|
+
});
|
|
8267
|
+
expect(query.map((r) => r.id).sort()).toEqual(
|
|
8268
|
+
fixtureResources
|
|
8269
|
+
.filter((r) => teams.includes(r.aString))
|
|
8270
|
+
.map((r) => r.id)
|
|
8271
|
+
.sort()
|
|
8272
|
+
);
|
|
8273
|
+
});
|
|
8274
|
+
|
|
8275
|
+
test("conditional - principal-all", async () => {
|
|
8276
|
+
const teams = buildTeams(size);
|
|
8277
|
+
const queryPlan = await cerbos.planResources({
|
|
8278
|
+
principal: { id: "user1", roles: ["USER"], attr: { teams } },
|
|
8279
|
+
resource: { kind: "resource" },
|
|
8280
|
+
action: "principal-all",
|
|
8281
|
+
});
|
|
8282
|
+
|
|
8283
|
+
expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
|
|
8284
|
+
expectShape(queryPlan, size, "and", "all");
|
|
8285
|
+
|
|
8286
|
+
const result = queryPlanToPrisma({
|
|
8287
|
+
queryPlan,
|
|
8288
|
+
mapper: {
|
|
8289
|
+
"request.resource.attr.aString": { field: "aString" },
|
|
8290
|
+
},
|
|
8291
|
+
});
|
|
8292
|
+
|
|
8293
|
+
if (result.kind !== PlanKind.CONDITIONAL) {
|
|
8294
|
+
throw new Error("Expected CONDITIONAL result");
|
|
8295
|
+
}
|
|
8296
|
+
|
|
8297
|
+
const query = await prisma.resource.findMany({
|
|
8298
|
+
where: { ...result.filters },
|
|
8299
|
+
});
|
|
8300
|
+
expect(query.map((r) => r.id).sort()).toEqual(
|
|
8301
|
+
fixtureResources
|
|
8302
|
+
.filter((r) => !teams.includes(r.aString))
|
|
8303
|
+
.map((r) => r.id)
|
|
8304
|
+
.sort()
|
|
8305
|
+
);
|
|
8306
|
+
});
|
|
8307
|
+
});
|
|
8308
|
+
});
|
|
8309
|
+
|
|
8310
|
+
describe("value-list lambda fold", () => {
|
|
8311
|
+
const valueListPlan = (
|
|
8312
|
+
operator: string,
|
|
8313
|
+
elements: unknown[],
|
|
8314
|
+
body: PlanExpressionOperand,
|
|
8315
|
+
variable = "t"
|
|
8316
|
+
): PlanResourcesConditionalResponse =>
|
|
8317
|
+
createConditionalPlan({
|
|
8318
|
+
operator,
|
|
8319
|
+
operands: [
|
|
8320
|
+
{ value: elements },
|
|
8321
|
+
{ operator: "lambda", operands: [body, { name: variable }] },
|
|
8322
|
+
],
|
|
8323
|
+
} as PlanExpressionOperand);
|
|
8324
|
+
|
|
8325
|
+
test("exists over a value list folds to OR of the substituted body", () => {
|
|
8326
|
+
const result = queryPlanToPrisma({
|
|
8327
|
+
queryPlan: valueListPlan("exists", ["a", "b", "c"], {
|
|
8328
|
+
operator: "eq",
|
|
8329
|
+
operands: [{ name: "request.resource.attr.aString" }, { name: "t" }],
|
|
8330
|
+
}),
|
|
8331
|
+
mapper: { "request.resource.attr.aString": { field: "aString" } },
|
|
8332
|
+
});
|
|
8333
|
+
|
|
8334
|
+
expect(result).toStrictEqual({
|
|
8335
|
+
kind: PlanKind.CONDITIONAL,
|
|
8336
|
+
filters: {
|
|
8337
|
+
OR: [
|
|
8338
|
+
{ aString: { equals: "a" } },
|
|
8339
|
+
{ aString: { equals: "b" } },
|
|
8340
|
+
{ aString: { equals: "c" } },
|
|
8341
|
+
],
|
|
8342
|
+
},
|
|
8343
|
+
});
|
|
8344
|
+
});
|
|
8345
|
+
|
|
8346
|
+
test("all over a value list folds to AND of the substituted body", () => {
|
|
8347
|
+
const result = queryPlanToPrisma({
|
|
8348
|
+
queryPlan: valueListPlan("all", ["a", "b"], {
|
|
8349
|
+
operator: "ne",
|
|
8350
|
+
operands: [{ name: "request.resource.attr.aString" }, { name: "t" }],
|
|
8351
|
+
}),
|
|
8352
|
+
mapper: { "request.resource.attr.aString": { field: "aString" } },
|
|
8353
|
+
});
|
|
8354
|
+
|
|
8355
|
+
expect(result).toStrictEqual({
|
|
8356
|
+
kind: PlanKind.CONDITIONAL,
|
|
8357
|
+
filters: {
|
|
8358
|
+
AND: [{ aString: { not: "a" } }, { aString: { not: "b" } }],
|
|
8359
|
+
},
|
|
8360
|
+
});
|
|
8361
|
+
});
|
|
8362
|
+
|
|
8363
|
+
test("substitutes variable path references into element fields", () => {
|
|
8364
|
+
const result = queryPlanToPrisma({
|
|
8365
|
+
queryPlan: valueListPlan(
|
|
8366
|
+
"exists",
|
|
8367
|
+
[{ name: "alpha", meta: { rank: 1 } }, { name: "beta", meta: { rank: 2 } }],
|
|
8368
|
+
{
|
|
8369
|
+
operator: "eq",
|
|
8370
|
+
operands: [{ name: "request.resource.attr.aString" }, { name: "t.name" }],
|
|
8371
|
+
}
|
|
8372
|
+
),
|
|
8373
|
+
mapper: { "request.resource.attr.aString": { field: "aString" } },
|
|
8374
|
+
});
|
|
8375
|
+
|
|
8376
|
+
expect(result).toStrictEqual({
|
|
8377
|
+
kind: PlanKind.CONDITIONAL,
|
|
8378
|
+
filters: {
|
|
8379
|
+
OR: [{ aString: { equals: "alpha" } }, { aString: { equals: "beta" } }],
|
|
8380
|
+
},
|
|
8381
|
+
});
|
|
8382
|
+
});
|
|
8383
|
+
|
|
8384
|
+
test("empty value list yields CEL identity semantics", () => {
|
|
8385
|
+
const body: PlanExpressionOperand = {
|
|
8386
|
+
operator: "eq",
|
|
8387
|
+
operands: [{ name: "request.resource.attr.aString" }, { name: "t" }],
|
|
8388
|
+
};
|
|
8389
|
+
const mapper = { "request.resource.attr.aString": { field: "aString" } };
|
|
8390
|
+
|
|
8391
|
+
// exists over [] is false — Prisma matches nothing for OR: []
|
|
8392
|
+
expect(
|
|
8393
|
+
queryPlanToPrisma({ queryPlan: valueListPlan("exists", [], body), mapper })
|
|
8394
|
+
).toStrictEqual({ kind: PlanKind.CONDITIONAL, filters: { OR: [] } });
|
|
8395
|
+
|
|
8396
|
+
// all over [] is true — Prisma matches everything for AND: []
|
|
8397
|
+
expect(
|
|
8398
|
+
queryPlanToPrisma({ queryPlan: valueListPlan("all", [], body), mapper })
|
|
8399
|
+
).toStrictEqual({ kind: PlanKind.CONDITIONAL, filters: { AND: [] } });
|
|
8400
|
+
});
|
|
8401
|
+
|
|
8402
|
+
test("nested lambda rebinding the variable shadows the outer substitution", () => {
|
|
8403
|
+
// Outer t is substituted; the inner exists rebinds t over a relation, so
|
|
8404
|
+
// its body must keep referencing the inner variable untouched.
|
|
8405
|
+
const result = queryPlanToPrisma({
|
|
8406
|
+
queryPlan: valueListPlan("exists", ["public", "private"], {
|
|
8407
|
+
operator: "exists",
|
|
8408
|
+
operands: [
|
|
8409
|
+
{ name: "request.resource.attr.tags" },
|
|
8410
|
+
{
|
|
8411
|
+
operator: "lambda",
|
|
8412
|
+
operands: [
|
|
8413
|
+
{
|
|
8414
|
+
operator: "eq",
|
|
8415
|
+
operands: [{ name: "t.name" }, { value: "fixed" }],
|
|
8416
|
+
},
|
|
8417
|
+
{ name: "t" },
|
|
8418
|
+
],
|
|
8419
|
+
},
|
|
8420
|
+
],
|
|
8421
|
+
}),
|
|
8422
|
+
mapper: {
|
|
8423
|
+
"request.resource.attr.tags": {
|
|
8424
|
+
relation: { name: "tags", type: "many" },
|
|
8425
|
+
},
|
|
8426
|
+
},
|
|
8427
|
+
});
|
|
8428
|
+
|
|
8429
|
+
expect(result).toStrictEqual({
|
|
8430
|
+
kind: PlanKind.CONDITIONAL,
|
|
8431
|
+
filters: {
|
|
8432
|
+
OR: [
|
|
8433
|
+
{ tags: { some: { name: { equals: "fixed" } } } },
|
|
8434
|
+
{ tags: { some: { name: { equals: "fixed" } } } },
|
|
8435
|
+
],
|
|
8436
|
+
},
|
|
8437
|
+
});
|
|
8438
|
+
});
|
|
8439
|
+
|
|
8440
|
+
test("throws when a variable path is missing on an element", () => {
|
|
8441
|
+
expect(() =>
|
|
8442
|
+
queryPlanToPrisma({
|
|
8443
|
+
queryPlan: valueListPlan("exists", [{ name: "alpha" }], {
|
|
8444
|
+
operator: "eq",
|
|
8445
|
+
operands: [
|
|
8446
|
+
{ name: "request.resource.attr.aString" },
|
|
8447
|
+
{ name: "t.missing" },
|
|
8448
|
+
],
|
|
8449
|
+
}),
|
|
8450
|
+
mapper: { "request.resource.attr.aString": { field: "aString" } },
|
|
8451
|
+
})
|
|
8452
|
+
).toThrow('Cannot resolve "t.missing"');
|
|
8453
|
+
});
|
|
8454
|
+
|
|
8455
|
+
test("throws for exists_one over a value list", () => {
|
|
8456
|
+
expect(() =>
|
|
8457
|
+
queryPlanToPrisma({
|
|
8458
|
+
queryPlan: valueListPlan("exists_one", ["a"], {
|
|
8459
|
+
operator: "eq",
|
|
8460
|
+
operands: [{ name: "request.resource.attr.aString" }, { name: "t" }],
|
|
8461
|
+
}),
|
|
8462
|
+
mapper: { "request.resource.attr.aString": { field: "aString" } },
|
|
8463
|
+
})
|
|
8464
|
+
).toThrow("exists_one over a literal collection value is not supported");
|
|
8465
|
+
});
|
|
8466
|
+
|
|
8467
|
+
test("throws for a non-list collection value", () => {
|
|
8468
|
+
expect(() =>
|
|
8469
|
+
queryPlanToPrisma({
|
|
8470
|
+
queryPlan: valueListPlan("exists", ["ignored"], {
|
|
8471
|
+
operator: "eq",
|
|
8472
|
+
operands: [{ name: "request.resource.attr.aString" }, { name: "t" }],
|
|
8473
|
+
}),
|
|
8474
|
+
mapper: { "request.resource.attr.aString": { field: "aString" } },
|
|
8475
|
+
})
|
|
8476
|
+
).not.toThrow();
|
|
8477
|
+
|
|
8478
|
+
const plan = createConditionalPlan({
|
|
8479
|
+
operator: "exists",
|
|
8480
|
+
operands: [
|
|
8481
|
+
{ value: { not: "a list" } },
|
|
8482
|
+
{
|
|
8483
|
+
operator: "lambda",
|
|
8484
|
+
operands: [
|
|
8485
|
+
{
|
|
8486
|
+
operator: "eq",
|
|
8487
|
+
operands: [
|
|
8488
|
+
{ name: "request.resource.attr.aString" },
|
|
8489
|
+
{ name: "t" },
|
|
8490
|
+
],
|
|
8491
|
+
},
|
|
8492
|
+
{ name: "t" },
|
|
8493
|
+
],
|
|
8494
|
+
},
|
|
8495
|
+
],
|
|
8496
|
+
} as PlanExpressionOperand);
|
|
8497
|
+
expect(() =>
|
|
8498
|
+
queryPlanToPrisma({
|
|
8499
|
+
queryPlan: plan,
|
|
8500
|
+
mapper: { "request.resource.attr.aString": { field: "aString" } },
|
|
8501
|
+
})
|
|
8502
|
+
).toThrow("exists over a literal collection requires a list value");
|
|
8503
|
+
});
|
|
8504
|
+
});
|
|
8505
|
+
});
|
|
8506
|
+
|
|
8507
|
+
describe("Promoted adversarial planner shapes", () => {
|
|
8508
|
+
const valueListMacro = (
|
|
8509
|
+
operator: "exists" | "all",
|
|
8510
|
+
elements: string[]
|
|
8511
|
+
): PlanExpressionOperand => ({
|
|
8512
|
+
operator,
|
|
8513
|
+
operands: [
|
|
8514
|
+
{ value: elements },
|
|
8515
|
+
{
|
|
8516
|
+
operator: "lambda",
|
|
8517
|
+
operands: [
|
|
8518
|
+
{
|
|
8519
|
+
operator: "eq",
|
|
8520
|
+
operands: [
|
|
8521
|
+
{ name: "request.resource.attr.aString" },
|
|
8522
|
+
{ name: "team" },
|
|
8523
|
+
],
|
|
8524
|
+
},
|
|
8525
|
+
{ name: "team" },
|
|
8526
|
+
],
|
|
8527
|
+
},
|
|
8528
|
+
],
|
|
8529
|
+
});
|
|
8530
|
+
|
|
8531
|
+
test("negated exists over a literal collection folds to AND of negated bodies (#294)", () => {
|
|
8532
|
+
const result = queryPlanToPrisma({
|
|
8533
|
+
queryPlan: createConditionalPlan({
|
|
8534
|
+
operator: "not",
|
|
8535
|
+
operands: [valueListMacro("exists", ["a", "b"])],
|
|
8536
|
+
}),
|
|
8537
|
+
mapper: {
|
|
8538
|
+
"request.resource.attr.aString": { field: "aString" },
|
|
8539
|
+
},
|
|
8540
|
+
});
|
|
8541
|
+
|
|
8542
|
+
expect(result).toStrictEqual({
|
|
8543
|
+
kind: PlanKind.CONDITIONAL,
|
|
8544
|
+
filters: {
|
|
8545
|
+
AND: [
|
|
8546
|
+
{ NOT: { aString: { equals: "a" } } },
|
|
8547
|
+
{ NOT: { aString: { equals: "b" } } },
|
|
8548
|
+
],
|
|
8549
|
+
},
|
|
8550
|
+
});
|
|
8551
|
+
});
|
|
8552
|
+
|
|
8553
|
+
test("negated all over a literal collection folds to OR of negated bodies (#294)", () => {
|
|
8554
|
+
const result = queryPlanToPrisma({
|
|
8555
|
+
queryPlan: createConditionalPlan({
|
|
8556
|
+
operator: "not",
|
|
8557
|
+
operands: [valueListMacro("all", ["a", "b"])],
|
|
8558
|
+
}),
|
|
8559
|
+
mapper: {
|
|
8560
|
+
"request.resource.attr.aString": { field: "aString" },
|
|
8561
|
+
},
|
|
8562
|
+
});
|
|
8563
|
+
|
|
8564
|
+
expect(result).toStrictEqual({
|
|
8565
|
+
kind: PlanKind.CONDITIONAL,
|
|
8566
|
+
filters: {
|
|
8567
|
+
OR: [
|
|
8568
|
+
{ NOT: { aString: { equals: "a" } } },
|
|
8569
|
+
{ NOT: { aString: { equals: "b" } } },
|
|
8570
|
+
],
|
|
8571
|
+
},
|
|
8572
|
+
});
|
|
8573
|
+
});
|
|
8574
|
+
|
|
8575
|
+
test("constant NaN ordering in a ternary branch is always false", () => {
|
|
8576
|
+
const result = queryPlanToPrisma({
|
|
8577
|
+
queryPlan: createConditionalPlan({
|
|
8578
|
+
operator: "gt",
|
|
8579
|
+
operands: [
|
|
8580
|
+
{
|
|
8581
|
+
operator: "if",
|
|
8582
|
+
operands: [
|
|
8583
|
+
{ name: "request.resource.attr.aBool" },
|
|
8584
|
+
{ value: 1 },
|
|
8585
|
+
{
|
|
8586
|
+
operator: "div",
|
|
8587
|
+
operands: [{ value: 0 }, { value: 0 }],
|
|
8588
|
+
},
|
|
8589
|
+
],
|
|
8590
|
+
},
|
|
8591
|
+
{ value: 0.5 },
|
|
8592
|
+
],
|
|
8593
|
+
}),
|
|
8594
|
+
mapper: { "request.resource.attr.aBool": { field: "aBool" } },
|
|
8595
|
+
});
|
|
8596
|
+
|
|
8597
|
+
expect(result).toStrictEqual({
|
|
8598
|
+
kind: PlanKind.CONDITIONAL,
|
|
8599
|
+
filters: {
|
|
8600
|
+
OR: [
|
|
8601
|
+
{ aBool: { equals: true } },
|
|
8602
|
+
{
|
|
8603
|
+
AND: [
|
|
8604
|
+
{ aBool: { equals: true } },
|
|
8605
|
+
{ aBool: { equals: false } },
|
|
8606
|
+
],
|
|
8607
|
+
},
|
|
8608
|
+
],
|
|
8609
|
+
},
|
|
8610
|
+
});
|
|
8611
|
+
});
|
|
8612
|
+
|
|
8613
|
+
test("value-first timestamp comparison mirrors and normalizes the instant", () => {
|
|
8614
|
+
const result = queryPlanToPrisma({
|
|
8615
|
+
queryPlan: createConditionalPlan({
|
|
8616
|
+
operator: "gt",
|
|
8617
|
+
operands: [
|
|
8618
|
+
{
|
|
8619
|
+
operator: "timestamp",
|
|
8620
|
+
operands: [{ value: "2024-06-01T02:00:00+02:00" }],
|
|
8621
|
+
},
|
|
8622
|
+
{
|
|
8623
|
+
operator: "timestamp",
|
|
8624
|
+
operands: [{ name: "request.resource.attr.createdAt" }],
|
|
8625
|
+
},
|
|
8626
|
+
],
|
|
8627
|
+
}),
|
|
8628
|
+
mapper: {
|
|
8629
|
+
"request.resource.attr.createdAt": {
|
|
8630
|
+
field: "createdAt",
|
|
8631
|
+
valueType: "dateTime",
|
|
8632
|
+
},
|
|
8633
|
+
},
|
|
8634
|
+
});
|
|
8635
|
+
|
|
8636
|
+
expect(result).toStrictEqual({
|
|
8637
|
+
kind: PlanKind.CONDITIONAL,
|
|
8638
|
+
filters: {
|
|
8639
|
+
createdAt: { lt: "2024-06-01T00:00:00.000Z" },
|
|
8640
|
+
},
|
|
8641
|
+
});
|
|
8642
|
+
});
|
|
8643
|
+
|
|
8644
|
+
test("timestamp over an untyped string mapping fails closed", () => {
|
|
8645
|
+
expect(() =>
|
|
8646
|
+
queryPlanToPrisma({
|
|
8647
|
+
queryPlan: createConditionalPlan({
|
|
8648
|
+
operator: "lt",
|
|
8649
|
+
operands: [
|
|
8650
|
+
{
|
|
8651
|
+
operator: "timestamp",
|
|
8652
|
+
operands: [{ name: "request.resource.attr.createdBy" }],
|
|
8653
|
+
},
|
|
8654
|
+
{
|
|
8655
|
+
operator: "timestamp",
|
|
8656
|
+
operands: [{ value: "2025-01-01T00:00:00Z" }],
|
|
8657
|
+
},
|
|
8658
|
+
],
|
|
8659
|
+
}),
|
|
8660
|
+
mapper: {
|
|
8661
|
+
"request.resource.attr.createdBy": { field: "createdBy" },
|
|
8662
|
+
},
|
|
8663
|
+
})
|
|
8664
|
+
).toThrow('must be mapped with valueType: "dateTime"');
|
|
8665
|
+
});
|
|
8666
|
+
|
|
8667
|
+
test.each([
|
|
8668
|
+
"2024-01-01",
|
|
8669
|
+
"0000-01-01T00:00:00Z",
|
|
8670
|
+
"2024-02-30T00:00:00Z",
|
|
8671
|
+
"2024-01-01T00:00:00.1234Z",
|
|
8672
|
+
"9999-12-31T23:00:00-02:00",
|
|
8673
|
+
])("timestamp literal %s fails closed", (value) => {
|
|
8674
|
+
expect(() =>
|
|
8675
|
+
queryPlanToPrisma({
|
|
8676
|
+
queryPlan: createConditionalPlan({
|
|
8677
|
+
operator: "eq",
|
|
8678
|
+
operands: [
|
|
8679
|
+
{
|
|
8680
|
+
operator: "timestamp",
|
|
8681
|
+
operands: [{ name: "request.resource.attr.createdAt" }],
|
|
8682
|
+
},
|
|
8683
|
+
{ operator: "timestamp", operands: [{ value }] },
|
|
8684
|
+
],
|
|
8685
|
+
}),
|
|
8686
|
+
mapper: {
|
|
8687
|
+
"request.resource.attr.createdAt": {
|
|
8688
|
+
field: "createdAt",
|
|
8689
|
+
valueType: "dateTime",
|
|
8690
|
+
},
|
|
8691
|
+
},
|
|
8692
|
+
})
|
|
8693
|
+
).toThrow(/RFC 3339|millisecond|instant range/);
|
|
8694
|
+
});
|
|
8695
|
+
|
|
8696
|
+
test("timestamp accepts excess fractional digits only when they are zero", () => {
|
|
8697
|
+
const result = queryPlanToPrisma({
|
|
8698
|
+
queryPlan: createConditionalPlan({
|
|
8699
|
+
operator: "eq",
|
|
8700
|
+
operands: [
|
|
8701
|
+
{
|
|
8702
|
+
operator: "timestamp",
|
|
8703
|
+
operands: [{ name: "request.resource.attr.createdAt" }],
|
|
8704
|
+
},
|
|
8705
|
+
{
|
|
8706
|
+
operator: "timestamp",
|
|
8707
|
+
operands: [{ value: "2024-01-01T00:00:00.123000Z" }],
|
|
8708
|
+
},
|
|
8709
|
+
],
|
|
8710
|
+
}),
|
|
8711
|
+
mapper: {
|
|
8712
|
+
"request.resource.attr.createdAt": {
|
|
8713
|
+
field: "createdAt",
|
|
8714
|
+
valueType: "dateTime",
|
|
8715
|
+
},
|
|
8716
|
+
},
|
|
8717
|
+
});
|
|
8718
|
+
|
|
8719
|
+
expect(result).toStrictEqual({
|
|
8720
|
+
kind: PlanKind.CONDITIONAL,
|
|
8721
|
+
filters: { createdAt: { equals: "2024-01-01T00:00:00.123Z" } },
|
|
8722
|
+
});
|
|
8723
|
+
});
|
|
8724
|
+
|
|
8725
|
+
test("membership list containing null becomes an explicit null disjunct", () => {
|
|
8726
|
+
const result = queryPlanToPrisma({
|
|
8727
|
+
queryPlan: createConditionalPlan({
|
|
8728
|
+
operator: "in",
|
|
8729
|
+
operands: [
|
|
8730
|
+
{ name: "request.resource.attr.owner" },
|
|
8731
|
+
{ value: ["x", "one_two", null] },
|
|
8732
|
+
],
|
|
8733
|
+
}),
|
|
8734
|
+
mapper: {
|
|
8735
|
+
"request.resource.attr.owner": { field: "aOptionalString" },
|
|
8736
|
+
},
|
|
8737
|
+
});
|
|
8738
|
+
|
|
8739
|
+
expect(result).toStrictEqual({
|
|
8740
|
+
kind: PlanKind.CONDITIONAL,
|
|
8741
|
+
filters: {
|
|
8742
|
+
OR: [
|
|
8743
|
+
{ aOptionalString: { in: ["x", "one_two"] } },
|
|
8744
|
+
{ aOptionalString: null },
|
|
8745
|
+
],
|
|
8746
|
+
},
|
|
8747
|
+
});
|
|
8748
|
+
});
|
|
8749
|
+
|
|
8750
|
+
test("fractional addition equality fails closed instead of algebraically solving", () => {
|
|
8751
|
+
expect(() =>
|
|
8752
|
+
queryPlanToPrisma({
|
|
8753
|
+
queryPlan: createConditionalPlan({
|
|
8754
|
+
operator: "eq",
|
|
8755
|
+
operands: [
|
|
8756
|
+
{
|
|
8757
|
+
operator: "add",
|
|
8758
|
+
operands: [
|
|
8759
|
+
{ name: "request.resource.attr.aDouble" },
|
|
8760
|
+
{ value: 0.7 },
|
|
8761
|
+
],
|
|
8762
|
+
},
|
|
8763
|
+
{ value: 0.1 },
|
|
8764
|
+
],
|
|
8765
|
+
}),
|
|
8766
|
+
mapper: {
|
|
8767
|
+
"request.resource.attr.aDouble": { field: "aDouble" },
|
|
8768
|
+
},
|
|
8769
|
+
})
|
|
8770
|
+
).toThrow(
|
|
8771
|
+
"solving IEEE-754 addition into a plain Prisma column comparison is not reversible"
|
|
8772
|
+
);
|
|
8773
|
+
});
|
|
8774
|
+
|
|
8775
|
+
test("hierarchy prefix containing LIKE metacharacters fails closed", () => {
|
|
8776
|
+
expect(() =>
|
|
8777
|
+
queryPlanToPrisma({
|
|
8778
|
+
queryPlan: createConditionalPlan({
|
|
8779
|
+
operator: "descendentOf",
|
|
8780
|
+
operands: [
|
|
8781
|
+
{
|
|
8782
|
+
operator: "hierarchy",
|
|
8783
|
+
operands: [
|
|
8784
|
+
{ name: "request.resource.attr.scope" },
|
|
8785
|
+
{ value: ":" },
|
|
8786
|
+
],
|
|
8787
|
+
},
|
|
8788
|
+
{
|
|
8789
|
+
operator: "hierarchy",
|
|
8790
|
+
operands: [{ value: "50%:a_b" }, { value: ":" }],
|
|
8791
|
+
},
|
|
8792
|
+
],
|
|
8793
|
+
}),
|
|
8794
|
+
mapper: { "request.resource.attr.scope": { field: "scope" } },
|
|
8795
|
+
})
|
|
8796
|
+
).toThrow("Prisma emits LIKE without an ESCAPE clause");
|
|
8797
|
+
});
|
|
8798
|
+
|
|
8799
|
+
test("nested nullable collection errors propagate through negated exists", () => {
|
|
8800
|
+
const result = queryPlanToPrisma({
|
|
8801
|
+
queryPlan: createConditionalPlan({
|
|
8802
|
+
operator: "not",
|
|
8803
|
+
operands: [
|
|
8804
|
+
{
|
|
8805
|
+
operator: "exists",
|
|
8806
|
+
operands: [
|
|
8807
|
+
{ name: "request.resource.attr.categories" },
|
|
8808
|
+
{
|
|
8809
|
+
operator: "lambda",
|
|
8810
|
+
operands: [
|
|
8811
|
+
{
|
|
8812
|
+
operator: "exists",
|
|
8813
|
+
operands: [
|
|
8814
|
+
{ name: "category.subCategories" },
|
|
8815
|
+
{
|
|
8816
|
+
operator: "lambda",
|
|
8817
|
+
operands: [
|
|
8818
|
+
{
|
|
8819
|
+
operator: "exists",
|
|
8820
|
+
operands: [
|
|
8821
|
+
{ name: "subCategory.labels" },
|
|
8822
|
+
{
|
|
8823
|
+
operator: "lambda",
|
|
8824
|
+
operands: [
|
|
8825
|
+
{
|
|
8826
|
+
operator: "eq",
|
|
8827
|
+
operands: [
|
|
8828
|
+
{ name: "label.name" },
|
|
8829
|
+
{ value: "gold" },
|
|
8830
|
+
],
|
|
8831
|
+
},
|
|
8832
|
+
{ name: "label" },
|
|
8833
|
+
],
|
|
8834
|
+
},
|
|
8835
|
+
],
|
|
8836
|
+
},
|
|
8837
|
+
{ name: "subCategory" },
|
|
8838
|
+
],
|
|
8839
|
+
},
|
|
8840
|
+
],
|
|
8841
|
+
},
|
|
8842
|
+
{ name: "category" },
|
|
8843
|
+
],
|
|
8844
|
+
},
|
|
8845
|
+
],
|
|
8846
|
+
},
|
|
8847
|
+
],
|
|
8848
|
+
}),
|
|
8849
|
+
mapper: {
|
|
8850
|
+
"request.resource.attr.categories": {
|
|
8851
|
+
relation: {
|
|
8852
|
+
name: "categories",
|
|
8853
|
+
type: "many",
|
|
8854
|
+
fields: {
|
|
8855
|
+
subCategories: {
|
|
8856
|
+
relation: {
|
|
8857
|
+
name: "subCategories",
|
|
8858
|
+
type: "many",
|
|
8859
|
+
fields: {
|
|
8860
|
+
labels: {
|
|
8861
|
+
relation: {
|
|
8862
|
+
name: "labels",
|
|
8863
|
+
type: "many",
|
|
8864
|
+
fields: {
|
|
8865
|
+
name: { field: "name", nullable: true },
|
|
8866
|
+
},
|
|
8867
|
+
},
|
|
8868
|
+
},
|
|
8869
|
+
},
|
|
8870
|
+
},
|
|
8871
|
+
},
|
|
8872
|
+
},
|
|
8873
|
+
},
|
|
8874
|
+
},
|
|
8875
|
+
},
|
|
8876
|
+
});
|
|
8877
|
+
|
|
8878
|
+
expect(result.kind).toBe(PlanKind.CONDITIONAL);
|
|
8879
|
+
if (result.kind !== PlanKind.CONDITIONAL) {
|
|
8880
|
+
throw new Error("Expected CONDITIONAL result");
|
|
8881
|
+
}
|
|
8882
|
+
expect(JSON.stringify(result.filters)).toContain('"name":null');
|
|
8883
|
+
expect(JSON.stringify(result.filters)).toContain('"NOT":{"categories"');
|
|
8884
|
+
});
|
|
8885
|
+
});
|