@guisao-llc/gambit-rbac 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/dist/authorize.d.ts +62 -0
- package/dist/authorize.d.ts.map +1 -0
- package/dist/authorize.js +100 -0
- package/dist/authorize.js.map +1 -0
- package/dist/cascades.d.ts +52 -0
- package/dist/cascades.d.ts.map +1 -0
- package/dist/cascades.js +66 -0
- package/dist/cascades.js.map +1 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +68 -0
- package/dist/index.js.map +1 -0
- package/dist/permission-cache.d.ts +75 -0
- package/dist/permission-cache.d.ts.map +1 -0
- package/dist/permission-cache.js +118 -0
- package/dist/permission-cache.js.map +1 -0
- package/dist/schema.d.ts +66 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +69 -0
- package/dist/schema.js.map +1 -0
- package/package.json +35 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Request, RequestHandler } from "express";
|
|
2
|
+
import { HttpError } from "@guisao-llc/gambit-auth";
|
|
3
|
+
import { CachedRole } from "./permission-cache";
|
|
4
|
+
/**
|
|
5
|
+
* The authorization middleware pair.
|
|
6
|
+
*
|
|
7
|
+
* Knows nothing about tenants, features, or any permission catalog. Everything
|
|
8
|
+
* app-specific arrives through config: how a token is verified, and an optional
|
|
9
|
+
* entitlement check that runs only after the permission check has passed.
|
|
10
|
+
*
|
|
11
|
+
* The two guards it returns differ in ways that are easy to collapse by
|
|
12
|
+
* accident. They are kept as distinct match policies over a shared preamble —
|
|
13
|
+
* see each one.
|
|
14
|
+
*/
|
|
15
|
+
/** The minimum a decoded token must carry. Apps extend this with their own. */
|
|
16
|
+
export interface AuthClaims {
|
|
17
|
+
roles?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface EntitlementContext<TClaims> {
|
|
20
|
+
/** The permission(s) the route is gated on. */
|
|
21
|
+
required: string[];
|
|
22
|
+
/**
|
|
23
|
+
* The permission(s) that actually DECIDED access — what an entitlement check
|
|
24
|
+
* should price. For `authorize` this is the REQUIREMENT, not whatever the
|
|
25
|
+
* role happened to hold: a `:read` granted by group access still gates on the
|
|
26
|
+
* requirement's entitlement.
|
|
27
|
+
*/
|
|
28
|
+
matched: string[];
|
|
29
|
+
roleName: string;
|
|
30
|
+
role: CachedRole | undefined;
|
|
31
|
+
claims: TClaims;
|
|
32
|
+
}
|
|
33
|
+
/** Return an HttpError to deny, or null to allow. */
|
|
34
|
+
export type EntitlementCheck<TClaims> = (ctx: EntitlementContext<TClaims>) => Promise<HttpError | null> | HttpError | null;
|
|
35
|
+
export interface AuthorizeConfig<TClaims extends AuthClaims> {
|
|
36
|
+
/** Verify the raw Authorization header and resolve the claims. Throws to reject. */
|
|
37
|
+
verifyToken: (authorization: string) => Promise<TClaims>;
|
|
38
|
+
/** Optional second layer, run only after the permission check passes. */
|
|
39
|
+
checkEntitlement?: EntitlementCheck<TClaims>;
|
|
40
|
+
/**
|
|
41
|
+
* Accept any permission in the same group for a `:read` requirement.
|
|
42
|
+
*
|
|
43
|
+
* Off by default, because it WIDENS access and a shared package must not
|
|
44
|
+
* widen access silently. Turn it on in an app whose UI no longer grants
|
|
45
|
+
* `<group>:read` keys directly — there, holding `invoices:write` is the only
|
|
46
|
+
* way anyone ends up able to read invoices at all.
|
|
47
|
+
*/
|
|
48
|
+
readImpliesGroupAccess?: boolean;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Build the `authorize` / `authorizeAny` pair for one app.
|
|
52
|
+
*
|
|
53
|
+
* Returns middleware factories with the signatures route files already use, so
|
|
54
|
+
* adopting this changes no route.
|
|
55
|
+
*/
|
|
56
|
+
export declare function createAuthorize<TClaims extends AuthClaims>(config: AuthorizeConfig<TClaims>): {
|
|
57
|
+
authorize: (permission: string) => RequestHandler;
|
|
58
|
+
authorizeAny: (permissions: string[]) => RequestHandler;
|
|
59
|
+
};
|
|
60
|
+
/** `Request` re-exported so consumers can type their own entitlement checks. */
|
|
61
|
+
export type { Request };
|
|
62
|
+
//# sourceMappingURL=authorize.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"authorize.d.ts","sourceRoot":"","sources":["../src/authorize.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAClD,OAAO,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;AACpD,OAAO,EAAE,UAAU,EAAkD,MAAM,oBAAoB,CAAC;AAEhG;;;;;;;;;;GAUG;AAEH,+EAA+E;AAC/E,MAAM,WAAW,UAAU;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,kBAAkB,CAAC,OAAO;IACzC,+CAA+C;IAC/C,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB;;;;;OAKG;IACH,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,UAAU,GAAG,SAAS,CAAC;IAC7B,MAAM,EAAE,OAAO,CAAC;CACjB;AAED,qDAAqD;AACrD,MAAM,MAAM,gBAAgB,CAAC,OAAO,IAAI,CACtC,GAAG,EAAE,kBAAkB,CAAC,OAAO,CAAC,KAC7B,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC,GAAG,SAAS,GAAG,IAAI,CAAC;AAElD,MAAM,WAAW,eAAe,CAAC,OAAO,SAAS,UAAU;IACzD,oFAAoF;IACpF,WAAW,EAAE,CAAC,aAAa,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IACzD,yEAAyE;IACzE,gBAAgB,CAAC,EAAE,gBAAgB,CAAC,OAAO,CAAC,CAAC;IAC7C;;;;;;;OAOG;IACH,sBAAsB,CAAC,EAAE,OAAO,CAAC;CAClC;AAcD;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,OAAO,SAAS,UAAU,EACxD,MAAM,EAAE,eAAe,CAAC,OAAO,CAAC;4BA+DD,MAAM,KAAG,cAAc;gCAkBnB,MAAM,EAAE,KAAG,cAAc;EAQ7D;AAED,gFAAgF;AAChF,YAAY,EAAE,OAAO,EAAE,CAAC"}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createAuthorize = createAuthorize;
|
|
4
|
+
const gambit_auth_1 = require("@guisao-llc/gambit-auth");
|
|
5
|
+
const permission_cache_1 = require("./permission-cache");
|
|
6
|
+
/** Any permission in the same group satisfies a `:read` requirement. */
|
|
7
|
+
function hasGroupAccess(roleName, permission) {
|
|
8
|
+
if ((0, permission_cache_1.hasPermission)(roleName, permission))
|
|
9
|
+
return true;
|
|
10
|
+
const group = permission.split(":")[0];
|
|
11
|
+
const cached = (0, permission_cache_1.getCachedRole)(roleName);
|
|
12
|
+
if (!cached)
|
|
13
|
+
return false;
|
|
14
|
+
for (const p of cached.permissions) {
|
|
15
|
+
if (p.startsWith(`${group}:`))
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Build the `authorize` / `authorizeAny` pair for one app.
|
|
22
|
+
*
|
|
23
|
+
* Returns middleware factories with the signatures route files already use, so
|
|
24
|
+
* adopting this changes no route.
|
|
25
|
+
*/
|
|
26
|
+
function createAuthorize(config) {
|
|
27
|
+
const { verifyToken, checkEntitlement, readImpliesGroupAccess = false } = config;
|
|
28
|
+
/**
|
|
29
|
+
* Everything the two guards share: header presence, verification, claims,
|
|
30
|
+
* the role check, the entitlement hand-off, and the error mapping. `match` is
|
|
31
|
+
* the only part that varies — it returns the permissions that decided access,
|
|
32
|
+
* or null to deny.
|
|
33
|
+
*/
|
|
34
|
+
const guard = (required, match) => {
|
|
35
|
+
return async (req, _res, next) => {
|
|
36
|
+
const { authorization } = req.headers;
|
|
37
|
+
if (!authorization) {
|
|
38
|
+
return next(new gambit_auth_1.HttpError("Not authorized", 401));
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const claims = await verifyToken(authorization);
|
|
42
|
+
// The host app types `Request.user` through its own augmentation, which
|
|
43
|
+
// this package cannot see. Writing the key without declaring its type
|
|
44
|
+
// is deliberate — see gambit-auth's createAuthenticate.
|
|
45
|
+
req.user = claims;
|
|
46
|
+
const roleName = claims.roles;
|
|
47
|
+
if (!roleName) {
|
|
48
|
+
return next(new gambit_auth_1.HttpError("No role assigned to this user", 403));
|
|
49
|
+
}
|
|
50
|
+
const matched = match(roleName);
|
|
51
|
+
if (!matched) {
|
|
52
|
+
return next(new gambit_auth_1.HttpError("You do not have permission to perform this action", 403));
|
|
53
|
+
}
|
|
54
|
+
if (checkEntitlement) {
|
|
55
|
+
const denial = await checkEntitlement({
|
|
56
|
+
required,
|
|
57
|
+
matched,
|
|
58
|
+
roleName,
|
|
59
|
+
role: (0, permission_cache_1.getCachedRole)(roleName),
|
|
60
|
+
claims,
|
|
61
|
+
});
|
|
62
|
+
if (denial)
|
|
63
|
+
return next(denial);
|
|
64
|
+
}
|
|
65
|
+
next();
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return next(new gambit_auth_1.HttpError("Unauthorized", 401));
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Requires the exact permission — unless `readImpliesGroupAccess` is on and
|
|
74
|
+
* the requirement ends in `:read`, in which case any permission in the same
|
|
75
|
+
* group satisfies it. Non-read requirements are always strict.
|
|
76
|
+
*/
|
|
77
|
+
const authorize = (permission) => guard([permission], (roleName) => {
|
|
78
|
+
const granted = readImpliesGroupAccess && permission.endsWith(":read")
|
|
79
|
+
? hasGroupAccess(roleName, permission)
|
|
80
|
+
: (0, permission_cache_1.hasPermission)(roleName, permission);
|
|
81
|
+
return granted ? [permission] : null;
|
|
82
|
+
});
|
|
83
|
+
/**
|
|
84
|
+
* Requires ANY ONE of the listed permissions.
|
|
85
|
+
*
|
|
86
|
+
* Two deliberate differences from `authorize`, kept explicit rather than
|
|
87
|
+
* unified because collapsing them would silently change who gets through:
|
|
88
|
+
* the match is STRICT — no group-prefix fallback, ever — and the entitlement
|
|
89
|
+
* check is priced on every listed permission the role actually holds, rather
|
|
90
|
+
* than on a single requirement.
|
|
91
|
+
*/
|
|
92
|
+
const authorizeAny = (permissions) => guard(permissions, (roleName) => {
|
|
93
|
+
if (!(0, permission_cache_1.hasAnyPermission)(roleName, permissions))
|
|
94
|
+
return null;
|
|
95
|
+
const role = (0, permission_cache_1.getCachedRole)(roleName);
|
|
96
|
+
return permissions.filter((p) => role?.permissions.has(p));
|
|
97
|
+
});
|
|
98
|
+
return { authorize, authorizeAny };
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=authorize.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"authorize.js","sourceRoot":"","sources":["../src/authorize.ts"],"names":[],"mappings":";;AA2EA,0CA0FC;AApKD,yDAAoD;AACpD,yDAAgG;AAuDhG,wEAAwE;AACxE,SAAS,cAAc,CAAC,QAAgB,EAAE,UAAkB;IAC1D,IAAI,IAAA,gCAAa,EAAC,QAAQ,EAAE,UAAU,CAAC;QAAE,OAAO,IAAI,CAAC;IACrD,MAAM,KAAK,GAAG,UAAU,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;IACvC,MAAM,MAAM,GAAG,IAAA,gCAAa,EAAC,QAAQ,CAAC,CAAC;IACvC,IAAI,CAAC,MAAM;QAAE,OAAO,KAAK,CAAC;IAC1B,KAAK,MAAM,CAAC,IAAI,MAAM,CAAC,WAAW,EAAE,CAAC;QACnC,IAAI,CAAC,CAAC,UAAU,CAAC,GAAG,KAAK,GAAG,CAAC;YAAE,OAAO,IAAI,CAAC;IAC7C,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;;;GAKG;AACH,SAAgB,eAAe,CAC7B,MAAgC;IAEhC,MAAM,EAAE,WAAW,EAAE,gBAAgB,EAAE,sBAAsB,GAAG,KAAK,EAAE,GAAG,MAAM,CAAC;IAEjF;;;;;OAKG;IACH,MAAM,KAAK,GAAG,CACZ,QAAkB,EAClB,KAA4C,EAC5B,EAAE;QAClB,OAAO,KAAK,EAAE,GAAG,EAAE,IAAI,EAAE,IAAI,EAAE,EAAE;YAC/B,MAAM,EAAE,aAAa,EAAE,GAAG,GAAG,CAAC,OAAO,CAAC;YAEtC,IAAI,CAAC,aAAa,EAAE,CAAC;gBACnB,OAAO,IAAI,CAAC,IAAI,uBAAS,CAAC,gBAAgB,EAAE,GAAG,CAAC,CAAC,CAAC;YACpD,CAAC;YAED,IAAI,CAAC;gBACH,MAAM,MAAM,GAAG,MAAM,WAAW,CAAC,aAAa,CAAC,CAAC;gBAChD,wEAAwE;gBACxE,sEAAsE;gBACtE,wDAAwD;gBACvD,GAA0C,CAAC,IAAI,GAAG,MAAM,CAAC;gBAC1D,MAAM,QAAQ,GAAG,MAAM,CAAC,KAAK,CAAC;gBAE9B,IAAI,CAAC,QAAQ,EAAE,CAAC;oBACd,OAAO,IAAI,CAAC,IAAI,uBAAS,CAAC,+BAA+B,EAAE,GAAG,CAAC,CAAC,CAAC;gBACnE,CAAC;gBAED,MAAM,OAAO,GAAG,KAAK,CAAC,QAAQ,CAAC,CAAC;gBAChC,IAAI,CAAC,OAAO,EAAE,CAAC;oBACb,OAAO,IAAI,CACT,IAAI,uBAAS,CAAC,mDAAmD,EAAE,GAAG,CAAC,CACxE,CAAC;gBACJ,CAAC;gBAED,IAAI,gBAAgB,EAAE,CAAC;oBACrB,MAAM,MAAM,GAAG,MAAM,gBAAgB,CAAC;wBACpC,QAAQ;wBACR,OAAO;wBACP,QAAQ;wBACR,IAAI,EAAE,IAAA,gCAAa,EAAC,QAAQ,CAAC;wBAC7B,MAAM;qBACP,CAAC,CAAC;oBACH,IAAI,MAAM;wBAAE,OAAO,IAAI,CAAC,MAAM,CAAC,CAAC;gBAClC,CAAC;gBAED,IAAI,EAAE,CAAC;YACT,CAAC;YAAC,MAAM,CAAC;gBACP,OAAO,IAAI,CAAC,IAAI,uBAAS,CAAC,cAAc,EAAE,GAAG,CAAC,CAAC,CAAC;YAClD,CAAC;QACH,CAAC,CAAC;IACJ,CAAC,CAAC;IAEF;;;;OAIG;IACH,MAAM,SAAS,GAAG,CAAC,UAAkB,EAAkB,EAAE,CACvD,KAAK,CAAC,CAAC,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,EAAE;QAC/B,MAAM,OAAO,GACX,sBAAsB,IAAI,UAAU,CAAC,QAAQ,CAAC,OAAO,CAAC;YACpD,CAAC,CAAC,cAAc,CAAC,QAAQ,EAAE,UAAU,CAAC;YACtC,CAAC,CAAC,IAAA,gCAAa,EAAC,QAAQ,EAAE,UAAU,CAAC,CAAC;QAC1C,OAAO,OAAO,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IACvC,CAAC,CAAC,CAAC;IAEL;;;;;;;;OAQG;IACH,MAAM,YAAY,GAAG,CAAC,WAAqB,EAAkB,EAAE,CAC7D,KAAK,CAAC,WAAW,EAAE,CAAC,QAAQ,EAAE,EAAE;QAC9B,IAAI,CAAC,IAAA,mCAAgB,EAAC,QAAQ,EAAE,WAAW,CAAC;YAAE,OAAO,IAAI,CAAC;QAC1D,MAAM,IAAI,GAAG,IAAA,gCAAa,EAAC,QAAQ,CAAC,CAAC;QACrC,OAAO,WAAW,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,EAAE,WAAW,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;IAC7D,CAAC,CAAC,CAAC;IAEL,OAAO,EAAE,SAAS,EAAE,YAAY,EAAE,CAAC;AACrC,CAAC"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { SequentialCascade } from "@guisao-llc/gambit-cascade";
|
|
2
|
+
/**
|
|
3
|
+
* Deleting a role, and deleting a permission.
|
|
4
|
+
*
|
|
5
|
+
* Both exist because roles and permissions are referenced BY STRING, not by
|
|
6
|
+
* id: a role is identified downstream by its `name`, and a permission by its
|
|
7
|
+
* `key`, which roles store in a plain array. Mongo will not clean either up,
|
|
8
|
+
* so removing one means purging strings out of documents that point at it.
|
|
9
|
+
*
|
|
10
|
+
* Both cascades therefore run keyed on a STRING rather than an ObjectId, which
|
|
11
|
+
* is why the runner is generic over its arguments at all.
|
|
12
|
+
*/
|
|
13
|
+
/** The narrowest slice of a mongoose model these cascades need. */
|
|
14
|
+
export interface RoleCollection {
|
|
15
|
+
deleteOne(filter: Record<string, unknown>): Promise<unknown>;
|
|
16
|
+
updateMany(filter: Record<string, unknown>, update: Record<string, unknown>): Promise<unknown>;
|
|
17
|
+
}
|
|
18
|
+
export interface PermissionCollection {
|
|
19
|
+
deleteOne(filter: Record<string, unknown>): Promise<unknown>;
|
|
20
|
+
}
|
|
21
|
+
export interface RoleDeletionConfig {
|
|
22
|
+
/** The app's role model. */
|
|
23
|
+
roleModel: RoleCollection;
|
|
24
|
+
/**
|
|
25
|
+
* Remove `roleName` from every account that currently holds it.
|
|
26
|
+
*
|
|
27
|
+
* INJECTED, and this is the seam that made the whole RBAC vertical portable.
|
|
28
|
+
* Clearing a deleted role off the accounts holding it is genuinely RBAC's
|
|
29
|
+
* business — but the ACCOUNT MODEL is not. One app's carries a school id and
|
|
30
|
+
* an account kind; another's carries something else entirely. So the cascade
|
|
31
|
+
* asks for one capability and the app supplies it.
|
|
32
|
+
*
|
|
33
|
+
* Accounts left with no role fall back to "no permissions", which is the
|
|
34
|
+
* safer default: a stale role string would otherwise reach the resolver,
|
|
35
|
+
* which cannot resolve it and cannot report that it failed to.
|
|
36
|
+
*/
|
|
37
|
+
clearRoleFromAccounts: (roleName: string) => Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Clear the role off its accounts, THEN delete it.
|
|
41
|
+
*
|
|
42
|
+
* The ordering is load-bearing. Both steps resolve the role by NAME, so
|
|
43
|
+
* deleting the document first destroys the only handle the account step has —
|
|
44
|
+
* leaving every holder pointing at a role that no longer exists.
|
|
45
|
+
*/
|
|
46
|
+
export declare function buildRoleDeletion(config: RoleDeletionConfig): SequentialCascade<[string]>;
|
|
47
|
+
export interface PermissionDeletionConfig {
|
|
48
|
+
roleModel: RoleCollection;
|
|
49
|
+
permissionModel: PermissionCollection;
|
|
50
|
+
}
|
|
51
|
+
export declare function buildPermissionDeletion(config: PermissionDeletionConfig): SequentialCascade<[string]>;
|
|
52
|
+
//# sourceMappingURL=cascades.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cascades.d.ts","sourceRoot":"","sources":["../src/cascades.ts"],"names":[],"mappings":"AAAA,OAAO,EAAoB,iBAAiB,EAAE,MAAM,4BAA4B,CAAC;AAEjF;;;;;;;;;;GAUG;AAEH,mEAAmE;AACnE,MAAM,WAAW,cAAc;IAC7B,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC7D,UAAU,CACR,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC9B,OAAO,CAAC,OAAO,CAAC,CAAC;CACrB;AAED,MAAM,WAAW,oBAAoB;IACnC,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CAC9D;AAED,MAAM,WAAW,kBAAkB;IACjC,4BAA4B;IAC5B,SAAS,EAAE,cAAc,CAAC;IAC1B;;;;;;;;;;;;OAYG;IACH,qBAAqB,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5D;AAiBD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,kBAAkB,GAAG,iBAAiB,CAAC,CAAC,MAAM,CAAC,CAAC,CAKzF;AAED,MAAM,WAAW,wBAAwB;IACvC,SAAS,EAAE,cAAc,CAAC;IAC1B,eAAe,EAAE,oBAAoB,CAAC;CACvC;AA2BD,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,wBAAwB,GAC/B,iBAAiB,CAAC,CAAC,MAAM,CAAC,CAAC,CAK7B"}
|
package/dist/cascades.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildRoleDeletion = buildRoleDeletion;
|
|
4
|
+
exports.buildPermissionDeletion = buildPermissionDeletion;
|
|
5
|
+
const gambit_cascade_1 = require("@guisao-llc/gambit-cascade");
|
|
6
|
+
class ClearRoleFromAccounts {
|
|
7
|
+
constructor(clear) {
|
|
8
|
+
this.clear = clear;
|
|
9
|
+
}
|
|
10
|
+
async execute(roleName) {
|
|
11
|
+
await this.clear(roleName);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** Runs LAST, so the name is still resolvable for every leaf before it. */
|
|
15
|
+
class DeleteRoleRecord {
|
|
16
|
+
constructor(roleModel) {
|
|
17
|
+
this.roleModel = roleModel;
|
|
18
|
+
}
|
|
19
|
+
async execute(roleName) {
|
|
20
|
+
await this.roleModel.deleteOne({ name: roleName });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Clear the role off its accounts, THEN delete it.
|
|
25
|
+
*
|
|
26
|
+
* The ordering is load-bearing. Both steps resolve the role by NAME, so
|
|
27
|
+
* deleting the document first destroys the only handle the account step has —
|
|
28
|
+
* leaving every holder pointing at a role that no longer exists.
|
|
29
|
+
*/
|
|
30
|
+
function buildRoleDeletion(config) {
|
|
31
|
+
return new gambit_cascade_1.SequentialCascade([
|
|
32
|
+
new ClearRoleFromAccounts(config.clearRoleFromAccounts),
|
|
33
|
+
new DeleteRoleRecord(config.roleModel),
|
|
34
|
+
]);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Pull the key out of every role that holds it.
|
|
38
|
+
*
|
|
39
|
+
* A boot-time catalog sync typically prunes unknown keys too, but only on the
|
|
40
|
+
* NEXT boot. This is the runtime mirror, so an authorization decision made a
|
|
41
|
+
* second after the delete does not consult a key that is gone.
|
|
42
|
+
*/
|
|
43
|
+
class PurgeKeyFromRolePermissions {
|
|
44
|
+
constructor(roleModel) {
|
|
45
|
+
this.roleModel = roleModel;
|
|
46
|
+
}
|
|
47
|
+
async execute(permissionKey) {
|
|
48
|
+
await this.roleModel.updateMany({ permissions: permissionKey }, { $pull: { permissions: permissionKey } });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Runs LAST, for the same reason DeleteRoleRecord does. */
|
|
52
|
+
class DeletePermissionRecord {
|
|
53
|
+
constructor(permissionModel) {
|
|
54
|
+
this.permissionModel = permissionModel;
|
|
55
|
+
}
|
|
56
|
+
async execute(permissionKey) {
|
|
57
|
+
await this.permissionModel.deleteOne({ key: permissionKey });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function buildPermissionDeletion(config) {
|
|
61
|
+
return new gambit_cascade_1.SequentialCascade([
|
|
62
|
+
new PurgeKeyFromRolePermissions(config.roleModel),
|
|
63
|
+
new DeletePermissionRecord(config.permissionModel),
|
|
64
|
+
]);
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=cascades.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cascades.js","sourceRoot":"","sources":["../src/cascades.ts"],"names":[],"mappings":";;AAoEA,8CAKC;AAgCD,0DAOC;AAhHD,+DAAiF;AA8CjF,MAAM,qBAAqB;IACzB,YAA6B,KAAkD;QAAlD,UAAK,GAAL,KAAK,CAA6C;IAAG,CAAC;IACnF,KAAK,CAAC,OAAO,CAAC,QAAgB;QAC5B,MAAM,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;IAC7B,CAAC;CACF;AAED,2EAA2E;AAC3E,MAAM,gBAAgB;IACpB,YAA6B,SAAyB;QAAzB,cAAS,GAAT,SAAS,CAAgB;IAAG,CAAC;IAC1D,KAAK,CAAC,OAAO,CAAC,QAAgB;QAC5B,MAAM,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC,CAAC;IACrD,CAAC;CACF;AAED;;;;;;GAMG;AACH,SAAgB,iBAAiB,CAAC,MAA0B;IAC1D,OAAO,IAAI,kCAAiB,CAAW;QACrC,IAAI,qBAAqB,CAAC,MAAM,CAAC,qBAAqB,CAAC;QACvD,IAAI,gBAAgB,CAAC,MAAM,CAAC,SAAS,CAAC;KACvC,CAAC,CAAC;AACL,CAAC;AAOD;;;;;;GAMG;AACH,MAAM,2BAA2B;IAC/B,YAA6B,SAAyB;QAAzB,cAAS,GAAT,SAAS,CAAgB;IAAG,CAAC;IAC1D,KAAK,CAAC,OAAO,CAAC,aAAqB;QACjC,MAAM,IAAI,CAAC,SAAS,CAAC,UAAU,CAC7B,EAAE,WAAW,EAAE,aAAa,EAAE,EAC9B,EAAE,KAAK,EAAE,EAAE,WAAW,EAAE,aAAa,EAAE,EAAE,CAC1C,CAAC;IACJ,CAAC;CACF;AAED,4DAA4D;AAC5D,MAAM,sBAAsB;IAC1B,YAA6B,eAAqC;QAArC,oBAAe,GAAf,eAAe,CAAsB;IAAG,CAAC;IACtE,KAAK,CAAC,OAAO,CAAC,aAAqB;QACjC,MAAM,IAAI,CAAC,eAAe,CAAC,SAAS,CAAC,EAAE,GAAG,EAAE,aAAa,EAAE,CAAC,CAAC;IAC/D,CAAC;CACF;AAED,SAAgB,uBAAuB,CACrC,MAAgC;IAEhC,OAAO,IAAI,kCAAiB,CAAW;QACrC,IAAI,2BAA2B,CAAC,MAAM,CAAC,SAAS,CAAC;QACjD,IAAI,sBAAsB,CAAC,MAAM,CAAC,eAAe,CAAC;KACnD,CAAC,CAAC;AACL,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { RequestHandler } from "express";
|
|
2
|
+
import { CachedRole } from "./permission-cache";
|
|
3
|
+
/**
|
|
4
|
+
* @guisao-llc/gambit-rbac
|
|
5
|
+
*
|
|
6
|
+
* Roles, permissions, and the middleware that enforces them.
|
|
7
|
+
*
|
|
8
|
+
* The whole vertical is portable because every place it would otherwise reach
|
|
9
|
+
* for something app-shaped, it takes that thing as configuration instead: the
|
|
10
|
+
* cache is given a loader rather than a model, the cascades are given models
|
|
11
|
+
* rather than importing them, and the role schema is a fragment so the app owns
|
|
12
|
+
* its tenant field. What is left names no application.
|
|
13
|
+
*/
|
|
14
|
+
export { configurePermissionCache, getPermissions, hasPermission, hasAnyPermission, listRoleNamesWithPermission, getCachedRole, refreshCache, _seedCacheForTesting, _resetForTesting, } from "./permission-cache";
|
|
15
|
+
export type { CachedRole, LoadedRole, PermissionCacheConfig } from "./permission-cache";
|
|
16
|
+
export { createAuthorize } from "./authorize";
|
|
17
|
+
export type { AuthClaims, AuthorizeConfig, EntitlementCheck, EntitlementContext, } from "./authorize";
|
|
18
|
+
export { roleFields, permissionFields, PERMISSION_ACTIONS } from "./schema";
|
|
19
|
+
export type { RoleFieldsConfig, PermissionFieldsConfig, PermissionAction, } from "./schema";
|
|
20
|
+
export { buildRoleDeletion, buildPermissionDeletion } from "./cascades";
|
|
21
|
+
export type { RoleDeletionConfig, PermissionDeletionConfig, RoleCollection, PermissionCollection, } from "./cascades";
|
|
22
|
+
/** Does this role hold the permission? Undefined role answers false. */
|
|
23
|
+
export declare function can(roleName: string | undefined, permission: string): boolean;
|
|
24
|
+
/** Does this role hold at least one of these? Undefined role answers false. */
|
|
25
|
+
export declare function canAny(roleName: string | undefined, permissions: string[]): boolean;
|
|
26
|
+
export interface PlatformAdminConfig {
|
|
27
|
+
/**
|
|
28
|
+
* Decide whether a role is a platform administrator.
|
|
29
|
+
*
|
|
30
|
+
* Required, with no default, on purpose. This gate sits ABOVE every tenant —
|
|
31
|
+
* it guards settings that affect all of them at once — so a package must not
|
|
32
|
+
* guess at what qualifies. An app that gets this wrong grants the keys to
|
|
33
|
+
* everything, and a silent default is exactly how that happens.
|
|
34
|
+
*/
|
|
35
|
+
isPlatformAdmin: (role: CachedRole | undefined) => boolean;
|
|
36
|
+
/** Where the role name lives on the request. Defaults to `req.user.roles`. */
|
|
37
|
+
getRoleName?: (req: unknown) => string | undefined;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* "Is the caller a platform-level administrator?"
|
|
41
|
+
*
|
|
42
|
+
* Mount AFTER authentication — this never verifies a token itself, which keeps
|
|
43
|
+
* exactly one place doing that.
|
|
44
|
+
*/
|
|
45
|
+
export declare function createRequirePlatformAdmin(config: PlatformAdminConfig): RequestHandler;
|
|
46
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAEzC,OAAO,EAAE,UAAU,EAAkD,MAAM,oBAAoB,CAAC;AAEhG;;;;;;;;;;GAUG;AAEH,OAAO,EACL,wBAAwB,EACxB,cAAc,EACd,aAAa,EACb,gBAAgB,EAChB,2BAA2B,EAC3B,aAAa,EACb,YAAY,EACZ,oBAAoB,EACpB,gBAAgB,GACjB,MAAM,oBAAoB,CAAC;AAC5B,YAAY,EAAE,UAAU,EAAE,UAAU,EAAE,qBAAqB,EAAE,MAAM,oBAAoB,CAAC;AAExF,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC9C,YAAY,EACV,UAAU,EACV,eAAe,EACf,gBAAgB,EAChB,kBAAkB,GACnB,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,UAAU,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAC;AAC5E,YAAY,EACV,gBAAgB,EAChB,sBAAsB,EACtB,gBAAgB,GACjB,MAAM,UAAU,CAAC;AAElB,OAAO,EAAE,iBAAiB,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAC;AACxE,YAAY,EACV,kBAAkB,EAClB,wBAAwB,EACxB,cAAc,EACd,oBAAoB,GACrB,MAAM,YAAY,CAAC;AAIpB,wEAAwE;AACxE,wBAAgB,GAAG,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAG7E;AAED,+EAA+E;AAC/E,wBAAgB,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAAE,WAAW,EAAE,MAAM,EAAE,GAAG,OAAO,CAGnF;AAID,MAAM,WAAW,mBAAmB;IAClC;;;;;;;OAOG;IACH,eAAe,EAAE,CAAC,IAAI,EAAE,UAAU,GAAG,SAAS,KAAK,OAAO,CAAC;IAC3D,8EAA8E;IAC9E,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,MAAM,GAAG,SAAS,CAAC;CACpD;AAED;;;;;GAKG;AACH,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,mBAAmB,GAAG,cAAc,CAgBtF"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildPermissionDeletion = exports.buildRoleDeletion = exports.PERMISSION_ACTIONS = exports.permissionFields = exports.roleFields = exports.createAuthorize = exports._resetForTesting = exports._seedCacheForTesting = exports.refreshCache = exports.getCachedRole = exports.listRoleNamesWithPermission = exports.hasAnyPermission = exports.hasPermission = exports.getPermissions = exports.configurePermissionCache = void 0;
|
|
4
|
+
exports.can = can;
|
|
5
|
+
exports.canAny = canAny;
|
|
6
|
+
exports.createRequirePlatformAdmin = createRequirePlatformAdmin;
|
|
7
|
+
const gambit_auth_1 = require("@guisao-llc/gambit-auth");
|
|
8
|
+
const permission_cache_1 = require("./permission-cache");
|
|
9
|
+
/**
|
|
10
|
+
* @guisao-llc/gambit-rbac
|
|
11
|
+
*
|
|
12
|
+
* Roles, permissions, and the middleware that enforces them.
|
|
13
|
+
*
|
|
14
|
+
* The whole vertical is portable because every place it would otherwise reach
|
|
15
|
+
* for something app-shaped, it takes that thing as configuration instead: the
|
|
16
|
+
* cache is given a loader rather than a model, the cascades are given models
|
|
17
|
+
* rather than importing them, and the role schema is a fragment so the app owns
|
|
18
|
+
* its tenant field. What is left names no application.
|
|
19
|
+
*/
|
|
20
|
+
var permission_cache_2 = require("./permission-cache");
|
|
21
|
+
Object.defineProperty(exports, "configurePermissionCache", { enumerable: true, get: function () { return permission_cache_2.configurePermissionCache; } });
|
|
22
|
+
Object.defineProperty(exports, "getPermissions", { enumerable: true, get: function () { return permission_cache_2.getPermissions; } });
|
|
23
|
+
Object.defineProperty(exports, "hasPermission", { enumerable: true, get: function () { return permission_cache_2.hasPermission; } });
|
|
24
|
+
Object.defineProperty(exports, "hasAnyPermission", { enumerable: true, get: function () { return permission_cache_2.hasAnyPermission; } });
|
|
25
|
+
Object.defineProperty(exports, "listRoleNamesWithPermission", { enumerable: true, get: function () { return permission_cache_2.listRoleNamesWithPermission; } });
|
|
26
|
+
Object.defineProperty(exports, "getCachedRole", { enumerable: true, get: function () { return permission_cache_2.getCachedRole; } });
|
|
27
|
+
Object.defineProperty(exports, "refreshCache", { enumerable: true, get: function () { return permission_cache_2.refreshCache; } });
|
|
28
|
+
Object.defineProperty(exports, "_seedCacheForTesting", { enumerable: true, get: function () { return permission_cache_2._seedCacheForTesting; } });
|
|
29
|
+
Object.defineProperty(exports, "_resetForTesting", { enumerable: true, get: function () { return permission_cache_2._resetForTesting; } });
|
|
30
|
+
var authorize_1 = require("./authorize");
|
|
31
|
+
Object.defineProperty(exports, "createAuthorize", { enumerable: true, get: function () { return authorize_1.createAuthorize; } });
|
|
32
|
+
var schema_1 = require("./schema");
|
|
33
|
+
Object.defineProperty(exports, "roleFields", { enumerable: true, get: function () { return schema_1.roleFields; } });
|
|
34
|
+
Object.defineProperty(exports, "permissionFields", { enumerable: true, get: function () { return schema_1.permissionFields; } });
|
|
35
|
+
Object.defineProperty(exports, "PERMISSION_ACTIONS", { enumerable: true, get: function () { return schema_1.PERMISSION_ACTIONS; } });
|
|
36
|
+
var cascades_1 = require("./cascades");
|
|
37
|
+
Object.defineProperty(exports, "buildRoleDeletion", { enumerable: true, get: function () { return cascades_1.buildRoleDeletion; } });
|
|
38
|
+
Object.defineProperty(exports, "buildPermissionDeletion", { enumerable: true, get: function () { return cascades_1.buildPermissionDeletion; } });
|
|
39
|
+
// ── Direct questions, for code that is not middleware ────────────────────────
|
|
40
|
+
/** Does this role hold the permission? Undefined role answers false. */
|
|
41
|
+
function can(roleName, permission) {
|
|
42
|
+
if (!roleName)
|
|
43
|
+
return false;
|
|
44
|
+
return (0, permission_cache_1.hasPermission)(roleName, permission);
|
|
45
|
+
}
|
|
46
|
+
/** Does this role hold at least one of these? Undefined role answers false. */
|
|
47
|
+
function canAny(roleName, permissions) {
|
|
48
|
+
if (!roleName)
|
|
49
|
+
return false;
|
|
50
|
+
return (0, permission_cache_1.hasAnyPermission)(roleName, permissions);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* "Is the caller a platform-level administrator?"
|
|
54
|
+
*
|
|
55
|
+
* Mount AFTER authentication — this never verifies a token itself, which keeps
|
|
56
|
+
* exactly one place doing that.
|
|
57
|
+
*/
|
|
58
|
+
function createRequirePlatformAdmin(config) {
|
|
59
|
+
const { isPlatformAdmin, getRoleName = (req) => req.user?.roles, } = config;
|
|
60
|
+
return (req, _res, next) => {
|
|
61
|
+
const roleName = getRoleName(req);
|
|
62
|
+
if (!roleName || !isPlatformAdmin((0, permission_cache_1.getCachedRole)(roleName))) {
|
|
63
|
+
return next(new gambit_auth_1.HttpError("Only a platform administrator can change this setting", 403));
|
|
64
|
+
}
|
|
65
|
+
return next();
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;AAuDA,kBAGC;AAGD,wBAGC;AAwBD,gEAgBC;AAvGD,yDAAoD;AACpD,yDAAgG;AAEhG;;;;;;;;;;GAUG;AAEH,uDAU4B;AAT1B,4HAAA,wBAAwB,OAAA;AACxB,kHAAA,cAAc,OAAA;AACd,iHAAA,aAAa,OAAA;AACb,oHAAA,gBAAgB,OAAA;AAChB,+HAAA,2BAA2B,OAAA;AAC3B,iHAAA,aAAa,OAAA;AACb,gHAAA,YAAY,OAAA;AACZ,wHAAA,oBAAoB,OAAA;AACpB,oHAAA,gBAAgB,OAAA;AAIlB,yCAA8C;AAArC,4GAAA,eAAe,OAAA;AAQxB,mCAA4E;AAAnE,oGAAA,UAAU,OAAA;AAAE,0GAAA,gBAAgB,OAAA;AAAE,4GAAA,kBAAkB,OAAA;AAOzD,uCAAwE;AAA/D,6GAAA,iBAAiB,OAAA;AAAE,mHAAA,uBAAuB,OAAA;AAQnD,gFAAgF;AAEhF,wEAAwE;AACxE,SAAgB,GAAG,CAAC,QAA4B,EAAE,UAAkB;IAClE,IAAI,CAAC,QAAQ;QAAE,OAAO,KAAK,CAAC;IAC5B,OAAO,IAAA,gCAAa,EAAC,QAAQ,EAAE,UAAU,CAAC,CAAC;AAC7C,CAAC;AAED,+EAA+E;AAC/E,SAAgB,MAAM,CAAC,QAA4B,EAAE,WAAqB;IACxE,IAAI,CAAC,QAAQ;QAAE,OAAO,KAAK,CAAC;IAC5B,OAAO,IAAA,mCAAgB,EAAC,QAAQ,EAAE,WAAW,CAAC,CAAC;AACjD,CAAC;AAkBD;;;;;GAKG;AACH,SAAgB,0BAA0B,CAAC,MAA2B;IACpE,MAAM,EACJ,eAAe,EACf,WAAW,GAAG,CAAC,GAAG,EAAE,EAAE,CACnB,GAAqC,CAAC,IAAI,EAAE,KAAK,GACrD,GAAG,MAAM,CAAC;IAEX,OAAO,CAAC,GAAG,EAAE,IAAI,EAAE,IAAI,EAAE,EAAE;QACzB,MAAM,QAAQ,GAAG,WAAW,CAAC,GAAG,CAAC,CAAC;QAClC,IAAI,CAAC,QAAQ,IAAI,CAAC,eAAe,CAAC,IAAA,gCAAa,EAAC,QAAQ,CAAC,CAAC,EAAE,CAAC;YAC3D,OAAO,IAAI,CACT,IAAI,uBAAS,CAAC,uDAAuD,EAAE,GAAG,CAAC,CAC5E,CAAC;QACJ,CAAC;QACD,OAAO,IAAI,EAAE,CAAC;IAChB,CAAC,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Role → permission cache.
|
|
3
|
+
*
|
|
4
|
+
* Every authorization decision reads from here, so it is a synchronous
|
|
5
|
+
* in-memory map rather than a query. It knows nothing about tenants: this
|
|
6
|
+
* answers "what may this ROLE do", which is the same question in any app.
|
|
7
|
+
*
|
|
8
|
+
* ── Why the loader is injected, and the cache is not ─────────────────────────
|
|
9
|
+
*
|
|
10
|
+
* The cache itself is module-level state, deliberately. Every consumer imports
|
|
11
|
+
* `hasPermission` directly and expects the same cache; handing out instances
|
|
12
|
+
* would mean threading one through every call site for no benefit, since a
|
|
13
|
+
* process has exactly one set of roles.
|
|
14
|
+
*
|
|
15
|
+
* But WHERE roles come from is the app's business — its model, its tenant
|
|
16
|
+
* scoping, its connection. So `refreshCache` does not import a model; it calls
|
|
17
|
+
* the loader the app registered. Same rule as everywhere else here: a shared
|
|
18
|
+
* module must not PICK the thing it depends on, it must be GIVEN it.
|
|
19
|
+
*/
|
|
20
|
+
export interface CachedRole {
|
|
21
|
+
permissions: Set<string>;
|
|
22
|
+
bypassFeatureChecks: boolean;
|
|
23
|
+
}
|
|
24
|
+
/** The shape `loadRoles` must return. Anything else about a role is the app's. */
|
|
25
|
+
export interface LoadedRole {
|
|
26
|
+
name: string;
|
|
27
|
+
permissions: string[];
|
|
28
|
+
bypassFeatureChecks?: boolean;
|
|
29
|
+
}
|
|
30
|
+
export interface PermissionCacheConfig {
|
|
31
|
+
/** Read every role. Called by `refreshCache` on boot and after role writes. */
|
|
32
|
+
loadRoles: () => Promise<LoadedRole[]>;
|
|
33
|
+
/** Where to report a refresh. Defaults to no output — a library should be quiet. */
|
|
34
|
+
onRefresh?: (roleCount: number) => void;
|
|
35
|
+
}
|
|
36
|
+
/** Register how roles are loaded. Call once, during app startup. */
|
|
37
|
+
export declare function configurePermissionCache(config: PermissionCacheConfig): void;
|
|
38
|
+
/** Every permission held by a role. Empty set when the role is unknown. */
|
|
39
|
+
export declare function getPermissions(roleName: string): Set<string>;
|
|
40
|
+
/** Does the role hold this exact permission? */
|
|
41
|
+
export declare function hasPermission(roleName: string, permission: string): boolean;
|
|
42
|
+
/** Does the role hold at least one of these? */
|
|
43
|
+
export declare function hasAnyPermission(roleName: string, permissions: string[]): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Which roles hold this permission.
|
|
46
|
+
*
|
|
47
|
+
* Lenient about junk input — empty or whitespace returns `[]` rather than
|
|
48
|
+
* throwing — to match `hasPermission`, which answers `false` for an unknown
|
|
49
|
+
* role instead of failing. Never mutates the cache.
|
|
50
|
+
*/
|
|
51
|
+
export declare function listRoleNamesWithPermission(permission: string): string[];
|
|
52
|
+
/** The whole cached entry, including `bypassFeatureChecks`. */
|
|
53
|
+
export declare function getCachedRole(roleName: string): CachedRole | undefined;
|
|
54
|
+
/**
|
|
55
|
+
* Rebuild from the loader. Call on boot and after any role write.
|
|
56
|
+
*
|
|
57
|
+
* Throws on failure rather than swallowing: a silent refresh failure leaves
|
|
58
|
+
* the cache stale after a write, so the app keeps enforcing permissions that
|
|
59
|
+
* were just changed — and nothing anywhere says so.
|
|
60
|
+
*/
|
|
61
|
+
export declare function refreshCache(): Promise<void>;
|
|
62
|
+
/**
|
|
63
|
+
* Seed the cache directly. TESTS ONLY — never call this in production code.
|
|
64
|
+
*
|
|
65
|
+
* Clears before seeding, which matters more than it looks: a test that seeds
|
|
66
|
+
* the role under test and expects a previously-seeded admin to survive gets
|
|
67
|
+
* neither. Seed every role a single assertion needs in ONE call.
|
|
68
|
+
*/
|
|
69
|
+
export declare function _seedCacheForTesting(entries: Record<string, string[] | {
|
|
70
|
+
permissions: string[];
|
|
71
|
+
bypassFeatureChecks: boolean;
|
|
72
|
+
}>): void;
|
|
73
|
+
/** Drop the loader and the contents. Tests that call `refreshCache` need this. */
|
|
74
|
+
export declare function _resetForTesting(): void;
|
|
75
|
+
//# sourceMappingURL=permission-cache.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"permission-cache.d.ts","sourceRoot":"","sources":["../src/permission-cache.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,MAAM,WAAW,UAAU;IACzB,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACzB,mBAAmB,EAAE,OAAO,CAAC;CAC9B;AAED,kFAAkF;AAClF,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,mBAAmB,CAAC,EAAE,OAAO,CAAC;CAC/B;AAED,MAAM,WAAW,qBAAqB;IACpC,+EAA+E;IAC/E,SAAS,EAAE,MAAM,OAAO,CAAC,UAAU,EAAE,CAAC,CAAC;IACvC,oFAAoF;IACpF,SAAS,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;CACzC;AAKD,oEAAoE;AACpE,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,qBAAqB,GAAG,IAAI,CAE5E;AAED,2EAA2E;AAC3E,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,CAE5D;AAED,gDAAgD;AAChD,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAE3E;AAED,gDAAgD;AAChD,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,GAAG,OAAO,CAIjF;AAED;;;;;;GAMG;AACH,wBAAgB,2BAA2B,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,EAAE,CAOxE;AAED,+DAA+D;AAC/D,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,UAAU,GAAG,SAAS,CAEtE;AAED;;;;;;GAMG;AACH,wBAAsB,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC,CAiBlD;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,MAAM,CACb,MAAM,EACN,MAAM,EAAE,GAAG;IAAE,WAAW,EAAE,MAAM,EAAE,CAAC;IAAC,mBAAmB,EAAE,OAAO,CAAA;CAAE,CACnE,GACA,IAAI,CAaN;AAED,kFAAkF;AAClF,wBAAgB,gBAAgB,IAAI,IAAI,CAGvC"}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Role → permission cache.
|
|
4
|
+
*
|
|
5
|
+
* Every authorization decision reads from here, so it is a synchronous
|
|
6
|
+
* in-memory map rather than a query. It knows nothing about tenants: this
|
|
7
|
+
* answers "what may this ROLE do", which is the same question in any app.
|
|
8
|
+
*
|
|
9
|
+
* ── Why the loader is injected, and the cache is not ─────────────────────────
|
|
10
|
+
*
|
|
11
|
+
* The cache itself is module-level state, deliberately. Every consumer imports
|
|
12
|
+
* `hasPermission` directly and expects the same cache; handing out instances
|
|
13
|
+
* would mean threading one through every call site for no benefit, since a
|
|
14
|
+
* process has exactly one set of roles.
|
|
15
|
+
*
|
|
16
|
+
* But WHERE roles come from is the app's business — its model, its tenant
|
|
17
|
+
* scoping, its connection. So `refreshCache` does not import a model; it calls
|
|
18
|
+
* the loader the app registered. Same rule as everywhere else here: a shared
|
|
19
|
+
* module must not PICK the thing it depends on, it must be GIVEN it.
|
|
20
|
+
*/
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.configurePermissionCache = configurePermissionCache;
|
|
23
|
+
exports.getPermissions = getPermissions;
|
|
24
|
+
exports.hasPermission = hasPermission;
|
|
25
|
+
exports.hasAnyPermission = hasAnyPermission;
|
|
26
|
+
exports.listRoleNamesWithPermission = listRoleNamesWithPermission;
|
|
27
|
+
exports.getCachedRole = getCachedRole;
|
|
28
|
+
exports.refreshCache = refreshCache;
|
|
29
|
+
exports._seedCacheForTesting = _seedCacheForTesting;
|
|
30
|
+
exports._resetForTesting = _resetForTesting;
|
|
31
|
+
const _cache = new Map();
|
|
32
|
+
let _config;
|
|
33
|
+
/** Register how roles are loaded. Call once, during app startup. */
|
|
34
|
+
function configurePermissionCache(config) {
|
|
35
|
+
_config = config;
|
|
36
|
+
}
|
|
37
|
+
/** Every permission held by a role. Empty set when the role is unknown. */
|
|
38
|
+
function getPermissions(roleName) {
|
|
39
|
+
return _cache.get(roleName)?.permissions ?? new Set();
|
|
40
|
+
}
|
|
41
|
+
/** Does the role hold this exact permission? */
|
|
42
|
+
function hasPermission(roleName, permission) {
|
|
43
|
+
return _cache.get(roleName)?.permissions.has(permission) ?? false;
|
|
44
|
+
}
|
|
45
|
+
/** Does the role hold at least one of these? */
|
|
46
|
+
function hasAnyPermission(roleName, permissions) {
|
|
47
|
+
const entry = _cache.get(roleName);
|
|
48
|
+
if (!entry)
|
|
49
|
+
return false;
|
|
50
|
+
return permissions.some((p) => entry.permissions.has(p));
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Which roles hold this permission.
|
|
54
|
+
*
|
|
55
|
+
* Lenient about junk input — empty or whitespace returns `[]` rather than
|
|
56
|
+
* throwing — to match `hasPermission`, which answers `false` for an unknown
|
|
57
|
+
* role instead of failing. Never mutates the cache.
|
|
58
|
+
*/
|
|
59
|
+
function listRoleNamesWithPermission(permission) {
|
|
60
|
+
if (typeof permission !== "string" || permission.trim() === "")
|
|
61
|
+
return [];
|
|
62
|
+
const names = [];
|
|
63
|
+
for (const [roleName, entry] of _cache.entries()) {
|
|
64
|
+
if (entry.permissions.has(permission))
|
|
65
|
+
names.push(roleName);
|
|
66
|
+
}
|
|
67
|
+
return names;
|
|
68
|
+
}
|
|
69
|
+
/** The whole cached entry, including `bypassFeatureChecks`. */
|
|
70
|
+
function getCachedRole(roleName) {
|
|
71
|
+
return _cache.get(roleName);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Rebuild from the loader. Call on boot and after any role write.
|
|
75
|
+
*
|
|
76
|
+
* Throws on failure rather than swallowing: a silent refresh failure leaves
|
|
77
|
+
* the cache stale after a write, so the app keeps enforcing permissions that
|
|
78
|
+
* were just changed — and nothing anywhere says so.
|
|
79
|
+
*/
|
|
80
|
+
async function refreshCache() {
|
|
81
|
+
if (!_config) {
|
|
82
|
+
throw new Error("gambit-rbac: refreshCache() before configurePermissionCache(). " +
|
|
83
|
+
"Register a loadRoles during startup.");
|
|
84
|
+
}
|
|
85
|
+
const roles = await _config.loadRoles();
|
|
86
|
+
_cache.clear();
|
|
87
|
+
for (const role of roles) {
|
|
88
|
+
_cache.set(role.name, {
|
|
89
|
+
permissions: new Set(role.permissions),
|
|
90
|
+
bypassFeatureChecks: role.bypassFeatureChecks ?? false,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
_config.onRefresh?.(roles.length);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Seed the cache directly. TESTS ONLY — never call this in production code.
|
|
97
|
+
*
|
|
98
|
+
* Clears before seeding, which matters more than it looks: a test that seeds
|
|
99
|
+
* the role under test and expects a previously-seeded admin to survive gets
|
|
100
|
+
* neither. Seed every role a single assertion needs in ONE call.
|
|
101
|
+
*/
|
|
102
|
+
function _seedCacheForTesting(entries) {
|
|
103
|
+
_cache.clear();
|
|
104
|
+
for (const [role, value] of Object.entries(entries)) {
|
|
105
|
+
_cache.set(role, Array.isArray(value)
|
|
106
|
+
? { permissions: new Set(value), bypassFeatureChecks: false }
|
|
107
|
+
: {
|
|
108
|
+
permissions: new Set(value.permissions),
|
|
109
|
+
bypassFeatureChecks: value.bypassFeatureChecks,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** Drop the loader and the contents. Tests that call `refreshCache` need this. */
|
|
114
|
+
function _resetForTesting() {
|
|
115
|
+
_cache.clear();
|
|
116
|
+
_config = undefined;
|
|
117
|
+
}
|
|
118
|
+
//# sourceMappingURL=permission-cache.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"permission-cache.js","sourceRoot":"","sources":["../src/permission-cache.ts"],"names":[],"mappings":"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"}
|
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { SchemaDefinitionProperty } from "mongoose";
|
|
2
|
+
/**
|
|
3
|
+
* The two records RBAC keeps: a role, and a permission.
|
|
4
|
+
*
|
|
5
|
+
* Schema FRAGMENTS rather than models, for the reason the account and person
|
|
6
|
+
* fragments are: a role belongs to a TENANT, and the tenant is the one thing
|
|
7
|
+
* this package cannot know. One app scopes roles by school, another by
|
|
8
|
+
* practice. A model defined here would have to pick, and picking means every
|
|
9
|
+
* other app carries a field named after somebody else's domain.
|
|
10
|
+
*
|
|
11
|
+
* The app builds the model:
|
|
12
|
+
*
|
|
13
|
+
* const roleSchema = new Schema(
|
|
14
|
+
* roleFields({ tenant: { field: "schoolId", ref: "Schools" } }),
|
|
15
|
+
* { timestamps: true },
|
|
16
|
+
* );
|
|
17
|
+
* roleSchema.index({ name: 1, schoolId: 1 }, { unique: true, sparse: true });
|
|
18
|
+
* export const Roles = mongoose.model("Roles", roleSchema);
|
|
19
|
+
*
|
|
20
|
+
* and hands it to whatever here needs one.
|
|
21
|
+
*/
|
|
22
|
+
export interface RoleFieldsConfig {
|
|
23
|
+
/**
|
|
24
|
+
* Scope roles to an organization. Omit for an app whose roles are global.
|
|
25
|
+
*
|
|
26
|
+
* Optional rather than required because a single-tenant app has no such
|
|
27
|
+
* concept, and forcing one on it would mean a field nothing ever sets.
|
|
28
|
+
*/
|
|
29
|
+
tenant?: {
|
|
30
|
+
field: string;
|
|
31
|
+
ref?: string;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Build the role fields for spreading into an app's schema.
|
|
36
|
+
*
|
|
37
|
+
* `permissions` holds permission KEY STRINGS, not references. That is
|
|
38
|
+
* deliberate and it propagates: because a role is identified downstream by its
|
|
39
|
+
* name and its permissions by their keys, deleting either one has to purge
|
|
40
|
+
* strings out of arrays rather than rely on referential integrity. The cascades
|
|
41
|
+
* in this package exist for exactly that reason.
|
|
42
|
+
*/
|
|
43
|
+
export declare function roleFields(config?: RoleFieldsConfig): Record<string, SchemaDefinitionProperty>;
|
|
44
|
+
/**
|
|
45
|
+
* The actions a permission can express.
|
|
46
|
+
*
|
|
47
|
+
* `add` is in this list because a catalog that uses it and a schema that
|
|
48
|
+
* forbids it is a lie waiting to be found. In the app this was extracted from,
|
|
49
|
+
* fourteen permissions declared `action: "add"` against an enum of
|
|
50
|
+
* `["read", "write", "delete"]` — and nothing failed, because the sync path
|
|
51
|
+
* used `bulkWrite`, which does not run validators by default. The enum was
|
|
52
|
+
* simply not enforced. Any validated write would have rejected those fourteen.
|
|
53
|
+
*/
|
|
54
|
+
export declare const PERMISSION_ACTIONS: readonly ["read", "add", "write", "delete"];
|
|
55
|
+
export type PermissionAction = (typeof PERMISSION_ACTIONS)[number];
|
|
56
|
+
export interface PermissionFieldsConfig {
|
|
57
|
+
/** Override the permitted actions if an app needs a different vocabulary. */
|
|
58
|
+
actions?: readonly string[];
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Build the permission fields. No tenant: a permission is a definition, not a
|
|
62
|
+
* grant. Which ROLE holds it is the tenant-scoped part, and that lives on the
|
|
63
|
+
* role.
|
|
64
|
+
*/
|
|
65
|
+
export declare function permissionFields(config?: PermissionFieldsConfig): Record<string, SchemaDefinitionProperty>;
|
|
66
|
+
//# sourceMappingURL=schema.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAU,wBAAwB,EAAE,MAAM,UAAU,CAAC;AAE5D;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,MAAM,WAAW,gBAAgB;IAC/B;;;;;OAKG;IACH,MAAM,CAAC,EAAE;QACP,KAAK,EAAE,MAAM,CAAC;QACd,GAAG,CAAC,EAAE,MAAM,CAAC;KACd,CAAC;CACH;AAED;;;;;;;;GAQG;AACH,wBAAgB,UAAU,CACxB,MAAM,GAAE,gBAAqB,GAC5B,MAAM,CAAC,MAAM,EAAE,wBAAwB,CAAC,CA8B1C;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,kBAAkB,6CAA8C,CAAC;AAC9E,MAAM,MAAM,gBAAgB,GAAG,CAAC,OAAO,kBAAkB,CAAC,CAAC,MAAM,CAAC,CAAC;AAEnE,MAAM,WAAW,sBAAsB;IACrC,6EAA6E;IAC7E,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC7B;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,GAAE,sBAA2B,GAClC,MAAM,CAAC,MAAM,EAAE,wBAAwB,CAAC,CAS1C"}
|
package/dist/schema.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PERMISSION_ACTIONS = void 0;
|
|
4
|
+
exports.roleFields = roleFields;
|
|
5
|
+
exports.permissionFields = permissionFields;
|
|
6
|
+
const mongoose_1 = require("mongoose");
|
|
7
|
+
/**
|
|
8
|
+
* Build the role fields for spreading into an app's schema.
|
|
9
|
+
*
|
|
10
|
+
* `permissions` holds permission KEY STRINGS, not references. That is
|
|
11
|
+
* deliberate and it propagates: because a role is identified downstream by its
|
|
12
|
+
* name and its permissions by their keys, deleting either one has to purge
|
|
13
|
+
* strings out of arrays rather than rely on referential integrity. The cascades
|
|
14
|
+
* in this package exist for exactly that reason.
|
|
15
|
+
*/
|
|
16
|
+
function roleFields(config = {}) {
|
|
17
|
+
const { tenant } = config;
|
|
18
|
+
const fields = {
|
|
19
|
+
name: { type: String, required: true },
|
|
20
|
+
/** Permission keys, e.g. "invoices:read". Strings, not refs — see above. */
|
|
21
|
+
permissions: { type: [String], default: [] },
|
|
22
|
+
/** A role every tenant sees, rather than one belonging to a single tenant. */
|
|
23
|
+
isGlobal: { type: Boolean, default: false },
|
|
24
|
+
/**
|
|
25
|
+
* Skip the host app's entitlement check entirely.
|
|
26
|
+
*
|
|
27
|
+
* Only meaningful in an app that HAS entitlements (see `createAuthorize`'s
|
|
28
|
+
* `checkEntitlement`). Keep it off the API surface: a role that can grant
|
|
29
|
+
* itself this can grant itself everything the entitlement layer was
|
|
30
|
+
* protecting.
|
|
31
|
+
*/
|
|
32
|
+
bypassFeatureChecks: { type: Boolean, default: false },
|
|
33
|
+
/** Whether the role can be handed out through the app's UI. */
|
|
34
|
+
assignable: { type: Boolean, default: true },
|
|
35
|
+
};
|
|
36
|
+
if (tenant) {
|
|
37
|
+
fields[tenant.field] = {
|
|
38
|
+
type: mongoose_1.Schema.Types.ObjectId,
|
|
39
|
+
...(tenant.ref ? { ref: tenant.ref } : {}),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return fields;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The actions a permission can express.
|
|
46
|
+
*
|
|
47
|
+
* `add` is in this list because a catalog that uses it and a schema that
|
|
48
|
+
* forbids it is a lie waiting to be found. In the app this was extracted from,
|
|
49
|
+
* fourteen permissions declared `action: "add"` against an enum of
|
|
50
|
+
* `["read", "write", "delete"]` — and nothing failed, because the sync path
|
|
51
|
+
* used `bulkWrite`, which does not run validators by default. The enum was
|
|
52
|
+
* simply not enforced. Any validated write would have rejected those fourteen.
|
|
53
|
+
*/
|
|
54
|
+
exports.PERMISSION_ACTIONS = ["read", "add", "write", "delete"];
|
|
55
|
+
/**
|
|
56
|
+
* Build the permission fields. No tenant: a permission is a definition, not a
|
|
57
|
+
* grant. Which ROLE holds it is the tenant-scoped part, and that lives on the
|
|
58
|
+
* role.
|
|
59
|
+
*/
|
|
60
|
+
function permissionFields(config = {}) {
|
|
61
|
+
const { actions = exports.PERMISSION_ACTIONS } = config;
|
|
62
|
+
return {
|
|
63
|
+
key: { type: String, required: true, unique: true },
|
|
64
|
+
label: { type: String, required: true },
|
|
65
|
+
group: { type: String, required: true },
|
|
66
|
+
action: { type: String, enum: [...actions], required: true },
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=schema.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema.js","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":";;;AA6CA,gCAgCC;AAyBD,4CAWC;AAjHD,uCAA4D;AAoC5D;;;;;;;;GAQG;AACH,SAAgB,UAAU,CACxB,SAA2B,EAAE;IAE7B,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,CAAC;IAE1B,MAAM,MAAM,GAA6C;QACvD,IAAI,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;QACtC,4EAA4E;QAC5E,WAAW,EAAE,EAAE,IAAI,EAAE,CAAC,MAAM,CAAC,EAAE,OAAO,EAAE,EAAE,EAAE;QAC5C,8EAA8E;QAC9E,QAAQ,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE;QAC3C;;;;;;;WAOG;QACH,mBAAmB,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE;QACtD,+DAA+D;QAC/D,UAAU,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE;KAC7C,CAAC;IAEF,IAAI,MAAM,EAAE,CAAC;QACX,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG;YACrB,IAAI,EAAE,iBAAM,CAAC,KAAK,CAAC,QAAQ;YAC3B,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SAC3C,CAAC;IACJ,CAAC;IAED,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;;;;;;;;GASG;AACU,QAAA,kBAAkB,GAAG,CAAC,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,QAAQ,CAAU,CAAC;AAQ9E;;;;GAIG;AACH,SAAgB,gBAAgB,CAC9B,SAAiC,EAAE;IAEnC,MAAM,EAAE,OAAO,GAAG,0BAAkB,EAAE,GAAG,MAAM,CAAC;IAEhD,OAAO;QACL,GAAG,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE;QACnD,KAAK,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;QACvC,KAAK,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;QACvC,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,GAAG,OAAO,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE;KAC7D,CAAC;AACJ,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@guisao-llc/gambit-rbac",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Roles, permissions, a permission cache, and the express middleware that enforces them.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/Guisao-LLC/Gambit.git",
|
|
9
|
+
"directory": "packages/gambit-rbac"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/Guisao-LLC/Gambit/tree/main/packages/gambit-rbac",
|
|
12
|
+
"keywords": ["rbac", "permissions", "authorization", "express", "middleware"],
|
|
13
|
+
"engines": { "node": ">=20 <23" },
|
|
14
|
+
"main": "dist/index.js",
|
|
15
|
+
"types": "dist/index.d.ts",
|
|
16
|
+
"files": ["dist"],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc -b",
|
|
19
|
+
"clean": "tsc -b --clean",
|
|
20
|
+
"test": "node --test test/*.test.js",
|
|
21
|
+
"prepublishOnly": "npm run build && npm test && node ../../scripts/prepublish-check.mjs"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@guisao-llc/gambit-auth": "^0.1.0",
|
|
25
|
+
"@guisao-llc/gambit-cascade": "^0.1.0"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"express": "^4.21.0",
|
|
29
|
+
"mongoose": ">=8"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/express": "^4.17.21"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": { "access": "public" }
|
|
35
|
+
}
|