@mostajs/rbac-tenant 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/README.md +28 -0
- package/llms.txt +28 -0
- package/package.json +34 -0
- package/src/index.js +121 -0
- package/src/memory-repos.js +35 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Changelog — @mostajs/rbac-tenant
|
|
2
|
+
## [0.1.0] — 2026-06-25
|
|
3
|
+
### Ajouté (MVP)
|
|
4
|
+
- RBAC étagé délégué : résolution à 2 étages `can(subject, perm, {tenantId})`, rôles tenant (`defineTenantRole`),
|
|
5
|
+
assignation/révocation scopées, délégation bornée (`grantDelegation`/`assertCanDelegate`) et boundary anti-escalade
|
|
6
|
+
(`assertGrantable`, catalogue `company.*`). Repos mémoire + matcher de repli. 8 tests (@mostajs/mjs-unit). Compose
|
|
7
|
+
@mostajs/rbac + multitenancy + repository + audit. Membre de mosta-rbac-stack.
|
package/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# @mostajs/rbac-tenant
|
|
2
|
+
|
|
3
|
+
**Auteur** : Dr Hamid MADANI <drmdh@msn.com>
|
|
4
|
+
|
|
5
|
+
**RBAC étagé délégué (multi-tenant)** : deux étages d'autorisation.
|
|
6
|
+
- **Plateforme** — rôles globaux gérés par l'hôte (via `@mostajs/rbac`).
|
|
7
|
+
- **Tenant** — chaque locataire (entreprise) a ses **rôles locaux**, qu'un **admin délégué** (ex. le responsable) définit et assigne **dans son périmètre**, **sans** pouvoir toucher la plateforme ni les autres tenants (**permission boundary** anti-escalade).
|
|
8
|
+
|
|
9
|
+
Compose (DEVRULES §10) `@mostajs/rbac` (matching) + `@mostajs/multitenancy` (scope) + `@mostajs/repository` (persistance) + `@mostajs/audit` — ne réimplémente rien. Membre de `mosta-rbac-stack`.
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
import { createScopedRbac } from '@mostajs/rbac-tenant';
|
|
13
|
+
import { hasPermission } from '@mostajs/rbac/helpers/permissions';
|
|
14
|
+
|
|
15
|
+
const rbacT = createScopedRbac({
|
|
16
|
+
match: hasPermission,
|
|
17
|
+
platformPermissionsOf: (id) => platformRbac.permissionsOf(id), // droits plateforme (Hadhinat)
|
|
18
|
+
catalog: ['company.projects.write', 'company.ged.write', 'company.members.invite'],
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
rbacT.grantDelegation('ent-42', 'resp-id'); // le responsable administre SON entreprise
|
|
22
|
+
rbacT.defineTenantRole('resp-id', 'ent-42', 'chef-projet', ['company.projects.write']);
|
|
23
|
+
rbacT.assignRole('resp-id', 'collab-id', 'chef-projet', { tenantId: 'ent-42' });
|
|
24
|
+
rbacT.can('collab-id', 'company.projects.write', { tenantId: 'ent-42' }); // true
|
|
25
|
+
rbacT.defineTenantRole('resp-id', 'ent-42', 'x', ['incubator.company.create']); // throw (anti-escalade)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
API complète : `llms.txt`. Tests : `npm test` (8). Étude/conception : `incubator/app/docs/10-ETUDE-RBAC-TENANT-24062026.md`. AGPL-3.0-or-later.
|
package/llms.txt
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# @mostajs/rbac-tenant
|
|
2
|
+
RBAC ÉTAGÉ délégué (multi-tenant). Membre de mosta-rbac-stack (à côté de @mostajs/rbac).
|
|
3
|
+
## RÔLE
|
|
4
|
+
Deux étages d'autorisation : (1) PLATEFORME = rôles globaux gérés par l'hôte (via @mostajs/rbac) ; (2) TENANT = rôles
|
|
5
|
+
locaux qu'un admin DÉLÉGUÉ définit/assigne dans SON périmètre, avec permission boundary anti-escalade (il ne peut
|
|
6
|
+
accorder QUE des permissions délégables `company.*`, jamais une permission plateforme ni un autre tenant).
|
|
7
|
+
Résolution : can(subject, perm, {tenantId}) = match(permsPlateforme ∪ permsTenant, perm).
|
|
8
|
+
## COMPOSE (injection, ne réimplémente rien)
|
|
9
|
+
- match : @mostajs/rbac hasPermission (matching exact/wildcard/glob). Repli interne defaultMatch.
|
|
10
|
+
- platformPermissionsOf : droits plateforme du sujet (fournis par l'hôte / RBAC global).
|
|
11
|
+
- repos : @mostajs/repository scopé tenant (repli mémoire createMemoryRepos).
|
|
12
|
+
- audit : @mostajs/audit (trace fire-and-forget).
|
|
13
|
+
## EXPORTS
|
|
14
|
+
createScopedRbac({ match, platformPermissionsOf, repos, catalog, audit }) → {
|
|
15
|
+
can(subjectId, perm, {tenantId}), permissionsOf(subjectId, {tenantId}),
|
|
16
|
+
isGrantable(perm), assertGrantable(perm), canDelegate(actor, tenantId), assertCanDelegate(actor, tenantId),
|
|
17
|
+
grantDelegation(tenantId, subjectId), revokeDelegation(...),
|
|
18
|
+
defineTenantRole(actor, tenantId, name, perms[]), deleteTenantRole(...), listTenantRoles(tenantId),
|
|
19
|
+
assignRole(actor, subjectId, roleName, {tenantId}), revokeRole(...), listMembers(tenantId), rolesOfMember(subjectId, tenantId),
|
|
20
|
+
}
|
|
21
|
+
defaultMatch(perms[], required) ; createMemoryRepos() (sous-chemin ./memory-repos)
|
|
22
|
+
## TYPES
|
|
23
|
+
ScopedAssignment {subjectId, roleName, scope:'tenant:<id>'} · TenantRole {tenantId, name, permissions[]} · DelegationGrant {tenantId, subjectId}
|
|
24
|
+
## PIÈGES
|
|
25
|
+
- can() SANS tenantId ne voit que les droits PLATEFORME (les droits tenant exigent {tenantId}).
|
|
26
|
+
- un rôle tenant ne référence QUE des permissions délégables (catalogue, def. `company.*`) — assertGrantable lève sinon.
|
|
27
|
+
- defineTenantRole/assignRole exigent que l'actor soit admin délégué du tenant (grantDelegation d'abord) — sinon « non déléguée ».
|
|
28
|
+
- assignRole n'assigne que des rôles TENANT existants (jamais un rôle plateforme).
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mostajs/rbac-tenant",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "RBAC étagé délégué (multi-tenant) : rôles plateforme (global) + rôles tenant gérés par un admin DÉLÉGUÉ dans son périmètre, avec permission boundary anti-escalade. Compose @mostajs/rbac (matching) + multitenancy + repository + audit. Membre de mosta-rbac-stack.",
|
|
5
|
+
"license": "AGPL-3.0-or-later",
|
|
6
|
+
"author": "Dr Hamid MADANI <drmdh@msn.com>",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "src/index.js",
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"llms.txt",
|
|
12
|
+
"README.md",
|
|
13
|
+
"CHANGELOG.md"
|
|
14
|
+
],
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./src/index.js",
|
|
17
|
+
"./memory-repos": "./src/memory-repos.js"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"mostajs",
|
|
21
|
+
"rbac",
|
|
22
|
+
"multi-tenant",
|
|
23
|
+
"authorization",
|
|
24
|
+
"delegation",
|
|
25
|
+
"permission-boundary",
|
|
26
|
+
"scoped"
|
|
27
|
+
],
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@mostajs/mjs-unit": "^0.3.0"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "bash test-scripts/run-tests.sh"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// @mostajs/rbac-tenant — RBAC ÉTAGÉ délégué (multi-tenant). Author: Dr Hamid MADANI <drmdh@msn.com>
|
|
2
|
+
// Deux étages : (1) PLATEFORME (rôles globaux gérés par l'hôte) ; (2) TENANT (rôles locaux qu'un admin DÉLÉGUÉ gère
|
|
3
|
+
// dans SON périmètre, sans pouvoir toucher la plateforme ni les autres tenants — permission boundary anti-escalade).
|
|
4
|
+
// COMPOSE (injection, DEVRULES §10) : le matching de permission (@mostajs/rbac hasPermission) + le contexte/persistance de l'hôte.
|
|
5
|
+
import { createMemoryRepos } from './memory-repos.js';
|
|
6
|
+
|
|
7
|
+
// Matcher par défaut (exact · wildcard '*' · glob 'a.b.*') — MIRROIR de @mostajs/rbac hasPermission.
|
|
8
|
+
// En production, INJECTER `match: hasPermission` de @mostajs/rbac (ne pas dépendre de ce repli).
|
|
9
|
+
export function defaultMatch(perms, required) {
|
|
10
|
+
if (!Array.isArray(perms)) return false;
|
|
11
|
+
for (const p of perms) {
|
|
12
|
+
if (p === '*' || p === required) return true;
|
|
13
|
+
if (p.endsWith('.*') && (required === p.slice(0, -2) || required.startsWith(p.slice(0, -1)))) return true;
|
|
14
|
+
}
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const tenantScope = (tenantId) => `tenant:${tenantId}`;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param match (perms[], required) → bool — matching de permission (def. @mostajs/rbac).
|
|
22
|
+
* @param platformPermissionsOf (subjectId) → string[] — droits PLATEFORME du sujet (fournis par l'hôte / @mostajs/rbac global).
|
|
23
|
+
* @param repos { assignments, tenantRoles, delegations } — persistance (def. mémoire).
|
|
24
|
+
* @param catalog string[] des permissions tenant DÉLÉGABLES ; vide → tout `company.*` (la boundary).
|
|
25
|
+
* @param audit { log({...}) } optionnel — traçabilité fire-and-forget.
|
|
26
|
+
*/
|
|
27
|
+
export function createScopedRbac({
|
|
28
|
+
match = defaultMatch,
|
|
29
|
+
platformPermissionsOf = () => [],
|
|
30
|
+
repos = createMemoryRepos(),
|
|
31
|
+
catalog = [],
|
|
32
|
+
audit = null,
|
|
33
|
+
} = {}) {
|
|
34
|
+
const trace = (action, data) => { try { audit?.log?.({ action, at: data?.at, ...data }); } catch { /* fire-and-forget */ } };
|
|
35
|
+
const idOf = (actor) => (typeof actor === 'string' ? actor : actor?.id);
|
|
36
|
+
|
|
37
|
+
// ── Boundary : une permission est-elle DÉLÉGABLE par un tenant (anti-escalade plateforme) ? ──
|
|
38
|
+
const isGrantable = (permission) => (catalog.length ? catalog.includes(permission) : /^company\./.test(permission));
|
|
39
|
+
function assertGrantable(permission) {
|
|
40
|
+
if (!isGrantable(permission)) throw new Error(`rbac-tenant: permission « ${permission} » non délégable (hors catalogue tenant)`);
|
|
41
|
+
}
|
|
42
|
+
// ── Boundary : l'acteur est-il admin DÉLÉGUÉ de CE tenant ? ──
|
|
43
|
+
const canDelegate = (actor, tenantId) => repos.delegations.isAdmin(tenantId, idOf(actor));
|
|
44
|
+
function assertCanDelegate(actor, tenantId) {
|
|
45
|
+
if (!tenantId) throw new Error('rbac-tenant: tenantId requis');
|
|
46
|
+
if (!canDelegate(actor, tenantId)) throw new Error('rbac-tenant: administration non déléguée pour ce tenant');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Résolution des permissions effectives (plateforme ∪ tenant) ──
|
|
50
|
+
function tenantPermissionsOf(subjectId, tenantId) {
|
|
51
|
+
if (!tenantId) return [];
|
|
52
|
+
const scope = tenantScope(tenantId);
|
|
53
|
+
const roleNames = repos.assignments.rolesOf(subjectId, scope); // rôles tenant assignés
|
|
54
|
+
const out = [];
|
|
55
|
+
for (const name of roleNames) {
|
|
56
|
+
const r = repos.tenantRoles.get(tenantId, name);
|
|
57
|
+
if (r) out.push(...r.permissions);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
function permissionsOf(subjectId, { tenantId } = {}) {
|
|
62
|
+
return [...(platformPermissionsOf(subjectId) || []), ...tenantPermissionsOf(subjectId, tenantId)];
|
|
63
|
+
}
|
|
64
|
+
function can(subjectId, permission, { tenantId } = {}) {
|
|
65
|
+
return match(permissionsOf(subjectId, { tenantId }), permission);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── Administration DÉLÉGUÉE (bornée) ──
|
|
69
|
+
/** Déclare un sujet comme admin délégué d'un tenant (typiquement le responsable de l'entreprise). */
|
|
70
|
+
function grantDelegation(tenantId, subjectId) {
|
|
71
|
+
if (!tenantId || !subjectId) throw new Error('rbac-tenant: tenantId et subjectId requis');
|
|
72
|
+
repos.delegations.add(tenantId, subjectId);
|
|
73
|
+
trace('delegation.grant', { tenantId, subjectId });
|
|
74
|
+
return { tenantId, subjectId };
|
|
75
|
+
}
|
|
76
|
+
function revokeDelegation(tenantId, subjectId) { repos.delegations.remove(tenantId, subjectId); trace('delegation.revoke', { tenantId, subjectId }); }
|
|
77
|
+
|
|
78
|
+
/** Définit/MAJ un rôle LOCAL au tenant — toutes ses permissions doivent être délégables (anti-escalade). */
|
|
79
|
+
function defineTenantRole(actor, tenantId, name, permissions = []) {
|
|
80
|
+
assertCanDelegate(actor, tenantId);
|
|
81
|
+
if (!name) throw new Error('rbac-tenant: nom de rôle requis');
|
|
82
|
+
permissions.forEach(assertGrantable);
|
|
83
|
+
const role = repos.tenantRoles.upsert(tenantId, name, permissions);
|
|
84
|
+
trace('tenant-role.define', { actor: idOf(actor), tenantId, name, permissions });
|
|
85
|
+
return role;
|
|
86
|
+
}
|
|
87
|
+
function deleteTenantRole(actor, tenantId, name) {
|
|
88
|
+
assertCanDelegate(actor, tenantId);
|
|
89
|
+
repos.tenantRoles.remove(tenantId, name);
|
|
90
|
+
repos.assignments.removeRoleEverywhere(tenantScope(tenantId), name);
|
|
91
|
+
trace('tenant-role.delete', { actor: idOf(actor), tenantId, name });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Assigne un rôle TENANT à un sujet, dans le périmètre du tenant (jamais un rôle plateforme). */
|
|
95
|
+
function assignRole(actor, subjectId, roleName, { tenantId } = {}) {
|
|
96
|
+
assertCanDelegate(actor, tenantId);
|
|
97
|
+
if (!repos.tenantRoles.get(tenantId, roleName)) throw new Error(`rbac-tenant: rôle « ${roleName} » inconnu pour ce tenant`);
|
|
98
|
+
repos.assignments.add(subjectId, roleName, tenantScope(tenantId));
|
|
99
|
+
trace('role.assign', { actor: idOf(actor), subjectId, roleName, tenantId });
|
|
100
|
+
return { subjectId, roleName, scope: tenantScope(tenantId) };
|
|
101
|
+
}
|
|
102
|
+
function revokeRole(actor, subjectId, roleName, { tenantId } = {}) {
|
|
103
|
+
assertCanDelegate(actor, tenantId);
|
|
104
|
+
repos.assignments.remove(subjectId, roleName, tenantScope(tenantId));
|
|
105
|
+
trace('role.revoke', { actor: idOf(actor), subjectId, roleName, tenantId });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const listTenantRoles = (tenantId) => repos.tenantRoles.list(tenantId);
|
|
109
|
+
const listMembers = (tenantId) => repos.assignments.subjectsIn(tenantScope(tenantId));
|
|
110
|
+
const rolesOfMember = (subjectId, tenantId) => repos.assignments.rolesOf(subjectId, tenantScope(tenantId));
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
can, permissionsOf, isGrantable, assertGrantable, assertCanDelegate, canDelegate,
|
|
114
|
+
grantDelegation, revokeDelegation,
|
|
115
|
+
defineTenantRole, deleteTenantRole, listTenantRoles,
|
|
116
|
+
assignRole, revokeRole, listMembers, rolesOfMember,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export { createMemoryRepos } from './memory-repos.js';
|
|
121
|
+
export default createScopedRbac;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// @mostajs/rbac-tenant — repos en mémoire (repli par défaut ; en prod, injecter @mostajs/repository scopé tenant). Author: Dr Hamid MADANI <drmdh@msn.com>
|
|
2
|
+
export function createMemoryRepos() {
|
|
3
|
+
const assignments = []; // { subjectId, roleName, scope }
|
|
4
|
+
const tenantRoles = new Map(); // `${tenantId}|${name}` → { tenantId, name, permissions }
|
|
5
|
+
const delegations = new Map(); // tenantId → Set(subjectId)
|
|
6
|
+
const trKey = (t, n) => `${t}|${n}`;
|
|
7
|
+
const same = (a, s, r, sc) => a.subjectId === s && a.roleName === r && a.scope === sc;
|
|
8
|
+
|
|
9
|
+
return {
|
|
10
|
+
assignments: {
|
|
11
|
+
add(subjectId, roleName, scope) {
|
|
12
|
+
if (!assignments.some((a) => same(a, subjectId, roleName, scope))) assignments.push({ subjectId, roleName, scope });
|
|
13
|
+
},
|
|
14
|
+
remove(subjectId, roleName, scope) {
|
|
15
|
+
for (let i = assignments.length - 1; i >= 0; i--) if (same(assignments[i], subjectId, roleName, scope)) assignments.splice(i, 1);
|
|
16
|
+
},
|
|
17
|
+
rolesOf(subjectId, scope) { return assignments.filter((a) => a.subjectId === subjectId && a.scope === scope).map((a) => a.roleName); },
|
|
18
|
+
subjectsIn(scope) { return [...new Set(assignments.filter((a) => a.scope === scope).map((a) => a.subjectId))]; },
|
|
19
|
+
removeRoleEverywhere(scope, roleName) {
|
|
20
|
+
for (let i = assignments.length - 1; i >= 0; i--) { const a = assignments[i]; if (a.scope === scope && a.roleName === roleName) assignments.splice(i, 1); }
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
tenantRoles: {
|
|
24
|
+
upsert(tenantId, name, permissions) { const r = { tenantId, name, permissions: [...permissions] }; tenantRoles.set(trKey(tenantId, name), r); return r; },
|
|
25
|
+
get(tenantId, name) { return tenantRoles.get(trKey(tenantId, name)) || null; },
|
|
26
|
+
remove(tenantId, name) { tenantRoles.delete(trKey(tenantId, name)); },
|
|
27
|
+
list(tenantId) { return [...tenantRoles.values()].filter((r) => r.tenantId === tenantId); },
|
|
28
|
+
},
|
|
29
|
+
delegations: {
|
|
30
|
+
add(tenantId, subjectId) { if (!delegations.has(tenantId)) delegations.set(tenantId, new Set()); delegations.get(tenantId).add(subjectId); },
|
|
31
|
+
remove(tenantId, subjectId) { delegations.get(tenantId)?.delete(subjectId); },
|
|
32
|
+
isAdmin(tenantId, subjectId) { return !!delegations.get(tenantId)?.has(subjectId); },
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|