Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,14 +92,15 @@ Understanding which key to use—and when—prevents the most common integration

## 🛡️ Row-Level Security (RLS)

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.
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.

**How it works:**

1. Enable RLS for a collection in the Dashboard (mode: `owner-write-only`).
2. Choose the **owner field** — the document field that stores the authenticated user's ID (e.g., `userId`).
3. The client must send a valid user JWT in the `Authorization: Bearer <token>` header.
4. urBackend enforces that the JWT's `userId` matches the document's owner field.
1. Enable RLS for a collection in the Dashboard and choose a mode.
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.)
3. Choose the **owner field** — the document field that stores the authenticated user's ID (e.g., `userId`).
4. The client must send a valid user JWT in the `Authorization: Bearer <token>` header for `pk_live` writes and for `private` reads.
5. urBackend enforces that the JWT's `userId` matches the document's owner field.
Comment on lines +99 to +103
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

The README still describes all pk_live reads as unauthenticated.

These new steps correctly introduce private read auth, but the “Key Behavior” table above still says Read any collection with pk_live needs no token. Please split that row into public-read and private so the entrypoint docs stay consistent.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` around lines 99 - 103, The "Read any collection" row in the Key
Behavior table must be split into two rows to match the README steps: create one
row for public-read indicating pk_live reads require no Authorization header
(public access), and a separate row for private indicating pk_live reads require
a valid JWT in the Authorization: Bearer <token> header and that urBackend
enforces the JWT's userId matches the document's owner field (owner field like
`userId`); also ensure the note about `owner-write-only` being treated as
`public-read` remains consistent and update any entrypoint/docs text that still
states pk_live reads are unauthenticated for all modes.


**Example — user creates a post:**

Expand Down
11 changes: 6 additions & 5 deletions apps/dashboard-api/src/controllers/project.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ const getDefaultRlsForCollection = (collectionName, schema = []) => {

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

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

const modelKeys = (collection.model || [])
Expand Down Expand Up @@ -1363,4 +1364,4 @@ module.exports.updateCollectionRls = async (req, res) => {
} catch (err) {
res.status(500).json({ error: err.message });
}
}
}
141 changes: 141 additions & 0 deletions apps/public-api/src/__tests__/authorizeReadOperation.test.js
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' });
});
});
104 changes: 104 additions & 0 deletions apps/public-api/src/__tests__/data.controller.read.test.js
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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Preserve both the route id and the RLS constraint in detail reads.

This test currently locks in the findOne({ _id: id, ...req.rlsFilter }) shape. When ownerField === '_id', that merge rewrites the requested id, so /.../:otherId can return the caller's own document instead of 404. Please fix the controller query to keep both predicates and update this assertion to cover the _id case.

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
Verify each finding against the current code and only fix it if needed.

In `@apps/public-api/src/__tests__/data.controller.read.test.js` around lines 82 -
91, The controller's getSingleDoc currently merges req.rlsFilter into the
findOne query which can overwrite the route _id when ownerField === '_id';
change the query construction in getSingleDoc to preserve both predicates by
using an $and clause when base/RLS filter exists (e.g., if baseFilter non-empty
use { $and: [{ _id: id }, baseFilter ] } else { _id: id }), and update the test
to expect mockFindOne to have been called with that $and-shaped query instead of
a merged object; refer to getSingleDoc and any baseFilter/rlsFilter/ownerField
variables to locate and modify the logic and the assertion.

expect(res.json).toHaveBeenCalled();
});
});
12 changes: 10 additions & 2 deletions apps/public-api/src/controllers/data.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,15 @@ module.exports.getAllData = async (req, res) => {
project.resources.db.isExternal,
);

const baseFilter = req.rlsFilter && typeof req.rlsFilter === 'object' ? req.rlsFilter : {};
const features = new QueryEngine(Model.find(), req.query)
.filter()
.filter();

if (Object.keys(baseFilter).length > 0) {
features.query = features.query.and([baseFilter]);
}

features
.sort()
.paginate();

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

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

res.json(doc);
Expand Down
47 changes: 47 additions & 0 deletions apps/public-api/src/middlewares/authorizeReadOperation.js
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 });
}
};
4 changes: 3 additions & 1 deletion apps/public-api/src/middlewares/authorizeWriteOperation.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ module.exports = async (req, res, next) => {
});
}

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

Expand Down
Loading
Loading