Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"github.copilot.chat.codeGeneration.useInstructionFiles": true,
"github.copilot.enable": {
"javascript": true,
"markdown": true
"markdown": true,
"json": true
}
}
96 changes: 96 additions & 0 deletions MIGRATIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Migrations

Breaking changes and upgrade notes for downstream projects.

---

## `acl` → `@casl/ability` (2026-02-20)

`acl@0.4.11` (unmaintained since 2018) has been replaced by `@casl/ability`.

### What changed

- `lib/middlewares/policy.js` no longer exports `Acl`.
- Policy files now call `policy.registerRules([...])` instead of `policy.Acl.allow([...])`.
- `isAllowed` and `isOwner` middleware signatures are **unchanged** — routes do not need to be updated.

### HTTP method → CASL action mapping

| HTTP method | CASL action |
|-----------------|-------------|
| `GET` | `read` |
| `POST` | `create` |
| `PUT` / `PATCH` | `update` |
| `DELETE` | `delete` |
| `*` (all) | `manage` |

### Migration example

**Before (`acl`):**

```js
import policy from '../../../lib/middlewares/policy.js';

const invokeRolesPolicies = () => {
policy.Acl.allow([
{
roles: ['user'],
allows: [
{ resources: '/api/tasks', permissions: '*' },
{ resources: '/api/tasks/:taskId', permissions: '*' },
],
},
{
roles: ['guest'],
allows: [
{ resources: '/api/tasks/stats', permissions: ['get'] },
{ resources: '/api/tasks', permissions: ['get'] },
{ resources: '/api/tasks/:taskId', permissions: ['get'] },
],
},
]);
};

export default { invokeRolesPolicies };
```

**After (`@casl/ability`):**

```js
import policy from '../../../lib/middlewares/policy.js';

const invokeRolesPolicies = () => {
policy.registerRules([
{ roles: ['user'], actions: 'manage', subject: '/api/tasks' },
{ roles: ['user'], actions: 'manage', subject: '/api/tasks/:taskId' },
{ roles: ['guest'], actions: ['read'], subject: '/api/tasks/stats' },
{ roles: ['guest'], actions: ['read'], subject: '/api/tasks' },
{ roles: ['guest'], actions: ['read'], subject: '/api/tasks/:taskId' },
]);
};

export default { invokeRolesPolicies };
```

### `defineAbilityFor` is now async

`policy.defineAbilityFor(user)` returns a `Promise<Ability>` (lazy-loads `@casl/ability` on first call). Express `isAllowed` middleware is `async` and works unchanged. If you test `defineAbilityFor` directly, `await` it:

```js
// Unit test
const ability = await policy.defineAbilityFor(null);
expect(ability.can('read', '/api/tasks')).toBe(true);
```

> **Jest note**: `policy.js` must be a static top-level import in the test file (not only reached via dynamic `import()`). This pre-loads the module in Jest's VM registry before policy files are dynamically imported in `beforeAll`.
> ```js
> import policy from '../../../lib/middlewares/policy.js'; // required at top level
> ```

### Steps for downstream projects

1. `npm remove acl && npm install @casl/ability`
2. Update every `modules/*/policies/*.policy.js` following the pattern above.
3. Remove any direct use of `policy.Acl` (it is no longer exported).
4. If you have unit tests that call `defineAbilityFor`, add `import policy from '...policy.js'` as a top-level static import and `await` the call.
5. Run `npm run lint && npm test` — all existing 403/200 assertions should pass unchanged.
Comment thread
PierreBrisorgueil marked this conversation as resolved.
24 changes: 15 additions & 9 deletions config/defaults/development.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,19 @@ const config = {
maxSize: 126, // max password size
minimumScore: 3, // min password complexity score
},
cookie: {
secure: false, // false in dev (HTTP localhost)
sameSite: 'strict',
},
rateLimit: {
auth: {
windowMs: 15 * 60 * 1000, // 15 min
max: 20, // 20 requests per window in dev (more lenient)
message: { message: 'Too many requests, please try again later.' },
standardHeaders: true,
legacyHeaders: false,
},
},
// jwt is for token authentification
jwt: {
secret: 'WaosSecretKeyExampleToChnageAbsolutely', // secret for hash
Expand Down Expand Up @@ -189,17 +202,10 @@ const config = {
privateKeyLocation: null,
},
},
// joi is used to manage schema restrictions, on the top of mongo / orm
joi: {
// validation is used to manage schema restrictions, on the top of mongo / orm
validation: {
// enabled HTTP methods for request data validation
supportedMethods: ['post', 'put'],
// Joi validation options
validationOptions: {
abortEarly: false, // abort after the last validation error
allowUnknown: true, // allow unknown keys that will be ignored
stripUnknown: true, // remove unknown keys from the validated data
noDefaults: false, // automatically set to true for put method (update)
},
},
seedDB: {
seed: true,
Expand Down
13 changes: 13 additions & 0 deletions config/defaults/production.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@ export default _.merge(config.default, {
certificate: './config/sslcerts/cert.pem',
caBundle: './config/sslcerts/cabundle.crt',
},
cookie: {
secure: true, // HTTPS only in prod
sameSite: 'strict',
},
rateLimit: {
auth: {
windowMs: 15 * 60 * 1000,
max: 10, // stricter in prod
message: { message: 'Too many requests, please try again later.' },
standardHeaders: true,
legacyHeaders: false,
},
},
log: {
format: 'custom',
pattern:
Expand Down
5 changes: 5 additions & 0 deletions config/defaults/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,9 @@ export default _.merge(config.default, {
},
},
},
rateLimit: {
auth: {
max: Number.MAX_SAFE_INTEGER, // disable rate limiting in tests
},
},
});
19 changes: 19 additions & 0 deletions lib/helpers/zod.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Module dependencies
*/
import zxcvbn from 'zxcvbn';
import config from '../../config/index.js';
import { z } from 'zod';

/**
* @desc Zod superRefine for zxcvbn password strength
*/
const passwordRefinement = (val, ctx) => {
if (config.zxcvbn.forbiddenPasswords.includes(val)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'password is too common' });
} else if (zxcvbn(val).score < config.zxcvbn.minimumScore) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `password must have a strength of at least ${config.zxcvbn.minimumScore}` });
}
};

export default { passwordRefinement };
57 changes: 23 additions & 34 deletions lib/middlewares/model.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,26 +15,24 @@ const cleanError = (string) =>
.trim();

/**
* get Joi result
* get Zod result
*/
const getResultFromJoi = (body, schema, options) =>
schema.validate(body, options, (err, data) => {
if (err) {
const output = {
status: 'failed',
error: {
original: err._object,
// fetch only message and type from each error
details: _.map(err.details, ({ message, type }) => ({
message: message.replace(/['"]/g, ''),
type,
})),
},
};
return output;
}
return data;
});
const getResultFromZod = (body, schema) => {
const result = schema.safeParse(body);
if (!result.success) {
return {
error: {
original: body,
_original: body,
details: result.error.issues.map(({ message, code }) => ({
message: message.replace(/['"]/g, ''),
type: code,
})),
},
};
}
return { value: result.data };
};

/**
* check error and return if needed
Expand All @@ -43,36 +41,27 @@ const checkError = (result) => {
if (result && result.error) {
if (result.error.original && (result.error.original.password || result.error.original.firstname))
result.error.original = _.pick(result.error.original, config.whitelists.users.default);
if (result.error._original && (result.error._original.password || result.error._original.firstname))
Comment thread
PierreBrisorgueil marked this conversation as resolved.
Outdated
result.error._original = _.pick(result.error._original, config.whitelists.users.default);
let description = '';
result.error.details.forEach((err) => {
const message = cleanError(err.message);
description += `${message.charAt(0).toUpperCase() + message.slice(1).toLowerCase()}. `;
});

if (result.error._original && (result.error._original.password || result.error._original.firstname))
result.error._original = _.pick(result.error._original, config.whitelists.users.default);
return description;
}
return false;
};

/**
* Check model is Valid with Joi schema
* Check model is Valid with Zod schema
*/
const isValid = (schema) => (req, res, next) => {
const method = req.method.toLowerCase();
const options = _.clone(config.joi.validationOptions);
if (_.includes(config.joi.supportedMethods, method)) {
if (method === 'put') {
options.noDefaults = true;
}
// Validate req.body using the schema and validation options
const result = getResultFromJoi(req.body, schema, options);
// check error
if (_.includes(config.validation.supportedMethods, method)) {
const result = getResultFromZod(req.body, schema);
const error = checkError(result);
if (error) return responses.error(res, 422, 'Schema validation error', error)(result.error);

// else return req.body with the data after Joi validation
req.body = result.value;
return next();
}
Expand All @@ -81,7 +70,7 @@ const isValid = (schema) => (req, res, next) => {

export default {
cleanError,
getResultFromJoi,
getResultFromZod,
checkError,
isValid,
};
45 changes: 34 additions & 11 deletions lib/middlewares/policy.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,47 @@
/**
* Module dependencies
*/
import ACL from 'acl';
import responses from '../helpers/responses.js';

/* eslint new-cap: 0 */
const Acl = new ACL(new ACL.memoryBackend()); // Using the memory backend
const methodToAction = {
get: 'read', post: 'create', put: 'update', patch: 'update', delete: 'delete',
};
Comment thread
PierreBrisorgueil marked this conversation as resolved.

// Global rules registry — populated at startup by each policy file
const rulesRegistry = [];

const registerRules = (rules) => rulesRegistry.push(...rules);

// Lazy CASL loader — dynamic import avoids Jest's experimental VM module linker
let _casl = null;
const loadCasl = async () => {
if (!_casl) _casl = await import('@casl/ability');
return _casl;
};

const defineAbilityFor = async (user) => {
const { AbilityBuilder, Ability } = await loadCasl();
const { can, build } = new AbilityBuilder(Ability);
const roles = user ? user.roles : ['guest'];
for (const rule of rulesRegistry) {
if (rule.roles.some((r) => roles.includes(r))) {
can(rule.actions, rule.subject);
}
}
return build();
};

/**
* @desc MiddleWare to check if user is allowed
* @param {Object} req - Express request object
* @param {Object} res - Express response object
* @param {Function} next - Express next middleware function
*/
const isAllowed = (req, res, next) => {
const roles = req.user ? req.user.roles : ['guest'];
Acl.areAnyRolesAllowed(roles, req.route.path, req.method.toLowerCase(), (err, isAllowed) => {
if (err) return responses.error(res, 500, 'Server Error', 'Unexpected authorization error')(err); // An authorization error occurred
if (isAllowed) return next(); // Access granted! Invoke next middleware
return responses.error(res, 403, 'Unauthorized', 'User is not authorized')();
});
const isAllowed = async (req, res, next) => {
const ability = await defineAbilityFor(req.user);
const action = methodToAction[req.method.toLowerCase()];
if (ability.can(action, req.route.path)) return next();
return responses.error(res, 403, 'Unauthorized', 'User is not authorized')();
};

/**
Expand All @@ -36,7 +58,8 @@ const isOwner = (req, res, next) => {
};

export default {
Acl,
registerRules,
defineAbilityFor,
isAllowed,
isOwner,
};
Loading