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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ const res = await fetch('https://api.urbackend.bitbros.in/api/data/products', {
});
```

By default, publishable keys are read-focused. Write operations require a secret key unless you enable collection-level RLS (owner-write-only), in which case publishable writes also require `Authorization: Bearer <user_jwt>`.

---

## 🏗️ How it Works (The Visual Flow)
Expand Down
117 changes: 111 additions & 6 deletions apps/dashboard-api/src/controllers/project.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,27 @@ const validateUsersSchema = (schema) => {
return !!(hasEmail && hasPassword);
};

const getDefaultRlsForCollection = (collectionName, schema = []) => {
const normalizedName = String(collectionName || '').toLowerCase();
const keys = Array.isArray(schema) ? schema.map(f => f?.key).filter(Boolean) : [];

let ownerField = 'userId';
if (normalizedName === 'users') {
ownerField = '_id';
} else if (keys.includes('userId')) {
ownerField = 'userId';
Comment on lines +35 to +36

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.

medium

This else if block is redundant because ownerField is already initialized to 'userId'. You can safely remove these lines to simplify the code and improve readability.

} else if (keys.includes('ownerId')) {
ownerField = 'ownerId';
}

return {
enabled: false,
mode: 'owner-write-only',
ownerField,
requireAuthForWrite: true
};
};




Expand Down Expand Up @@ -124,10 +145,19 @@ module.exports.getSingleProject = async (req, res) => {
if (col.name === 'users' && col.model) {
return {
...col,
model: col.model.filter(m => m.key !== 'password')
model: col.model.filter(m => m.key !== 'password'),
rls: col.rls || {
enabled: false,
mode: 'owner-write-only',
ownerField: '_id',
requireAuthForWrite: true
}
};
}
return col;
return {
...col,
rls: col.rls || getDefaultRlsForCollection(col.name, col.model)
};
Comment on lines 145 to +160

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.

medium

There's significant code duplication here for setting default RLS configurations. This logic is also slightly different from the getDefaultRlsForCollection helper function you've added, which could lead to inconsistencies.

You can refactor this to remove duplication and reuse the helper function, making the code more maintainable.

                const sanitizedCol = { ...col };
                if (sanitizedCol.name === 'users' && sanitizedCol.model) {
                    sanitizedCol.model = sanitizedCol.model.filter(m => m.key !== 'password');
                }
                sanitizedCol.rls = sanitizedCol.rls || getDefaultRlsForCollection(sanitizedCol.name, sanitizedCol.model);
                return sanitizedCol;

});
}

Expand Down Expand Up @@ -324,7 +354,11 @@ module.exports.createCollection = async (req, res) => {
}
}

project.collections.push({ name: collectionName, model: schema });
project.collections.push({
name: collectionName,
model: schema,
rls: getDefaultRlsForCollection(collectionName, schema)
});
await project.save();

await deleteProjectById(projectId);
Expand Down Expand Up @@ -930,10 +964,19 @@ module.exports.toggleAuth = async (req, res) => {
if (col.name === 'users' && col.model) {
return {
...col,
model: col.model.filter(m => m.key !== 'password')
model: col.model.filter(m => m.key !== 'password'),
rls: col.rls || {
enabled: false,
mode: 'owner-write-only',
ownerField: '_id',
requireAuthForWrite: true
}
};
}
return col;
return {
...col,
rls: col.rls || getDefaultRlsForCollection(col.name, col.model)
};
Comment on lines 964 to +979

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.

medium

Similar to getSingleProject, there's duplicated code here for setting default RLS configurations. Reusing the getDefaultRlsForCollection helper function would make this code more maintainable and consistent with other parts of the controller.

                const sanitizedCol = { ...col };
                if (sanitizedCol.name === 'users' && sanitizedCol.model) {
                    sanitizedCol.model = sanitizedCol.model.filter(m => m.key !== 'password');
                }
                sanitizedCol.rls = sanitizedCol.rls || getDefaultRlsForCollection(sanitizedCol.name, sanitizedCol.model);
                return sanitizedCol;

});
}

Expand All @@ -945,4 +988,66 @@ module.exports.toggleAuth = async (req, res) => {
} catch (err) {
res.status(500).json({ error: err.message });
}
}
}

// PATCH REQ - UPDATE COLLECTION RLS (V1)
module.exports.updateCollectionRls = async (req, res) => {
try {
const { projectId, collectionName } = req.params;
const { enabled, mode, ownerField, requireAuthForWrite } = req.body || {};

const project = await Project.findOne({ _id: projectId, owner: req.user._id });
if (!project) return res.status(404).json({ error: "Project not found" });

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 modelKeys = (collection.model || []).map(f => f.key);
const nextOwnerField = ownerField || collection?.rls?.ownerField || 'userId';

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

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

updateCollectionRls allows ownerField: '_id' for any collection (it bypasses the schema-key check). For non-users collections, selecting _id will effectively block inserts (public-api middleware explicitly denies POST when ownerField === '_id') and likely make updates/deletes impossible unless document _id equals the user id. Consider restricting _id to the users collection (or returning a clear 400 explaining why _id isn’t a valid ownerField for general collections).

Suggested change
// Restrict use of '_id' as ownerField to the 'users' collection only.
if (nextOwnerField === '_id' && collection.name !== 'users') {
return res.status(400).json({
error: "Invalid owner field",
message: "ownerField '_id' is only allowed for the 'users' collection"
});
}

Copilot uses AI. Check for mistakes.
if (nextOwnerField !== '_id' && !modelKeys.includes(nextOwnerField)) {
return res.status(400).json({
error: "Invalid owner field",
message: `ownerField '${nextOwnerField}' not found in collection schema`
});
}

// Restrict use of '_id' as ownerField to the 'users' collection only.
if (nextOwnerField === '_id' && collection.name !== 'users') {
return res.status(400).json({
error: "Invalid owner field",
message: "ownerField '_id' is only allowed for the 'users' collection"
});
}

collection.rls = {
enabled: typeof enabled === 'boolean' ? enabled : !!collection?.rls?.enabled,
mode: validMode,
ownerField: nextOwnerField,
requireAuthForWrite: typeof requireAuthForWrite === 'boolean'
? requireAuthForWrite
: (collection?.rls?.requireAuthForWrite ?? true)
};

await project.save();

await deleteProjectById(projectId);
await deleteProjectByApiKeyCache(project.publishableKey);
await deleteProjectByApiKeyCache(project.secretKey);

res.json({
message: "Collection RLS updated",
collection: {
name: collection.name,
rls: collection.rls
}
});
} catch (err) {
res.status(500).json({ error: err.message });
}
}
79 changes: 61 additions & 18 deletions apps/dashboard-api/src/controllers/userAuth.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,49 @@ const { loginSchema, signupSchema, userSignupSchema, resetPasswordSchema, onlyEm
const { getConnection } = require('@urbackend/common');
const { getCompiledModel } = require('@urbackend/common');

const hasRequiredField = (usersColConfig, fieldKey) => {
const model = usersColConfig?.model || [];
return model.some((f) => f?.key === fieldKey && !!f?.required);
};

const getVerificationField = (usersColConfig) => {
const modelKeys = (usersColConfig?.model || []).map((f) => f?.key);
if (modelKeys.includes('emailVerified')) return 'emailVerified';
if (modelKeys.includes('isVerified')) return 'isVerified';
if (modelKeys.includes('isverified')) return 'isverified';
return null;
};

const buildAuthUserPayload = (usersColConfig, parsedData, hashedPassword, verifiedValue) => {
const { email, password: _password, username, ...otherData } = parsedData;

const payload = {
email,
password: hashedPassword,
...otherData,
createdAt: new Date()
};

if (username !== undefined) {
payload.username = username;
}

const verificationField = getVerificationField(usersColConfig);
if (verificationField !== null) {
payload[verificationField] = verifiedValue;
}

if (hasRequiredField(usersColConfig, 'name') && (payload.name === undefined || payload.name === null || payload.name === '')) {
payload.name = username || email.split('@')[0];
}

if (hasRequiredField(usersColConfig, 'username') && (payload.username === undefined || payload.username === null || payload.username === '')) {
payload.username = payload.name || email.split('@')[0];
}

return payload;
};


// POST REQ FOR SIGNUP
module.exports.signup = async (req, res) => {
Expand All @@ -34,14 +77,12 @@ module.exports.signup = async (req, res) => {

const otp = Math.floor(100000 + Math.random() * 900000).toString();

const newUserPayload = {
username,
email,
password: hashedPassword,
emailVerified: false,
...otherData,
createdAt: new Date()
};
const newUserPayload = buildAuthUserPayload(
usersColConfig,
{ email, password, username, ...otherData },
hashedPassword,
false
);

// Model.create handles validation and default values
const result = await Model.create(newUserPayload);
Expand Down Expand Up @@ -167,14 +208,12 @@ module.exports.createAdminUser = async (req, res) => {
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(password, salt);

const newUserPayload = {
username,
email,
password: hashedPassword,
emailVerified: true,
...otherData,
createdAt: new Date()
};
const newUserPayload = buildAuthUserPayload(
usersColConfig,
{ email, password, username, ...otherData },
hashedPassword,
true
);

const result = await Model.create(newUserPayload);

Expand Down Expand Up @@ -248,9 +287,13 @@ module.exports.verifyEmail = async (req, res) => {
const connection = await getConnection(project._id);
const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal);

const verificationField = getVerificationField(usersColConfig);
if (!verificationField) {
return res.status(500).json({ error: "No verification field found in users schema" });
}
const result = await Model.updateOne(
{ email },
{ $set: { emailVerified: true } }
{ $set: { [verificationField]: true } }
);

if (result.matchedCount === 0) return res.status(404).json({ error: "User not found" });
Expand Down Expand Up @@ -465,4 +508,4 @@ module.exports.updateAdminUser = async (req, res) => {
} catch (err) {
res.status(500).json({ error: err.message });
}
};
};
8 changes: 6 additions & 2 deletions apps/dashboard-api/src/routes/projects.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ const {
deleteExternalStorageConfig,
analytics,
updateAllowedDomains,
toggleAuth
toggleAuth,
updateCollectionRls
} = require("../controllers/project.controller")

const { createAdminUser, resetPassword, getUserDetails, updateAdminUser } = require('../controllers/userAuth.controller');
Expand Down Expand Up @@ -101,6 +102,9 @@ router.get('/:projectId/analytics', authMiddleware, analytics);
// PATCH REQ FOR TOGGLE AUTH
router.patch('/:projectId/auth/toggle', authMiddleware, verifyEmail, toggleAuth);

// PATCH REQ FOR COLLECTION RLS SETTINGS
router.patch('/:projectId/collections/:collectionName/rls', authMiddleware, verifyEmail, updateCollectionRls);

// ADMIN AUTH ROUTES
const {checkAuthEnabled} = require('@urbackend/common');
const {loadProjectForAdmin} = require('@urbackend/common');
Expand All @@ -110,4 +114,4 @@ router.patch('/:projectId/admin/users/:userId/password', authMiddleware, loadPro
router.get('/:projectId/admin/users/:userId', authMiddleware, loadProjectForAdmin, checkAuthEnabled, getUserDetails);
router.put('/:projectId/admin/users/:userId', authMiddleware, loadProjectForAdmin, checkAuthEnabled, updateAdminUser);

module.exports = router;
module.exports = router;
Loading