@mahiframework/permissions 0.1.4
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 +7 -0
- package/dist/assignee.d.ts +57 -0
- package/dist/assignee.d.ts.map +1 -0
- package/dist/assignee.js +46 -0
- package/dist/assignee.js.map +1 -0
- package/dist/commands/permissions-cache-reset.d.ts +20 -0
- package/dist/commands/permissions-cache-reset.d.ts.map +1 -0
- package/dist/commands/permissions-cache-reset.js +24 -0
- package/dist/commands/permissions-cache-reset.js.map +1 -0
- package/dist/commands/permissions-check.d.ts +37 -0
- package/dist/commands/permissions-check.d.ts.map +1 -0
- package/dist/commands/permissions-check.js +92 -0
- package/dist/commands/permissions-check.js.map +1 -0
- package/dist/commands/permissions-show.d.ts +20 -0
- package/dist/commands/permissions-show.d.ts.map +1 -0
- package/dist/commands/permissions-show.js +49 -0
- package/dist/commands/permissions-show.js.map +1 -0
- package/dist/errors.d.ts +77 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +106 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/listeners/invalidate-permission-cache.listener.d.ts +39 -0
- package/dist/listeners/invalidate-permission-cache.listener.d.ts.map +1 -0
- package/dist/listeners/invalidate-permission-cache.listener.js +50 -0
- package/dist/listeners/invalidate-permission-cache.listener.js.map +1 -0
- package/dist/middleware/current-subject.d.ts +23 -0
- package/dist/middleware/current-subject.d.ts.map +1 -0
- package/dist/middleware/current-subject.js +31 -0
- package/dist/middleware/current-subject.js.map +1 -0
- package/dist/middleware/permission.d.ts +19 -0
- package/dist/middleware/permission.d.ts.map +1 -0
- package/dist/middleware/permission.js +34 -0
- package/dist/middleware/permission.js.map +1 -0
- package/dist/middleware/role-or-permission.d.ts +29 -0
- package/dist/middleware/role-or-permission.d.ts.map +1 -0
- package/dist/middleware/role-or-permission.js +45 -0
- package/dist/middleware/role-or-permission.js.map +1 -0
- package/dist/middleware/role.d.ts +21 -0
- package/dist/middleware/role.d.ts.map +1 -0
- package/dist/middleware/role.js +36 -0
- package/dist/middleware/role.js.map +1 -0
- package/dist/migrations/0001_create_permission_tables.d.ts +63 -0
- package/dist/migrations/0001_create_permission_tables.d.ts.map +1 -0
- package/dist/migrations/0001_create_permission_tables.js +113 -0
- package/dist/migrations/0001_create_permission_tables.js.map +1 -0
- package/dist/models/permission.model.d.ts +58 -0
- package/dist/models/permission.model.d.ts.map +1 -0
- package/dist/models/permission.model.js +18 -0
- package/dist/models/permission.model.js.map +1 -0
- package/dist/models/role.model.d.ts +60 -0
- package/dist/models/role.model.d.ts.map +1 -0
- package/dist/models/role.model.js +18 -0
- package/dist/models/role.model.js.map +1 -0
- package/dist/permission-map.d.ts +97 -0
- package/dist/permission-map.d.ts.map +1 -0
- package/dist/permission-map.js +93 -0
- package/dist/permission-map.js.map +1 -0
- package/dist/permission-registrar.d.ts +183 -0
- package/dist/permission-registrar.d.ts.map +1 -0
- package/dist/permission-registrar.js +573 -0
- package/dist/permission-registrar.js.map +1 -0
- package/dist/permissions-config.d.ts +80 -0
- package/dist/permissions-config.d.ts.map +1 -0
- package/dist/permissions-config.js +22 -0
- package/dist/permissions-config.js.map +1 -0
- package/dist/permissions-facade.d.ts +74 -0
- package/dist/permissions-facade.d.ts.map +1 -0
- package/dist/permissions-facade.js +131 -0
- package/dist/permissions-facade.js.map +1 -0
- package/dist/permissions-service-provider.d.ts +91 -0
- package/dist/permissions-service-provider.d.ts.map +1 -0
- package/dist/permissions-service-provider.js +137 -0
- package/dist/permissions-service-provider.js.map +1 -0
- package/dist/relations.d.ts +63 -0
- package/dist/relations.d.ts.map +1 -0
- package/dist/relations.js +62 -0
- package/dist/relations.js.map +1 -0
- package/dist/request-cache.d.ts +50 -0
- package/dist/request-cache.d.ts.map +1 -0
- package/dist/request-cache.js +40 -0
- package/dist/request-cache.js.map +1 -0
- package/dist/tokens.d.ts +15 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +15 -0
- package/dist/tokens.js.map +1 -0
- package/package.json +69 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export { PermissionsServiceProvider, PERMISSIONS_TOKEN } from "./permissions-service-provider.js";
|
|
2
|
+
export { Permissions } from "./permissions-facade.js";
|
|
3
|
+
export { PermissionRegistrar } from "./permission-registrar.js";
|
|
4
|
+
export { Role } from "./models/role.model.js";
|
|
5
|
+
export { Permission } from "./models/permission.model.js";
|
|
6
|
+
export { rolesRelation, permissionsRelation } from "./relations.js";
|
|
7
|
+
export { resolveAssignee, assigneeCacheKey } from "./assignee.js";
|
|
8
|
+
export { resolveConfig } from "./permissions-config.js";
|
|
9
|
+
export { role } from "./middleware/role.js";
|
|
10
|
+
export { permission } from "./middleware/permission.js";
|
|
11
|
+
export { roleOrPermission } from "./middleware/role-or-permission.js";
|
|
12
|
+
export { withPermissionCache } from "./request-cache.js";
|
|
13
|
+
export { InvalidatePermissionCacheListener } from "./listeners/invalidate-permission-cache.listener.js";
|
|
14
|
+
export { PermissionsCacheResetCommand } from "./commands/permissions-cache-reset.js";
|
|
15
|
+
export { PermissionsCheckCommand } from "./commands/permissions-check.js";
|
|
16
|
+
export { PermissionsShowCommand } from "./commands/permissions-show.js";
|
|
17
|
+
export { PermissionsError, RoleNotFoundError, PermissionNotFoundError, DuplicateNameError, UnsupportedAssigneeKeyError, UnresolvedGuardError, UnknownMorphAliasError, } from "./errors.js";
|
|
18
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,0BAA0B,EAAE,iBAAiB,EAAE,MAAM,mCAAmC,CAAC;AAClG,OAAO,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AACtD,OAAO,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AAGhE,OAAO,EAAE,IAAI,EAAE,MAAM,wBAAwB,CAAC;AAE9C,OAAO,EAAE,UAAU,EAAE,MAAM,8BAA8B,CAAC;AAG1D,OAAO,EAAE,aAAa,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AAEpE,OAAO,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAGlE,OAAO,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAC;AAOxD,OAAO,EAAE,IAAI,EAAE,MAAM,sBAAsB,CAAC;AAC5C,OAAO,EAAE,UAAU,EAAE,MAAM,4BAA4B,CAAC;AACxD,OAAO,EAAE,gBAAgB,EAAE,MAAM,oCAAoC,CAAC;AAGtE,OAAO,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAEzD,OAAO,EAAE,iCAAiC,EAAE,MAAM,qDAAqD,CAAC;AAExG,OAAO,EAAE,4BAA4B,EAAE,MAAM,uCAAuC,CAAC;AACrF,OAAO,EAAE,uBAAuB,EAAE,MAAM,iCAAiC,CAAC;AAC1E,OAAO,EAAE,sBAAsB,EAAE,MAAM,gCAAgC,CAAC;AAYxE,OAAO,EACL,gBAAgB,EAChB,iBAAiB,EACjB,uBAAuB,EACvB,kBAAkB,EAClB,2BAA2B,EAC3B,oBAAoB,EACpB,sBAAsB,GACvB,MAAM,aAAa,CAAC"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Application } from "@mahiframework/core";
|
|
2
|
+
import type { Listener } from "@mahiframework/events";
|
|
3
|
+
import type { ModelLifecycleEvent } from "@mahiframework/database";
|
|
4
|
+
/**
|
|
5
|
+
* Forgets the cached role/permission map when a `Role` or `Permission`
|
|
6
|
+
* row is written outside this package.
|
|
7
|
+
*
|
|
8
|
+
* `PermissionRegistrar` already forgets the cache on every write it
|
|
9
|
+
* performs, so this listener exists entirely for the writes it never
|
|
10
|
+
* sees: a seeder calling `Role.create(...)`, a migration backfilling
|
|
11
|
+
* `guard_name`, an admin screen saving a renamed role through the model.
|
|
12
|
+
* Without it, the map would keep answering from the old names until the
|
|
13
|
+
* TTL expired — which is precisely the class of bug a permission system
|
|
14
|
+
* cannot ship with.
|
|
15
|
+
*
|
|
16
|
+
* Subscribed to the three past-tense classes individually, not a
|
|
17
|
+
* `"model.*"` pattern. The pattern would also match `retrieved`, which
|
|
18
|
+
* fires on every row read in the application, so this would sit on the
|
|
19
|
+
* hottest path only to filter itself out. `ModelSaved` is skipped: it
|
|
20
|
+
* fires alongside both `ModelCreated` and `ModelUpdated` and would
|
|
21
|
+
* double the work. `ModelRestored` is skipped too — neither model uses
|
|
22
|
+
* soft deletes, so it cannot fire for them.
|
|
23
|
+
*
|
|
24
|
+
* ## Errors are not swallowed
|
|
25
|
+
*
|
|
26
|
+
* `EventDispatcher` awaits listeners with no isolation and
|
|
27
|
+
* `dispatchModelEvent` is awaited inside `save()`, so a throw here fails
|
|
28
|
+
* the caller's `Role.create()`. That is the right trade, and the opposite
|
|
29
|
+
* of the one `activity-logs` makes: a missing audit row is a lost record,
|
|
30
|
+
* but a cache that still grants a deleted role's permissions is a
|
|
31
|
+
* security failure. Better the write fails loudly and is retried than
|
|
32
|
+
* succeeds while leaving the cache lying.
|
|
33
|
+
*/
|
|
34
|
+
export declare class InvalidatePermissionCacheListener implements Listener<ModelLifecycleEvent> {
|
|
35
|
+
private readonly app;
|
|
36
|
+
constructor(app: Application);
|
|
37
|
+
handle(event: ModelLifecycleEvent): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=invalidate-permission-cache.listener.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"invalidate-permission-cache.listener.d.ts","sourceRoot":"","sources":["../../src/listeners/invalidate-permission-cache.listener.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AACvD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AACtD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,yBAAyB,CAAC;AAMnE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,qBAAa,iCAAkC,YAAW,QAAQ,CAAC,mBAAmB,CAAC;IACzE,OAAO,CAAC,QAAQ,CAAC,GAAG;gBAAH,GAAG,EAAE,WAAW;IAEvC,MAAM,CAAC,KAAK,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC;CAWxD"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { PERMISSIONS_TOKEN } from "../tokens.js";
|
|
2
|
+
import { Permission } from "../models/permission.model.js";
|
|
3
|
+
import { Role } from "../models/role.model.js";
|
|
4
|
+
/**
|
|
5
|
+
* Forgets the cached role/permission map when a `Role` or `Permission`
|
|
6
|
+
* row is written outside this package.
|
|
7
|
+
*
|
|
8
|
+
* `PermissionRegistrar` already forgets the cache on every write it
|
|
9
|
+
* performs, so this listener exists entirely for the writes it never
|
|
10
|
+
* sees: a seeder calling `Role.create(...)`, a migration backfilling
|
|
11
|
+
* `guard_name`, an admin screen saving a renamed role through the model.
|
|
12
|
+
* Without it, the map would keep answering from the old names until the
|
|
13
|
+
* TTL expired — which is precisely the class of bug a permission system
|
|
14
|
+
* cannot ship with.
|
|
15
|
+
*
|
|
16
|
+
* Subscribed to the three past-tense classes individually, not a
|
|
17
|
+
* `"model.*"` pattern. The pattern would also match `retrieved`, which
|
|
18
|
+
* fires on every row read in the application, so this would sit on the
|
|
19
|
+
* hottest path only to filter itself out. `ModelSaved` is skipped: it
|
|
20
|
+
* fires alongside both `ModelCreated` and `ModelUpdated` and would
|
|
21
|
+
* double the work. `ModelRestored` is skipped too — neither model uses
|
|
22
|
+
* soft deletes, so it cannot fire for them.
|
|
23
|
+
*
|
|
24
|
+
* ## Errors are not swallowed
|
|
25
|
+
*
|
|
26
|
+
* `EventDispatcher` awaits listeners with no isolation and
|
|
27
|
+
* `dispatchModelEvent` is awaited inside `save()`, so a throw here fails
|
|
28
|
+
* the caller's `Role.create()`. That is the right trade, and the opposite
|
|
29
|
+
* of the one `activity-logs` makes: a missing audit row is a lost record,
|
|
30
|
+
* but a cache that still grants a deleted role's permissions is a
|
|
31
|
+
* security failure. Better the write fails loudly and is retried than
|
|
32
|
+
* succeeds while leaving the cache lying.
|
|
33
|
+
*/
|
|
34
|
+
export class InvalidatePermissionCacheListener {
|
|
35
|
+
app;
|
|
36
|
+
constructor(app) {
|
|
37
|
+
this.app = app;
|
|
38
|
+
}
|
|
39
|
+
async handle(event) {
|
|
40
|
+
// Identity, not `morphAlias()`: an app may legitimately map `Role` to
|
|
41
|
+
// some other alias, and the question here is only whether the row
|
|
42
|
+
// that moved belongs to one of the two tables this cache is built
|
|
43
|
+
// from.
|
|
44
|
+
if (event.model !== Role && event.model !== Permission) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
await this.app.make(PERMISSIONS_TOKEN).forgetCache();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=invalidate-permission-cache.listener.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"invalidate-permission-cache.listener.js","sourceRoot":"","sources":["../../src/listeners/invalidate-permission-cache.listener.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AACjD,OAAO,EAAE,UAAU,EAAE,MAAM,+BAA+B,CAAC;AAC3D,OAAO,EAAE,IAAI,EAAE,MAAM,yBAAyB,CAAC;AAE/C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,MAAM,OAAO,iCAAiC;IACf;IAA7B,YAA6B,GAAgB;QAAhB,QAAG,GAAH,GAAG,CAAa;IAAG,CAAC;IAEjD,KAAK,CAAC,MAAM,CAAC,KAA0B;QACrC,sEAAsE;QACtE,kEAAkE;QAClE,kEAAkE;QAClE,QAAQ;QACR,IAAI,KAAK,CAAC,KAAK,KAAM,IAAc,IAAI,KAAK,CAAC,KAAK,KAAM,UAAoB,EAAE,CAAC;YAC7E,OAAO;QACT,CAAC;QAED,MAAM,IAAI,CAAC,GAAG,CAAC,IAAI,CAAsB,iBAAiB,CAAC,CAAC,WAAW,EAAE,CAAC;IAC5E,CAAC;CACF"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Assignee } from "../assignee.js";
|
|
2
|
+
/**
|
|
3
|
+
* The authenticated subject, or null for a guest.
|
|
4
|
+
*
|
|
5
|
+
* Reads `AUTH_TOKEN` directly rather than `request.user()`, which wraps
|
|
6
|
+
* its lookup in a `try`/`catch` and returns `undefined` on any failure.
|
|
7
|
+
* That is the wrong shape here: it would turn "the route forgot
|
|
8
|
+
* `authenticate()`, so there is no auth scope at all" into "this is a
|
|
9
|
+
* guest", which is an authorization decision made by accident. The
|
|
10
|
+
* underlying `MissingAuthContextError` must propagate and 500, exactly
|
|
11
|
+
* as it does through `can()`.
|
|
12
|
+
*
|
|
13
|
+
* Returns null when auth isn't bound at all, so these pipes still behave
|
|
14
|
+
* (as deny) in an app with no authentication.
|
|
15
|
+
*
|
|
16
|
+
* Only a `BaseModel` is usable: the pivots need a morph alias and a
|
|
17
|
+
* `bigint` key, and a non-model user (a token-guard adapter, a stub)
|
|
18
|
+
* has neither. Treated as a guest rather than thrown, because a pipe's
|
|
19
|
+
* job is to answer allow/deny and a subject that cannot hold a role
|
|
20
|
+
* holds none.
|
|
21
|
+
*/
|
|
22
|
+
export declare function currentSubject(): Assignee | null;
|
|
23
|
+
//# sourceMappingURL=current-subject.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"current-subject.d.ts","sourceRoot":"","sources":["../../src/middleware/current-subject.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAO/C;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,cAAc,IAAI,QAAQ,GAAG,IAAI,CAUhD"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { app, AUTH_TOKEN } from "@mahiframework/core";
|
|
2
|
+
import { BaseModel } from "@mahiframework/database";
|
|
3
|
+
/**
|
|
4
|
+
* The authenticated subject, or null for a guest.
|
|
5
|
+
*
|
|
6
|
+
* Reads `AUTH_TOKEN` directly rather than `request.user()`, which wraps
|
|
7
|
+
* its lookup in a `try`/`catch` and returns `undefined` on any failure.
|
|
8
|
+
* That is the wrong shape here: it would turn "the route forgot
|
|
9
|
+
* `authenticate()`, so there is no auth scope at all" into "this is a
|
|
10
|
+
* guest", which is an authorization decision made by accident. The
|
|
11
|
+
* underlying `MissingAuthContextError` must propagate and 500, exactly
|
|
12
|
+
* as it does through `can()`.
|
|
13
|
+
*
|
|
14
|
+
* Returns null when auth isn't bound at all, so these pipes still behave
|
|
15
|
+
* (as deny) in an app with no authentication.
|
|
16
|
+
*
|
|
17
|
+
* Only a `BaseModel` is usable: the pivots need a morph alias and a
|
|
18
|
+
* `bigint` key, and a non-model user (a token-guard adapter, a stub)
|
|
19
|
+
* has neither. Treated as a guest rather than thrown, because a pipe's
|
|
20
|
+
* job is to answer allow/deny and a subject that cannot hold a role
|
|
21
|
+
* holds none.
|
|
22
|
+
*/
|
|
23
|
+
export function currentSubject() {
|
|
24
|
+
const container = app();
|
|
25
|
+
if (!container.has(AUTH_TOKEN)) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const user = container.make(AUTH_TOKEN).userOrNull();
|
|
29
|
+
return user instanceof BaseModel ? user : null;
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=current-subject.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"current-subject.js","sourceRoot":"","sources":["../../src/middleware/current-subject.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AACtD,OAAO,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;AAQpD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,UAAU,cAAc;IAC5B,MAAM,SAAS,GAAG,GAAG,EAAE,CAAC;IAExB,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,UAAU,CAAC,EAAE,CAAC;QAC/B,OAAO,IAAI,CAAC;IACd,CAAC;IAED,MAAM,IAAI,GAAG,SAAS,CAAC,IAAI,CAAoB,UAAU,CAAC,CAAC,UAAU,EAAE,CAAC;IAExE,OAAO,IAAI,YAAY,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC;AACjD,CAAC"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type HttpPipeFn } from "@mahiframework/http";
|
|
2
|
+
import type { GuardOption } from "../permission-registrar.js";
|
|
3
|
+
/**
|
|
4
|
+
* Require the authenticated subject to hold one of `permissions`, by
|
|
5
|
+
* either route (a role, or directly).
|
|
6
|
+
*
|
|
7
|
+
* posts.patch("/{post}", update).middleware(authenticate(), permission("posts.edit"));
|
|
8
|
+
*
|
|
9
|
+
* ANY-of, as `role()` is. See that pipe for the guest/403 reasoning and
|
|
10
|
+
* the ordering constraint.
|
|
11
|
+
*
|
|
12
|
+
* `can("posts.edit")` from `@mahiframework/authorization` does the same
|
|
13
|
+
* thing, via the gate hook this package registers. Reach for this one
|
|
14
|
+
* when the gate hook is disabled (`permissions.gate: false`), when the
|
|
15
|
+
* check needs a non-default guard, or when the route should read as a
|
|
16
|
+
* permission check rather than an ability check.
|
|
17
|
+
*/
|
|
18
|
+
export declare function permission(permissions: string | string[], options?: GuardOption): HttpPipeFn;
|
|
19
|
+
//# sourceMappingURL=permission.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"permission.d.ts","sourceRoot":"","sources":["../../src/middleware/permission.ts"],"names":[],"mappings":"AACA,OAAO,EAAa,KAAK,UAAU,EAAE,MAAM,qBAAqB,CAAC;AACjE,OAAO,KAAK,EAAE,WAAW,EAAuB,MAAM,4BAA4B,CAAC;AAInF;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,UAAU,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,EAAE,EAAE,OAAO,GAAE,WAAgB,GAAG,UAAU,CAkBhG"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { app } from "@mahiframework/core";
|
|
2
|
+
import { HttpError } from "@mahiframework/http";
|
|
3
|
+
import { PERMISSIONS_TOKEN } from "../tokens.js";
|
|
4
|
+
import { currentSubject } from "./current-subject.js";
|
|
5
|
+
/**
|
|
6
|
+
* Require the authenticated subject to hold one of `permissions`, by
|
|
7
|
+
* either route (a role, or directly).
|
|
8
|
+
*
|
|
9
|
+
* posts.patch("/{post}", update).middleware(authenticate(), permission("posts.edit"));
|
|
10
|
+
*
|
|
11
|
+
* ANY-of, as `role()` is. See that pipe for the guest/403 reasoning and
|
|
12
|
+
* the ordering constraint.
|
|
13
|
+
*
|
|
14
|
+
* `can("posts.edit")` from `@mahiframework/authorization` does the same
|
|
15
|
+
* thing, via the gate hook this package registers. Reach for this one
|
|
16
|
+
* when the gate hook is disabled (`permissions.gate: false`), when the
|
|
17
|
+
* check needs a non-default guard, or when the route should read as a
|
|
18
|
+
* permission check rather than an ability check.
|
|
19
|
+
*/
|
|
20
|
+
export function permission(permissions, options = {}) {
|
|
21
|
+
const names = Array.isArray(permissions) ? permissions : [permissions];
|
|
22
|
+
return async (request, next) => {
|
|
23
|
+
const subject = currentSubject();
|
|
24
|
+
if (subject === null) {
|
|
25
|
+
throw HttpError.forbidden();
|
|
26
|
+
}
|
|
27
|
+
const registrar = app().make(PERMISSIONS_TOKEN);
|
|
28
|
+
if (!(await registrar.hasAnyPermission(subject, names, options))) {
|
|
29
|
+
throw HttpError.forbidden();
|
|
30
|
+
}
|
|
31
|
+
return next(request);
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=permission.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"permission.js","sourceRoot":"","sources":["../../src/middleware/permission.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,qBAAqB,CAAC;AAC1C,OAAO,EAAE,SAAS,EAAmB,MAAM,qBAAqB,CAAC;AAEjE,OAAO,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AACjD,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAEtD;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,UAAU,CAAC,WAA8B,EAAE,UAAuB,EAAE;IAClF,MAAM,KAAK,GAAG,KAAK,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC;IAEvE,OAAO,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,EAAE;QAC7B,MAAM,OAAO,GAAG,cAAc,EAAE,CAAC;QAEjC,IAAI,OAAO,KAAK,IAAI,EAAE,CAAC;YACrB,MAAM,SAAS,CAAC,SAAS,EAAE,CAAC;QAC9B,CAAC;QAED,MAAM,SAAS,GAAG,GAAG,EAAE,CAAC,IAAI,CAAsB,iBAAiB,CAAC,CAAC;QAErE,IAAI,CAAC,CAAC,MAAM,SAAS,CAAC,gBAAgB,CAAC,OAAO,EAAE,KAAK,EAAE,OAAO,CAAC,CAAC,EAAE,CAAC;YACjE,MAAM,SAAS,CAAC,SAAS,EAAE,CAAC;QAC9B,CAAC;QAED,OAAO,IAAI,CAAC,OAAO,CAAC,CAAC;IACvB,CAAC,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type HttpPipeFn } from "@mahiframework/http";
|
|
2
|
+
import type { GuardOption } from "../permission-registrar.js";
|
|
3
|
+
export interface RoleOrPermissionOptions extends GuardOption {
|
|
4
|
+
roles?: string[];
|
|
5
|
+
permissions?: string[];
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Require the subject to hold one of `roles` OR one of `permissions`.
|
|
9
|
+
*
|
|
10
|
+
* group.middleware(
|
|
11
|
+
* authenticate(),
|
|
12
|
+
* roleOrPermission({ roles: ["admin"], permissions: ["posts.edit"] }),
|
|
13
|
+
* );
|
|
14
|
+
*
|
|
15
|
+
* A separate pipe rather than stacking `role()` and `permission()`,
|
|
16
|
+
* because stacking them is an AND: both would have to pass. This is the
|
|
17
|
+
* OR, and spatie's `role_or_permission:` middleware exists for the same
|
|
18
|
+
* reason.
|
|
19
|
+
*
|
|
20
|
+
* Takes a named object rather than two positional arrays: `roleOrPermission(
|
|
21
|
+
* ["admin"], ["posts.edit"])` is unreadable at the call site and silently
|
|
22
|
+
* wrong if the arguments are swapped, which is undetectable because both
|
|
23
|
+
* are `string[]`.
|
|
24
|
+
*
|
|
25
|
+
* Roles are checked first, so an admin costs no permission lookup. Both
|
|
26
|
+
* reads come from the same per-request memo regardless.
|
|
27
|
+
*/
|
|
28
|
+
export declare function roleOrPermission(options: RoleOrPermissionOptions): HttpPipeFn;
|
|
29
|
+
//# sourceMappingURL=role-or-permission.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"role-or-permission.d.ts","sourceRoot":"","sources":["../../src/middleware/role-or-permission.ts"],"names":[],"mappings":"AACA,OAAO,EAAa,KAAK,UAAU,EAAE,MAAM,qBAAqB,CAAC;AACjE,OAAO,KAAK,EAAE,WAAW,EAAuB,MAAM,4BAA4B,CAAC;AAInF,MAAM,WAAW,uBAAwB,SAAQ,WAAW;IAC1D,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;CACxB;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,uBAAuB,GAAG,UAAU,CA0B7E"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { app } from "@mahiframework/core";
|
|
2
|
+
import { HttpError } from "@mahiframework/http";
|
|
3
|
+
import { PERMISSIONS_TOKEN } from "../tokens.js";
|
|
4
|
+
import { currentSubject } from "./current-subject.js";
|
|
5
|
+
/**
|
|
6
|
+
* Require the subject to hold one of `roles` OR one of `permissions`.
|
|
7
|
+
*
|
|
8
|
+
* group.middleware(
|
|
9
|
+
* authenticate(),
|
|
10
|
+
* roleOrPermission({ roles: ["admin"], permissions: ["posts.edit"] }),
|
|
11
|
+
* );
|
|
12
|
+
*
|
|
13
|
+
* A separate pipe rather than stacking `role()` and `permission()`,
|
|
14
|
+
* because stacking them is an AND: both would have to pass. This is the
|
|
15
|
+
* OR, and spatie's `role_or_permission:` middleware exists for the same
|
|
16
|
+
* reason.
|
|
17
|
+
*
|
|
18
|
+
* Takes a named object rather than two positional arrays: `roleOrPermission(
|
|
19
|
+
* ["admin"], ["posts.edit"])` is unreadable at the call site and silently
|
|
20
|
+
* wrong if the arguments are swapped, which is undetectable because both
|
|
21
|
+
* are `string[]`.
|
|
22
|
+
*
|
|
23
|
+
* Roles are checked first, so an admin costs no permission lookup. Both
|
|
24
|
+
* reads come from the same per-request memo regardless.
|
|
25
|
+
*/
|
|
26
|
+
export function roleOrPermission(options) {
|
|
27
|
+
const roles = options.roles ?? [];
|
|
28
|
+
const permissions = options.permissions ?? [];
|
|
29
|
+
return async (request, next) => {
|
|
30
|
+
const subject = currentSubject();
|
|
31
|
+
if (subject === null) {
|
|
32
|
+
throw HttpError.forbidden();
|
|
33
|
+
}
|
|
34
|
+
const registrar = app().make(PERMISSIONS_TOKEN);
|
|
35
|
+
if (roles.length > 0 && (await registrar.hasAnyRole(subject, roles, options))) {
|
|
36
|
+
return next(request);
|
|
37
|
+
}
|
|
38
|
+
if (permissions.length > 0 &&
|
|
39
|
+
(await registrar.hasAnyPermission(subject, permissions, options))) {
|
|
40
|
+
return next(request);
|
|
41
|
+
}
|
|
42
|
+
throw HttpError.forbidden();
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=role-or-permission.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"role-or-permission.js","sourceRoot":"","sources":["../../src/middleware/role-or-permission.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,qBAAqB,CAAC;AAC1C,OAAO,EAAE,SAAS,EAAmB,MAAM,qBAAqB,CAAC;AAEjE,OAAO,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AACjD,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAOtD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,UAAU,gBAAgB,CAAC,OAAgC;IAC/D,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,IAAI,EAAE,CAAC;IAClC,MAAM,WAAW,GAAG,OAAO,CAAC,WAAW,IAAI,EAAE,CAAC;IAE9C,OAAO,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,EAAE;QAC7B,MAAM,OAAO,GAAG,cAAc,EAAE,CAAC;QAEjC,IAAI,OAAO,KAAK,IAAI,EAAE,CAAC;YACrB,MAAM,SAAS,CAAC,SAAS,EAAE,CAAC;QAC9B,CAAC;QAED,MAAM,SAAS,GAAG,GAAG,EAAE,CAAC,IAAI,CAAsB,iBAAiB,CAAC,CAAC;QAErE,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,IAAI,CAAC,MAAM,SAAS,CAAC,UAAU,CAAC,OAAO,EAAE,KAAK,EAAE,OAAO,CAAC,CAAC,EAAE,CAAC;YAC9E,OAAO,IAAI,CAAC,OAAO,CAAC,CAAC;QACvB,CAAC;QAED,IACE,WAAW,CAAC,MAAM,GAAG,CAAC;YACtB,CAAC,MAAM,SAAS,CAAC,gBAAgB,CAAC,OAAO,EAAE,WAAW,EAAE,OAAO,CAAC,CAAC,EACjE,CAAC;YACD,OAAO,IAAI,CAAC,OAAO,CAAC,CAAC;QACvB,CAAC;QAED,MAAM,SAAS,CAAC,SAAS,EAAE,CAAC;IAC9B,CAAC,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type HttpPipeFn } from "@mahiframework/http";
|
|
2
|
+
import type { GuardOption } from "../permission-registrar.js";
|
|
3
|
+
/**
|
|
4
|
+
* Require the authenticated subject to hold one of `roles`.
|
|
5
|
+
*
|
|
6
|
+
* admin.middleware(authenticate(), role("admin"));
|
|
7
|
+
* admin.middleware(authenticate(), role(["admin", "editor"]));
|
|
8
|
+
*
|
|
9
|
+
* ANY-of, not all-of, matching spatie's `role:` middleware. For all-of,
|
|
10
|
+
* use `Permissions.hasAllRoles()` in the controller, where the intent is
|
|
11
|
+
* legible; a route string cannot express the difference and silently
|
|
12
|
+
* choosing one is how an app ends up with the wrong one.
|
|
13
|
+
*
|
|
14
|
+
* Place AFTER `authenticate()`. A guest is denied with 403, not 401,
|
|
15
|
+
* because 401 is not an authorization decision — the same stance the
|
|
16
|
+
* gate takes. `Router.middleware()` throws when called after a route is
|
|
17
|
+
* registered, so getting the order wrong inside a group is a boot
|
|
18
|
+
* failure rather than a hole.
|
|
19
|
+
*/
|
|
20
|
+
export declare function role(roles: string | string[], options?: GuardOption): HttpPipeFn;
|
|
21
|
+
//# sourceMappingURL=role.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"role.d.ts","sourceRoot":"","sources":["../../src/middleware/role.ts"],"names":[],"mappings":"AACA,OAAO,EAAa,KAAK,UAAU,EAAE,MAAM,qBAAqB,CAAC;AACjE,OAAO,KAAK,EAAE,WAAW,EAAuB,MAAM,4BAA4B,CAAC;AAInF;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,EAAE,OAAO,GAAE,WAAgB,GAAG,UAAU,CAkBpF"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { app } from "@mahiframework/core";
|
|
2
|
+
import { HttpError } from "@mahiframework/http";
|
|
3
|
+
import { PERMISSIONS_TOKEN } from "../tokens.js";
|
|
4
|
+
import { currentSubject } from "./current-subject.js";
|
|
5
|
+
/**
|
|
6
|
+
* Require the authenticated subject to hold one of `roles`.
|
|
7
|
+
*
|
|
8
|
+
* admin.middleware(authenticate(), role("admin"));
|
|
9
|
+
* admin.middleware(authenticate(), role(["admin", "editor"]));
|
|
10
|
+
*
|
|
11
|
+
* ANY-of, not all-of, matching spatie's `role:` middleware. For all-of,
|
|
12
|
+
* use `Permissions.hasAllRoles()` in the controller, where the intent is
|
|
13
|
+
* legible; a route string cannot express the difference and silently
|
|
14
|
+
* choosing one is how an app ends up with the wrong one.
|
|
15
|
+
*
|
|
16
|
+
* Place AFTER `authenticate()`. A guest is denied with 403, not 401,
|
|
17
|
+
* because 401 is not an authorization decision — the same stance the
|
|
18
|
+
* gate takes. `Router.middleware()` throws when called after a route is
|
|
19
|
+
* registered, so getting the order wrong inside a group is a boot
|
|
20
|
+
* failure rather than a hole.
|
|
21
|
+
*/
|
|
22
|
+
export function role(roles, options = {}) {
|
|
23
|
+
const names = Array.isArray(roles) ? roles : [roles];
|
|
24
|
+
return async (request, next) => {
|
|
25
|
+
const subject = currentSubject();
|
|
26
|
+
if (subject === null) {
|
|
27
|
+
throw HttpError.forbidden();
|
|
28
|
+
}
|
|
29
|
+
const registrar = app().make(PERMISSIONS_TOKEN);
|
|
30
|
+
if (!(await registrar.hasAnyRole(subject, names, options))) {
|
|
31
|
+
throw HttpError.forbidden();
|
|
32
|
+
}
|
|
33
|
+
return next(request);
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=role.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"role.js","sourceRoot":"","sources":["../../src/middleware/role.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,qBAAqB,CAAC;AAC1C,OAAO,EAAE,SAAS,EAAmB,MAAM,qBAAqB,CAAC;AAEjE,OAAO,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AACjD,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAEtD;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,IAAI,CAAC,KAAwB,EAAE,UAAuB,EAAE;IACtE,MAAM,KAAK,GAAG,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;IAErD,OAAO,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,EAAE;QAC7B,MAAM,OAAO,GAAG,cAAc,EAAE,CAAC;QAEjC,IAAI,OAAO,KAAK,IAAI,EAAE,CAAC;YACrB,MAAM,SAAS,CAAC,SAAS,EAAE,CAAC;QAC9B,CAAC;QAED,MAAM,SAAS,GAAG,GAAG,EAAE,CAAC,IAAI,CAAsB,iBAAiB,CAAC,CAAC;QAErE,IAAI,CAAC,CAAC,MAAM,SAAS,CAAC,UAAU,CAAC,OAAO,EAAE,KAAK,EAAE,OAAO,CAAC,CAAC,EAAE,CAAC;YAC3D,MAAM,SAAS,CAAC,SAAS,EAAE,CAAC;QAC9B,CAAC;QAED,OAAO,IAAI,CAAC,OAAO,CAAC,CAAC;IACvB,CAAC,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type Migration } from "@mahiframework/database";
|
|
2
|
+
/**
|
|
3
|
+
* All five permission tables in one migration, mirroring spatie's single
|
|
4
|
+
* `create_permission_tables`.
|
|
5
|
+
*
|
|
6
|
+
* One unit rather than five because the three pivots are meaningless
|
|
7
|
+
* without the two entity tables, and a partial rollback that left
|
|
8
|
+
* `model_has_roles` pointing at a dropped `roles` would be a worse state
|
|
9
|
+
* than either end of the migration.
|
|
10
|
+
*
|
|
11
|
+
* `roles` and `permissions` are identical in shape and deliberately kept
|
|
12
|
+
* as separate tables rather than one table with a `kind` column: the two
|
|
13
|
+
* are joined to each other (`role_has_permissions`), so a single table
|
|
14
|
+
* would need a self-referential pivot and every query would carry a
|
|
15
|
+
* `kind` predicate that the schema should have made impossible.
|
|
16
|
+
*
|
|
17
|
+
* `guard_name` is NOT NULL on both, and `(name, guard_name)` is unique.
|
|
18
|
+
* There is no "applies to every guard" value: a nullable `guard_name`
|
|
19
|
+
* cannot be made unique portably, because every engine treats NULLs as
|
|
20
|
+
* distinct in a unique index and the fix (`nullsNotDistinct`) is Postgres
|
|
21
|
+
* 15+ only and throws on SQLite/MySQL. Nullable would therefore have
|
|
22
|
+
* allowed unlimited duplicate `('admin', NULL)` rows, which is exactly
|
|
23
|
+
* the integrity this table exists to have.
|
|
24
|
+
*
|
|
25
|
+
* `id` on both is a `bigInteger` primary key, never auto-increment: the
|
|
26
|
+
* models assign a snowflake via `keyType: snowflake()`.
|
|
27
|
+
*
|
|
28
|
+
* COMPOSITE PRIMARY KEYS, not surrogate ids, on all three pivots. They
|
|
29
|
+
* ARE the dedupe mechanism: nothing in the framework deduplicates pivot
|
|
30
|
+
* inserts, so "assign the same role twice" has to be either a database
|
|
31
|
+
* error or a diffed write, and the constraint is what makes the first
|
|
32
|
+
* possible. `model_has_*` leads with the foreign id so the index also
|
|
33
|
+
* serves "who holds this role", with a second index on
|
|
34
|
+
* `(model_id, model_type)` for the far commoner "what does this subject
|
|
35
|
+
* hold".
|
|
36
|
+
*
|
|
37
|
+
* FOREIGN KEYS ON THE PACKAGE-OWNED SIDE ONLY. `role_id` and
|
|
38
|
+
* `permission_id` cascade on delete, because both tables are this
|
|
39
|
+
* package's and deleting a role must not leave assignments behind.
|
|
40
|
+
* `model_id` carries no foreign key at all: `users` is app-owned so the
|
|
41
|
+
* framework cannot assume its name, and the column holds the key of any
|
|
42
|
+
* assignable model. Same reasoning as `activity_logs.model_id` and
|
|
43
|
+
* `sessions.user_id`.
|
|
44
|
+
*
|
|
45
|
+
* `model_id` is a `bigInteger`, NOT text, which is the one place this
|
|
46
|
+
* schema diverges from `activity_logs`/`notifications`. Those tables are
|
|
47
|
+
* only ever read back by equality from code that already knows the type,
|
|
48
|
+
* so text (which holds every key type losslessly) costs them nothing.
|
|
49
|
+
* Here the column is the local side of a `morphToMany` pivot, and
|
|
50
|
+
* `buildPivotQuery()` binds the local key value RAW — a `bigint` against
|
|
51
|
+
* a `varchar` column makes Postgres raise `operator does not exist`. Text
|
|
52
|
+
* would therefore have broken the exported relation helpers, and with
|
|
53
|
+
* them `with("roles")` and `whereHas("roles", ...)`. The cost is that
|
|
54
|
+
* only snowflake-keyed models can hold roles, which
|
|
55
|
+
* `resolveAssignee()` enforces with a clear error rather than letting it
|
|
56
|
+
* reach SQL.
|
|
57
|
+
*
|
|
58
|
+
* No timestamps on the pivots. spatie has none either, and there is
|
|
59
|
+
* nothing to record: the composite key carries the entire fact.
|
|
60
|
+
*/
|
|
61
|
+
declare const migration: Migration;
|
|
62
|
+
export default migration;
|
|
63
|
+
//# sourceMappingURL=0001_create_permission_tables.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"0001_create_permission_tables.d.ts","sourceRoot":"","sources":["../../src/migrations/0001_create_permission_tables.ts"],"names":[],"mappings":"AAAA,OAAO,EAAU,KAAK,SAAS,EAAkB,MAAM,yBAAyB,CAAC;AAEjF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0DG;AACH,QAAA,MAAM,SAAS,EAAE,SAuDhB,CAAC;AAEF,eAAe,SAAS,CAAC"}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { Schema } from "@mahiframework/database";
|
|
2
|
+
/**
|
|
3
|
+
* All five permission tables in one migration, mirroring spatie's single
|
|
4
|
+
* `create_permission_tables`.
|
|
5
|
+
*
|
|
6
|
+
* One unit rather than five because the three pivots are meaningless
|
|
7
|
+
* without the two entity tables, and a partial rollback that left
|
|
8
|
+
* `model_has_roles` pointing at a dropped `roles` would be a worse state
|
|
9
|
+
* than either end of the migration.
|
|
10
|
+
*
|
|
11
|
+
* `roles` and `permissions` are identical in shape and deliberately kept
|
|
12
|
+
* as separate tables rather than one table with a `kind` column: the two
|
|
13
|
+
* are joined to each other (`role_has_permissions`), so a single table
|
|
14
|
+
* would need a self-referential pivot and every query would carry a
|
|
15
|
+
* `kind` predicate that the schema should have made impossible.
|
|
16
|
+
*
|
|
17
|
+
* `guard_name` is NOT NULL on both, and `(name, guard_name)` is unique.
|
|
18
|
+
* There is no "applies to every guard" value: a nullable `guard_name`
|
|
19
|
+
* cannot be made unique portably, because every engine treats NULLs as
|
|
20
|
+
* distinct in a unique index and the fix (`nullsNotDistinct`) is Postgres
|
|
21
|
+
* 15+ only and throws on SQLite/MySQL. Nullable would therefore have
|
|
22
|
+
* allowed unlimited duplicate `('admin', NULL)` rows, which is exactly
|
|
23
|
+
* the integrity this table exists to have.
|
|
24
|
+
*
|
|
25
|
+
* `id` on both is a `bigInteger` primary key, never auto-increment: the
|
|
26
|
+
* models assign a snowflake via `keyType: snowflake()`.
|
|
27
|
+
*
|
|
28
|
+
* COMPOSITE PRIMARY KEYS, not surrogate ids, on all three pivots. They
|
|
29
|
+
* ARE the dedupe mechanism: nothing in the framework deduplicates pivot
|
|
30
|
+
* inserts, so "assign the same role twice" has to be either a database
|
|
31
|
+
* error or a diffed write, and the constraint is what makes the first
|
|
32
|
+
* possible. `model_has_*` leads with the foreign id so the index also
|
|
33
|
+
* serves "who holds this role", with a second index on
|
|
34
|
+
* `(model_id, model_type)` for the far commoner "what does this subject
|
|
35
|
+
* hold".
|
|
36
|
+
*
|
|
37
|
+
* FOREIGN KEYS ON THE PACKAGE-OWNED SIDE ONLY. `role_id` and
|
|
38
|
+
* `permission_id` cascade on delete, because both tables are this
|
|
39
|
+
* package's and deleting a role must not leave assignments behind.
|
|
40
|
+
* `model_id` carries no foreign key at all: `users` is app-owned so the
|
|
41
|
+
* framework cannot assume its name, and the column holds the key of any
|
|
42
|
+
* assignable model. Same reasoning as `activity_logs.model_id` and
|
|
43
|
+
* `sessions.user_id`.
|
|
44
|
+
*
|
|
45
|
+
* `model_id` is a `bigInteger`, NOT text, which is the one place this
|
|
46
|
+
* schema diverges from `activity_logs`/`notifications`. Those tables are
|
|
47
|
+
* only ever read back by equality from code that already knows the type,
|
|
48
|
+
* so text (which holds every key type losslessly) costs them nothing.
|
|
49
|
+
* Here the column is the local side of a `morphToMany` pivot, and
|
|
50
|
+
* `buildPivotQuery()` binds the local key value RAW — a `bigint` against
|
|
51
|
+
* a `varchar` column makes Postgres raise `operator does not exist`. Text
|
|
52
|
+
* would therefore have broken the exported relation helpers, and with
|
|
53
|
+
* them `with("roles")` and `whereHas("roles", ...)`. The cost is that
|
|
54
|
+
* only snowflake-keyed models can hold roles, which
|
|
55
|
+
* `resolveAssignee()` enforces with a clear error rather than letting it
|
|
56
|
+
* reach SQL.
|
|
57
|
+
*
|
|
58
|
+
* No timestamps on the pivots. spatie has none either, and there is
|
|
59
|
+
* nothing to record: the composite key carries the entire fact.
|
|
60
|
+
*/
|
|
61
|
+
const migration = {
|
|
62
|
+
async up() {
|
|
63
|
+
await Schema.create("roles", (table) => {
|
|
64
|
+
table.bigInteger("id").primary();
|
|
65
|
+
table.string("name");
|
|
66
|
+
table.string("guard_name");
|
|
67
|
+
table.timestamp("created_at");
|
|
68
|
+
table.timestamp("updated_at");
|
|
69
|
+
table.unique(["name", "guard_name"]);
|
|
70
|
+
});
|
|
71
|
+
await Schema.create("permissions", (table) => {
|
|
72
|
+
table.bigInteger("id").primary();
|
|
73
|
+
table.string("name");
|
|
74
|
+
table.string("guard_name");
|
|
75
|
+
table.timestamp("created_at");
|
|
76
|
+
table.timestamp("updated_at");
|
|
77
|
+
table.unique(["name", "guard_name"]);
|
|
78
|
+
});
|
|
79
|
+
await Schema.create("role_has_permissions", (table) => {
|
|
80
|
+
table.bigInteger("role_id");
|
|
81
|
+
table.bigInteger("permission_id");
|
|
82
|
+
table.primary(["role_id", "permission_id"]);
|
|
83
|
+
table.foreign("role_id").references("id").on("roles").cascadeOnDelete();
|
|
84
|
+
table.foreign("permission_id").references("id").on("permissions").cascadeOnDelete();
|
|
85
|
+
});
|
|
86
|
+
await Schema.create("model_has_roles", (table) => {
|
|
87
|
+
table.bigInteger("role_id");
|
|
88
|
+
table.string("model_type");
|
|
89
|
+
table.bigInteger("model_id");
|
|
90
|
+
table.primary(["role_id", "model_id", "model_type"]);
|
|
91
|
+
table.index(["model_id", "model_type"]);
|
|
92
|
+
table.foreign("role_id").references("id").on("roles").cascadeOnDelete();
|
|
93
|
+
});
|
|
94
|
+
await Schema.create("model_has_permissions", (table) => {
|
|
95
|
+
table.bigInteger("permission_id");
|
|
96
|
+
table.string("model_type");
|
|
97
|
+
table.bigInteger("model_id");
|
|
98
|
+
table.primary(["permission_id", "model_id", "model_type"]);
|
|
99
|
+
table.index(["model_id", "model_type"]);
|
|
100
|
+
table.foreign("permission_id").references("id").on("permissions").cascadeOnDelete();
|
|
101
|
+
});
|
|
102
|
+
},
|
|
103
|
+
/** Reverse creation order, so a pivot never outlives the table it references. */
|
|
104
|
+
async down() {
|
|
105
|
+
await Schema.drop("model_has_permissions");
|
|
106
|
+
await Schema.drop("model_has_roles");
|
|
107
|
+
await Schema.drop("role_has_permissions");
|
|
108
|
+
await Schema.drop("permissions");
|
|
109
|
+
await Schema.drop("roles");
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
export default migration;
|
|
113
|
+
//# sourceMappingURL=0001_create_permission_tables.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"0001_create_permission_tables.js","sourceRoot":"","sources":["../../src/migrations/0001_create_permission_tables.ts"],"names":[],"mappings":"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"}
|