-
Notifications
You must be signed in to change notification settings - Fork 24
feat(rls): implement flexible RLS modes (public-read vs private) #80
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,141 @@ | ||
| 'use strict'; | ||
|
|
||
| const authorizeReadOperation = require('../middlewares/authorizeReadOperation'); | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Helpers | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| function makeProject(rlsOverrides = {}) { | ||
| return { | ||
| _id: 'proj_1', | ||
| collections: [ | ||
| { | ||
| name: 'posts', | ||
| rls: { | ||
| enabled: true, | ||
| mode: 'public-read', | ||
| ownerField: 'userId', | ||
| requireAuthForWrite: true, | ||
| ...rlsOverrides, | ||
| }, | ||
| }, | ||
| ], | ||
| }; | ||
| } | ||
|
|
||
| function makeReq(overrides = {}) { | ||
| return { | ||
| keyRole: 'publishable', | ||
| params: { collectionName: 'posts' }, | ||
| project: makeProject(), | ||
| authUser: null, | ||
| rlsFilter: undefined, | ||
| ...overrides, | ||
| }; | ||
| } | ||
|
|
||
| function makeRes() { | ||
| const res = { | ||
| statusCode: null, | ||
| body: null, | ||
| status: jest.fn().mockReturnThis(), | ||
| json: jest.fn().mockReturnThis(), | ||
| }; | ||
| res.status.mockImplementation((code) => { | ||
| res.statusCode = code; | ||
| return res; | ||
| }); | ||
| res.json.mockImplementation((data) => { | ||
| res.body = data; | ||
| return res; | ||
| }); | ||
| return res; | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Tests | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| describe('authorizeReadOperation middleware', () => { | ||
| let next; | ||
|
|
||
| beforeEach(() => { | ||
| next = jest.fn(); | ||
| }); | ||
|
|
||
| test('secret key bypass sets empty filter', async () => { | ||
| const req = makeReq({ keyRole: 'secret' }); | ||
| const res = makeRes(); | ||
|
|
||
| await authorizeReadOperation(req, res, next); | ||
|
|
||
| expect(next).toHaveBeenCalledTimes(1); | ||
| expect(req.rlsFilter).toEqual({}); | ||
| }); | ||
|
|
||
| test('returns 404 when collection is not found', async () => { | ||
| const req = makeReq({ params: { collectionName: 'unknown' } }); | ||
| const res = makeRes(); | ||
|
|
||
| await authorizeReadOperation(req, res, next); | ||
|
|
||
| expect(res.statusCode).toBe(404); | ||
| expect(res.body.error).toBe('Collection not found'); | ||
| expect(next).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| test('rls disabled allows public read', async () => { | ||
| const req = makeReq({ project: makeProject({ enabled: false }) }); | ||
| const res = makeRes(); | ||
|
|
||
| await authorizeReadOperation(req, res, next); | ||
|
|
||
| expect(next).toHaveBeenCalledTimes(1); | ||
| expect(req.rlsFilter).toEqual({}); | ||
| }); | ||
|
|
||
| test('public-read allows read without auth', async () => { | ||
| const req = makeReq({ authUser: null }); | ||
| const res = makeRes(); | ||
|
|
||
| await authorizeReadOperation(req, res, next); | ||
|
|
||
| expect(next).toHaveBeenCalledTimes(1); | ||
| expect(req.rlsFilter).toEqual({}); | ||
| }); | ||
|
|
||
| test('owner-write-only is treated as public-read', async () => { | ||
| const req = makeReq({ project: makeProject({ mode: 'owner-write-only' }) }); | ||
| const res = makeRes(); | ||
|
|
||
| await authorizeReadOperation(req, res, next); | ||
|
|
||
| expect(next).toHaveBeenCalledTimes(1); | ||
| expect(req.rlsFilter).toEqual({}); | ||
| }); | ||
|
|
||
| test('private mode requires auth', async () => { | ||
| const req = makeReq({ project: makeProject({ mode: 'private' }) }); | ||
| const res = makeRes(); | ||
|
|
||
| await authorizeReadOperation(req, res, next); | ||
|
|
||
| expect(res.statusCode).toBe(401); | ||
| expect(res.body.error).toBe('Authentication required'); | ||
| expect(next).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| test('private mode sets owner filter when authed', async () => { | ||
| const req = makeReq({ | ||
| project: makeProject({ mode: 'private', ownerField: 'userId' }), | ||
| authUser: { userId: 'user_abc' }, | ||
| }); | ||
| const res = makeRes(); | ||
|
|
||
| await authorizeReadOperation(req, res, next); | ||
|
|
||
| expect(next).toHaveBeenCalledTimes(1); | ||
| expect(req.rlsFilter).toEqual({ userId: 'user_abc' }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| 'use strict'; | ||
|
|
||
| const mockFind = jest.fn(); | ||
| const mockAnd = jest.fn(); | ||
| const mockFindOne = jest.fn(); | ||
| const mockQueryLean = jest.fn().mockResolvedValue([]); | ||
|
|
||
| // mockAnd returns an object with .lean() so features.query.lean() works after .and() | ||
| mockAnd.mockReturnValue({ lean: mockQueryLean }); | ||
|
|
||
| const mockQueryEngine = jest.fn((query) => { | ||
| const engine = { | ||
| query, | ||
| filter() { return engine; }, | ||
| sort() { return engine; }, | ||
| paginate() { return engine; }, | ||
| }; | ||
| return engine; | ||
| }); | ||
|
|
||
| jest.mock('@urbackend/common', () => ({ | ||
| sanitize: (v) => v, | ||
| Project: {}, | ||
| getConnection: jest.fn().mockResolvedValue({}), | ||
| getCompiledModel: jest.fn(() => ({ | ||
| find: (...args) => { | ||
| mockFind(...args); | ||
| return { and: mockAnd, lean: mockQueryLean }; | ||
| }, | ||
| findOne: (...args) => { | ||
| mockFindOne(...args); | ||
| return { lean: jest.fn().mockResolvedValue({ _id: 'doc_1' }) }; | ||
| }, | ||
| })), | ||
| QueryEngine: mockQueryEngine, | ||
| validateData: jest.fn(), | ||
| validateUpdateData: jest.fn(), | ||
| })); | ||
|
|
||
| const { getAllData, getSingleDoc } = require('../controllers/data.controller'); | ||
|
|
||
| function makeReq(overrides = {}) { | ||
| return { | ||
| params: { collectionName: 'posts', id: '507f1f77bcf86cd799439011' }, | ||
| project: { | ||
| _id: 'proj_1', | ||
| resources: { db: { isExternal: false } }, | ||
| collections: [{ name: 'posts', model: [] }], | ||
| }, | ||
| query: {}, | ||
| rlsFilter: {}, | ||
| ...overrides, | ||
| }; | ||
| } | ||
|
|
||
| function makeRes() { | ||
| const res = { | ||
| statusCode: null, | ||
| body: null, | ||
| status: jest.fn().mockReturnThis(), | ||
| json: jest.fn().mockReturnThis(), | ||
| }; | ||
| res.status.mockImplementation((code) => { | ||
| res.statusCode = code; | ||
| return res; | ||
| }); | ||
| res.json.mockImplementation((data) => { | ||
| res.body = data; | ||
| return res; | ||
| }); | ||
| return res; | ||
| } | ||
|
|
||
| describe('data.controller read RLS filters', () => { | ||
| beforeEach(() => { | ||
| jest.clearAllMocks(); | ||
| }); | ||
|
|
||
| test('getAllData applies rlsFilter to find()', async () => { | ||
| const req = makeReq({ rlsFilter: { userId: 'user_1' } }); | ||
| const res = makeRes(); | ||
|
|
||
| await getAllData(req, res); | ||
|
|
||
| expect(mockFind).toHaveBeenCalledWith(); | ||
| expect(mockAnd).toHaveBeenCalledWith([{ userId: 'user_1' }]); | ||
| expect(res.json).toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| test('getSingleDoc applies rlsFilter to findOne()', async () => { | ||
| const req = makeReq({ rlsFilter: { userId: 'user_1' } }); | ||
| const res = makeRes(); | ||
|
|
||
| await getSingleDoc(req, res); | ||
|
|
||
| expect(mockFindOne).toHaveBeenCalledWith({ | ||
| $and: [ | ||
| { _id: '507f1f77bcf86cd799439011' }, | ||
| { userId: 'user_1' }, | ||
| ], | ||
| }); | ||
|
Comment on lines
+90
to
+101
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Preserve both the route id and the RLS constraint in detail reads. This test currently locks in the Possible update once the controller preserves both predicates- expect(mockFindOne).toHaveBeenCalledWith({
- _id: '507f1f77bcf86cd799439011',
- userId: 'user_1',
- });
+ expect(mockFindOne).toHaveBeenCalledWith({
+ $and: [
+ { _id: '507f1f77bcf86cd799439011' },
+ { userId: 'user_1' },
+ ],
+ });const query = Object.keys(baseFilter).length
? { $and: [{ _id: id }, baseFilter] }
: { _id: id };🤖 Prompt for AI Agents |
||
| expect(res.json).toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| module.exports = async (req, res, next) => { | ||
| try { | ||
| if (req.keyRole === 'secret') { | ||
| req.rlsFilter = {}; | ||
| return next(); | ||
| } | ||
|
|
||
| const { collectionName } = req.params; | ||
| const project = req.project; | ||
| const collectionConfig = project.collections.find(c => c.name === collectionName); | ||
|
|
||
| if (!collectionConfig) { | ||
| return res.status(404).json({ error: 'Collection not found' }); | ||
| } | ||
|
|
||
| const rls = collectionConfig.rls || {}; | ||
| if (!rls.enabled) { | ||
| req.rlsFilter = {}; | ||
| return next(); | ||
| } | ||
|
|
||
| const modeRaw = rls.mode || 'public-read'; | ||
| const mode = modeRaw === 'owner-write-only' ? 'public-read' : modeRaw; | ||
|
|
||
| if (mode === 'private') { | ||
| if (!req.authUser?.userId) { | ||
| return res.status(401).json({ | ||
| error: 'Authentication required', | ||
| message: 'Provide a valid user Bearer token for private reads.' | ||
| }); | ||
| } | ||
|
|
||
| const ownerField = rls.ownerField || 'userId'; | ||
| req.rlsFilter = { [ownerField]: req.authUser.userId }; | ||
| return next(); | ||
| } | ||
|
|
||
| if (mode === 'public-read') { | ||
| req.rlsFilter = {}; | ||
| return next(); | ||
| } | ||
|
|
||
| return res.status(403).json({ error: 'Unsupported RLS mode' }); | ||
| } catch (err) { | ||
| return res.status(500).json({ error: err.message }); | ||
| } | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The README still describes all
pk_livereads as unauthenticated.These new steps correctly introduce
privateread auth, but the “Key Behavior” table above still saysRead any collectionwithpk_liveneeds no token. Please split that row intopublic-readandprivateso the entrypoint docs stay consistent.🤖 Prompt for AI Agents