@nwire/rbac 0.7.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/LICENSE +21 -0
- package/README.md +84 -0
- package/dist/__tests__/polish.test.d.ts +9 -0
- package/dist/__tests__/polish.test.d.ts.map +1 -0
- package/dist/__tests__/polish.test.js +130 -0
- package/dist/__tests__/polish.test.js.map +1 -0
- package/dist/__tests__/rbac.test.d.ts +15 -0
- package/dist/__tests__/rbac.test.d.ts.map +1 -0
- package/dist/__tests__/rbac.test.js +139 -0
- package/dist/__tests__/rbac.test.js.map +1 -0
- package/dist/errors.d.ts +81 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +86 -0
- package/dist/errors.js.map +1 -0
- package/dist/filters.d.ts +44 -0
- package/dist/filters.d.ts.map +1 -0
- package/dist/filters.js +34 -0
- package/dist/filters.js.map +1 -0
- package/dist/rbac.d.ts +116 -0
- package/dist/rbac.d.ts.map +1 -0
- package/dist/rbac.js +177 -0
- package/dist/rbac.js.map +1 -0
- package/dist/resolvers.d.ts +61 -0
- package/dist/resolvers.d.ts.map +1 -0
- package/dist/resolvers.js +100 -0
- package/dist/resolvers.js.map +1 -0
- package/dist/typed.d.ts +37 -0
- package/dist/typed.d.ts.map +1 -0
- package/dist/typed.js +38 -0
- package/dist/typed.js.map +1 -0
- package/package.json +45 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query filter helpers — the synthesis's "getFilterFor to prevent N+1".
|
|
3
|
+
*
|
|
4
|
+
* When listing resources ("show me all posts I can read"), checking
|
|
5
|
+
* permission per row is the classic N+1. CASL solves this with
|
|
6
|
+
* `rulesToQuery(ability, action, subjectType, ...)` from `@casl/ability/extra`,
|
|
7
|
+
* which compiles the ability's matching rules into a single disjunctive
|
|
8
|
+
* MongoDB-style query.
|
|
9
|
+
*
|
|
10
|
+
* import { conditionsFor } from "@nwire/rbac";
|
|
11
|
+
*
|
|
12
|
+
* const q = conditionsFor(ability, "read", "Post");
|
|
13
|
+
* // null → no rules → user can read NOTHING (return [])
|
|
14
|
+
* // { $or: [{}] } → an unrestricted rule exists → user can read ALL
|
|
15
|
+
* // { $or: [{ authorId: user.id }, { teamId: ... }] } → disjunctive
|
|
16
|
+
*
|
|
17
|
+
* // For Drizzle, translate via @casl/drizzle (community) or roll your own.
|
|
18
|
+
*
|
|
19
|
+
* The raw query lives in the canonical shape from CASL so the ecosystem
|
|
20
|
+
* bridges (`@casl/mongoose`, `@casl/prisma`, `@casl/drizzle`) drop right
|
|
21
|
+
* in. We expose it; users pick their bridge.
|
|
22
|
+
*/
|
|
23
|
+
import type { Ability } from "./rbac";
|
|
24
|
+
/**
|
|
25
|
+
* The MongoDB-style disjunctive query the ability allows for the given
|
|
26
|
+
* (action, subjectType). Three return shapes:
|
|
27
|
+
* - `null` — no allowing rules; user can access nothing
|
|
28
|
+
* - `{ $or: [{}] }` — an unrestricted rule; user can access all
|
|
29
|
+
* - `{ $or: [{...}, ...]}` — conditional; OR over each allowed shape
|
|
30
|
+
*/
|
|
31
|
+
export declare function conditionsFor(ability: Ability, action: string, subjectType: string): {
|
|
32
|
+
$or?: object[];
|
|
33
|
+
$and?: object[];
|
|
34
|
+
} | null;
|
|
35
|
+
/**
|
|
36
|
+
* Convenience: a Drizzle-friendly variant that returns a function
|
|
37
|
+
* producing a where-clause expression, given the ORM-specific column
|
|
38
|
+
* references. Keeps the bridge logic explicit and tested.
|
|
39
|
+
*/
|
|
40
|
+
export interface DrizzleFilterBuilder {
|
|
41
|
+
/** Return `null` when no filter needed; falsy expr when denied; otherwise the where clause. */
|
|
42
|
+
build<TColumns>(columns: TColumns): unknown;
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=filters.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"filters.d.ts","sourceRoot":"","sources":["../src/filters.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAEtC;;;;;;GAMG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,MAAM,EACd,WAAW,EAAE,MAAM,GAClB;IAAE,GAAG,CAAC,EAAE,MAAM,EAAE,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,EAAE,CAAA;CAAE,GAAG,IAAI,CAE5C;AAED;;;;GAIG;AACH,MAAM,WAAW,oBAAoB;IACnC,+FAA+F;IAC/F,KAAK,CAAC,QAAQ,EAAE,OAAO,EAAE,QAAQ,GAAG,OAAO,CAAC;CAC7C"}
|
package/dist/filters.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query filter helpers — the synthesis's "getFilterFor to prevent N+1".
|
|
3
|
+
*
|
|
4
|
+
* When listing resources ("show me all posts I can read"), checking
|
|
5
|
+
* permission per row is the classic N+1. CASL solves this with
|
|
6
|
+
* `rulesToQuery(ability, action, subjectType, ...)` from `@casl/ability/extra`,
|
|
7
|
+
* which compiles the ability's matching rules into a single disjunctive
|
|
8
|
+
* MongoDB-style query.
|
|
9
|
+
*
|
|
10
|
+
* import { conditionsFor } from "@nwire/rbac";
|
|
11
|
+
*
|
|
12
|
+
* const q = conditionsFor(ability, "read", "Post");
|
|
13
|
+
* // null → no rules → user can read NOTHING (return [])
|
|
14
|
+
* // { $or: [{}] } → an unrestricted rule exists → user can read ALL
|
|
15
|
+
* // { $or: [{ authorId: user.id }, { teamId: ... }] } → disjunctive
|
|
16
|
+
*
|
|
17
|
+
* // For Drizzle, translate via @casl/drizzle (community) or roll your own.
|
|
18
|
+
*
|
|
19
|
+
* The raw query lives in the canonical shape from CASL so the ecosystem
|
|
20
|
+
* bridges (`@casl/mongoose`, `@casl/prisma`, `@casl/drizzle`) drop right
|
|
21
|
+
* in. We expose it; users pick their bridge.
|
|
22
|
+
*/
|
|
23
|
+
import { rulesToQuery } from "@casl/ability/extra";
|
|
24
|
+
/**
|
|
25
|
+
* The MongoDB-style disjunctive query the ability allows for the given
|
|
26
|
+
* (action, subjectType). Three return shapes:
|
|
27
|
+
* - `null` — no allowing rules; user can access nothing
|
|
28
|
+
* - `{ $or: [{}] }` — an unrestricted rule; user can access all
|
|
29
|
+
* - `{ $or: [{...}, ...]}` — conditional; OR over each allowed shape
|
|
30
|
+
*/
|
|
31
|
+
export function conditionsFor(ability, action, subjectType) {
|
|
32
|
+
return rulesToQuery(ability, action, subjectType, (rule) => rule.conditions ?? {});
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=filters.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"filters.js","sourceRoot":"","sources":["../src/filters.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAGnD;;;;;;GAMG;AACH,MAAM,UAAU,aAAa,CAC3B,OAAgB,EAChB,MAAc,EACd,WAAmB;IAEnB,OAAO,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,UAAU,IAAI,EAAE,CAAC,CAAC;AACrF,CAAC"}
|
package/dist/rbac.d.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nwire/rbac` — declarative permissions powered by CASL.
|
|
3
|
+
*
|
|
4
|
+
* import { defineAbility, rbacPlugin, can } from "@nwire/rbac";
|
|
5
|
+
*
|
|
6
|
+
* // 1. Declare what each role can do
|
|
7
|
+
* export const buildAbility = defineAbility((user, { allow, deny }) => {
|
|
8
|
+
* if (!user) return;
|
|
9
|
+
* if (user.roles?.includes("admin")) { allow("manage", "all"); return; }
|
|
10
|
+
* allow("read", "Post");
|
|
11
|
+
* allow("create", "Post");
|
|
12
|
+
* allow("update", "Post", { authorId: user.id });
|
|
13
|
+
* allow("delete", "Post", { authorId: user.id });
|
|
14
|
+
* });
|
|
15
|
+
*
|
|
16
|
+
* // 2. Plug it in
|
|
17
|
+
* defineApp("my-app", {
|
|
18
|
+
* plugins: [identityPlugin({ adapter }), rbacPlugin({ buildAbility })],
|
|
19
|
+
* });
|
|
20
|
+
*
|
|
21
|
+
* // 3a. Declarative — gate an HTTP route binding
|
|
22
|
+
* httpInterface()
|
|
23
|
+
* .wire(post("/posts/:id", { policy: ["update", "Post"] }), async ({ input }) => { ... })
|
|
24
|
+
* .run();
|
|
25
|
+
*
|
|
26
|
+
* // 3b. Programmatic — gate inside a handler
|
|
27
|
+
* defineAction({
|
|
28
|
+
* name: "posts.delete",
|
|
29
|
+
* handler: async (input, ctx) => {
|
|
30
|
+
* const post = await db.query.posts.findFirst({ where: eq(posts.id, input.id) });
|
|
31
|
+
* if (!post) throw NotFound;
|
|
32
|
+
* const ability = ctx.ability();
|
|
33
|
+
* if (ability.cannot("delete", subject("Post", post))) throw Forbidden;
|
|
34
|
+
* ...
|
|
35
|
+
* }
|
|
36
|
+
* });
|
|
37
|
+
*
|
|
38
|
+
* Why CASL: 8-yr-old, mature, MongoDB-style conditions (`{ authorId: user.id }`),
|
|
39
|
+
* field-level permissions, full TypeScript support, used by NestJS / RedwoodJS /
|
|
40
|
+
* many production teams. We don't reinvent — we glue.
|
|
41
|
+
*/
|
|
42
|
+
import { type MongoAbility, type MongoQuery, subject as caslSubject } from "@casl/ability";
|
|
43
|
+
import { type User } from "@nwire/auth";
|
|
44
|
+
import { type HandlerContext, type PluginDefinition } from "@nwire/forge";
|
|
45
|
+
/**
|
|
46
|
+
* The Ability type — what `buildAbility(user)` returns and what
|
|
47
|
+
* `ctx.ability()` exposes. We use CASL's `MongoAbility` so MongoDB-style
|
|
48
|
+
* conditions (e.g. `{ authorId: user.id }`) work out of the box without
|
|
49
|
+
* any extra `conditionsMatcher` wiring at the call site.
|
|
50
|
+
*/
|
|
51
|
+
export type Ability = MongoAbility;
|
|
52
|
+
/**
|
|
53
|
+
* Builder API exposed to `defineAbility(user, { allow, deny }) => ...`.
|
|
54
|
+
* Wraps CASL's `AbilityBuilder.can/cannot` under nicer names (`allow/deny`
|
|
55
|
+
* read better in business code; `can/cannot` is already overloaded with
|
|
56
|
+
* the query verbs we expose elsewhere).
|
|
57
|
+
*/
|
|
58
|
+
export interface AbilityBuilderCtx {
|
|
59
|
+
allow: (action: string | string[], subject: any, conditions?: MongoQuery, fields?: string | string[]) => void;
|
|
60
|
+
deny: (action: string | string[], subject: any, conditions?: MongoQuery, fields?: string | string[]) => void;
|
|
61
|
+
}
|
|
62
|
+
/** Re-export CASL's subject() helper — tag a plain object with its type. */
|
|
63
|
+
export declare const subject: typeof caslSubject;
|
|
64
|
+
/**
|
|
65
|
+
* Define an ability factory — pure function from User to a CASL Ability.
|
|
66
|
+
* Called once per dispatch (cached per envelope by the middleware).
|
|
67
|
+
*
|
|
68
|
+
* const buildAbility = defineAbility((user, { allow, deny }) => {
|
|
69
|
+
* allow("read", "Post");
|
|
70
|
+
* if (user.role === "admin") allow("manage", "all");
|
|
71
|
+
* });
|
|
72
|
+
*/
|
|
73
|
+
export declare function defineAbility(factory: (user: User | null, ctx: AbilityBuilderCtx) => void): (user: User | null) => Ability;
|
|
74
|
+
export interface RbacPluginOptions {
|
|
75
|
+
readonly buildAbility: (user: User | null) => Ability;
|
|
76
|
+
/**
|
|
77
|
+
* Container token the ability factory is registered under. Default:
|
|
78
|
+
* `"buildAbility"`. The middleware looks it up by this key, so custom
|
|
79
|
+
* names let an app register multiple factories (e.g. for different
|
|
80
|
+
* tenants).
|
|
81
|
+
*/
|
|
82
|
+
readonly name?: string;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* RBAC plugin — registers `buildAbility` on the container and installs a
|
|
86
|
+
* dispatch middleware that resolves the current user's Ability, stashes it
|
|
87
|
+
* on the handler ctx, and enforces `action.policy` when it's an
|
|
88
|
+
* `[action, subject]` tuple.
|
|
89
|
+
*/
|
|
90
|
+
export declare function rbacPlugin(options: RbacPluginOptions): PluginDefinition;
|
|
91
|
+
/**
|
|
92
|
+
* Test helper — build an ability and return it directly. Used in unit
|
|
93
|
+
* tests that don't want to spin up the full app/plugin stack.
|
|
94
|
+
*/
|
|
95
|
+
export declare function abilityFor(buildAbility: (user: User | null) => Ability, user: User | null): Ability;
|
|
96
|
+
/**
|
|
97
|
+
* Koa middleware variant of `can(verb, subj)` for use with the http
|
|
98
|
+
* builder's `RouteBinding.middleware`:
|
|
99
|
+
*
|
|
100
|
+
* .wire(post("/posts/:id", { params, body, middleware: [canKoa("update", "Post")] }), handler)
|
|
101
|
+
*
|
|
102
|
+
* Lifts `buildAbility` off the container (registered by `rbacPlugin`),
|
|
103
|
+
* resolves the current user from `ctx.state.user` (set by an upstream
|
|
104
|
+
* auth middleware), and throws `ForbiddenError` if the verb is denied.
|
|
105
|
+
*
|
|
106
|
+
* For instance-level checks (e.g. owner-only `update`), the handler
|
|
107
|
+
* should still call `ability.cannot(verb, subject("Post", record))`
|
|
108
|
+
* after loading the instance — same as in dispatch handlers.
|
|
109
|
+
*/
|
|
110
|
+
export declare function canKoa(verb: string, subj: string): import("koa").Middleware;
|
|
111
|
+
export declare function abilityFromCtx(ctx: HandlerContext): Ability | null;
|
|
112
|
+
export { permission, parsePermission, type Permission, } from "./typed";
|
|
113
|
+
export { resolver, ownedBy, sameTenant, authorize, type Resolver, type ResolverFn, } from "./resolvers";
|
|
114
|
+
export { conditionsFor, type DrizzleFilterBuilder, } from "./filters";
|
|
115
|
+
export { ActionDeniedError, OwnershipMismatchError, ScopeMissingError, RoleMissingError, isOwnershipMismatchError, isScopeMissingError, isRoleMissingError, } from "./errors";
|
|
116
|
+
//# sourceMappingURL=rbac.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rbac.d.ts","sourceRoot":"","sources":["../src/rbac.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAEH,OAAO,EAGL,KAAK,YAAY,EACjB,KAAK,UAAU,EACf,OAAO,IAAI,WAAW,EACvB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAkB,KAAK,IAAI,EAAE,MAAM,aAAa,CAAC;AACxD,OAAO,EAGL,KAAK,cAAc,EACnB,KAAK,gBAAgB,EACtB,MAAM,cAAc,CAAC;AAEtB;;;;;GAKG;AACH,MAAM,MAAM,OAAO,GAAG,YAAY,CAAC;AAEnC;;;;;GAKG;AACH,MAAM,WAAW,iBAAiB;IAEhC,KAAK,EAAE,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,CAAC,EAAE,UAAU,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,KAAK,IAAI,CAAC;IAE9G,IAAI,EAAG,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,CAAC,EAAE,UAAU,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,KAAK,IAAI,CAAC;CAC/G;AAED,4EAA4E;AAC5E,eAAO,MAAM,OAAO,oBAAc,CAAC;AAEnC;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,CAAC,IAAI,EAAE,IAAI,GAAG,IAAI,EAAE,GAAG,EAAE,iBAAiB,KAAK,IAAI,GAC3D,CAAC,IAAI,EAAE,IAAI,GAAG,IAAI,KAAK,OAAO,CAShC;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,YAAY,EAAE,CAAC,IAAI,EAAE,IAAI,GAAG,IAAI,KAAK,OAAO,CAAC;IACtD;;;;;OAKG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;CACxB;AAsBD;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,iBAAiB,GAAG,gBAAgB,CA4BvE;AAED;;;GAGG;AACH,wBAAgB,UAAU,CACxB,YAAY,EAAE,CAAC,IAAI,EAAE,IAAI,GAAG,IAAI,KAAK,OAAO,EAC5C,IAAI,EAAE,IAAI,GAAG,IAAI,GAChB,OAAO,CAET;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,KAAK,EAAE,UAAU,CAuB3E;AAID,wBAAgB,cAAc,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,GAAG,IAAI,CAGlE;AAWD,OAAO,EACL,UAAU,EACV,eAAe,EACf,KAAK,UAAU,GAChB,MAAM,SAAS,CAAC;AAEjB,OAAO,EACL,QAAQ,EACR,OAAO,EACP,UAAU,EACV,SAAS,EACT,KAAK,QAAQ,EACb,KAAK,UAAU,GAChB,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,aAAa,EACb,KAAK,oBAAoB,GAC1B,MAAM,WAAW,CAAC;AAEnB,OAAO,EACL,iBAAiB,EACjB,sBAAsB,EACtB,iBAAiB,EACjB,gBAAgB,EAChB,wBAAwB,EACxB,mBAAmB,EACnB,kBAAkB,GACnB,MAAM,UAAU,CAAC"}
|
package/dist/rbac.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nwire/rbac` — declarative permissions powered by CASL.
|
|
3
|
+
*
|
|
4
|
+
* import { defineAbility, rbacPlugin, can } from "@nwire/rbac";
|
|
5
|
+
*
|
|
6
|
+
* // 1. Declare what each role can do
|
|
7
|
+
* export const buildAbility = defineAbility((user, { allow, deny }) => {
|
|
8
|
+
* if (!user) return;
|
|
9
|
+
* if (user.roles?.includes("admin")) { allow("manage", "all"); return; }
|
|
10
|
+
* allow("read", "Post");
|
|
11
|
+
* allow("create", "Post");
|
|
12
|
+
* allow("update", "Post", { authorId: user.id });
|
|
13
|
+
* allow("delete", "Post", { authorId: user.id });
|
|
14
|
+
* });
|
|
15
|
+
*
|
|
16
|
+
* // 2. Plug it in
|
|
17
|
+
* defineApp("my-app", {
|
|
18
|
+
* plugins: [identityPlugin({ adapter }), rbacPlugin({ buildAbility })],
|
|
19
|
+
* });
|
|
20
|
+
*
|
|
21
|
+
* // 3a. Declarative — gate an HTTP route binding
|
|
22
|
+
* httpInterface()
|
|
23
|
+
* .wire(post("/posts/:id", { policy: ["update", "Post"] }), async ({ input }) => { ... })
|
|
24
|
+
* .run();
|
|
25
|
+
*
|
|
26
|
+
* // 3b. Programmatic — gate inside a handler
|
|
27
|
+
* defineAction({
|
|
28
|
+
* name: "posts.delete",
|
|
29
|
+
* handler: async (input, ctx) => {
|
|
30
|
+
* const post = await db.query.posts.findFirst({ where: eq(posts.id, input.id) });
|
|
31
|
+
* if (!post) throw NotFound;
|
|
32
|
+
* const ability = ctx.ability();
|
|
33
|
+
* if (ability.cannot("delete", subject("Post", post))) throw Forbidden;
|
|
34
|
+
* ...
|
|
35
|
+
* }
|
|
36
|
+
* });
|
|
37
|
+
*
|
|
38
|
+
* Why CASL: 8-yr-old, mature, MongoDB-style conditions (`{ authorId: user.id }`),
|
|
39
|
+
* field-level permissions, full TypeScript support, used by NestJS / RedwoodJS /
|
|
40
|
+
* many production teams. We don't reinvent — we glue.
|
|
41
|
+
*/
|
|
42
|
+
import { AbilityBuilder, createMongoAbility, subject as caslSubject, } from "@casl/ability";
|
|
43
|
+
import { ForbiddenError } from "@nwire/auth";
|
|
44
|
+
import { definePlugin, } from "@nwire/forge";
|
|
45
|
+
/** Re-export CASL's subject() helper — tag a plain object with its type. */
|
|
46
|
+
export const subject = caslSubject;
|
|
47
|
+
/**
|
|
48
|
+
* Define an ability factory — pure function from User to a CASL Ability.
|
|
49
|
+
* Called once per dispatch (cached per envelope by the middleware).
|
|
50
|
+
*
|
|
51
|
+
* const buildAbility = defineAbility((user, { allow, deny }) => {
|
|
52
|
+
* allow("read", "Post");
|
|
53
|
+
* if (user.role === "admin") allow("manage", "all");
|
|
54
|
+
* });
|
|
55
|
+
*/
|
|
56
|
+
export function defineAbility(factory) {
|
|
57
|
+
return (user) => {
|
|
58
|
+
const builder = new AbilityBuilder(createMongoAbility);
|
|
59
|
+
factory(user, {
|
|
60
|
+
allow: builder.can.bind(builder),
|
|
61
|
+
deny: builder.cannot.bind(builder),
|
|
62
|
+
});
|
|
63
|
+
return builder.build();
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const ABILITY_BUILDER_KEY = Symbol.for("nwire.rbac.builder");
|
|
67
|
+
const CURRENT_ABILITY_KEY = Symbol.for("nwire.rbac.ability");
|
|
68
|
+
/**
|
|
69
|
+
* Stash the ability + builder on the handler ctx so:
|
|
70
|
+
* 1. The `can()` middleware can read the resolved ability for the gate.
|
|
71
|
+
* 2. Handlers can call `ctx.ability()` programmatically.
|
|
72
|
+
* Implementation: we side-channel through `handlerCtx` (passed across
|
|
73
|
+
* middleware) using well-known Symbol keys — type-safe AND framework-agnostic.
|
|
74
|
+
*/
|
|
75
|
+
function getAbility(ctx) {
|
|
76
|
+
if (!ctx || typeof ctx !== "object")
|
|
77
|
+
return null;
|
|
78
|
+
return ctx[CURRENT_ABILITY_KEY] ?? null;
|
|
79
|
+
}
|
|
80
|
+
function setAbility(ctx, ability) {
|
|
81
|
+
if (!ctx || typeof ctx !== "object")
|
|
82
|
+
return;
|
|
83
|
+
ctx[CURRENT_ABILITY_KEY] = ability;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* RBAC plugin — registers `buildAbility` on the container and installs a
|
|
87
|
+
* dispatch middleware that resolves the current user's Ability, stashes it
|
|
88
|
+
* on the handler ctx, and enforces `action.policy` when it's an
|
|
89
|
+
* `[action, subject]` tuple.
|
|
90
|
+
*/
|
|
91
|
+
export function rbacPlugin(options) {
|
|
92
|
+
const containerKey = options.name ?? "buildAbility";
|
|
93
|
+
const middleware = async (next, action, _input, ctx) => {
|
|
94
|
+
const user = (ctx.envelope?.user ?? null);
|
|
95
|
+
const ability = options.buildAbility(user);
|
|
96
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
97
|
+
setAbility(ctx, ability);
|
|
98
|
+
// Tuple-form policy on actions: `policy: ["update", "Post"]`.
|
|
99
|
+
if (Array.isArray(action.policy) && action.policy.length === 2) {
|
|
100
|
+
const [verb, subj] = action.policy;
|
|
101
|
+
if (ability.cannot(verb, subj)) {
|
|
102
|
+
throw new ForbiddenError(`Not allowed to ${verb} ${subj}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return next();
|
|
106
|
+
};
|
|
107
|
+
return definePlugin("rbac", {
|
|
108
|
+
register: (container) => {
|
|
109
|
+
// Container factory: wrapping in `() => fn` so Awilix returns the
|
|
110
|
+
// function itself instead of treating it as a factory of its return.
|
|
111
|
+
container.register(containerKey, () => options.buildAbility);
|
|
112
|
+
container.register(ABILITY_BUILDER_KEY.toString(), () => options.buildAbility);
|
|
113
|
+
},
|
|
114
|
+
middleware: [middleware],
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Test helper — build an ability and return it directly. Used in unit
|
|
119
|
+
* tests that don't want to spin up the full app/plugin stack.
|
|
120
|
+
*/
|
|
121
|
+
export function abilityFor(buildAbility, user) {
|
|
122
|
+
return buildAbility(user);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Koa middleware variant of `can(verb, subj)` for use with the http
|
|
126
|
+
* builder's `RouteBinding.middleware`:
|
|
127
|
+
*
|
|
128
|
+
* .wire(post("/posts/:id", { params, body, middleware: [canKoa("update", "Post")] }), handler)
|
|
129
|
+
*
|
|
130
|
+
* Lifts `buildAbility` off the container (registered by `rbacPlugin`),
|
|
131
|
+
* resolves the current user from `ctx.state.user` (set by an upstream
|
|
132
|
+
* auth middleware), and throws `ForbiddenError` if the verb is denied.
|
|
133
|
+
*
|
|
134
|
+
* For instance-level checks (e.g. owner-only `update`), the handler
|
|
135
|
+
* should still call `ability.cannot(verb, subject("Post", record))`
|
|
136
|
+
* after loading the instance — same as in dispatch handlers.
|
|
137
|
+
*/
|
|
138
|
+
export function canKoa(verb, subj) {
|
|
139
|
+
return async (koaCtx, next) => {
|
|
140
|
+
// The container reaches Koa through `ctx.state._container` (set by
|
|
141
|
+
// `@nwire/http` when an app is being served) or by reading the
|
|
142
|
+
// app's container via the http builder's `.provide(...)` plumbing.
|
|
143
|
+
// We don't want a hard dep on those internals — the builder
|
|
144
|
+
// exposes the container indirectly on the request scope. Read it
|
|
145
|
+
// via the documented Koa `state` channel.
|
|
146
|
+
const state = koaCtx.state;
|
|
147
|
+
const container = state._container;
|
|
148
|
+
if (!container) {
|
|
149
|
+
throw new ForbiddenError("RBAC: container not available on Koa state (mount canKoa downstream of provide(container))");
|
|
150
|
+
}
|
|
151
|
+
const buildAbility = container.resolve("buildAbility");
|
|
152
|
+
const ability = buildAbility(state.user ?? null);
|
|
153
|
+
if (ability.cannot(verb, subj)) {
|
|
154
|
+
throw new ForbiddenError(`Not allowed to ${verb} ${subj}`);
|
|
155
|
+
}
|
|
156
|
+
await next();
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
// Expose the dispatch-ctx accessor so handler code (or higher-level helpers)
|
|
160
|
+
// can retrieve the ability without poking at Symbols directly.
|
|
161
|
+
export function abilityFromCtx(ctx) {
|
|
162
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
163
|
+
return getAbility(ctx);
|
|
164
|
+
}
|
|
165
|
+
// ─── Polish layer ──────────────────────────────────────────────────
|
|
166
|
+
//
|
|
167
|
+
// Adds the synthesis's "best DX" affordances on top of the CASL core:
|
|
168
|
+
// - typed `resource:action` permission strings (template literals)
|
|
169
|
+
// - composable ownership/relation resolvers
|
|
170
|
+
// - conditionsFor → CASL accessibleBy for query-time filtering (no N+1)
|
|
171
|
+
// - structured error subclasses (OwnershipMismatchError, ScopeMissing,
|
|
172
|
+
// RoleMissing) so the API can tell the UI *why* a denial happened
|
|
173
|
+
export { permission, parsePermission, } from "./typed";
|
|
174
|
+
export { resolver, ownedBy, sameTenant, authorize, } from "./resolvers";
|
|
175
|
+
export { conditionsFor, } from "./filters";
|
|
176
|
+
export { ActionDeniedError, OwnershipMismatchError, ScopeMissingError, RoleMissingError, isOwnershipMismatchError, isScopeMissingError, isRoleMissingError, } from "./errors";
|
|
177
|
+
//# sourceMappingURL=rbac.js.map
|
package/dist/rbac.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rbac.js","sourceRoot":"","sources":["../src/rbac.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composable resolver helpers — the synthesis's "named rules" pattern.
|
|
3
|
+
*
|
|
4
|
+
* Real apps have a handful of relationship predicates that show up over
|
|
5
|
+
* and over: "is the owner of this resource", "is a member of the same
|
|
6
|
+
* team", "is in the same tenant", "is the assignee". The synthesis
|
|
7
|
+
* recommends naming them once and composing.
|
|
8
|
+
*
|
|
9
|
+
* const isOwner = resolver<User, Post>((u, p) => u.id === p.authorId);
|
|
10
|
+
* const isTeammate = resolver<User, Post>(async (u, p) => (
|
|
11
|
+
* await db.query.memberships.findFirst({
|
|
12
|
+
* where: and(eq(memberships.userId, u.id), eq(memberships.teamId, p.teamId)),
|
|
13
|
+
* }) != null
|
|
14
|
+
* ));
|
|
15
|
+
*
|
|
16
|
+
* // Compose
|
|
17
|
+
* const canEdit = isOwner.or(isTeammate);
|
|
18
|
+
*
|
|
19
|
+
* // Use programmatically
|
|
20
|
+
* if (!(await canEdit(user, post))) throw new OwnershipMismatchError({...});
|
|
21
|
+
*
|
|
22
|
+
* // Or as a CASL ability condition (returns the matcher function)
|
|
23
|
+
* allow("update", "Post", canEdit.condition(user));
|
|
24
|
+
*
|
|
25
|
+
* Why a separate primitive from CASL's MongoQuery conditions: the
|
|
26
|
+
* MongoQuery form is great for "fields on the row" checks (e.g.
|
|
27
|
+
* `{ authorId: user.id }`), but it can't express async I/O ("is the
|
|
28
|
+
* user in this team's membership table"). `Resolver` handles both.
|
|
29
|
+
*/
|
|
30
|
+
import type { User } from "@nwire/auth";
|
|
31
|
+
export type ResolverFn<TSubject> = (user: User, subject: TSubject) => boolean | Promise<boolean>;
|
|
32
|
+
export interface Resolver<TSubject> {
|
|
33
|
+
readonly fn: ResolverFn<TSubject>;
|
|
34
|
+
(user: User, subject: TSubject): Promise<boolean>;
|
|
35
|
+
or(other: Resolver<TSubject> | ResolverFn<TSubject>): Resolver<TSubject>;
|
|
36
|
+
and(other: Resolver<TSubject> | ResolverFn<TSubject>): Resolver<TSubject>;
|
|
37
|
+
not(): Resolver<TSubject>;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Wrap a `(user, subject) => boolean` predicate as a composable resolver.
|
|
41
|
+
* The returned value is both callable and chainable.
|
|
42
|
+
*/
|
|
43
|
+
export declare function resolver<TSubject>(fn: ResolverFn<TSubject>): Resolver<TSubject>;
|
|
44
|
+
/**
|
|
45
|
+
* Convenience — the most common predicate. Pass the field name on the
|
|
46
|
+
* subject that holds the owner id (defaults to `"authorId"`, which
|
|
47
|
+
* matches the synthesis's "article.authorId" example).
|
|
48
|
+
*/
|
|
49
|
+
export declare function ownedBy<TSubject extends Record<string, unknown>>(field?: string): Resolver<TSubject>;
|
|
50
|
+
/**
|
|
51
|
+
* Convenience — same-tenant predicate. The synthesis calls this out as
|
|
52
|
+
* one of the most common reusable rules in multi-tenant SaaS.
|
|
53
|
+
*/
|
|
54
|
+
export declare function sameTenant<TSubject extends {
|
|
55
|
+
tenant?: string;
|
|
56
|
+
tenantId?: string;
|
|
57
|
+
}>(): Resolver<TSubject>;
|
|
58
|
+
export declare function authorize<TSubject>(user: User | null | undefined, action: string, subject: TSubject, rule: Resolver<TSubject> | ResolverFn<TSubject>, opts?: {
|
|
59
|
+
readonly subjectName?: string;
|
|
60
|
+
}): Promise<void>;
|
|
61
|
+
//# sourceMappingURL=resolvers.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolvers.d.ts","sourceRoot":"","sources":["../src/resolvers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,aAAa,CAAC;AAExC,MAAM,MAAM,UAAU,CAAC,QAAQ,IAAI,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,QAAQ,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;AAEjG,MAAM,WAAW,QAAQ,CAAC,QAAQ;IAChC,QAAQ,CAAC,EAAE,EAAE,UAAU,CAAC,QAAQ,CAAC,CAAC;IAClC,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,QAAQ,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAClD,EAAE,CAAC,KAAK,EAAE,QAAQ,CAAC,QAAQ,CAAC,GAAG,UAAU,CAAC,QAAQ,CAAC,GAAG,QAAQ,CAAC,QAAQ,CAAC,CAAC;IACzE,GAAG,CAAC,KAAK,EAAE,QAAQ,CAAC,QAAQ,CAAC,GAAG,UAAU,CAAC,QAAQ,CAAC,GAAG,QAAQ,CAAC,QAAQ,CAAC,CAAC;IAC1E,GAAG,IAAI,QAAQ,CAAC,QAAQ,CAAC,CAAC;CAC3B;AAQD;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,QAAQ,EAAE,EAAE,EAAE,UAAU,CAAC,QAAQ,CAAC,GAAG,QAAQ,CAAC,QAAQ,CAAC,CAgB/E;AAED;;;;GAIG;AACH,wBAAgB,OAAO,CAAC,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9D,KAAK,GAAE,MAAmB,GACzB,QAAQ,CAAC,QAAQ,CAAC,CAEpB;AAED;;;GAGG;AACH,wBAAgB,UAAU,CAAC,QAAQ,SAAS;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,KAAK,QAAQ,CAAC,QAAQ,CAAC,CAKxG;AAYD,wBAAsB,SAAS,CAAC,QAAQ,EACtC,IAAI,EAAE,IAAI,GAAG,IAAI,GAAG,SAAS,EAC7B,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,QAAQ,EACjB,IAAI,EAAE,QAAQ,CAAC,QAAQ,CAAC,GAAG,UAAU,CAAC,QAAQ,CAAC,EAC/C,IAAI,GAAE;IAAE,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;CAAO,GAC3C,OAAO,CAAC,IAAI,CAAC,CAiBf"}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composable resolver helpers — the synthesis's "named rules" pattern.
|
|
3
|
+
*
|
|
4
|
+
* Real apps have a handful of relationship predicates that show up over
|
|
5
|
+
* and over: "is the owner of this resource", "is a member of the same
|
|
6
|
+
* team", "is in the same tenant", "is the assignee". The synthesis
|
|
7
|
+
* recommends naming them once and composing.
|
|
8
|
+
*
|
|
9
|
+
* const isOwner = resolver<User, Post>((u, p) => u.id === p.authorId);
|
|
10
|
+
* const isTeammate = resolver<User, Post>(async (u, p) => (
|
|
11
|
+
* await db.query.memberships.findFirst({
|
|
12
|
+
* where: and(eq(memberships.userId, u.id), eq(memberships.teamId, p.teamId)),
|
|
13
|
+
* }) != null
|
|
14
|
+
* ));
|
|
15
|
+
*
|
|
16
|
+
* // Compose
|
|
17
|
+
* const canEdit = isOwner.or(isTeammate);
|
|
18
|
+
*
|
|
19
|
+
* // Use programmatically
|
|
20
|
+
* if (!(await canEdit(user, post))) throw new OwnershipMismatchError({...});
|
|
21
|
+
*
|
|
22
|
+
* // Or as a CASL ability condition (returns the matcher function)
|
|
23
|
+
* allow("update", "Post", canEdit.condition(user));
|
|
24
|
+
*
|
|
25
|
+
* Why a separate primitive from CASL's MongoQuery conditions: the
|
|
26
|
+
* MongoQuery form is great for "fields on the row" checks (e.g.
|
|
27
|
+
* `{ authorId: user.id }`), but it can't express async I/O ("is the
|
|
28
|
+
* user in this team's membership table"). `Resolver` handles both.
|
|
29
|
+
*/
|
|
30
|
+
function intoResolver(x) {
|
|
31
|
+
return typeof x === "function" && "fn" in x === false
|
|
32
|
+
? resolver(x)
|
|
33
|
+
: x;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Wrap a `(user, subject) => boolean` predicate as a composable resolver.
|
|
37
|
+
* The returned value is both callable and chainable.
|
|
38
|
+
*/
|
|
39
|
+
export function resolver(fn) {
|
|
40
|
+
const call = async (user, subject) => Boolean(await fn(user, subject));
|
|
41
|
+
const r = call;
|
|
42
|
+
// Attach properties — `r` is the call function so `r(u, s)` works.
|
|
43
|
+
Object.defineProperty(r, "fn", { value: fn });
|
|
44
|
+
r.or = (other) => {
|
|
45
|
+
const o = intoResolver(other);
|
|
46
|
+
return resolver(async (u, s) => (await call(u, s)) || (await o(u, s)));
|
|
47
|
+
};
|
|
48
|
+
r.and = (other) => {
|
|
49
|
+
const o = intoResolver(other);
|
|
50
|
+
return resolver(async (u, s) => (await call(u, s)) && (await o(u, s)));
|
|
51
|
+
};
|
|
52
|
+
r.not = () => resolver(async (u, s) => !(await call(u, s)));
|
|
53
|
+
return r;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Convenience — the most common predicate. Pass the field name on the
|
|
57
|
+
* subject that holds the owner id (defaults to `"authorId"`, which
|
|
58
|
+
* matches the synthesis's "article.authorId" example).
|
|
59
|
+
*/
|
|
60
|
+
export function ownedBy(field = "authorId") {
|
|
61
|
+
return resolver((user, subject) => subject[field] === user.id);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Convenience — same-tenant predicate. The synthesis calls this out as
|
|
65
|
+
* one of the most common reusable rules in multi-tenant SaaS.
|
|
66
|
+
*/
|
|
67
|
+
export function sameTenant() {
|
|
68
|
+
return resolver((user, subject) => {
|
|
69
|
+
const subTenant = (subject.tenant ?? subject.tenantId);
|
|
70
|
+
return Boolean(user.tenant && subTenant && user.tenant === subTenant);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Higher-order helper: turn a resolver into an authorize call that
|
|
75
|
+
* throws the right structured error on denial. The synthesis's
|
|
76
|
+
* "structured error" pattern.
|
|
77
|
+
*
|
|
78
|
+
* import { authorize, ownedBy } from "@nwire/rbac";
|
|
79
|
+
* await authorize(user, "update", post, ownedBy());
|
|
80
|
+
*/
|
|
81
|
+
import { OwnershipMismatchError } from "./errors";
|
|
82
|
+
export async function authorize(user, action, subject, rule, opts = {}) {
|
|
83
|
+
if (!user) {
|
|
84
|
+
throw new OwnershipMismatchError({
|
|
85
|
+
action,
|
|
86
|
+
subject: opts.subjectName ?? "resource",
|
|
87
|
+
reason: "Not authenticated",
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
const r = intoResolver(rule);
|
|
91
|
+
const ok = await r(user, subject);
|
|
92
|
+
if (!ok) {
|
|
93
|
+
throw new OwnershipMismatchError({
|
|
94
|
+
action,
|
|
95
|
+
subject: opts.subjectName ?? "resource",
|
|
96
|
+
userId: user.id,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=resolvers.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolvers.js","sourceRoot":"","sources":["../src/resolvers.ts"],"names":[],"mappings":"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"}
|
package/dist/typed.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed permission helpers — the "Read-Like-English" + autocomplete layer
|
|
3
|
+
* over CASL. Apps declare their vocabulary once (verbs + subjects); the
|
|
4
|
+
* rest of the codebase gets autocompletion + typo-prevention on every
|
|
5
|
+
* `can("update", "Post")` call.
|
|
6
|
+
*
|
|
7
|
+
* // Declare your vocabulary once.
|
|
8
|
+
* type AppVerbs = "read" | "create" | "update" | "delete" | "manage";
|
|
9
|
+
* type AppSubjects = "Post" | "Comment" | "User" | "all";
|
|
10
|
+
* export type AppPermission = Permission<AppVerbs, AppSubjects>;
|
|
11
|
+
* // ^ "read:Post" | "create:Post" | ... | "manage:all"
|
|
12
|
+
*
|
|
13
|
+
* // Now per-route middleware is autocomplete-driven:
|
|
14
|
+
* http().wire(post("/posts", {
|
|
15
|
+
* body, middleware: [canKoa<AppVerbs, AppSubjects>("update", "Post")],
|
|
16
|
+
* }), handler);
|
|
17
|
+
*
|
|
18
|
+
* The `resource:action` string form is also exposed via `parsePermission`
|
|
19
|
+
* for places where a single string is more ergonomic (config files, route
|
|
20
|
+
* tags, audit logs).
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* The `resource:action` permission string — Template Literal Type the
|
|
24
|
+
* synthesis recommends. Apps narrow Verbs/Subjects to their domain
|
|
25
|
+
* vocabulary and get IDE autocomplete + typo-prevention everywhere.
|
|
26
|
+
*/
|
|
27
|
+
export type Permission<TVerb extends string = string, TSubject extends string = string> = `${TVerb}:${TSubject}`;
|
|
28
|
+
/**
|
|
29
|
+
* Build a permission string. Type-narrow when called with const-literal
|
|
30
|
+
* arguments; the inferred string lands in IDE tooltips.
|
|
31
|
+
*
|
|
32
|
+
* const p = permission("update", "Post"); // typed `update:Post`
|
|
33
|
+
*/
|
|
34
|
+
export declare function permission<TVerb extends string, TSubject extends string>(verb: TVerb, subject: TSubject): Permission<TVerb, TSubject>;
|
|
35
|
+
/** Split a `resource:action` permission string into a tuple. */
|
|
36
|
+
export declare function parsePermission<P extends Permission>(p: P): readonly [string, string];
|
|
37
|
+
//# sourceMappingURL=typed.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"typed.d.ts","sourceRoot":"","sources":["../src/typed.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH;;;;GAIG;AACH,MAAM,MAAM,UAAU,CAAC,KAAK,SAAS,MAAM,GAAG,MAAM,EAAE,QAAQ,SAAS,MAAM,GAAG,MAAM,IACpF,GAAG,KAAK,IAAI,QAAQ,EAAE,CAAC;AAEzB;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,KAAK,SAAS,MAAM,EAAE,QAAQ,SAAS,MAAM,EACtE,IAAI,EAAK,KAAK,EACd,OAAO,EAAE,QAAQ,GAChB,UAAU,CAAC,KAAK,EAAE,QAAQ,CAAC,CAE7B;AAED,gEAAgE;AAChE,wBAAgB,eAAe,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,EAAE,CAAC,GAAG,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,CAIrF"}
|
package/dist/typed.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed permission helpers — the "Read-Like-English" + autocomplete layer
|
|
3
|
+
* over CASL. Apps declare their vocabulary once (verbs + subjects); the
|
|
4
|
+
* rest of the codebase gets autocompletion + typo-prevention on every
|
|
5
|
+
* `can("update", "Post")` call.
|
|
6
|
+
*
|
|
7
|
+
* // Declare your vocabulary once.
|
|
8
|
+
* type AppVerbs = "read" | "create" | "update" | "delete" | "manage";
|
|
9
|
+
* type AppSubjects = "Post" | "Comment" | "User" | "all";
|
|
10
|
+
* export type AppPermission = Permission<AppVerbs, AppSubjects>;
|
|
11
|
+
* // ^ "read:Post" | "create:Post" | ... | "manage:all"
|
|
12
|
+
*
|
|
13
|
+
* // Now per-route middleware is autocomplete-driven:
|
|
14
|
+
* http().wire(post("/posts", {
|
|
15
|
+
* body, middleware: [canKoa<AppVerbs, AppSubjects>("update", "Post")],
|
|
16
|
+
* }), handler);
|
|
17
|
+
*
|
|
18
|
+
* The `resource:action` string form is also exposed via `parsePermission`
|
|
19
|
+
* for places where a single string is more ergonomic (config files, route
|
|
20
|
+
* tags, audit logs).
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* Build a permission string. Type-narrow when called with const-literal
|
|
24
|
+
* arguments; the inferred string lands in IDE tooltips.
|
|
25
|
+
*
|
|
26
|
+
* const p = permission("update", "Post"); // typed `update:Post`
|
|
27
|
+
*/
|
|
28
|
+
export function permission(verb, subject) {
|
|
29
|
+
return `${verb}:${subject}`;
|
|
30
|
+
}
|
|
31
|
+
/** Split a `resource:action` permission string into a tuple. */
|
|
32
|
+
export function parsePermission(p) {
|
|
33
|
+
const idx = p.indexOf(":");
|
|
34
|
+
if (idx < 0)
|
|
35
|
+
return [p, ""];
|
|
36
|
+
return [p.slice(0, idx), p.slice(idx + 1)];
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=typed.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"typed.js","sourceRoot":"","sources":["../src/typed.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAUH;;;;;GAKG;AACH,MAAM,UAAU,UAAU,CACxB,IAAc,EACd,OAAiB;IAEjB,OAAO,GAAG,IAAI,IAAI,OAAO,EAAiC,CAAC;AAC7D,CAAC;AAED,gEAAgE;AAChE,MAAM,UAAU,eAAe,CAAuB,CAAI;IACxD,MAAM,GAAG,GAAG,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;IAC3B,IAAI,GAAG,GAAG,CAAC;QAAE,OAAO,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IAC5B,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC;AAC7C,CAAC"}
|