Skip to content

Commit eefa0b9

Browse files
Merge pull request #80 from yash-pouranik/feature/flexible-rls-modes
feat(rls): implement flexible RLS modes (public-read vs private)
2 parents f286c6f + befeb46 commit eefa0b9

15 files changed

Lines changed: 384 additions & 42 deletions

File tree

README.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -92,14 +92,15 @@ Understanding which key to use—and when—prevents the most common integration
9292

9393
## 🛡️ Row-Level Security (RLS)
9494

95-
RLS lets you safely allow frontend clients to write data without exposing your secret key. When enabled on a collection, `pk_live` writes are gated by user ownership.
95+
RLS lets you safely allow frontend clients to write data without exposing your secret key. When enabled on a collection, `pk_live` writes are gated by user ownership and reads can be scoped by mode.
9696

9797
**How it works:**
9898

99-
1. Enable RLS for a collection in the Dashboard (mode: `owner-write-only`).
100-
2. Choose the **owner field** — the document field that stores the authenticated user's ID (e.g., `userId`).
101-
3. The client must send a valid user JWT in the `Authorization: Bearer <token>` header.
102-
4. urBackend enforces that the JWT's `userId` matches the document's owner field.
99+
1. Enable RLS for a collection in the Dashboard and choose a mode.
100+
2. Use `public-read` for content anyone can view, or `private` for owner-only access. (`owner-write-only` is treated as `public-read` for legacy projects.)
101+
3. Choose the **owner field** — the document field that stores the authenticated user's ID (e.g., `userId`).
102+
4. The client must send a valid user JWT in the `Authorization: Bearer <token>` header for `pk_live` writes and for `private` reads.
103+
5. urBackend enforces that the JWT's `userId` matches the document's owner field.
103104

104105
**Example — user creates a post:**
105106

apps/dashboard-api/src/controllers/project.controller.js

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ const getDefaultRlsForCollection = (collectionName, schema = []) => {
120120

121121
return {
122122
enabled: false,
123-
mode: "owner-write-only",
123+
mode: "public-read",
124124
ownerField,
125125
requireAuthForWrite: true,
126126
};
@@ -1305,9 +1305,10 @@ module.exports.updateCollectionRls = async (req, res) => {
13051305
const collection = project.collections.find(c => c.name === collectionName);
13061306
if (!collection) return res.status(404).json({ error: "Collection not found" });
13071307

1308-
const validMode = mode || collection?.rls?.mode || 'owner-write-only';
1309-
if (validMode !== 'owner-write-only') {
1310-
return res.status(400).json({ error: "Unsupported RLS mode. Only 'owner-write-only' is allowed in V1." });
1308+
const validMode = mode || collection?.rls?.mode || 'public-read';
1309+
const allowedModes = new Set(['public-read', 'private', 'owner-write-only']);
1310+
if (!allowedModes.has(validMode)) {
1311+
return res.status(400).json({ error: "Unsupported RLS mode. Allowed: public-read, private, owner-write-only (legacy)." });
13111312
}
13121313

13131314
const modelKeys = (collection.model || [])
@@ -1363,4 +1364,4 @@ module.exports.updateCollectionRls = async (req, res) => {
13631364
} catch (err) {
13641365
res.status(500).json({ error: err.message });
13651366
}
1366-
}
1367+
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
'use strict';
2+
3+
const authorizeReadOperation = require('../middlewares/authorizeReadOperation');
4+
5+
// ---------------------------------------------------------------------------
6+
// Helpers
7+
// ---------------------------------------------------------------------------
8+
9+
function makeProject(rlsOverrides = {}) {
10+
return {
11+
_id: 'proj_1',
12+
collections: [
13+
{
14+
name: 'posts',
15+
rls: {
16+
enabled: true,
17+
mode: 'public-read',
18+
ownerField: 'userId',
19+
requireAuthForWrite: true,
20+
...rlsOverrides,
21+
},
22+
},
23+
],
24+
};
25+
}
26+
27+
function makeReq(overrides = {}) {
28+
return {
29+
keyRole: 'publishable',
30+
params: { collectionName: 'posts' },
31+
project: makeProject(),
32+
authUser: null,
33+
rlsFilter: undefined,
34+
...overrides,
35+
};
36+
}
37+
38+
function makeRes() {
39+
const res = {
40+
statusCode: null,
41+
body: null,
42+
status: jest.fn().mockReturnThis(),
43+
json: jest.fn().mockReturnThis(),
44+
};
45+
res.status.mockImplementation((code) => {
46+
res.statusCode = code;
47+
return res;
48+
});
49+
res.json.mockImplementation((data) => {
50+
res.body = data;
51+
return res;
52+
});
53+
return res;
54+
}
55+
56+
// ---------------------------------------------------------------------------
57+
// Tests
58+
// ---------------------------------------------------------------------------
59+
60+
describe('authorizeReadOperation middleware', () => {
61+
let next;
62+
63+
beforeEach(() => {
64+
next = jest.fn();
65+
});
66+
67+
test('secret key bypass sets empty filter', async () => {
68+
const req = makeReq({ keyRole: 'secret' });
69+
const res = makeRes();
70+
71+
await authorizeReadOperation(req, res, next);
72+
73+
expect(next).toHaveBeenCalledTimes(1);
74+
expect(req.rlsFilter).toEqual({});
75+
});
76+
77+
test('returns 404 when collection is not found', async () => {
78+
const req = makeReq({ params: { collectionName: 'unknown' } });
79+
const res = makeRes();
80+
81+
await authorizeReadOperation(req, res, next);
82+
83+
expect(res.statusCode).toBe(404);
84+
expect(res.body.error).toBe('Collection not found');
85+
expect(next).not.toHaveBeenCalled();
86+
});
87+
88+
test('rls disabled allows public read', async () => {
89+
const req = makeReq({ project: makeProject({ enabled: false }) });
90+
const res = makeRes();
91+
92+
await authorizeReadOperation(req, res, next);
93+
94+
expect(next).toHaveBeenCalledTimes(1);
95+
expect(req.rlsFilter).toEqual({});
96+
});
97+
98+
test('public-read allows read without auth', async () => {
99+
const req = makeReq({ authUser: null });
100+
const res = makeRes();
101+
102+
await authorizeReadOperation(req, res, next);
103+
104+
expect(next).toHaveBeenCalledTimes(1);
105+
expect(req.rlsFilter).toEqual({});
106+
});
107+
108+
test('owner-write-only is treated as public-read', async () => {
109+
const req = makeReq({ project: makeProject({ mode: 'owner-write-only' }) });
110+
const res = makeRes();
111+
112+
await authorizeReadOperation(req, res, next);
113+
114+
expect(next).toHaveBeenCalledTimes(1);
115+
expect(req.rlsFilter).toEqual({});
116+
});
117+
118+
test('private mode requires auth', async () => {
119+
const req = makeReq({ project: makeProject({ mode: 'private' }) });
120+
const res = makeRes();
121+
122+
await authorizeReadOperation(req, res, next);
123+
124+
expect(res.statusCode).toBe(401);
125+
expect(res.body.error).toBe('Authentication required');
126+
expect(next).not.toHaveBeenCalled();
127+
});
128+
129+
test('private mode sets owner filter when authed', async () => {
130+
const req = makeReq({
131+
project: makeProject({ mode: 'private', ownerField: 'userId' }),
132+
authUser: { userId: 'user_abc' },
133+
});
134+
const res = makeRes();
135+
136+
await authorizeReadOperation(req, res, next);
137+
138+
expect(next).toHaveBeenCalledTimes(1);
139+
expect(req.rlsFilter).toEqual({ userId: 'user_abc' });
140+
});
141+
});
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
'use strict';
2+
3+
const mockFind = jest.fn();
4+
const mockAnd = jest.fn();
5+
const mockFindOne = jest.fn();
6+
const mockQueryLean = jest.fn().mockResolvedValue([]);
7+
8+
// mockAnd returns an object with .lean() so features.query.lean() works after .and()
9+
mockAnd.mockReturnValue({ lean: mockQueryLean });
10+
11+
const mockQueryEngine = jest.fn((query) => {
12+
const engine = {
13+
query,
14+
filter() { return engine; },
15+
sort() { return engine; },
16+
paginate() { return engine; },
17+
};
18+
return engine;
19+
});
20+
21+
jest.mock('@urbackend/common', () => ({
22+
sanitize: (v) => v,
23+
Project: {},
24+
getConnection: jest.fn().mockResolvedValue({}),
25+
getCompiledModel: jest.fn(() => ({
26+
find: (...args) => {
27+
mockFind(...args);
28+
return { and: mockAnd, lean: mockQueryLean };
29+
},
30+
findOne: (...args) => {
31+
mockFindOne(...args);
32+
return { lean: jest.fn().mockResolvedValue({ _id: 'doc_1' }) };
33+
},
34+
})),
35+
QueryEngine: mockQueryEngine,
36+
validateData: jest.fn(),
37+
validateUpdateData: jest.fn(),
38+
}));
39+
40+
const { getAllData, getSingleDoc } = require('../controllers/data.controller');
41+
42+
function makeReq(overrides = {}) {
43+
return {
44+
params: { collectionName: 'posts', id: '507f1f77bcf86cd799439011' },
45+
project: {
46+
_id: 'proj_1',
47+
resources: { db: { isExternal: false } },
48+
collections: [{ name: 'posts', model: [] }],
49+
},
50+
query: {},
51+
rlsFilter: {},
52+
...overrides,
53+
};
54+
}
55+
56+
function makeRes() {
57+
const res = {
58+
statusCode: null,
59+
body: null,
60+
status: jest.fn().mockReturnThis(),
61+
json: jest.fn().mockReturnThis(),
62+
};
63+
res.status.mockImplementation((code) => {
64+
res.statusCode = code;
65+
return res;
66+
});
67+
res.json.mockImplementation((data) => {
68+
res.body = data;
69+
return res;
70+
});
71+
return res;
72+
}
73+
74+
describe('data.controller read RLS filters', () => {
75+
beforeEach(() => {
76+
jest.clearAllMocks();
77+
});
78+
79+
test('getAllData applies rlsFilter to find()', async () => {
80+
const req = makeReq({ rlsFilter: { userId: 'user_1' } });
81+
const res = makeRes();
82+
83+
await getAllData(req, res);
84+
85+
expect(mockFind).toHaveBeenCalledWith();
86+
expect(mockAnd).toHaveBeenCalledWith([{ userId: 'user_1' }]);
87+
expect(res.json).toHaveBeenCalled();
88+
});
89+
90+
test('getSingleDoc applies rlsFilter to findOne()', async () => {
91+
const req = makeReq({ rlsFilter: { userId: 'user_1' } });
92+
const res = makeRes();
93+
94+
await getSingleDoc(req, res);
95+
96+
expect(mockFindOne).toHaveBeenCalledWith({
97+
$and: [
98+
{ _id: '507f1f77bcf86cd799439011' },
99+
{ userId: 'user_1' },
100+
],
101+
});
102+
expect(res.json).toHaveBeenCalled();
103+
});
104+
});

apps/public-api/src/controllers/data.controller.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,15 @@ module.exports.getAllData = async (req, res) => {
102102
project.resources.db.isExternal,
103103
);
104104

105+
const baseFilter = req.rlsFilter && typeof req.rlsFilter === 'object' ? req.rlsFilter : {};
105106
const features = new QueryEngine(Model.find(), req.query)
106-
.filter()
107+
.filter();
108+
109+
if (Object.keys(baseFilter).length > 0) {
110+
features.query = features.query.and([baseFilter]);
111+
}
112+
113+
features
107114
.sort()
108115
.paginate();
109116

@@ -140,7 +147,8 @@ module.exports.getSingleDoc = async (req, res) => {
140147
project.resources.db.isExternal,
141148
);
142149

143-
const doc = await Model.findById(id).lean();
150+
const baseFilter = req.rlsFilter && typeof req.rlsFilter === 'object' ? req.rlsFilter : {};
151+
const doc = await Model.findOne({ $and: [{ _id: id }, baseFilter] }).lean();
144152
if (!doc) return res.status(404).json({ error: "Document not found." });
145153

146154
res.json(doc);
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
module.exports = async (req, res, next) => {
2+
try {
3+
if (req.keyRole === 'secret') {
4+
req.rlsFilter = {};
5+
return next();
6+
}
7+
8+
const { collectionName } = req.params;
9+
const project = req.project;
10+
const collectionConfig = project.collections.find(c => c.name === collectionName);
11+
12+
if (!collectionConfig) {
13+
return res.status(404).json({ error: 'Collection not found' });
14+
}
15+
16+
const rls = collectionConfig.rls || {};
17+
if (!rls.enabled) {
18+
req.rlsFilter = {};
19+
return next();
20+
}
21+
22+
const modeRaw = rls.mode || 'public-read';
23+
const mode = modeRaw === 'owner-write-only' ? 'public-read' : modeRaw;
24+
25+
if (mode === 'private') {
26+
if (!req.authUser?.userId) {
27+
return res.status(401).json({
28+
error: 'Authentication required',
29+
message: 'Provide a valid user Bearer token for private reads.'
30+
});
31+
}
32+
33+
const ownerField = rls.ownerField || 'userId';
34+
req.rlsFilter = { [ownerField]: req.authUser.userId };
35+
return next();
36+
}
37+
38+
if (mode === 'public-read') {
39+
req.rlsFilter = {};
40+
return next();
41+
}
42+
43+
return res.status(403).json({ error: 'Unsupported RLS mode' });
44+
} catch (err) {
45+
return res.status(500).json({ error: err.message });
46+
}
47+
};

apps/public-api/src/middlewares/authorizeWriteOperation.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ module.exports = async (req, res, next) => {
3131
});
3232
}
3333

34-
if ((rls.mode || 'owner-write-only') !== 'owner-write-only') {
34+
const modeRaw = rls.mode || 'public-read';
35+
const allowedModes = new Set(['public-read', 'private', 'owner-write-only']);
36+
if (!allowedModes.has(modeRaw)) {
3537
return res.status(403).json({ error: 'Unsupported RLS mode' });
3638
}
3739

0 commit comments

Comments
 (0)