@opensaas/stack-core 0.40.0 → 0.42.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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +65 -0
- package/CLAUDE.md +52 -0
- package/dist/access/access-filter.d.ts +88 -18
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +343 -36
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +759 -6
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/denied-relation-visibility.test.d.ts +2 -0
- package/dist/access/denied-relation-visibility.test.d.ts.map +1 -0
- package/dist/access/denied-relation-visibility.test.js +114 -0
- package/dist/access/denied-relation-visibility.test.js.map +1 -0
- package/dist/access/engine.d.ts +11 -0
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +26 -0
- package/dist/access/engine.js.map +1 -1
- package/dist/access/errors.d.ts +34 -0
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +47 -0
- package/dist/access/errors.js.map +1 -1
- package/dist/access/field-visibility.d.ts +2 -2
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +73 -23
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +7 -3
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +9 -2
- package/dist/access/index.js.map +1 -1
- package/dist/access/multi-column-read-write.test.js.map +1 -1
- package/dist/access/query-validation.d.ts +31 -0
- package/dist/access/query-validation.d.ts.map +1 -1
- package/dist/access/query-validation.js +75 -16
- package/dist/access/query-validation.js.map +1 -1
- package/dist/access/relationship-count.d.ts +41 -3
- package/dist/access/relationship-count.d.ts.map +1 -1
- package/dist/access/relationship-count.js +44 -7
- package/dist/access/relationship-count.js.map +1 -1
- package/dist/access/relationship-count.test.js +62 -0
- package/dist/access/relationship-count.test.js.map +1 -1
- package/dist/access/synthetic-include-read.test.d.ts +2 -0
- package/dist/access/synthetic-include-read.test.d.ts.map +1 -0
- package/dist/access/synthetic-include-read.test.js +121 -0
- package/dist/access/synthetic-include-read.test.js.map +1 -0
- package/dist/config/types.d.ts +70 -58
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/hook-pipeline.d.ts +2 -2
- package/dist/context/hook-pipeline.d.ts.map +1 -1
- package/dist/context/hook-pipeline.js.map +1 -1
- package/dist/context/index.d.ts +16 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +76 -26
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts +2 -2
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +21 -3
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +23 -29
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/hooks/index.d.ts +16 -15
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js.map +1 -1
- package/package.json +2 -2
- package/src/access/access-filter.test.ts +1403 -159
- package/src/access/access-filter.ts +525 -47
- package/src/access/denied-relation-visibility.test.ts +173 -0
- package/src/access/engine.ts +28 -0
- package/src/access/errors.ts +55 -0
- package/src/access/field-visibility.ts +105 -24
- package/src/access/index.ts +11 -0
- package/src/access/multi-column-read-write.test.ts +3 -2
- package/src/access/query-validation.ts +136 -9
- package/src/access/relationship-count.test.ts +75 -0
- package/src/access/relationship-count.ts +53 -10
- package/src/access/synthetic-include-read.test.ts +179 -0
- package/src/config/types.ts +76 -50
- package/src/context/hook-pipeline.ts +4 -2
- package/src/context/index.ts +120 -39
- package/src/context/nested-operations.ts +35 -11
- package/src/context/write-pipeline.ts +52 -32
- package/src/hooks/index.ts +22 -15
- package/tests/context.test.ts +193 -5
- package/tests/hook-context-secured.test.ts +424 -0
- package/tests/nested-access-and-hooks.test.ts +324 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1471,6 +1471,330 @@ describe('Nested Operations - Access Control and Hooks', () => {
|
|
|
1471
1471
|
}),
|
|
1472
1472
|
).rejects.toThrow('Access denied: Cannot update related item')
|
|
1473
1473
|
})
|
|
1474
|
+
|
|
1475
|
+
// #1081: a nested update/delete access rule returning a Prisma filter (the
|
|
1476
|
+
// common `gate ? filter : false` composition) must be re-checked against
|
|
1477
|
+
// the target row in the DATABASE, mirroring the top-level write pipeline's
|
|
1478
|
+
// `resolveExistingTarget` and the nested-connect reachability check (#578)
|
|
1479
|
+
// — not treated as an unconditional allow just because it isn't `false`.
|
|
1480
|
+
describe('Nested update/delete honour a filter-result access control (#1081)', () => {
|
|
1481
|
+
it('denies nested update (to-one) when the update access filter does not match the target row', async () => {
|
|
1482
|
+
const userResolveInput = vi.fn(async ({ resolvedData }) => resolvedData)
|
|
1483
|
+
|
|
1484
|
+
const testConfig = config({
|
|
1485
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
1486
|
+
lists: {
|
|
1487
|
+
User: list({
|
|
1488
|
+
fields: { name: text() },
|
|
1489
|
+
access: {
|
|
1490
|
+
operation: {
|
|
1491
|
+
query: () => true,
|
|
1492
|
+
update: ({ session }) => ({ id: { equals: session?.userId } }),
|
|
1493
|
+
},
|
|
1494
|
+
},
|
|
1495
|
+
hooks: { resolveInput: userResolveInput },
|
|
1496
|
+
}),
|
|
1497
|
+
Post: list({
|
|
1498
|
+
fields: {
|
|
1499
|
+
title: text(),
|
|
1500
|
+
author: relationship({ ref: 'User.posts' }),
|
|
1501
|
+
},
|
|
1502
|
+
access: {
|
|
1503
|
+
operation: { query: () => true, update: () => true },
|
|
1504
|
+
},
|
|
1505
|
+
}),
|
|
1506
|
+
},
|
|
1507
|
+
})
|
|
1508
|
+
|
|
1509
|
+
mockPrisma.post.findUnique.mockResolvedValue({ id: '1', title: 'Original Title' })
|
|
1510
|
+
// The target row exists ...
|
|
1511
|
+
mockPrisma.user.findUnique.mockResolvedValue({ id: '2', name: 'John Doe' })
|
|
1512
|
+
// ... but is NOT reachable under { id: { equals: '1' } } for this caller.
|
|
1513
|
+
mockPrisma.user.findFirst.mockResolvedValue(null)
|
|
1514
|
+
|
|
1515
|
+
const context = getContext(await testConfig, mockPrisma, { userId: '1' })
|
|
1516
|
+
|
|
1517
|
+
await expect(
|
|
1518
|
+
context.db.post.update({
|
|
1519
|
+
where: { id: '1' },
|
|
1520
|
+
data: {
|
|
1521
|
+
author: {
|
|
1522
|
+
update: { where: { id: '2' }, data: { name: 'Jane Doe' } },
|
|
1523
|
+
},
|
|
1524
|
+
},
|
|
1525
|
+
}),
|
|
1526
|
+
).rejects.toThrow('Access denied: Cannot update related item')
|
|
1527
|
+
|
|
1528
|
+
// Reachability re-checked in the DB, AND-combining the target `where`
|
|
1529
|
+
// with the returned filter — not an in-memory test.
|
|
1530
|
+
expect(mockPrisma.user.findFirst).toHaveBeenCalledWith({
|
|
1531
|
+
where: { AND: [{ id: { equals: '1' } }, { id: '2' }] },
|
|
1532
|
+
})
|
|
1533
|
+
expect(mockPrisma.post.update).not.toHaveBeenCalled()
|
|
1534
|
+
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
|
1535
|
+
// Denied before any of the target list's hooks fire.
|
|
1536
|
+
expect(userResolveInput).not.toHaveBeenCalled()
|
|
1537
|
+
})
|
|
1538
|
+
|
|
1539
|
+
it('allows nested update (to-one) when the update access filter matches the target row', async () => {
|
|
1540
|
+
const testConfig = config({
|
|
1541
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
1542
|
+
lists: {
|
|
1543
|
+
User: list({
|
|
1544
|
+
fields: { name: text() },
|
|
1545
|
+
access: {
|
|
1546
|
+
operation: {
|
|
1547
|
+
query: () => true,
|
|
1548
|
+
update: ({ session }) => ({ id: { equals: session?.userId } }),
|
|
1549
|
+
},
|
|
1550
|
+
},
|
|
1551
|
+
}),
|
|
1552
|
+
Post: list({
|
|
1553
|
+
fields: {
|
|
1554
|
+
title: text(),
|
|
1555
|
+
author: relationship({ ref: 'User.posts' }),
|
|
1556
|
+
},
|
|
1557
|
+
access: {
|
|
1558
|
+
operation: { query: () => true, update: () => true },
|
|
1559
|
+
},
|
|
1560
|
+
}),
|
|
1561
|
+
},
|
|
1562
|
+
})
|
|
1563
|
+
|
|
1564
|
+
mockPrisma.post.findUnique.mockResolvedValue({ id: '1', title: 'Original Title' })
|
|
1565
|
+
mockPrisma.user.findUnique.mockResolvedValue({ id: '1', name: 'John Doe' })
|
|
1566
|
+
// Reachable: the target IS the caller's own row.
|
|
1567
|
+
mockPrisma.user.findFirst.mockResolvedValue({ id: '1', name: 'John Doe' })
|
|
1568
|
+
mockPrisma.post.update.mockResolvedValue({
|
|
1569
|
+
id: '1',
|
|
1570
|
+
title: 'Original Title',
|
|
1571
|
+
authorId: '1',
|
|
1572
|
+
})
|
|
1573
|
+
|
|
1574
|
+
const context = getContext(await testConfig, mockPrisma, { userId: '1' })
|
|
1575
|
+
|
|
1576
|
+
const result = await context.db.post.update({
|
|
1577
|
+
where: { id: '1' },
|
|
1578
|
+
data: {
|
|
1579
|
+
author: {
|
|
1580
|
+
update: { where: { id: '1' }, data: { name: 'Jane Doe' } },
|
|
1581
|
+
},
|
|
1582
|
+
},
|
|
1583
|
+
})
|
|
1584
|
+
|
|
1585
|
+
expect(result).toBeDefined()
|
|
1586
|
+
// The re-check matched, so the nested write reaches Prisma's own
|
|
1587
|
+
// nested-update syntax on the PARENT's `update` call (Prisma persists
|
|
1588
|
+
// it, not a separate `user.update` call).
|
|
1589
|
+
expect(mockPrisma.post.update).toHaveBeenCalledWith(
|
|
1590
|
+
expect.objectContaining({
|
|
1591
|
+
data: expect.objectContaining({
|
|
1592
|
+
author: { update: { where: { id: '1' }, data: { name: 'Jane Doe' } } },
|
|
1593
|
+
}),
|
|
1594
|
+
}),
|
|
1595
|
+
)
|
|
1596
|
+
})
|
|
1597
|
+
|
|
1598
|
+
it('denies nested update (to-many array) when the update access filter does not match the target row', async () => {
|
|
1599
|
+
const testConfig = config({
|
|
1600
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
1601
|
+
lists: {
|
|
1602
|
+
Comment: list({
|
|
1603
|
+
fields: { body: text() },
|
|
1604
|
+
access: {
|
|
1605
|
+
operation: {
|
|
1606
|
+
query: () => true,
|
|
1607
|
+
update: ({ session }) => ({ authorId: { equals: session?.userId } }),
|
|
1608
|
+
},
|
|
1609
|
+
},
|
|
1610
|
+
}),
|
|
1611
|
+
Post: list({
|
|
1612
|
+
fields: {
|
|
1613
|
+
title: text(),
|
|
1614
|
+
comments: relationship({ ref: 'Comment', many: true }),
|
|
1615
|
+
},
|
|
1616
|
+
access: {
|
|
1617
|
+
operation: { query: () => true, update: () => true },
|
|
1618
|
+
},
|
|
1619
|
+
}),
|
|
1620
|
+
},
|
|
1621
|
+
})
|
|
1622
|
+
|
|
1623
|
+
mockPrisma.post.findUnique.mockResolvedValue({ id: '1', title: 'Original Title' })
|
|
1624
|
+
mockPrisma.comment.findUnique.mockResolvedValue({
|
|
1625
|
+
id: 'c1',
|
|
1626
|
+
body: 'hi',
|
|
1627
|
+
authorId: 'someone-else',
|
|
1628
|
+
})
|
|
1629
|
+
mockPrisma.comment.findFirst.mockResolvedValue(null)
|
|
1630
|
+
|
|
1631
|
+
const context = getContext(await testConfig, mockPrisma, { userId: '1' })
|
|
1632
|
+
|
|
1633
|
+
await expect(
|
|
1634
|
+
context.db.post.update({
|
|
1635
|
+
where: { id: '1' },
|
|
1636
|
+
data: {
|
|
1637
|
+
comments: {
|
|
1638
|
+
update: [{ where: { id: 'c1' }, data: { body: 'edited' } }],
|
|
1639
|
+
},
|
|
1640
|
+
},
|
|
1641
|
+
}),
|
|
1642
|
+
).rejects.toThrow('Access denied: Cannot update related item')
|
|
1643
|
+
|
|
1644
|
+
expect(mockPrisma.comment.findFirst).toHaveBeenCalledWith({
|
|
1645
|
+
where: { AND: [{ authorId: { equals: '1' } }, { id: 'c1' }] },
|
|
1646
|
+
})
|
|
1647
|
+
expect(mockPrisma.post.update).not.toHaveBeenCalled()
|
|
1648
|
+
})
|
|
1649
|
+
|
|
1650
|
+
it('denies nested delete when the delete access filter does not match the target row', async () => {
|
|
1651
|
+
const commentBeforeOperation = vi.fn(async () => {})
|
|
1652
|
+
|
|
1653
|
+
const testConfig = config({
|
|
1654
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
1655
|
+
lists: {
|
|
1656
|
+
Comment: list({
|
|
1657
|
+
fields: { body: text() },
|
|
1658
|
+
access: {
|
|
1659
|
+
operation: {
|
|
1660
|
+
query: () => true,
|
|
1661
|
+
delete: ({ session }) => ({ authorId: { equals: session?.userId } }),
|
|
1662
|
+
},
|
|
1663
|
+
},
|
|
1664
|
+
hooks: { beforeOperation: commentBeforeOperation },
|
|
1665
|
+
}),
|
|
1666
|
+
Post: list({
|
|
1667
|
+
fields: {
|
|
1668
|
+
title: text(),
|
|
1669
|
+
comments: relationship({ ref: 'Comment', many: true }),
|
|
1670
|
+
},
|
|
1671
|
+
access: {
|
|
1672
|
+
operation: { query: () => true, update: () => true },
|
|
1673
|
+
},
|
|
1674
|
+
}),
|
|
1675
|
+
},
|
|
1676
|
+
})
|
|
1677
|
+
|
|
1678
|
+
mockPrisma.post.findUnique.mockResolvedValue({ id: '1', title: 'Original Title' })
|
|
1679
|
+
mockPrisma.comment.findUnique.mockResolvedValue({
|
|
1680
|
+
id: 'c1',
|
|
1681
|
+
body: 'hi',
|
|
1682
|
+
authorId: 'someone-else',
|
|
1683
|
+
})
|
|
1684
|
+
mockPrisma.comment.findFirst.mockResolvedValue(null)
|
|
1685
|
+
|
|
1686
|
+
const context = getContext(await testConfig, mockPrisma, { userId: '1' })
|
|
1687
|
+
|
|
1688
|
+
await expect(
|
|
1689
|
+
context.db.post.update({
|
|
1690
|
+
where: { id: '1' },
|
|
1691
|
+
data: {
|
|
1692
|
+
comments: { delete: { id: 'c1' } },
|
|
1693
|
+
},
|
|
1694
|
+
}),
|
|
1695
|
+
).rejects.toThrow('Access denied: Cannot delete related item')
|
|
1696
|
+
|
|
1697
|
+
expect(mockPrisma.comment.findFirst).toHaveBeenCalledWith({
|
|
1698
|
+
where: { AND: [{ authorId: { equals: '1' } }, { id: 'c1' }] },
|
|
1699
|
+
})
|
|
1700
|
+
expect(mockPrisma.post.update).not.toHaveBeenCalled()
|
|
1701
|
+
expect(commentBeforeOperation).not.toHaveBeenCalled()
|
|
1702
|
+
})
|
|
1703
|
+
|
|
1704
|
+
it('allows nested delete when the delete access filter matches the target row', async () => {
|
|
1705
|
+
const testConfig = config({
|
|
1706
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
1707
|
+
lists: {
|
|
1708
|
+
Comment: list({
|
|
1709
|
+
fields: { body: text() },
|
|
1710
|
+
access: {
|
|
1711
|
+
operation: {
|
|
1712
|
+
query: () => true,
|
|
1713
|
+
delete: ({ session }) => ({ authorId: { equals: session?.userId } }),
|
|
1714
|
+
},
|
|
1715
|
+
},
|
|
1716
|
+
}),
|
|
1717
|
+
Post: list({
|
|
1718
|
+
fields: {
|
|
1719
|
+
title: text(),
|
|
1720
|
+
comments: relationship({ ref: 'Comment', many: true }),
|
|
1721
|
+
},
|
|
1722
|
+
access: {
|
|
1723
|
+
operation: { query: () => true, update: () => true },
|
|
1724
|
+
},
|
|
1725
|
+
}),
|
|
1726
|
+
},
|
|
1727
|
+
})
|
|
1728
|
+
|
|
1729
|
+
mockPrisma.post.findUnique.mockResolvedValue({ id: '1', title: 'Original Title' })
|
|
1730
|
+
mockPrisma.comment.findUnique.mockResolvedValue({ id: 'c1', body: 'hi', authorId: '1' })
|
|
1731
|
+
mockPrisma.comment.findFirst.mockResolvedValue({ id: 'c1', body: 'hi', authorId: '1' })
|
|
1732
|
+
mockPrisma.post.update.mockResolvedValue({ id: '1', title: 'Original Title' })
|
|
1733
|
+
|
|
1734
|
+
const context = getContext(await testConfig, mockPrisma, { userId: '1' })
|
|
1735
|
+
|
|
1736
|
+
const result = await context.db.post.update({
|
|
1737
|
+
where: { id: '1' },
|
|
1738
|
+
data: {
|
|
1739
|
+
comments: { delete: { id: 'c1' } },
|
|
1740
|
+
},
|
|
1741
|
+
})
|
|
1742
|
+
|
|
1743
|
+
expect(result).toBeDefined()
|
|
1744
|
+
expect(mockPrisma.post.update).toHaveBeenCalled()
|
|
1745
|
+
})
|
|
1746
|
+
|
|
1747
|
+
it('sudo bypasses the reachability re-check for both nested update and nested delete', async () => {
|
|
1748
|
+
const updateAccess = vi.fn(() => ({ id: { equals: 'nobody' } }))
|
|
1749
|
+
const deleteAccess = vi.fn(() => ({ id: { equals: 'nobody' } }))
|
|
1750
|
+
|
|
1751
|
+
const testConfig = config({
|
|
1752
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
1753
|
+
lists: {
|
|
1754
|
+
User: list({
|
|
1755
|
+
fields: { name: text() },
|
|
1756
|
+
access: { operation: { query: () => true, update: updateAccess } },
|
|
1757
|
+
}),
|
|
1758
|
+
Comment: list({
|
|
1759
|
+
fields: { body: text() },
|
|
1760
|
+
access: { operation: { query: () => true, delete: deleteAccess } },
|
|
1761
|
+
}),
|
|
1762
|
+
Post: list({
|
|
1763
|
+
fields: {
|
|
1764
|
+
title: text(),
|
|
1765
|
+
author: relationship({ ref: 'User.posts' }),
|
|
1766
|
+
comments: relationship({ ref: 'Comment', many: true }),
|
|
1767
|
+
},
|
|
1768
|
+
access: {
|
|
1769
|
+
operation: { query: () => true, update: () => true },
|
|
1770
|
+
},
|
|
1771
|
+
}),
|
|
1772
|
+
},
|
|
1773
|
+
})
|
|
1774
|
+
|
|
1775
|
+
mockPrisma.post.findUnique.mockResolvedValue({ id: '1', title: 'Original Title' })
|
|
1776
|
+
mockPrisma.user.findUnique.mockResolvedValue({ id: '2', name: 'John Doe' })
|
|
1777
|
+
mockPrisma.comment.findUnique.mockResolvedValue({ id: 'c1', body: 'hi' })
|
|
1778
|
+
mockPrisma.post.update.mockResolvedValue({ id: '1', title: 'Original Title' })
|
|
1779
|
+
|
|
1780
|
+
const context = getContext(await testConfig, mockPrisma, { userId: '1' }).sudo()
|
|
1781
|
+
|
|
1782
|
+
const result = await context.db.post.update({
|
|
1783
|
+
where: { id: '1' },
|
|
1784
|
+
data: {
|
|
1785
|
+
author: { update: { where: { id: '2' }, data: { name: 'Jane Doe' } } },
|
|
1786
|
+
comments: { delete: { id: 'c1' } },
|
|
1787
|
+
},
|
|
1788
|
+
})
|
|
1789
|
+
|
|
1790
|
+
expect(result).toBeDefined()
|
|
1791
|
+
expect(mockPrisma.post.update).toHaveBeenCalled()
|
|
1792
|
+
expect(updateAccess).not.toHaveBeenCalled()
|
|
1793
|
+
expect(deleteAccess).not.toHaveBeenCalled()
|
|
1794
|
+
expect(mockPrisma.user.findFirst).not.toHaveBeenCalled()
|
|
1795
|
+
expect(mockPrisma.comment.findFirst).not.toHaveBeenCalled()
|
|
1796
|
+
})
|
|
1797
|
+
})
|
|
1474
1798
|
})
|
|
1475
1799
|
|
|
1476
1800
|
// #588 — nested connect must ALSO be gated by the OWNING relationship field's
|