Skip to content
21 changes: 21 additions & 0 deletions MIGRATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,27 @@ Breaking changes and upgrade notes for downstream projects.

---

## Decentralized Policy Subject Resolution (2026-04-03)

Subject resolution in `lib/middlewares/policy.js` is now registry-based instead of hardcoded. Each module's policy file exports a `*SubjectRegistration()` function that registers its own document-level and path-level subjects during `discoverPolicies()`.

### What changed

- `resolveSubject()` iterates `documentSubjectRegistry` instead of hardcoded if/else chain
- `deriveSubjectType()` iterates `pathSubjectRegistry` instead of hardcoded if/else chain
- New exports: `registerDocumentSubject`, `registerPathSubject`
- New helper: `lib/helpers/authorize.js` — simple middleware for route-level CASL checks
- Each module policy file now exports a `*SubjectRegistration({ registerDocumentSubject, registerPathSubject })` function

### Action for downstream

1. Run `/update-stack` to pull the change
2. If you have custom modules with policy files, add a `*SubjectRegistration()` export following the pattern in any existing module (e.g. `modules/tasks/policies/tasks.policy.js`)
3. `policy.isAllowed` continues to work unchanged — no route file modifications needed
4. Optional: use `authorize(action, subject)` from `lib/helpers/authorize.js` for simple route guards
Comment thread
PierreBrisorgueil marked this conversation as resolved.

---

## Audit GDPR Flags (2026-03-26)

New config flags to control IP and User-Agent capture in audit logs for GDPR compliance.
Expand Down
14 changes: 14 additions & 0 deletions lib/helpers/authorize.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Simple middleware helper for route-level CASL authorization.
* Usage: router.get('/tasks', authorize('read', 'Task'), controller.list);
Comment thread
PierreBrisorgueil marked this conversation as resolved.
Outdated
*
* @param {string} action - CASL action (read, create, update, delete)
* @param {string} subject - CASL subject type string
* @returns {Function} Express middleware
*/
const authorize = (action, subject) => (req, res, next) => {
if (req.ability && req.ability.can(action, subject)) return next();
return res.status(403).json({ type: 'error', message: 'Forbidden' });
Comment thread
PierreBrisorgueil marked this conversation as resolved.
Outdated
};
Comment thread
PierreBrisorgueil marked this conversation as resolved.

export default authorize;
112 changes: 61 additions & 51 deletions lib/middlewares/policy.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,46 @@ const methodToAction = {
*/
const abilitiesRegistry = [];

/**
* Registry of document-level subject resolvers.
* Each entry maps a req property to a CASL subject type string.
* Modules register themselves via registerDocumentSubject() during startup.
* @type {Array<{reqProperty: string, subjectType: string}>}
*/
const documentSubjectRegistry = [];

/**
* Registry of path-level subject resolvers.
* Each entry maps a route path (exact or prefix) to a CASL subject type string.
* Modules register themselves via registerPathSubject() during startup.
* Entries are matched in registration order; first match wins.
* @type {Array<{routeMatch: string|Function, subjectType: string}>}
*/
const pathSubjectRegistry = [];
Comment thread
PierreBrisorgueil marked this conversation as resolved.

/**
* Register a document-level subject resolver.
* When a request property (e.g. 'task') is present on req, the corresponding
* subject type (e.g. 'Task') is used for CASL document-level checks.
* @param {string} reqProperty - Property name on the Express req object
* @param {string} subjectType - CASL subject type string
* @param {Function} [guard] - Optional guard function(req) => boolean; when provided,
* the entry only matches if guard returns true (allows excluding certain routes)
*/
const registerDocumentSubject = (reqProperty, subjectType, guard) => {
documentSubjectRegistry.push({ reqProperty, subjectType, guard });
};

/**
* Register a path-level subject resolver.
* Used for collection-level CASL checks when no document is loaded.
* @param {string|Function} routeMatch - Exact route path string, or a function(routePath) => boolean
* @param {string} subjectType - CASL subject type string
*/
const registerPathSubject = (routeMatch, subjectType) => {
pathSubjectRegistry.push({ routeMatch, subjectType });
};
Comment thread
PierreBrisorgueil marked this conversation as resolved.

// Lazy CASL loader — dynamic import avoids Jest's experimental VM module linker
let _casl = null;

Expand Down Expand Up @@ -114,20 +154,16 @@ const normalizeForCasl = (doc) => {

/**
* Resolve the CASL subject type string from a loaded document on the request.
* Maps known req properties to their CASL subject type name.
* Iterates the documentSubjectRegistry populated by module policy files.
* Documents are normalized so populated references become bare ObjectIds.
* @param {Object} req - Express request object
* @returns {{ subjectType: string, document: Object }|null} subject info or null
*/
const resolveSubject = (req) => {
if (req.task) return { subjectType: 'Task', document: normalizeForCasl(req.task) };
if (req.upload) return { subjectType: 'Upload', document: normalizeForCasl(req.upload) };
// For user admin routes, the loaded user is stored in req.model by userByID param middleware
if (req.model) return { subjectType: 'UserAdmin', document: normalizeForCasl(req.model) };
if (req.membershipDoc) return { subjectType: 'Membership', document: normalizeForCasl(req.membershipDoc) };
// Billing routes use req.organization for context but authorize via route-derived subjects
if (req.organization && !req.route?.path?.startsWith('/api/billing')) {
return { subjectType: 'Organization', document: normalizeForCasl(req.organization) };
for (const { reqProperty, subjectType, guard } of documentSubjectRegistry) {
if (!req[reqProperty]) continue;
if (guard && !guard(req)) continue;
return { subjectType, document: normalizeForCasl(req[reqProperty]) };
}
return null;
};
Expand Down Expand Up @@ -164,54 +200,22 @@ const isAllowed = async (req, res, next) => {
return responses.error(res, 403, 'Unauthorized', 'User is not authorized')();
};

/**
* Mapping of exact route paths to CASL subject types for account-related routes.
* Self-service routes use 'UserAccount', admin routes use 'UserAdmin',
* and the base '/api/users' uses 'UserSelf' to split update/delete (user).
* @type {Object<string, string>}
*/
const USER_ROUTE_SUBJECTS = {
'/api/users/me': 'UserAccount',
'/api/users/terms': 'UserAccount',
'/api/users/password': 'UserAccount',
'/api/users/avatar': 'UserAccount',
'/api/users/data': 'UserAccount',
'/api/users/data/mail': 'UserAccount',
'/api/users/stats': 'UserStats',
'/api/users': 'UserSelf',
};

/**
* Derive a CASL subject type string from an Express route path.
* Maps known route path prefixes to their corresponding subject type.
* User routes are split into several subject types:
* - 'UserAccount' for self-service (me, terms, password, avatar, data)
* - 'UserSelf' for the base /api/users path (user update/delete own account + admin list)
* - 'UserAdmin' for admin management (get/update/delete by userId, pagination)
* Iterates the pathSubjectRegistry populated by module policy files.
* Each entry's routeMatch can be an exact string, or a function(routePath) => boolean.
* First match wins.
* @param {string} routePath - Express route path (e.g. '/api/tasks' or '/api/users/me')
* @returns {string|null} CASL subject type or null if not mappable
*/
const deriveSubjectType = (routePath) => {
if (routePath === '/api/billing/checkout') return 'BillingCheckout';
if (routePath === '/api/billing/portal') return 'BillingPortal';
if (routePath === '/api/billing/subscription') return 'BillingSubscription';
if (routePath === '/api/billing/usage') return 'BillingUsage';
if (routePath === '/api/billing/plans') return 'BillingPlans';
if (routePath.startsWith('/api/audit')) return 'AuditLog';
if (routePath.startsWith('/api/tasks')) return 'Task';
if (routePath.startsWith('/api/uploads')) return 'Upload';
if (routePath.startsWith('/api/home')) return 'Home';
if (routePath === '/api/admin/readiness') return 'Readiness';
if (routePath.startsWith('/api/admin/organizations')) return 'Organization';
if (routePath.includes('/requests')) return 'Membership';
if (routePath.includes('/members')) return 'Membership';
if (routePath.startsWith('/api/organizations')) return 'Organization';
// Check exact account route matches first
if (USER_ROUTE_SUBJECTS[routePath]) return USER_ROUTE_SUBJECTS[routePath];
// Account routes fallback
if (routePath.startsWith('/api/users')) return 'UserAccount';
// Admin user routes (/:userId, /page/:userPage)
if (routePath.startsWith('/api/admin/users')) return 'UserAdmin';
for (const { routeMatch, subjectType } of pathSubjectRegistry) {
if (typeof routeMatch === 'function') {
if (routeMatch(routePath)) return subjectType;
} else if (routeMatch === routePath) {
return subjectType;
}
}
return null;
};

Expand All @@ -235,6 +239,10 @@ const discoverPolicies = async (policyPaths) => {
} else if (normalizedKey.endsWith('abilities')) {
entry.abilities = value;
}
// Call subject registration functions exported by module policy files
if (normalizedKey.endsWith('subjectregistration')) {
value({ registerDocumentSubject, registerPathSubject });
}
}
// Also support old invokeRolesPolicies pattern during transition — skip if new-style exports found
if (entry.abilities || entry.guestAbilities) {
Expand All @@ -248,6 +256,8 @@ const discoverPolicies = async (policyPaths) => {

export default {
registerAbilities,
registerDocumentSubject,
registerPathSubject,
defineAbilityFor,
isAllowed,
discoverPolicies,
Expand Down
9 changes: 9 additions & 0 deletions modules/audit/policies/audit.policy.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@
* Audit ability definitions for CASL document-level authorization.
*/

/**
* Register audit-related subjects for path-level resolution.
* @param {Object} registry - Subject registration helpers
* @param {Function} registry.registerPathSubject - Register route path → subject type
*/
export function auditSubjectRegistration({ registerPathSubject }) {
Comment thread
PierreBrisorgueil marked this conversation as resolved.
registerPathSubject((p) => p.startsWith('/api/audit'), 'AuditLog');
}

/**
* Define audit-related abilities for an authenticated user.
* Only platform admins can read audit logs.
Expand Down
14 changes: 14 additions & 0 deletions modules/billing/policies/billing.policy.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@
* Billing ability definitions for CASL document-level authorization.
*/

/**
* Register billing-related subjects for path-level resolution.
* Billing uses exact route matches since each route maps to a distinct subject.
* @param {Object} registry - Subject registration helpers
* @param {Function} registry.registerPathSubject - Register route path → subject type
*/
Comment thread
coderabbitai[bot] marked this conversation as resolved.
export function billingSubjectRegistration({ registerPathSubject }) {
registerPathSubject('/api/billing/checkout', 'BillingCheckout');
registerPathSubject('/api/billing/portal', 'BillingPortal');
registerPathSubject('/api/billing/subscription', 'BillingSubscription');
registerPathSubject('/api/billing/usage', 'BillingUsage');
registerPathSubject('/api/billing/plans', 'BillingPlans');
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/**
* Define billing-related abilities for an authenticated user.
* Any authenticated user with an organization membership can manage billing.
Expand Down
10 changes: 10 additions & 0 deletions modules/home/policies/home.policy.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@
* Home ability definitions for CASL document-level authorization.
*/

/**
* Register home-related subjects for path-level resolution.
* @param {Object} registry - Subject registration helpers
* @param {Function} registry.registerPathSubject - Register route path → subject type
*/
export function homeSubjectRegistration({ registerPathSubject }) {
Comment thread
PierreBrisorgueil marked this conversation as resolved.
registerPathSubject((p) => p.startsWith('/api/home'), 'Home');
registerPathSubject('/api/admin/readiness', 'Readiness');
}

/**
* Define home-related abilities for an authenticated user.
* All authenticated users can read home content (releases, changelogs, team, pages).
Expand Down
22 changes: 22 additions & 0 deletions modules/organizations/policies/organizations.policy.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,28 @@
* Organization ability definitions for CASL document-level authorization.
*/

/**
* Register organization-related subjects for document-level and path-level resolution.
* The organization document subject uses a guard to exclude billing routes (which
* set req.organization but authorize via their own path-derived subjects).
* @param {Object} registry - Subject registration helpers
* @param {Function} registry.registerDocumentSubject - Register req property → subject type
* @param {Function} registry.registerPathSubject - Register route path → subject type
*/
export function organizationSubjectRegistration({ registerDocumentSubject, registerPathSubject }) {
registerDocumentSubject('membershipDoc', 'Membership');
// Guard: only resolve req.organization as an Organization subject on actual organization routes.
// Other modules (billing, tasks, etc.) also set req.organization but authorize via their own subjects.
registerDocumentSubject('organization', 'Organization', (req) => {
const p = req.route?.path || '';
return p.startsWith('/api/organizations') || p.startsWith('/api/admin/organizations');
});
registerPathSubject((p) => p.startsWith('/api/admin/organizations'), 'Organization');
registerPathSubject((p) => p.includes('/requests'), 'Membership');
registerPathSubject((p) => p.includes('/members'), 'Membership');
Comment thread
PierreBrisorgueil marked this conversation as resolved.
Outdated
registerPathSubject((p) => p.startsWith('/api/organizations'), 'Organization');
Comment thread
PierreBrisorgueil marked this conversation as resolved.
}
Comment thread
PierreBrisorgueil marked this conversation as resolved.

/**
* Define organization-related abilities for an authenticated user.
* Platform admins get full access. Regular users get abilities based on
Expand Down
12 changes: 12 additions & 0 deletions modules/tasks/policies/tasks.policy.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@
* Task ability definitions for CASL document-level authorization.
*/

/**
* Register task-related subjects for document-level and path-level resolution.
* Called automatically by discoverPolicies() during startup.
* @param {Object} registry - Subject registration helpers
* @param {Function} registry.registerDocumentSubject - Register req property → subject type
* @param {Function} registry.registerPathSubject - Register route path → subject type
*/
export function taskSubjectRegistration({ registerDocumentSubject, registerPathSubject }) {
Comment thread
PierreBrisorgueil marked this conversation as resolved.
registerDocumentSubject('task', 'Task');
registerPathSubject((p) => p.startsWith('/api/tasks'), 'Task');
}

/**
* Define task-related abilities for an authenticated user.
* Platform admins get full access. When an organization membership exists,
Expand Down
11 changes: 11 additions & 0 deletions modules/uploads/policies/uploads.policy.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@
* Upload ability definitions for CASL document-level authorization.
*/

/**
* Register upload-related subjects for document-level and path-level resolution.
* @param {Object} registry - Subject registration helpers
* @param {Function} registry.registerDocumentSubject - Register req property → subject type
* @param {Function} registry.registerPathSubject - Register route path → subject type
*/
export function uploadSubjectRegistration({ registerDocumentSubject, registerPathSubject }) {
Comment thread
PierreBrisorgueil marked this conversation as resolved.
registerDocumentSubject('upload', 'Upload');
registerPathSubject((p) => p.startsWith('/api/uploads'), 'Upload');
}

/**
* Define upload-related abilities for an authenticated user.
* Platform admins get full access. Regular users can read any upload
Expand Down
11 changes: 11 additions & 0 deletions modules/users/policies/users.admin.policy.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@
* Uses 'UserSelf' read for the admin user list at GET /api/admin/users.
*/

/**
* Register admin user-related subjects for document-level and path-level resolution.
* @param {Object} registry - Subject registration helpers
* @param {Function} registry.registerDocumentSubject - Register req property → subject type
* @param {Function} registry.registerPathSubject - Register route path → subject type
*/
export function adminSubjectRegistration({ registerDocumentSubject, registerPathSubject }) {
registerDocumentSubject('model', 'UserAdmin');
registerPathSubject((p) => p.startsWith('/api/admin/users'), 'UserAdmin');
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Comment thread
PierreBrisorgueil marked this conversation as resolved.
Comment thread
PierreBrisorgueil marked this conversation as resolved.

/**
* Define admin-level user management abilities for an authenticated user.
* Only admins can list, get, update, and delete other users.
Expand Down
20 changes: 20 additions & 0 deletions modules/users/policies/users.policy.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,26 @@
* Uses 'UserSelf' for the base /api/users path (update/delete own account).
*/

/**
* Register user account-related subjects for path-level resolution.
* Exact matches are registered first (highest priority), then prefix fallback.
* @param {Object} registry - Subject registration helpers
* @param {Function} registry.registerPathSubject - Register route path → subject type
*/
Comment thread
PierreBrisorgueil marked this conversation as resolved.
export function usersSubjectRegistration({ registerPathSubject }) {
// Exact matches — checked before prefix fallback
registerPathSubject('/api/users/me', 'UserAccount');
registerPathSubject('/api/users/terms', 'UserAccount');
registerPathSubject('/api/users/password', 'UserAccount');
registerPathSubject('/api/users/avatar', 'UserAccount');
registerPathSubject('/api/users/data', 'UserAccount');
registerPathSubject('/api/users/data/mail', 'UserAccount');
registerPathSubject('/api/users/stats', 'UserStats');
registerPathSubject('/api/users', 'UserSelf');
// Prefix fallback for any other /api/users/* routes
registerPathSubject((p) => p.startsWith('/api/users'), 'UserAccount');
}

/**
* Define account-related abilities for an authenticated user.
* Regular users can manage their own account via self-service routes.
Expand Down
Loading