@cedarjs/permissions 0.0.0-stage → 8.0.0-canary.3570

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/README.md CHANGED
@@ -1,3 +1,105 @@
1
- # Temporary Holding Version
1
+ # @cedarjs/permissions
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Permissions for a Cedar app: who may do what, defined in one place. A role is a
4
+ named bundle of permissions. Fields, services and components check a permission,
5
+ never a role, so a role can do exactly what its list says, and a field added
6
+ later is closed to it unless its permission is on the list.
7
+
8
+ `yarn cedar setup permissions` sets this up. In an app with tenancy
9
+ (`yarn cedar setup tenancy`), `@cedarjs/tenancy` checks the same definitions
10
+ against the user's role in the current organization.
11
+
12
+ ## Defining permissions
13
+
14
+ The definitions live in the app's `packages/permissions`, shared by the api and
15
+ web sides:
16
+
17
+ ```ts
18
+ // packages/permissions/src/index.ts
19
+ import { definePermissions } from '@cedarjs/permissions'
20
+ import type { PermissionName } from '@cedarjs/permissions'
21
+
22
+ const reader = ['posts.read'] as const
23
+
24
+ export const permissions = definePermissions({
25
+ permissions: ['posts.read', 'posts.edit', 'users.manage'],
26
+ roles: {
27
+ admin: [...reader, 'posts.edit', 'users.manage'],
28
+ editor: [...reader, 'posts.edit'],
29
+ reader,
30
+ },
31
+ })
32
+
33
+ export type Permission = PermissionName<typeof permissions>
34
+ ```
35
+
36
+ The explicit `permissions` list makes a typo in a role's list a type error (and
37
+ an error at runtime) instead of a new permission. A role the definitions don't
38
+ list has no permissions.
39
+
40
+ `@cedarjs/permissions` is plain data and functions with no api code in it, so
41
+ the web can import it too. It also exports:
42
+
43
+ - `permissionsOf(definitions, roles)`: the permissions of a role, or of a list
44
+ of roles (their union). The one place a role becomes permissions.
45
+ - `coversRole(definitions, held, role)`: whether the permissions in `held`
46
+ include every permission of `role`.
47
+ - `isPermission(definitions, name)` and `assertPermission(definitions, name)`:
48
+ whether `name` is defined, as a type guard or an assertion.
49
+
50
+ ## Checking permissions on the api
51
+
52
+ `createPermissions` from `@cedarjs/permissions/api` checks the signed-in user's
53
+ permissions: those of their roles, in `currentUser.roles` (one role or a list of
54
+ them).
55
+
56
+ ```ts
57
+ // api/src/lib/permissions.ts
58
+ import { createPermissions } from '@cedarjs/permissions/api'
59
+
60
+ import { permissions } from '@my-app/permissions'
61
+
62
+ export const {
63
+ hasPermission,
64
+ requirePermission,
65
+ canGrantRole,
66
+ permissionsOf,
67
+ withPermissions,
68
+ } = createPermissions(permissions)
69
+ ```
70
+
71
+ - `hasPermission('posts.edit')`: whether one of the user's roles has it. False
72
+ when no one is signed in.
73
+ - `requirePermission('posts.edit')`: throws `AuthenticationError` when no one is
74
+ signed in, and `ForbiddenError` when they may not.
75
+ - `canGrantRole(role)`: whether the user may give someone `role`, or take it
76
+ away: they hold every permission it has, so no one can hand out more than they
77
+ may do themselves. False for a role the definitions don't list.
78
+ - `permissionsOf(user)`: the permissions of `user.roles`.
79
+ - `withPermissions(user)`: `user` with a `permissions` list added, for
80
+ `getCurrentUser` to return.
81
+
82
+ Permission names are typed from the definitions, so a typo in a service is a
83
+ type error. A name that isn't defined (a typo in an SDL directive) throws.
84
+
85
+ Fields use the `@requirePermission` directive that setup adds:
86
+
87
+ ```graphql
88
+ type Mutation {
89
+ updatePost(id: Int!, input: UpdatePostInput!): Post!
90
+ @requirePermission(permission: "posts.edit")
91
+ }
92
+ ```
93
+
94
+ ## On the web
95
+
96
+ `getCurrentUser` returns the user through `withPermissions`, so
97
+ `currentUser.permissions` has the user's permissions, and
98
+ `useAuth().hasPermission('posts.edit')` and
99
+ `<PrivateSet permission="posts.edit">` check them. The web only decides what to
100
+ show; the api's checks are the guard.
101
+
102
+ ## In tests
103
+
104
+ `mockCurrentUser({ roles: ['editor'] })` gives the user the permissions the
105
+ app's definitions give `editor`, on the api and on the web.
package/dist/api.d.ts ADDED
@@ -0,0 +1,64 @@
1
+ import type { PermissionDefinitions } from './definePermissions.js';
2
+ /** A user as `getCurrentUser` returns it, with its permissions */
3
+ export type WithPermissions<User, Permission extends string> = User & {
4
+ permissions: Permission[];
5
+ };
6
+ export interface Permissions<Permission extends string> {
7
+ /**
8
+ * Whether the signed-in user may do `permission`: one of their roles,
9
+ * in `currentUser.roles`, has it. False when no one is signed in.
10
+ */
11
+ hasPermission(permission: Permission): boolean;
12
+ /**
13
+ * Guards a service or directive on `hasPermission(permission)`. Throws
14
+ * `AuthenticationError` when no one is signed in, and `ForbiddenError`
15
+ * when they may not.
16
+ */
17
+ requirePermission(permission: Permission): void;
18
+ /**
19
+ * Whether the signed-in user may give `role` to someone, or take it
20
+ * away: they hold every permission `role` has, so no one can hand out
21
+ * more than they may do themselves. False for a role the definitions
22
+ * don't list.
23
+ *
24
+ * This is only the role half: who may manage users at all is a
25
+ * permission of its own (`users.manage`, say) on the field.
26
+ */
27
+ canGrantRole(role: string): boolean;
28
+ /**
29
+ * What `user` may do: the permissions of each of its roles, from
30
+ * `user.roles` (one role, or a list of them). The one place a user's
31
+ * permissions are worked out, which `hasPermission`, `requirePermission`
32
+ * and `withPermissions` all use.
33
+ */
34
+ permissionsOf(user: unknown): Permission[];
35
+ /**
36
+ * `user` with its permissions added, for `getCurrentUser` to return, so
37
+ * the web gets them (`useAuth().hasPermission`)
38
+ */
39
+ withPermissions<User extends object>(user: User): WithPermissions<User, Permission>;
40
+ withPermissions<User extends object>(user: User | null): WithPermissions<User, Permission> | null;
41
+ }
42
+ /**
43
+ * Permissions on top of the user's roles: roles map to permissions in one
44
+ * place, and fields and services ask for a permission instead of listing
45
+ * roles, so adding a role is one entry in the definitions instead of an
46
+ * edit to every field it should reach.
47
+ *
48
+ * ```ts
49
+ * // api/src/lib/permissions.ts
50
+ * import { permissions } from '@my-app/permissions'
51
+ *
52
+ * export const { hasPermission, requirePermission, canGrantRole, withPermissions } =
53
+ * createPermissions(permissions)
54
+ * ```
55
+ *
56
+ * `permissions` comes from `definePermissions`. A user's permissions are
57
+ * those of their roles, in `currentUser.roles`. Fails closed: a user whose
58
+ * roles don't have the permission is refused, as is a role the definitions
59
+ * don't list. Permission names are typed, so a typo in a service is a type
60
+ * error; a name that isn't defined (a typo in an SDL directive) throws
61
+ * instead of quietly refusing everyone.
62
+ */
63
+ export declare function createPermissions<const Permission extends string>(definitions: PermissionDefinitions<Permission>): Permissions<Permission>;
64
+ //# sourceMappingURL=api.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAA;AAEnE,kEAAkE;AAClE,MAAM,MAAM,eAAe,CAAC,IAAI,EAAE,UAAU,SAAS,MAAM,IAAI,IAAI,GAAG;IACpE,WAAW,EAAE,UAAU,EAAE,CAAA;CAC1B,CAAA;AAED,MAAM,WAAW,WAAW,CAAC,UAAU,SAAS,MAAM;IACpD;;;OAGG;IACH,aAAa,CAAC,UAAU,EAAE,UAAU,GAAG,OAAO,CAAA;IAC9C;;;;OAIG;IACH,iBAAiB,CAAC,UAAU,EAAE,UAAU,GAAG,IAAI,CAAA;IAC/C;;;;;;;;OAQG;IACH,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAA;IACnC;;;;;OAKG;IACH,aAAa,CAAC,IAAI,EAAE,OAAO,GAAG,UAAU,EAAE,CAAA;IAC1C;;;OAGG;IACH,eAAe,CAAC,IAAI,SAAS,MAAM,EACjC,IAAI,EAAE,IAAI,GACT,eAAe,CAAC,IAAI,EAAE,UAAU,CAAC,CAAA;IACpC,eAAe,CAAC,IAAI,SAAS,MAAM,EACjC,IAAI,EAAE,IAAI,GAAG,IAAI,GAChB,eAAe,CAAC,IAAI,EAAE,UAAU,CAAC,GAAG,IAAI,CAAA;CAC5C;AAmBD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,CAAC,UAAU,SAAS,MAAM,EAC/D,WAAW,EAAE,qBAAqB,CAAC,UAAU,CAAC,GAC7C,WAAW,CAAC,UAAU,CAAC,CA6CzB"}
package/dist/api.js ADDED
@@ -0,0 +1,55 @@
1
+ import { context } from "@cedarjs/context";
2
+ import { AuthenticationError, ForbiddenError } from "@cedarjs/graphql-server";
3
+ import {
4
+ assertPermission,
5
+ assertPermissionDefinitions,
6
+ coversRole,
7
+ permissionsOf
8
+ } from "./definePermissions.js";
9
+ function rolesOf(user) {
10
+ if (typeof user !== "object" || user === null || !("roles" in user)) {
11
+ return [];
12
+ }
13
+ const { roles } = user;
14
+ if (typeof roles === "string") {
15
+ return [roles];
16
+ }
17
+ return Array.isArray(roles) ? roles.filter((role) => typeof role === "string") : [];
18
+ }
19
+ function createPermissions(definitions) {
20
+ assertPermissionDefinitions(definitions);
21
+ const userPermissions = (user) => permissionsOf(definitions, rolesOf(user));
22
+ const hasPermission = (permission) => {
23
+ assertPermission(definitions, permission);
24
+ return userPermissions(context.currentUser).includes(permission);
25
+ };
26
+ function withPermissions(user) {
27
+ if (!user) {
28
+ return null;
29
+ }
30
+ return { ...user, permissions: userPermissions(user) };
31
+ }
32
+ return {
33
+ hasPermission,
34
+ permissionsOf: userPermissions,
35
+ withPermissions,
36
+ canGrantRole(role) {
37
+ if (!context.currentUser) {
38
+ return false;
39
+ }
40
+ return coversRole(definitions, userPermissions(context.currentUser), role);
41
+ },
42
+ requirePermission(permission) {
43
+ assertPermission(definitions, permission);
44
+ if (!context.currentUser) {
45
+ throw new AuthenticationError("You don't have permission to do that.");
46
+ }
47
+ if (!hasPermission(permission)) {
48
+ throw new ForbiddenError("You don't have access to do that.");
49
+ }
50
+ }
51
+ };
52
+ }
53
+ export {
54
+ createPermissions
55
+ };
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Every permission an app checks, and what each role may do.
3
+ *
4
+ * Plain data with no api code in it, so both sides can import it: the api to
5
+ * check permissions (`createPermissions`), and the web for the names.
6
+ */
7
+ export interface PermissionDefinitions<Name extends string = string> {
8
+ /**
9
+ * Every permission the app checks, by name. Fields, services and the web
10
+ * ask for these names, so they're defined in code. A name is a stable id,
11
+ * so it can also key what an app shows for it, such as translations.
12
+ */
13
+ permissions: readonly Name[];
14
+ /**
15
+ * What each role may do: a role is a named bundle of permissions. A role
16
+ * that isn't listed has no permissions.
17
+ */
18
+ roles: Readonly<Record<string, readonly Name[]>>;
19
+ }
20
+ /** The permission names in `definitions` */
21
+ export type PermissionName<Definitions> = Definitions extends PermissionDefinitions<infer Name> ? Name : never;
22
+ /**
23
+ * Throws when a role lists a permission that `permissions` doesn't define.
24
+ * The types catch this in TypeScript; this is for JavaScript, and for
25
+ * definitions built at runtime.
26
+ */
27
+ export declare function assertPermissionDefinitions(definitions: PermissionDefinitions): void;
28
+ /**
29
+ * Defines an app's permissions, and what each role may do. Permission names
30
+ * are typed from `permissions`, so a role listing a name that isn't defined
31
+ * is a type error.
32
+ *
33
+ * ```ts
34
+ * export const permissions = definePermissions({
35
+ * permissions: ['projects.read', 'members.manage'],
36
+ * roles: {
37
+ * owner: ['projects.read', 'members.manage'],
38
+ * member: ['projects.read'],
39
+ * },
40
+ * })
41
+ * ```
42
+ *
43
+ * Pass the result to `createPermissions` on the api.
44
+ */
45
+ export declare function definePermissions<const Name extends string>(definitions: {
46
+ permissions: readonly Name[];
47
+ roles: Readonly<Record<string, readonly NoInfer<Name>[]>>;
48
+ }): PermissionDefinitions<Name>;
49
+ /** Whether `name` is one of the permissions `definitions` defines */
50
+ export declare function isPermission<Name extends string>(definitions: PermissionDefinitions<Name>, name: unknown): name is Name;
51
+ /**
52
+ * Throws when `definitions` doesn't define `name`, so a typo (in an SDL
53
+ * directive, say) is an error instead of a check that refuses everyone
54
+ */
55
+ export declare function assertPermission<Name extends string>(definitions: PermissionDefinitions<Name>, name: string): asserts name is Name;
56
+ /**
57
+ * What `roles` may do: every permission any of them has. The one place a
58
+ * role becomes permissions. A role the definitions don't list has none.
59
+ */
60
+ export declare function permissionsOf<Name extends string>(definitions: PermissionDefinitions<Name>, roles: string | readonly string[] | null | undefined): Name[];
61
+ /**
62
+ * Whether `held` covers `role`: it has every permission `role` has. This is
63
+ * who may give someone a role, so no one can hand out more than they may do
64
+ * themselves. False for a role the definitions don't list.
65
+ */
66
+ export declare function coversRole<Name extends string>(definitions: PermissionDefinitions<Name>, held: readonly string[], role: string): boolean;
67
+ //# sourceMappingURL=definePermissions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"definePermissions.d.ts","sourceRoot":"","sources":["../src/definePermissions.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,WAAW,qBAAqB,CAAC,IAAI,SAAS,MAAM,GAAG,MAAM;IACjE;;;;OAIG;IACH,WAAW,EAAE,SAAS,IAAI,EAAE,CAAA;IAC5B;;;OAGG;IACH,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,IAAI,EAAE,CAAC,CAAC,CAAA;CACjD;AAED,4CAA4C;AAC5C,MAAM,MAAM,cAAc,CAAC,WAAW,IACpC,WAAW,SAAS,qBAAqB,CAAC,MAAM,IAAI,CAAC,GAAG,IAAI,GAAG,KAAK,CAAA;AAEtE;;;;GAIG;AACH,wBAAgB,2BAA2B,CACzC,WAAW,EAAE,qBAAqB,QAanC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,CAAC,IAAI,SAAS,MAAM,EAAE,WAAW,EAAE;IACxE,WAAW,EAAE,SAAS,IAAI,EAAE,CAAA;IAC5B,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAA;CAC1D,GAAG,qBAAqB,CAAC,IAAI,CAAC,CAI9B;AAED,qEAAqE;AACrE,wBAAgB,YAAY,CAAC,IAAI,SAAS,MAAM,EAC9C,WAAW,EAAE,qBAAqB,CAAC,IAAI,CAAC,EACxC,IAAI,EAAE,OAAO,GACZ,IAAI,IAAI,IAAI,CAEd;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,SAAS,MAAM,EAClD,WAAW,EAAE,qBAAqB,CAAC,IAAI,CAAC,EACxC,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,IAAI,IAAI,IAAI,CAOtB;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,IAAI,SAAS,MAAM,EAC/C,WAAW,EAAE,qBAAqB,CAAC,IAAI,CAAC,EACxC,KAAK,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,GAAG,IAAI,GAAG,SAAS,GACnD,IAAI,EAAE,CAcR;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,IAAI,SAAS,MAAM,EAC5C,WAAW,EAAE,qBAAqB,CAAC,IAAI,CAAC,EACxC,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,IAAI,EAAE,MAAM,GACX,OAAO,CAQT"}
@@ -0,0 +1,54 @@
1
+ function assertPermissionDefinitions(definitions) {
2
+ const defined = new Set(definitions.permissions);
3
+ for (const [role, names] of Object.entries(definitions.roles)) {
4
+ for (const name of names) {
5
+ if (!defined.has(name)) {
6
+ throw new Error(
7
+ `Unknown permission "${name}" in role "${role}". Add it to \`permissions\` in your permission definitions first.`
8
+ );
9
+ }
10
+ }
11
+ }
12
+ }
13
+ function definePermissions(definitions) {
14
+ assertPermissionDefinitions(definitions);
15
+ return definitions;
16
+ }
17
+ function isPermission(definitions, name) {
18
+ return definitions.permissions.some((permission) => permission === name);
19
+ }
20
+ function assertPermission(definitions, name) {
21
+ if (!isPermission(definitions, name)) {
22
+ throw new Error(
23
+ `Unknown permission "${name}". Add it to \`permissions\` in your permission definitions before using it.`
24
+ );
25
+ }
26
+ }
27
+ function permissionsOf(definitions, roles) {
28
+ const list = typeof roles === "string" ? [roles] : roles ?? [];
29
+ const permissions = /* @__PURE__ */ new Set();
30
+ for (const role of list) {
31
+ if (Object.hasOwn(definitions.roles, role)) {
32
+ for (const permission of definitions.roles[role]) {
33
+ permissions.add(permission);
34
+ }
35
+ }
36
+ }
37
+ return [...permissions];
38
+ }
39
+ function coversRole(definitions, held, role) {
40
+ if (!Object.hasOwn(definitions.roles, role)) {
41
+ return false;
42
+ }
43
+ return definitions.roles[role].every(
44
+ (permission) => held.includes(permission)
45
+ );
46
+ }
47
+ export {
48
+ assertPermission,
49
+ assertPermissionDefinitions,
50
+ coversRole,
51
+ definePermissions,
52
+ isPermission,
53
+ permissionsOf
54
+ };
@@ -0,0 +1,3 @@
1
+ export { assertPermission, assertPermissionDefinitions, coversRole, definePermissions, isPermission, permissionsOf, } from './definePermissions.js';
2
+ export type { PermissionDefinitions, PermissionName, } from './definePermissions.js';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,gBAAgB,EAChB,2BAA2B,EAC3B,UAAU,EACV,iBAAiB,EACjB,YAAY,EACZ,aAAa,GACd,MAAM,wBAAwB,CAAA;AAC/B,YAAY,EACV,qBAAqB,EACrB,cAAc,GACf,MAAM,wBAAwB,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,16 @@
1
+ import {
2
+ assertPermission,
3
+ assertPermissionDefinitions,
4
+ coversRole,
5
+ definePermissions,
6
+ isPermission,
7
+ permissionsOf
8
+ } from "./definePermissions.js";
9
+ export {
10
+ assertPermission,
11
+ assertPermissionDefinitions,
12
+ coversRole,
13
+ definePermissions,
14
+ isPermission,
15
+ permissionsOf
16
+ };
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "@cedarjs/permissions",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "8.0.0-canary.3570",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/cedarjs/cedar.git",
7
+ "directory": "packages/permissions"
8
+ },
9
+ "license": "MIT",
10
+ "type": "module",
11
+ "exports": {
12
+ ".": {
13
+ "default": {
14
+ "types": "./dist/index.d.ts",
15
+ "default": "./dist/index.js"
16
+ }
17
+ },
18
+ "./api": {
19
+ "default": {
20
+ "types": "./dist/api.d.ts",
21
+ "default": "./dist/api.js"
22
+ }
23
+ }
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "!dist/**/*.test.d.*"
28
+ ],
29
+ "scripts": {
30
+ "build": "node ./build.mts",
31
+ "build:pack": "yarn pack -o cedarjs-permissions.tgz",
32
+ "build:types": "tsc --build --verbose ./tsconfig.build.json",
33
+ "check:attw": "yarn cedar-fwtools-attw",
34
+ "check:package": "concurrently npm:check:attw yarn:publint",
35
+ "test": "vitest run",
36
+ "test:types": "tstyche",
37
+ "test:watch": "vitest watch"
38
+ },
39
+ "dependencies": {
40
+ "@cedarjs/context": "8.0.0-canary.3570",
41
+ "@cedarjs/graphql-server": "8.0.0-canary.3570"
42
+ },
43
+ "devDependencies": {
44
+ "@arethetypeswrong/cli": "0.18.5",
45
+ "@cedarjs/framework-tools": "8.0.0-canary.3570",
46
+ "concurrently": "9.2.5",
47
+ "publint": "0.3.25",
48
+ "tstyche": "5.0.2",
49
+ "typescript": "5.9.3",
50
+ "vitest": "4.1.12"
51
+ },
52
+ "engines": {
53
+ "node": ">=24"
54
+ },
55
+ "publishConfig": {
56
+ "access": "public"
57
+ }
58
+ }