@cerbos/orm-prisma 3.3.0 → 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 +83 -6
- package/lib/index.d.ts +29 -2
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +1459 -114
- package/lib/index.js.map +1 -1
- package/package.json +19 -13
- package/src/adversarial.test.ts +586 -0
- package/src/generated/prisma/commonInputTypes.ts +26 -0
- package/src/generated/prisma/internal/class.ts +9 -9
- 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 +1 -1
- package/src/generated/prisma/models/Label.ts +1 -1
- package/src/generated/prisma/models/NestedResource.ts +1 -1
- package/src/generated/prisma/models/NextLevelNestedResource.ts +1 -1
- package/src/generated/prisma/models/Resource.ts +62 -2
- package/src/generated/prisma/models/SubCategory.ts +1 -1
- package/src/generated/prisma/models/Tag.ts +1 -1
- package/src/generated/prisma/models/User.ts +1 -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 +3199 -636
- package/src/index.ts +2055 -148
- 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,30 +2435,229 @@ 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
2438
|
|
|
2293
|
-
|
|
2294
|
-
|
|
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
|
-
{
|
|
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" },
|
|
2303
2661
|
],
|
|
2304
2662
|
});
|
|
2305
2663
|
|
|
@@ -4100,6 +4458,95 @@ describe("Complex Operations", () => {
|
|
|
4100
4458
|
);
|
|
4101
4459
|
});
|
|
4102
4460
|
|
|
4461
|
+
test("conditional - or - leaf and exists", async () => {
|
|
4462
|
+
const queryPlan = await cerbos.planResources({
|
|
4463
|
+
principal: { id: "user1", roles: ["USER"] },
|
|
4464
|
+
resource: { kind: "resource" },
|
|
4465
|
+
action: "or-leaf-exists",
|
|
4466
|
+
});
|
|
4467
|
+
|
|
4468
|
+
expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
|
|
4469
|
+
expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual(
|
|
4470
|
+
{
|
|
4471
|
+
operator: "or",
|
|
4472
|
+
operands: [
|
|
4473
|
+
{
|
|
4474
|
+
operator: "eq",
|
|
4475
|
+
operands: [
|
|
4476
|
+
{ name: "request.resource.attr.aBool" },
|
|
4477
|
+
{ value: true },
|
|
4478
|
+
],
|
|
4479
|
+
},
|
|
4480
|
+
{
|
|
4481
|
+
operator: "exists",
|
|
4482
|
+
operands: [
|
|
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
|
+
},
|
|
4497
|
+
],
|
|
4498
|
+
},
|
|
4499
|
+
],
|
|
4500
|
+
}
|
|
4501
|
+
);
|
|
4502
|
+
|
|
4503
|
+
const result = queryPlanToPrisma({
|
|
4504
|
+
queryPlan,
|
|
4505
|
+
mapper: {
|
|
4506
|
+
"request.resource.attr.aBool": { field: "aBool" },
|
|
4507
|
+
"request.resource.attr.tags": {
|
|
4508
|
+
relation: {
|
|
4509
|
+
name: "tags",
|
|
4510
|
+
type: "many",
|
|
4511
|
+
},
|
|
4512
|
+
},
|
|
4513
|
+
},
|
|
4514
|
+
});
|
|
4515
|
+
|
|
4516
|
+
expect(result).toStrictEqual({
|
|
4517
|
+
kind: PlanKind.CONDITIONAL,
|
|
4518
|
+
filters: {
|
|
4519
|
+
OR: [
|
|
4520
|
+
{ aBool: { equals: true } },
|
|
4521
|
+
{ tags: { some: { name: { equals: "public" } } } },
|
|
4522
|
+
],
|
|
4523
|
+
},
|
|
4524
|
+
});
|
|
4525
|
+
|
|
4526
|
+
if (result.kind !== PlanKind.CONDITIONAL) {
|
|
4527
|
+
throw new Error("Expected CONDITIONAL result");
|
|
4528
|
+
}
|
|
4529
|
+
|
|
4530
|
+
const query = await prisma.resource.findMany({
|
|
4531
|
+
where: { ...result.filters },
|
|
4532
|
+
});
|
|
4533
|
+
expect(query.map((r) => r.id).sort()).toEqual(
|
|
4534
|
+
fixtureResources
|
|
4535
|
+
.filter((r) => {
|
|
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
|
+
});
|
|
4544
|
+
})
|
|
4545
|
+
.map((r) => r.id)
|
|
4546
|
+
.sort()
|
|
4547
|
+
);
|
|
4548
|
+
});
|
|
4549
|
+
|
|
4103
4550
|
test("conditional - relation multiple all", async () => {
|
|
4104
4551
|
const queryPlan = await cerbos.planResources({
|
|
4105
4552
|
principal: { id: "user1", roles: ["USER"] },
|
|
@@ -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,181 +4904,296 @@ 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
|
-
some: {
|
|
4552
|
-
subCategories: {
|
|
4553
|
-
some: {
|
|
4554
|
-
name: { in: ["finance", "tech"] },
|
|
4555
|
-
},
|
|
4556
|
-
},
|
|
4557
|
-
},
|
|
4558
|
-
},
|
|
5016
|
+
NOT: { aNumber: { gt: 1 } },
|
|
4559
5017
|
},
|
|
4560
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
|
+
);
|
|
4561
5032
|
});
|
|
4562
|
-
});
|
|
4563
5033
|
|
|
4564
|
-
|
|
4565
|
-
test("filter on deeply nested relation", async () => {
|
|
5034
|
+
test("conditional - not-lt", async () => {
|
|
4566
5035
|
const queryPlan = await cerbos.planResources({
|
|
4567
5036
|
principal: { id: "user1", roles: ["USER"] },
|
|
4568
5037
|
resource: { kind: "resource" },
|
|
4569
|
-
action: "
|
|
5038
|
+
action: "not-lt",
|
|
4570
5039
|
});
|
|
4571
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
|
+
],
|
|
5052
|
+
},
|
|
5053
|
+
],
|
|
5054
|
+
}
|
|
5055
|
+
);
|
|
5056
|
+
|
|
4572
5057
|
const result = queryPlanToPrisma({
|
|
4573
5058
|
queryPlan,
|
|
4574
5059
|
mapper: {
|
|
4575
|
-
"request.resource.attr.
|
|
4576
|
-
relation: {
|
|
4577
|
-
name: "categories",
|
|
4578
|
-
type: "many",
|
|
4579
|
-
fields: {
|
|
4580
|
-
subCategories: {
|
|
4581
|
-
relation: {
|
|
4582
|
-
name: "subCategories",
|
|
4583
|
-
type: "many",
|
|
4584
|
-
fields: {
|
|
4585
|
-
labels: {
|
|
4586
|
-
relation: {
|
|
4587
|
-
name: "labels",
|
|
4588
|
-
type: "many",
|
|
4589
|
-
fields: {
|
|
4590
|
-
name: { field: "name" },
|
|
4591
|
-
},
|
|
4592
|
-
},
|
|
4593
|
-
},
|
|
4594
|
-
},
|
|
4595
|
-
},
|
|
4596
|
-
},
|
|
4597
|
-
},
|
|
4598
|
-
},
|
|
4599
|
-
},
|
|
5060
|
+
"request.resource.attr.aNumber": { field: "aNumber" },
|
|
4600
5061
|
},
|
|
4601
5062
|
});
|
|
4602
5063
|
|
|
4603
5064
|
expect(result).toStrictEqual({
|
|
4604
5065
|
kind: PlanKind.CONDITIONAL,
|
|
4605
5066
|
filters: {
|
|
4606
|
-
|
|
4607
|
-
some: {
|
|
4608
|
-
subCategories: {
|
|
4609
|
-
some: {
|
|
4610
|
-
labels: {
|
|
4611
|
-
some: {
|
|
4612
|
-
name: { equals: "important" },
|
|
4613
|
-
},
|
|
4614
|
-
},
|
|
4615
|
-
},
|
|
4616
|
-
},
|
|
4617
|
-
},
|
|
4618
|
-
},
|
|
5067
|
+
NOT: { aNumber: { lt: 2 } },
|
|
4619
5068
|
},
|
|
4620
5069
|
});
|
|
4621
|
-
});
|
|
4622
5070
|
|
|
4623
|
-
|
|
4624
|
-
|
|
4625
|
-
|
|
4626
|
-
resource: { kind: "resource" },
|
|
4627
|
-
action: "map-deeply-nested",
|
|
4628
|
-
});
|
|
5071
|
+
if (result.kind !== PlanKind.CONDITIONAL) {
|
|
5072
|
+
throw new Error("Expected CONDITIONAL result");
|
|
5073
|
+
}
|
|
4629
5074
|
|
|
4630
|
-
const
|
|
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" } },
|
|
5170
|
+
},
|
|
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
|
+
);
|
|
5185
|
+
});
|
|
5186
|
+
});
|
|
5187
|
+
|
|
5188
|
+
describe("Collection Operations", () => {
|
|
5189
|
+
test("conditional - exists on nested collection", async () => {
|
|
5190
|
+
const queryPlan = await cerbos.planResources({
|
|
5191
|
+
principal: { id: "user1", roles: ["USER"] },
|
|
5192
|
+
resource: { kind: "resource" },
|
|
5193
|
+
action: "exists-nested-collection",
|
|
5194
|
+
});
|
|
5195
|
+
|
|
5196
|
+
const result = queryPlanToPrisma({
|
|
4631
5197
|
queryPlan,
|
|
4632
5198
|
mapper: {
|
|
4633
5199
|
"request.resource.attr.categories": {
|
|
@@ -4635,6 +5201,7 @@ describe("Complex Operations", () => {
|
|
|
4635
5201
|
name: "categories",
|
|
4636
5202
|
type: "many",
|
|
4637
5203
|
fields: {
|
|
5204
|
+
name: { field: "name" },
|
|
4638
5205
|
subCategories: {
|
|
4639
5206
|
relation: {
|
|
4640
5207
|
name: "subCategories",
|
|
@@ -4655,87 +5222,311 @@ describe("Complex Operations", () => {
|
|
|
4655
5222
|
filters: {
|
|
4656
5223
|
categories: {
|
|
4657
5224
|
some: {
|
|
4658
|
-
|
|
4659
|
-
|
|
4660
|
-
|
|
5225
|
+
AND: [
|
|
5226
|
+
{ name: { equals: "business" } },
|
|
5227
|
+
{
|
|
5228
|
+
subCategories: {
|
|
5229
|
+
some: {
|
|
5230
|
+
name: { equals: "finance" },
|
|
5231
|
+
},
|
|
5232
|
+
},
|
|
4661
5233
|
},
|
|
4662
|
-
|
|
5234
|
+
],
|
|
4663
5235
|
},
|
|
4664
5236
|
},
|
|
4665
5237
|
},
|
|
4666
5238
|
});
|
|
4667
|
-
});
|
|
4668
|
-
});
|
|
4669
|
-
});
|
|
4670
|
-
|
|
4671
|
-
// Error Handling
|
|
4672
|
-
describe("Error Cases", () => {
|
|
4673
|
-
test("throws error for invalid query plan", () => {
|
|
4674
|
-
const invalidQueryPlan = {
|
|
4675
|
-
kind: "INVALID_KIND" as PlanKind,
|
|
4676
|
-
};
|
|
4677
5239
|
|
|
4678
|
-
|
|
4679
|
-
|
|
4680
|
-
|
|
4681
|
-
mapper: {},
|
|
4682
|
-
})
|
|
4683
|
-
).toThrow("Invalid query plan.");
|
|
4684
|
-
});
|
|
5240
|
+
if (result.kind !== PlanKind.CONDITIONAL) {
|
|
5241
|
+
throw new Error("Expected CONDITIONAL result");
|
|
5242
|
+
}
|
|
4685
5243
|
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
|
|
4689
|
-
condition: {
|
|
4690
|
-
// Missing operator and operands
|
|
4691
|
-
},
|
|
4692
|
-
};
|
|
5244
|
+
const query = await prisma.resource.findMany({
|
|
5245
|
+
where: { ...result.filters },
|
|
5246
|
+
});
|
|
4693
5247
|
|
|
4694
|
-
|
|
4695
|
-
|
|
4696
|
-
|
|
4697
|
-
|
|
4698
|
-
|
|
4699
|
-
|
|
4700
|
-
|
|
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
|
+
});
|
|
4701
5273
|
|
|
4702
|
-
|
|
4703
|
-
|
|
4704
|
-
|
|
4705
|
-
|
|
4706
|
-
|
|
4707
|
-
|
|
4708
|
-
},
|
|
4709
|
-
};
|
|
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
|
+
});
|
|
4710
5280
|
|
|
4711
|
-
|
|
4712
|
-
|
|
4713
|
-
|
|
4714
|
-
|
|
4715
|
-
|
|
4716
|
-
|
|
4717
|
-
|
|
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" },
|
|
5290
|
+
},
|
|
5291
|
+
},
|
|
5292
|
+
},
|
|
5293
|
+
},
|
|
5294
|
+
});
|
|
4718
5295
|
|
|
4719
|
-
|
|
4720
|
-
|
|
4721
|
-
|
|
4722
|
-
|
|
4723
|
-
|
|
4724
|
-
|
|
4725
|
-
|
|
4726
|
-
|
|
4727
|
-
|
|
4728
|
-
}
|
|
4729
|
-
};
|
|
5296
|
+
expect(result).toStrictEqual({
|
|
5297
|
+
kind: PlanKind.CONDITIONAL,
|
|
5298
|
+
filters: {
|
|
5299
|
+
nested: {
|
|
5300
|
+
is: {
|
|
5301
|
+
aOptionalString: { not: null },
|
|
5302
|
+
},
|
|
5303
|
+
},
|
|
5304
|
+
},
|
|
5305
|
+
});
|
|
5306
|
+
});
|
|
4730
5307
|
|
|
4731
|
-
|
|
4732
|
-
|
|
4733
|
-
|
|
4734
|
-
|
|
4735
|
-
|
|
4736
|
-
|
|
4737
|
-
|
|
4738
|
-
|
|
5308
|
+
test("hasIntersection with nested collection", async () => {
|
|
5309
|
+
const queryPlan = await cerbos.planResources({
|
|
5310
|
+
principal: { id: "user1", roles: ["USER"] },
|
|
5311
|
+
resource: { kind: "resource" },
|
|
5312
|
+
action: "has-intersection-nested",
|
|
5313
|
+
});
|
|
5314
|
+
|
|
5315
|
+
const result = queryPlanToPrisma({
|
|
5316
|
+
queryPlan,
|
|
5317
|
+
mapper: {
|
|
5318
|
+
"request.resource.attr.categories": {
|
|
5319
|
+
relation: {
|
|
5320
|
+
name: "categories",
|
|
5321
|
+
type: "many",
|
|
5322
|
+
fields: {
|
|
5323
|
+
subCategories: {
|
|
5324
|
+
relation: {
|
|
5325
|
+
name: "subCategories",
|
|
5326
|
+
type: "many",
|
|
5327
|
+
fields: {
|
|
5328
|
+
name: { field: "name" },
|
|
5329
|
+
},
|
|
5330
|
+
},
|
|
5331
|
+
},
|
|
5332
|
+
},
|
|
5333
|
+
},
|
|
5334
|
+
},
|
|
5335
|
+
},
|
|
5336
|
+
});
|
|
5337
|
+
|
|
5338
|
+
expect(result).toStrictEqual({
|
|
5339
|
+
kind: PlanKind.CONDITIONAL,
|
|
5340
|
+
filters: {
|
|
5341
|
+
categories: {
|
|
5342
|
+
some: {
|
|
5343
|
+
subCategories: {
|
|
5344
|
+
some: {
|
|
5345
|
+
name: { in: ["finance", "tech"] },
|
|
5346
|
+
},
|
|
5347
|
+
},
|
|
5348
|
+
},
|
|
5349
|
+
},
|
|
5350
|
+
},
|
|
5351
|
+
});
|
|
5352
|
+
});
|
|
5353
|
+
});
|
|
5354
|
+
|
|
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,
|
|
5467
|
+
};
|
|
5468
|
+
|
|
5469
|
+
expect(() =>
|
|
5470
|
+
queryPlanToPrisma({
|
|
5471
|
+
queryPlan: invalidQueryPlan as unknown as PlanResourcesResponse,
|
|
5472
|
+
mapper: {},
|
|
5473
|
+
})
|
|
5474
|
+
).toThrow("Invalid query plan.");
|
|
5475
|
+
});
|
|
5476
|
+
|
|
5477
|
+
test("throws error for invalid expression structure", () => {
|
|
5478
|
+
const invalidQueryPlan = {
|
|
5479
|
+
kind: PlanKind.CONDITIONAL,
|
|
5480
|
+
condition: {
|
|
5481
|
+
// Missing operator and operands
|
|
5482
|
+
},
|
|
5483
|
+
};
|
|
5484
|
+
|
|
5485
|
+
expect(() =>
|
|
5486
|
+
queryPlanToPrisma({
|
|
5487
|
+
queryPlan: invalidQueryPlan as unknown as PlanResourcesResponse,
|
|
5488
|
+
mapper: {},
|
|
5489
|
+
})
|
|
5490
|
+
).toThrow("Invalid Cerbos expression structure");
|
|
5491
|
+
});
|
|
5492
|
+
|
|
5493
|
+
test("throws error for unsupported operator", () => {
|
|
5494
|
+
const invalidQueryPlan = {
|
|
5495
|
+
kind: PlanKind.CONDITIONAL,
|
|
5496
|
+
condition: {
|
|
5497
|
+
operator: "unsupported",
|
|
5498
|
+
operands: [],
|
|
5499
|
+
},
|
|
5500
|
+
};
|
|
5501
|
+
|
|
5502
|
+
expect(() =>
|
|
5503
|
+
queryPlanToPrisma({
|
|
5504
|
+
queryPlan: invalidQueryPlan as unknown as PlanResourcesResponse,
|
|
5505
|
+
mapper: {},
|
|
5506
|
+
})
|
|
5507
|
+
).toThrow("Unsupported operator: unsupported");
|
|
5508
|
+
});
|
|
5509
|
+
|
|
5510
|
+
test("throws error for invalid operand structure", () => {
|
|
5511
|
+
const invalidQueryPlan = {
|
|
5512
|
+
kind: PlanKind.CONDITIONAL,
|
|
5513
|
+
condition: {
|
|
5514
|
+
operator: "eq",
|
|
5515
|
+
operands: [
|
|
5516
|
+
{}, // Invalid operand without name or value
|
|
5517
|
+
{ value: "test" },
|
|
5518
|
+
],
|
|
5519
|
+
},
|
|
5520
|
+
};
|
|
5521
|
+
|
|
5522
|
+
expect(() =>
|
|
5523
|
+
queryPlanToPrisma({
|
|
5524
|
+
queryPlan: invalidQueryPlan as unknown as PlanResourcesResponse,
|
|
5525
|
+
mapper: {},
|
|
5526
|
+
})
|
|
5527
|
+
).toThrow("No valid left operand found");
|
|
5528
|
+
});
|
|
5529
|
+
});
|
|
4739
5530
|
|
|
4740
5531
|
// Utility Tests
|
|
4741
5532
|
describe("Mapper Functions", () => {
|
|
@@ -5769,542 +6560,898 @@ describe("Add Operator", () => {
|
|
|
5769
6560
|
});
|
|
5770
6561
|
});
|
|
5771
6562
|
|
|
5772
|
-
//
|
|
5773
|
-
//
|
|
5774
|
-
|
|
5775
|
-
|
|
5776
|
-
|
|
5777
|
-
|
|
5778
|
-
|
|
5779
|
-
|
|
5780
|
-
|
|
5781
|
-
|
|
5782
|
-
|
|
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",
|
|
5783
6579
|
operands: [
|
|
5784
6580
|
{
|
|
5785
|
-
operator: "
|
|
5786
|
-
operands: [{ value: "projects:123" }, { value: ":" }],
|
|
5787
|
-
},
|
|
5788
|
-
{
|
|
5789
|
-
operator: "hierarchy",
|
|
6581
|
+
operator: "add",
|
|
5790
6582
|
operands: [
|
|
5791
|
-
{
|
|
5792
|
-
|
|
5793
|
-
operands: [
|
|
5794
|
-
{ value: "projects" },
|
|
5795
|
-
{ name: "request.resource.id" },
|
|
5796
|
-
],
|
|
5797
|
-
},
|
|
6583
|
+
{ name: "request.resource.attr.aNumber" },
|
|
6584
|
+
{ value: 1 },
|
|
5798
6585
|
],
|
|
5799
6586
|
},
|
|
6587
|
+
{ value: 2 },
|
|
5800
6588
|
],
|
|
5801
6589
|
});
|
|
5802
6590
|
|
|
5803
|
-
//
|
|
6591
|
+
// aNumber + 1 > 2 ⇔ aNumber > 1
|
|
5804
6592
|
const result = queryPlanToPrisma({
|
|
5805
|
-
queryPlan
|
|
6593
|
+
queryPlan,
|
|
5806
6594
|
mapper: {
|
|
5807
|
-
"request.resource.
|
|
6595
|
+
"request.resource.attr.aNumber": { field: "aNumber" },
|
|
5808
6596
|
},
|
|
5809
6597
|
});
|
|
5810
|
-
|
|
5811
|
-
// #then
|
|
5812
|
-
expect(result).toEqual({
|
|
6598
|
+
expect(result).toStrictEqual({
|
|
5813
6599
|
kind: PlanKind.CONDITIONAL,
|
|
5814
|
-
filters: {
|
|
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 },
|
|
5815
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
|
+
);
|
|
5816
6615
|
});
|
|
5817
6616
|
|
|
5818
|
-
test("
|
|
5819
|
-
|
|
5820
|
-
|
|
5821
|
-
|
|
5822
|
-
|
|
5823
|
-
|
|
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",
|
|
5824
6627
|
operands: [
|
|
5825
6628
|
{
|
|
5826
|
-
operator: "
|
|
5827
|
-
operands: [{ value: "admin" }, { value: ":" }],
|
|
5828
|
-
},
|
|
5829
|
-
{
|
|
5830
|
-
operator: "hierarchy",
|
|
6629
|
+
operator: "sub",
|
|
5831
6630
|
operands: [
|
|
5832
|
-
{
|
|
5833
|
-
|
|
5834
|
-
operands: [
|
|
5835
|
-
{ value: "admin" },
|
|
5836
|
-
{ name: "request.resource.attr.scope" },
|
|
5837
|
-
],
|
|
5838
|
-
},
|
|
6631
|
+
{ name: "request.resource.attr.aNumber" },
|
|
6632
|
+
{ value: 1 },
|
|
5839
6633
|
],
|
|
5840
6634
|
},
|
|
6635
|
+
{ value: 2 },
|
|
5841
6636
|
],
|
|
5842
6637
|
});
|
|
5843
6638
|
|
|
5844
|
-
//
|
|
6639
|
+
// aNumber - 1 < 2 ⇔ aNumber < 3
|
|
5845
6640
|
const result = queryPlanToPrisma({
|
|
5846
|
-
queryPlan
|
|
6641
|
+
queryPlan,
|
|
5847
6642
|
mapper: {
|
|
5848
|
-
"request.resource.attr.
|
|
6643
|
+
"request.resource.attr.aNumber": { field: "aNumber" },
|
|
5849
6644
|
},
|
|
5850
6645
|
});
|
|
5851
|
-
|
|
5852
|
-
// #then always true
|
|
5853
|
-
expect(result).toEqual({
|
|
6646
|
+
expect(result).toStrictEqual({
|
|
5854
6647
|
kind: PlanKind.CONDITIONAL,
|
|
5855
|
-
filters: {},
|
|
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 },
|
|
5856
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
|
+
);
|
|
5857
6663
|
});
|
|
5858
6664
|
|
|
5859
|
-
test("
|
|
5860
|
-
|
|
5861
|
-
|
|
5862
|
-
|
|
5863
|
-
|
|
5864
|
-
|
|
5865
|
-
|
|
5866
|
-
|
|
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",
|
|
5867
6675
|
operands: [
|
|
5868
6676
|
{
|
|
5869
|
-
operator: "
|
|
5870
|
-
operands: [
|
|
5871
|
-
{ value: "dept:engineering:frontend" },
|
|
5872
|
-
{ value: ":" },
|
|
5873
|
-
],
|
|
5874
|
-
},
|
|
5875
|
-
{
|
|
5876
|
-
operator: "hierarchy",
|
|
6677
|
+
operator: "mult",
|
|
5877
6678
|
operands: [
|
|
5878
|
-
{
|
|
5879
|
-
|
|
5880
|
-
operands: [
|
|
5881
|
-
{ value: "dept" },
|
|
5882
|
-
{ name: "request.resource.attr.department" },
|
|
5883
|
-
],
|
|
5884
|
-
},
|
|
6679
|
+
{ name: "request.resource.attr.aNumber" },
|
|
6680
|
+
{ value: 2 },
|
|
5885
6681
|
],
|
|
5886
6682
|
},
|
|
6683
|
+
{ value: 2 },
|
|
5887
6684
|
],
|
|
5888
6685
|
});
|
|
5889
6686
|
|
|
5890
|
-
//
|
|
6687
|
+
// aNumber * 2 > 2 ⇔ aNumber > 1
|
|
5891
6688
|
const result = queryPlanToPrisma({
|
|
5892
|
-
queryPlan
|
|
6689
|
+
queryPlan,
|
|
5893
6690
|
mapper: {
|
|
5894
|
-
"request.resource.attr.
|
|
6691
|
+
"request.resource.attr.aNumber": { field: "aNumber" },
|
|
5895
6692
|
},
|
|
5896
6693
|
});
|
|
5897
|
-
|
|
5898
|
-
// #then
|
|
5899
|
-
expect(result).toEqual({
|
|
6694
|
+
expect(result).toStrictEqual({
|
|
5900
6695
|
kind: PlanKind.CONDITIONAL,
|
|
5901
|
-
filters: {
|
|
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 },
|
|
5902
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
|
+
);
|
|
5903
6711
|
});
|
|
5904
6712
|
|
|
5905
|
-
test("
|
|
5906
|
-
|
|
5907
|
-
|
|
5908
|
-
|
|
5909
|
-
|
|
5910
|
-
|
|
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",
|
|
5911
6723
|
operands: [
|
|
5912
6724
|
{
|
|
5913
|
-
operator: "
|
|
5914
|
-
operands: [{ value: "org:acme:team1" }, { value: ":" }],
|
|
5915
|
-
},
|
|
5916
|
-
{
|
|
5917
|
-
operator: "hierarchy",
|
|
6725
|
+
operator: "div",
|
|
5918
6726
|
operands: [
|
|
5919
|
-
{
|
|
5920
|
-
|
|
5921
|
-
operands: [
|
|
5922
|
-
{ value: "company" },
|
|
5923
|
-
{ name: "request.resource.attr.team" },
|
|
5924
|
-
],
|
|
5925
|
-
},
|
|
6727
|
+
{ name: "request.resource.attr.aNumber" },
|
|
6728
|
+
{ value: 2 },
|
|
5926
6729
|
],
|
|
5927
6730
|
},
|
|
6731
|
+
{ value: 0 },
|
|
5928
6732
|
],
|
|
5929
6733
|
});
|
|
5930
6734
|
|
|
5931
|
-
//
|
|
6735
|
+
// aNumber / 2 > 0 ⇔ aNumber > 0
|
|
5932
6736
|
const result = queryPlanToPrisma({
|
|
5933
|
-
queryPlan
|
|
6737
|
+
queryPlan,
|
|
5934
6738
|
mapper: {
|
|
5935
|
-
"request.resource.attr.
|
|
6739
|
+
"request.resource.attr.aNumber": { field: "aNumber" },
|
|
5936
6740
|
},
|
|
5937
6741
|
});
|
|
5938
|
-
|
|
5939
|
-
// #then impossible
|
|
5940
|
-
expect(result).toEqual({
|
|
6742
|
+
expect(result).toStrictEqual({
|
|
5941
6743
|
kind: PlanKind.CONDITIONAL,
|
|
5942
|
-
filters: {
|
|
5943
|
-
|
|
5944
|
-
|
|
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 },
|
|
5945
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
|
+
);
|
|
5946
6759
|
});
|
|
5947
6760
|
|
|
5948
|
-
test("
|
|
5949
|
-
|
|
5950
|
-
|
|
5951
|
-
|
|
5952
|
-
|
|
5953
|
-
|
|
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",
|
|
5954
6771
|
operands: [
|
|
5955
6772
|
{
|
|
5956
|
-
operator: "
|
|
5957
|
-
operands: [{ value: "region:us:west" }, { value: ":" }],
|
|
5958
|
-
},
|
|
5959
|
-
{
|
|
5960
|
-
operator: "hierarchy",
|
|
6773
|
+
operator: "mod",
|
|
5961
6774
|
operands: [
|
|
5962
6775
|
{
|
|
5963
|
-
operator: "
|
|
5964
|
-
operands: [
|
|
5965
|
-
{ value: "region" },
|
|
5966
|
-
{ value: "us" },
|
|
5967
|
-
{ name: "request.resource.attr.zone" },
|
|
5968
|
-
],
|
|
6776
|
+
operator: "int",
|
|
6777
|
+
operands: [{ name: "request.resource.attr.aNumber" }],
|
|
5969
6778
|
},
|
|
6779
|
+
{ value: 2 },
|
|
5970
6780
|
],
|
|
5971
6781
|
},
|
|
6782
|
+
{ value: 0 },
|
|
5972
6783
|
],
|
|
5973
6784
|
});
|
|
5974
6785
|
|
|
5975
|
-
|
|
5976
|
-
|
|
5977
|
-
|
|
5978
|
-
|
|
5979
|
-
|
|
5980
|
-
|
|
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",
|
|
5981
6803
|
});
|
|
5982
6804
|
|
|
5983
|
-
|
|
5984
|
-
expect(
|
|
5985
|
-
|
|
5986
|
-
|
|
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
|
+
],
|
|
5987
6812
|
});
|
|
6813
|
+
|
|
6814
|
+
expect(() =>
|
|
6815
|
+
queryPlanToPrisma({
|
|
6816
|
+
queryPlan,
|
|
6817
|
+
mapper: {
|
|
6818
|
+
"request.resource.attr.aString": { field: "aString" },
|
|
6819
|
+
},
|
|
6820
|
+
})
|
|
6821
|
+
).toThrow("Unsupported operator: matches");
|
|
5988
6822
|
});
|
|
6823
|
+
});
|
|
5989
6824
|
|
|
5990
|
-
|
|
5991
|
-
|
|
5992
|
-
|
|
5993
|
-
|
|
5994
|
-
|
|
5995
|
-
|
|
5996
|
-
|
|
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",
|
|
5997
6836
|
operands: [
|
|
5998
6837
|
{
|
|
5999
|
-
operator: "
|
|
6000
|
-
operands: [{ value: "team:alpha" }, { value: ":" }],
|
|
6001
|
-
},
|
|
6002
|
-
{
|
|
6003
|
-
operator: "hierarchy",
|
|
6838
|
+
operator: "index",
|
|
6004
6839
|
operands: [
|
|
6005
|
-
{
|
|
6006
|
-
|
|
6007
|
-
operands: [{ name: "request.resource.attr.group" }],
|
|
6008
|
-
},
|
|
6840
|
+
{ name: "request.resource.attr.ownedBy" },
|
|
6841
|
+
{ value: 0 },
|
|
6009
6842
|
],
|
|
6010
6843
|
},
|
|
6844
|
+
{ value: "user1" },
|
|
6011
6845
|
],
|
|
6012
6846
|
});
|
|
6013
6847
|
|
|
6014
|
-
|
|
6015
|
-
|
|
6016
|
-
|
|
6017
|
-
|
|
6018
|
-
|
|
6019
|
-
|
|
6020
|
-
|
|
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
|
+
});
|
|
6021
6860
|
|
|
6022
|
-
|
|
6023
|
-
|
|
6024
|
-
|
|
6025
|
-
|
|
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",
|
|
6026
6867
|
});
|
|
6027
|
-
});
|
|
6028
6868
|
|
|
6029
|
-
|
|
6030
|
-
|
|
6031
|
-
|
|
6032
|
-
const plan = createConditionalPlan({
|
|
6033
|
-
operator: "overlaps",
|
|
6869
|
+
expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
|
|
6870
|
+
expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual({
|
|
6871
|
+
operator: "eq",
|
|
6034
6872
|
operands: [
|
|
6035
6873
|
{
|
|
6036
|
-
operator: "
|
|
6037
|
-
operands: [{
|
|
6038
|
-
},
|
|
6039
|
-
{
|
|
6040
|
-
operator: "hierarchy",
|
|
6041
|
-
operands: [
|
|
6042
|
-
{
|
|
6043
|
-
operator: "list",
|
|
6044
|
-
operands: [
|
|
6045
|
-
{ value: "a" },
|
|
6046
|
-
{ value: "b" },
|
|
6047
|
-
{ name: "request.resource.attr.zone" },
|
|
6048
|
-
],
|
|
6049
|
-
},
|
|
6050
|
-
],
|
|
6874
|
+
operator: "string",
|
|
6875
|
+
operands: [{ name: "request.resource.attr.aNumber" }],
|
|
6051
6876
|
},
|
|
6877
|
+
{ value: "1" },
|
|
6052
6878
|
],
|
|
6053
6879
|
});
|
|
6054
6880
|
|
|
6055
|
-
|
|
6056
|
-
|
|
6057
|
-
|
|
6058
|
-
|
|
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",
|
|
6059
6896
|
});
|
|
6060
6897
|
|
|
6061
|
-
|
|
6062
|
-
expect(
|
|
6063
|
-
|
|
6064
|
-
|
|
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
|
+
],
|
|
6065
6908
|
});
|
|
6909
|
+
|
|
6910
|
+
expect(() =>
|
|
6911
|
+
queryPlanToPrisma({
|
|
6912
|
+
queryPlan,
|
|
6913
|
+
mapper: {
|
|
6914
|
+
"request.resource.attr.aNumber": { field: "aNumber" },
|
|
6915
|
+
},
|
|
6916
|
+
})
|
|
6917
|
+
).toThrow("Unsupported operator: double");
|
|
6066
6918
|
});
|
|
6067
6919
|
|
|
6068
|
-
test("
|
|
6920
|
+
test("convert-int is unsupported", async () => {
|
|
6069
6921
|
const queryPlan = await cerbos.planResources({
|
|
6070
|
-
principal: {
|
|
6071
|
-
id: "user1",
|
|
6072
|
-
roles: ["USER"],
|
|
6073
|
-
attr: { context: "projects:123" },
|
|
6074
|
-
},
|
|
6922
|
+
principal: { id: "user1", roles: ["USER"] },
|
|
6075
6923
|
resource: { kind: "resource" },
|
|
6076
|
-
action: "
|
|
6924
|
+
action: "convert-int",
|
|
6077
6925
|
});
|
|
6078
6926
|
|
|
6079
|
-
expect(queryPlan.kind).
|
|
6080
|
-
|
|
6081
|
-
|
|
6082
|
-
|
|
6083
|
-
|
|
6084
|
-
|
|
6085
|
-
|
|
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
|
+
],
|
|
6086
6937
|
});
|
|
6087
6938
|
|
|
6088
|
-
|
|
6089
|
-
|
|
6090
|
-
|
|
6091
|
-
|
|
6092
|
-
|
|
6093
|
-
|
|
6094
|
-
|
|
6939
|
+
expect(() =>
|
|
6940
|
+
queryPlanToPrisma({
|
|
6941
|
+
queryPlan,
|
|
6942
|
+
mapper: {
|
|
6943
|
+
"request.resource.attr.aString": { field: "aString" },
|
|
6944
|
+
},
|
|
6945
|
+
})
|
|
6946
|
+
).toThrow("Unsupported operator: int");
|
|
6095
6947
|
});
|
|
6096
6948
|
});
|
|
6097
6949
|
|
|
6098
|
-
|
|
6099
|
-
|
|
6100
|
-
|
|
6101
|
-
}
|
|
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
|
+
};
|
|
6102
6958
|
|
|
6103
|
-
|
|
6104
|
-
|
|
6105
|
-
|
|
6106
|
-
|
|
6107
|
-
|
|
6108
|
-
|
|
6109
|
-
|
|
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",
|
|
6110
6982
|
operands: [
|
|
6111
6983
|
{
|
|
6112
|
-
operator: "
|
|
6113
|
-
operands: [
|
|
6114
|
-
|
|
6115
|
-
|
|
6116
|
-
|
|
6117
|
-
|
|
6984
|
+
operator: "if",
|
|
6985
|
+
operands: [
|
|
6986
|
+
{ name: "request.resource.attr.aBool" },
|
|
6987
|
+
{ name: "request.resource.attr.aNumber" },
|
|
6988
|
+
{ value: 0 },
|
|
6989
|
+
],
|
|
6118
6990
|
},
|
|
6991
|
+
{ value: 0 },
|
|
6119
6992
|
],
|
|
6120
6993
|
});
|
|
6121
6994
|
|
|
6122
|
-
|
|
6123
|
-
const result = queryPlanToPrisma({ queryPlan: plan, mapper: hierarchyMapper });
|
|
6124
|
-
|
|
6125
|
-
// #then - R.attr.scope must start with "a.b.c." (ancestor means strict prefix)
|
|
6126
|
-
expect(result).toEqual({
|
|
6995
|
+
expect(queryPlanToPrisma({ queryPlan, mapper })).toStrictEqual({
|
|
6127
6996
|
kind: PlanKind.CONDITIONAL,
|
|
6128
|
-
filters: {
|
|
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
|
+
},
|
|
6129
7013
|
});
|
|
7014
|
+
expect(await matchingResourceIds(queryPlan)).toEqual(["resource1"]);
|
|
6130
7015
|
});
|
|
6131
7016
|
|
|
6132
|
-
test("
|
|
6133
|
-
|
|
6134
|
-
|
|
6135
|
-
// Possible prefixes: "a", "a.b", "a.b.c"
|
|
6136
|
-
const plan = createConditionalPlan({
|
|
6137
|
-
operator: "ancestorOf",
|
|
7017
|
+
test("rewrites a ternary used directly as a boolean predicate", async () => {
|
|
7018
|
+
const queryPlan = createConditionalPlan({
|
|
7019
|
+
operator: "if",
|
|
6138
7020
|
operands: [
|
|
7021
|
+
{ name: "request.resource.attr.aBool" },
|
|
6139
7022
|
{
|
|
6140
|
-
operator: "
|
|
6141
|
-
operands: [
|
|
7023
|
+
operator: "gt",
|
|
7024
|
+
operands: [
|
|
7025
|
+
{ name: "request.resource.attr.aNumber" },
|
|
7026
|
+
{ value: 0 },
|
|
7027
|
+
],
|
|
6142
7028
|
},
|
|
6143
7029
|
{
|
|
6144
|
-
operator: "
|
|
6145
|
-
operands: [
|
|
7030
|
+
operator: "eq",
|
|
7031
|
+
operands: [
|
|
7032
|
+
{ name: "request.resource.attr.aString" },
|
|
7033
|
+
{ value: "string3" },
|
|
7034
|
+
],
|
|
6146
7035
|
},
|
|
6147
7036
|
],
|
|
6148
7037
|
});
|
|
6149
7038
|
|
|
6150
|
-
|
|
6151
|
-
|
|
6152
|
-
|
|
6153
|
-
|
|
6154
|
-
expect(result).toEqual({
|
|
6155
|
-
kind: PlanKind.CONDITIONAL,
|
|
6156
|
-
filters: { scope: { in: ["a", "a.b", "a.b.c"] } },
|
|
6157
|
-
});
|
|
7039
|
+
expect(await matchingResourceIds(queryPlan)).toEqual([
|
|
7040
|
+
"resource1",
|
|
7041
|
+
"resource3",
|
|
7042
|
+
]);
|
|
6158
7043
|
});
|
|
6159
7044
|
|
|
6160
|
-
test("
|
|
6161
|
-
|
|
6162
|
-
|
|
6163
|
-
operator: "ancestorOf",
|
|
7045
|
+
test("recursively rewrites nested ternary branches", async () => {
|
|
7046
|
+
const queryPlan = createConditionalPlan({
|
|
7047
|
+
operator: "gt",
|
|
6164
7048
|
operands: [
|
|
6165
7049
|
{
|
|
6166
|
-
operator: "
|
|
6167
|
-
operands: [
|
|
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
|
+
],
|
|
6168
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 },
|
|
6169
7082
|
{
|
|
6170
|
-
operator: "
|
|
6171
|
-
operands: [
|
|
7083
|
+
operator: "if",
|
|
7084
|
+
operands: [
|
|
7085
|
+
{ name: "request.resource.attr.aBool" },
|
|
7086
|
+
{ name: "request.resource.attr.aNumber" },
|
|
7087
|
+
{ value: -1 },
|
|
7088
|
+
],
|
|
6172
7089
|
},
|
|
6173
7090
|
],
|
|
6174
7091
|
});
|
|
6175
7092
|
|
|
6176
|
-
|
|
6177
|
-
|
|
6178
|
-
|
|
6179
|
-
// #then
|
|
6180
|
-
expect(result).toEqual({
|
|
7093
|
+
const result = queryPlanToPrisma({ queryPlan, mapper });
|
|
7094
|
+
expect(result).toStrictEqual({
|
|
6181
7095
|
kind: PlanKind.CONDITIONAL,
|
|
6182
|
-
filters: {
|
|
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
|
+
},
|
|
6183
7112
|
});
|
|
7113
|
+
expect(await matchingResourceIds(queryPlan)).toEqual(["resource1"]);
|
|
6184
7114
|
});
|
|
6185
7115
|
|
|
6186
|
-
test("
|
|
6187
|
-
|
|
6188
|
-
|
|
6189
|
-
const plan = createConditionalPlan({
|
|
6190
|
-
operator: "ancestorOf",
|
|
7116
|
+
test("keeps a nullable condition unknown under outer negation", async () => {
|
|
7117
|
+
const queryPlan = createConditionalPlan({
|
|
7118
|
+
operator: "not",
|
|
6191
7119
|
operands: [
|
|
6192
7120
|
{
|
|
6193
|
-
operator: "
|
|
6194
|
-
operands: [
|
|
6195
|
-
|
|
6196
|
-
|
|
6197
|
-
|
|
6198
|
-
|
|
7121
|
+
operator: "if",
|
|
7122
|
+
operands: [
|
|
7123
|
+
{ name: "request.resource.attr.aOptionalBool" },
|
|
7124
|
+
{ value: false },
|
|
7125
|
+
{ value: false },
|
|
7126
|
+
],
|
|
6199
7127
|
},
|
|
6200
7128
|
],
|
|
6201
7129
|
});
|
|
6202
7130
|
|
|
6203
|
-
|
|
6204
|
-
|
|
6205
|
-
|
|
6206
|
-
|
|
6207
|
-
expect(result).toEqual({
|
|
6208
|
-
kind: PlanKind.CONDITIONAL,
|
|
6209
|
-
filters: { path: { startsWith: "org:team:" } },
|
|
6210
|
-
});
|
|
7131
|
+
expect(await matchingResourceIds(queryPlan)).toEqual([
|
|
7132
|
+
"resource1",
|
|
7133
|
+
"resource2",
|
|
7134
|
+
]);
|
|
6211
7135
|
});
|
|
6212
7136
|
|
|
6213
|
-
test("
|
|
6214
|
-
const
|
|
6215
|
-
|
|
6216
|
-
|
|
6217
|
-
|
|
6218
|
-
|
|
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
|
+
],
|
|
6219
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"] },
|
|
6220
7299
|
resource: { kind: "resource" },
|
|
6221
|
-
action: "
|
|
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
|
+
],
|
|
6222
7347
|
});
|
|
6223
7348
|
|
|
6224
7349
|
const result = queryPlanToPrisma({
|
|
6225
|
-
queryPlan
|
|
7350
|
+
queryPlan,
|
|
6226
7351
|
mapper: {
|
|
6227
|
-
"request.resource.attr.
|
|
7352
|
+
"request.resource.attr.tags": {
|
|
7353
|
+
relation: { name: "tags", type: "many" },
|
|
7354
|
+
},
|
|
6228
7355
|
},
|
|
6229
7356
|
});
|
|
6230
7357
|
|
|
6231
|
-
|
|
6232
|
-
// "a.b.c" is ancestor of R.attr.scope → R.attr.scope starts with "a.b.c."
|
|
6233
|
-
expect(result).toEqual({
|
|
7358
|
+
expect(result).toStrictEqual({
|
|
6234
7359
|
kind: PlanKind.CONDITIONAL,
|
|
6235
|
-
filters: {
|
|
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 },
|
|
6236
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
|
+
);
|
|
6237
7379
|
});
|
|
6238
7380
|
});
|
|
6239
7381
|
|
|
6240
|
-
//
|
|
6241
|
-
// hierarchy
|
|
6242
|
-
describe("Hierarchy
|
|
6243
|
-
test("
|
|
6244
|
-
// #given hierarchy(
|
|
6245
|
-
//
|
|
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"
|
|
6246
7391
|
const plan = createConditionalPlan({
|
|
6247
|
-
operator: "
|
|
7392
|
+
operator: "overlaps",
|
|
6248
7393
|
operands: [
|
|
6249
7394
|
{
|
|
6250
7395
|
operator: "hierarchy",
|
|
6251
|
-
operands: [{
|
|
7396
|
+
operands: [{ value: "projects:123" }, { value: ":" }],
|
|
6252
7397
|
},
|
|
6253
7398
|
{
|
|
6254
7399
|
operator: "hierarchy",
|
|
6255
|
-
operands: [
|
|
7400
|
+
operands: [
|
|
7401
|
+
{
|
|
7402
|
+
operator: "list",
|
|
7403
|
+
operands: [
|
|
7404
|
+
{ value: "projects" },
|
|
7405
|
+
{ name: "request.resource.id" },
|
|
7406
|
+
],
|
|
7407
|
+
},
|
|
7408
|
+
],
|
|
6256
7409
|
},
|
|
6257
7410
|
],
|
|
6258
7411
|
});
|
|
6259
7412
|
|
|
6260
7413
|
// #when
|
|
6261
|
-
const result = queryPlanToPrisma({
|
|
7414
|
+
const result = queryPlanToPrisma({
|
|
7415
|
+
queryPlan: plan,
|
|
7416
|
+
mapper: {
|
|
7417
|
+
"request.resource.id": { field: "id" },
|
|
7418
|
+
},
|
|
7419
|
+
});
|
|
6262
7420
|
|
|
6263
|
-
// #then
|
|
7421
|
+
// #then
|
|
6264
7422
|
expect(result).toEqual({
|
|
6265
7423
|
kind: PlanKind.CONDITIONAL,
|
|
6266
|
-
filters: {
|
|
7424
|
+
filters: { id: { equals: "123" } },
|
|
6267
7425
|
});
|
|
6268
7426
|
});
|
|
6269
7427
|
|
|
6270
|
-
test("
|
|
6271
|
-
// #given hierarchy("
|
|
6272
|
-
// "
|
|
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
|
|
6273
7432
|
const plan = createConditionalPlan({
|
|
6274
|
-
operator: "
|
|
7433
|
+
operator: "overlaps",
|
|
6275
7434
|
operands: [
|
|
6276
7435
|
{
|
|
6277
7436
|
operator: "hierarchy",
|
|
6278
|
-
operands: [{ value: "
|
|
7437
|
+
operands: [{ value: "admin" }, { value: ":" }],
|
|
6279
7438
|
},
|
|
6280
7439
|
{
|
|
6281
7440
|
operator: "hierarchy",
|
|
6282
|
-
operands: [
|
|
7441
|
+
operands: [
|
|
7442
|
+
{
|
|
7443
|
+
operator: "list",
|
|
7444
|
+
operands: [
|
|
7445
|
+
{ value: "admin" },
|
|
7446
|
+
{ name: "request.resource.attr.scope" },
|
|
7447
|
+
],
|
|
7448
|
+
},
|
|
7449
|
+
],
|
|
6283
7450
|
},
|
|
6284
7451
|
],
|
|
6285
7452
|
});
|
|
6286
7453
|
|
|
6287
7454
|
// #when
|
|
6288
|
-
const result = queryPlanToPrisma({ queryPlan: plan, mapper: hierarchyMapper });
|
|
6289
|
-
|
|
6290
|
-
// #then - scope must be one of the strict prefixes of "a.b.c.d"
|
|
6291
|
-
expect(result).toEqual({
|
|
6292
|
-
kind: PlanKind.CONDITIONAL,
|
|
6293
|
-
filters: { scope: { in: ["a", "a.b", "a.b.c"] } },
|
|
6294
|
-
});
|
|
6295
|
-
});
|
|
6296
|
-
|
|
6297
|
-
test("descendentOf via cerbos integration", async () => {
|
|
6298
|
-
const plan = await cerbos.planResources({
|
|
6299
|
-
principal: {
|
|
6300
|
-
id: "user1",
|
|
6301
|
-
roles: ["USER"],
|
|
6302
|
-
attr: { scope: "a.b.c" },
|
|
6303
|
-
},
|
|
6304
|
-
resource: { kind: "resource" },
|
|
6305
|
-
action: "hierarchy-descendent-of",
|
|
6306
|
-
});
|
|
6307
|
-
|
|
6308
7455
|
const result = queryPlanToPrisma({
|
|
6309
7456
|
queryPlan: plan,
|
|
6310
7457
|
mapper: {
|
|
@@ -6312,11 +7459,1427 @@ describe("Hierarchy descendentOf", () => {
|
|
|
6312
7459
|
},
|
|
6313
7460
|
});
|
|
6314
7461
|
|
|
6315
|
-
//
|
|
6316
|
-
// R.attr.scope descends from "a.b.c" → R.attr.scope starts with "a.b.c."
|
|
7462
|
+
// #then always true
|
|
6317
7463
|
expect(result).toEqual({
|
|
6318
7464
|
kind: PlanKind.CONDITIONAL,
|
|
6319
|
-
filters: {
|
|
7465
|
+
filters: {},
|
|
6320
7466
|
});
|
|
6321
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
|
+
});
|
|
6322
8885
|
});
|