@cedarjs/cli 8.0.0-canary.3570 → 8.0.0-canary.3571

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.
@@ -0,0 +1,125 @@
1
+ import j from "jscodeshift";
2
+ function addPermissionsImport(source) {
3
+ if (/import \{[^}]*\bwithPermissions\b[^}]*\} from/.test(source)) {
4
+ return source;
5
+ }
6
+ const eol = source.includes("\r\n") ? "\r\n" : "\n";
7
+ const dbImport = /^import \{ db \} from (['"])(.*?)db(\.js)?\1;?\r?\n/m;
8
+ if (dbImport.test(source)) {
9
+ return source.replace(
10
+ dbImport,
11
+ (line, quote, dir, ext = "") => `${line}import { withPermissions } from ${quote}${dir}permissions${ext}${quote}${eol}`
12
+ );
13
+ }
14
+ return `import { withPermissions } from 'src/lib/permissions'${eol}${source}`;
15
+ }
16
+ function findGetCurrentUser(root) {
17
+ const expression = root.find(j.VariableDeclarator, { id: { name: "getCurrentUser" } }).map((path) => path.get("init")).filter(
18
+ (path) => j.ArrowFunctionExpression.check(path.node) || j.FunctionExpression.check(path.node)
19
+ );
20
+ return expression.size() > 0 ? expression : root.find(j.FunctionDeclaration, { id: { name: "getCurrentUser" } });
21
+ }
22
+ const isWithPermissions = (node) => j.Identifier.check(node) && node.name === "withPermissions";
23
+ function isWrapped(node) {
24
+ if (!j.CallExpression.check(node)) {
25
+ return false;
26
+ }
27
+ if (isWithPermissions(node.callee)) {
28
+ return true;
29
+ }
30
+ return j.MemberExpression.check(node.callee) && j.Identifier.check(node.callee.property) && node.callee.property.name === "then" && isWithPermissions(node.arguments[0]);
31
+ }
32
+ function isNothing(node) {
33
+ return j.NullLiteral.check(node) || j.Identifier.check(node) && node.name === "undefined";
34
+ }
35
+ function isDbCall(node) {
36
+ return j.CallExpression.check(node) && j.MemberExpression.check(node.callee) && j.MemberExpression.check(node.callee.object) && j.Identifier.check(node.callee.object.object) && node.callee.object.object.name === "db";
37
+ }
38
+ function wrap(node, isAsync) {
39
+ const withPermissions = j.identifier("withPermissions");
40
+ if (j.ObjectExpression.check(node) || j.AwaitExpression.check(node)) {
41
+ return j.callExpression(withPermissions, [node]);
42
+ }
43
+ if (isAsync) {
44
+ return j.callExpression(withPermissions, [j.awaitExpression(node)]);
45
+ }
46
+ const promise = isDbCall(node) ? node : j.callExpression(
47
+ j.memberExpression(j.identifier("Promise"), j.identifier("resolve")),
48
+ [node]
49
+ );
50
+ return j.callExpression(j.memberExpression(promise, j.identifier("then")), [
51
+ withPermissions
52
+ ]);
53
+ }
54
+ function selectRoles(getCurrentUser) {
55
+ let added = false;
56
+ getCurrentUser.find(j.CallExpression, {
57
+ callee: {
58
+ type: "MemberExpression",
59
+ object: {
60
+ type: "MemberExpression",
61
+ object: { name: "db" },
62
+ property: { name: "user" }
63
+ },
64
+ property: { name: "findUnique" }
65
+ }
66
+ }).forEach((path) => {
67
+ const [args] = path.node.arguments;
68
+ const select = j.ObjectExpression.check(args) ? args.properties.find(
69
+ (prop) => j.ObjectProperty.check(prop) && j.Identifier.check(prop.key) && prop.key.name === "select"
70
+ ) : void 0;
71
+ if (!j.ObjectProperty.check(select) || !j.ObjectExpression.check(select.value)) {
72
+ return;
73
+ }
74
+ const hasRoles = select.value.properties.some(
75
+ (prop) => j.ObjectProperty.check(prop) && j.Identifier.check(prop.key) && prop.key.name === "roles"
76
+ );
77
+ if (!hasRoles) {
78
+ select.value.properties.push(
79
+ j.objectProperty(j.identifier("roles"), j.booleanLiteral(true))
80
+ );
81
+ added = true;
82
+ }
83
+ });
84
+ return added;
85
+ }
86
+ function transform(fileInfo, _api, options = {}) {
87
+ const root = j.withParser("tsx")(fileInfo.source);
88
+ const getCurrentUser = findGetCurrentUser(root).at(0);
89
+ const fn = getCurrentUser.nodes()[0];
90
+ if (!j.ArrowFunctionExpression.check(fn) && !j.FunctionExpression.check(fn) && !j.FunctionDeclaration.check(fn)) {
91
+ throw new Error("CEDAR_CODEMOD_ERR_GET_CURRENT_USER_NOT_FOUND");
92
+ }
93
+ const isAsync = !!fn.async;
94
+ let wrapsAUser = false;
95
+ let changed = false;
96
+ if (j.BlockStatement.check(fn.body)) {
97
+ getCurrentUser.find(j.ReturnStatement).filter((path) => j(path).closest(j.Function).nodes()[0] === fn).forEach((path) => {
98
+ const argument = path.node.argument;
99
+ if (!argument || isNothing(argument)) {
100
+ return;
101
+ }
102
+ wrapsAUser = true;
103
+ if (!isWrapped(argument)) {
104
+ path.node.argument = wrap(argument, isAsync);
105
+ changed = true;
106
+ }
107
+ });
108
+ } else if (j.ArrowFunctionExpression.check(fn) && !isNothing(fn.body)) {
109
+ wrapsAUser = true;
110
+ if (!isWrapped(fn.body)) {
111
+ fn.body = wrap(fn.body, isAsync);
112
+ changed = true;
113
+ }
114
+ }
115
+ if (!wrapsAUser) {
116
+ throw new Error("CEDAR_CODEMOD_ERR_GET_CURRENT_USER_NOT_FOUND");
117
+ }
118
+ if (options.userHasRoles === true && selectRoles(getCurrentUser)) {
119
+ changed = true;
120
+ }
121
+ return addPermissionsImport(changed ? root.toSource() : fileInfo.source);
122
+ }
123
+ export {
124
+ transform as default
125
+ };
@@ -0,0 +1,25 @@
1
+ import { recordTelemetryAttributes } from "@cedarjs/cli-helpers/telemetry";
2
+ const command = "permissions";
3
+ const description = "Set up permissions: a map of what each role may do, checked with @requirePermission and hasPermission";
4
+ const builder = (yargs) => {
5
+ yargs.option("force", {
6
+ alias: "f",
7
+ default: false,
8
+ description: "Overwrite existing configuration",
9
+ type: "boolean"
10
+ });
11
+ };
12
+ const handler = async (options) => {
13
+ recordTelemetryAttributes({
14
+ command: "setup permissions",
15
+ force: options.force
16
+ });
17
+ const { handler: handler2 } = await import("./permissionsHandler.js");
18
+ return handler2(options);
19
+ };
20
+ export {
21
+ builder,
22
+ command,
23
+ description,
24
+ handler
25
+ };
@@ -0,0 +1,229 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { Listr } from "listr2";
5
+ import { format } from "prettier";
6
+ import { terminalLink } from "termi-link";
7
+ import { colors as c } from "@cedarjs/cli-helpers/colors";
8
+ import { getPrettierOptions } from "@cedarjs/cli-helpers/fileHelpers";
9
+ import { addApiPackages } from "@cedarjs/cli-helpers/installHelpers";
10
+ import { formatCedarCommand } from "@cedarjs/cli-helpers/packageManager/display";
11
+ import { runBinSync } from "@cedarjs/cli-helpers/packageManager/exec";
12
+ import { getPrismaSchemas } from "@cedarjs/project-config";
13
+ import { errorTelemetry } from "@cedarjs/telemetry";
14
+ import { getPaths, transformTSToJS, writeFile } from "../../../lib/index.js";
15
+ import { isTypeScriptProject } from "../../../lib/project.js";
16
+ import { runTransform } from "../../../lib/runTransform.js";
17
+ import { hasWebAuthFile } from "../tenancy/preflight.js";
18
+ import {
19
+ addPermissionsPackageTasks,
20
+ permissionsPackageName
21
+ } from "./permissionsPackage.js";
22
+ import { authNotSetUpMessage, hasTenancyMessage } from "./preflight.js";
23
+ import {
24
+ findRoles,
25
+ permissionsIndexSource,
26
+ userModelHasRoles
27
+ } from "./roles.js";
28
+ const AUTH_SNIPPET = `
29
+ import { withPermissions } from 'src/lib/permissions'
30
+
31
+ export const getCurrentUser = async (session) => {
32
+ return withPermissions(await db.user.findUnique({
33
+ where: { id: session.id },
34
+ select: { id: true, roles: true },
35
+ }))
36
+ }`;
37
+ const templatePath = (templateFile) => path.resolve(import.meta.dirname, "templates", templateFile);
38
+ function dependsOnTenancy(packageJsonPath) {
39
+ try {
40
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
41
+ return !!packageJson.dependencies?.["@cedarjs/tenancy"] || !!packageJson.devDependencies?.["@cedarjs/tenancy"];
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+ const handler = async ({ force }) => {
47
+ const paths = getPaths();
48
+ if (!hasWebAuthFile(paths.web.src)) {
49
+ console.error(c.error(authNotSetUpMessage()));
50
+ process.exit(1);
51
+ }
52
+ if (dependsOnTenancy(path.join(paths.api.base, "package.json"))) {
53
+ console.error(c.error(hasTenancyMessage()));
54
+ process.exit(1);
55
+ }
56
+ const projectIsTypescript = isTypeScriptProject();
57
+ const ext = projectIsTypescript ? "ts" : "js";
58
+ const packageJson = await import(pathToFileURL(path.join(paths.base, "package.json")).href, { with: { type: "json" } });
59
+ const cedarVersion = packageJson.default.devDependencies["@cedarjs/core"] ?? "latest";
60
+ const authPath = path.join(paths.api.lib, `auth.${ext}`);
61
+ const roles = findRoles([paths.api.src, paths.web.src]);
62
+ const writtenFiles = [];
63
+ const keptFiles = [];
64
+ const writeTemplateFile = async (templateFile, outputPath) => {
65
+ const templateContent = fs.readFileSync(templatePath(templateFile), "utf-8").replaceAll("__PERMISSIONS_PACKAGE__", permissionsPackageName());
66
+ if (fs.existsSync(outputPath) && !force) {
67
+ keptFiles.push(outputPath);
68
+ return;
69
+ }
70
+ const content = projectIsTypescript ? templateContent : await transformTSToJS(outputPath, templateContent);
71
+ writtenFiles.push(outputPath);
72
+ return writeFile(outputPath, content, { overwriteExisting: force });
73
+ };
74
+ const tasks = new Listr(
75
+ [
76
+ {
77
+ ...addApiPackages([`@cedarjs/permissions@${cedarVersion}`]),
78
+ title: "Adding @cedarjs/permissions to your api side..."
79
+ },
80
+ {
81
+ title: "Adding the permissions package...",
82
+ task: () => addPermissionsPackageTasks({
83
+ cedarVersion,
84
+ indexSource: permissionsIndexSource(
85
+ fs.readFileSync(
86
+ templatePath("permissionsPackage.index.ts.template"),
87
+ "utf-8"
88
+ ),
89
+ roles
90
+ ),
91
+ force
92
+ })
93
+ },
94
+ {
95
+ title: "Adding permission checks and the requirePermission directive...",
96
+ task: async () => {
97
+ await writeTemplateFile(
98
+ "permissions.ts.template",
99
+ path.join(paths.api.lib, `permissions.${ext}`)
100
+ );
101
+ const directiveDir = path.join(
102
+ paths.api.directives,
103
+ "requirePermission"
104
+ );
105
+ await writeTemplateFile(
106
+ "requirePermission.ts.template",
107
+ path.join(directiveDir, `requirePermission.${ext}`)
108
+ );
109
+ await writeTemplateFile(
110
+ "requirePermission.test.ts.template",
111
+ path.join(directiveDir, `requirePermission.test.${ext}`)
112
+ );
113
+ }
114
+ },
115
+ {
116
+ title: "Adding the user\u2019s permissions to getCurrentUser...",
117
+ task: async (_ctx, task) => {
118
+ if (!fs.existsSync(authPath)) {
119
+ task.skip("No api/src/lib/auth file found; skipping.");
120
+ return;
121
+ }
122
+ let userHasRoles = false;
123
+ try {
124
+ const { schemas } = await getPrismaSchemas();
125
+ userHasRoles = userModelHasRoles(
126
+ schemas.map(([, schema]) => schema).join("\n")
127
+ );
128
+ } catch {
129
+ }
130
+ const transformResult = await runTransform({
131
+ transformPath: path.join(import.meta.dirname, "authCodemod.js"),
132
+ targetPaths: [authPath],
133
+ options: { userHasRoles }
134
+ });
135
+ if (transformResult.error) {
136
+ task.output = `Could not update api/src/lib/auth automatically. Please add this by hand:
137
+ ${AUTH_SNIPPET}`;
138
+ }
139
+ }
140
+ },
141
+ {
142
+ title: "Formatting and linting changed files...",
143
+ task: async (_ctx, task) => {
144
+ const changedFiles = [...writtenFiles, authPath].filter(
145
+ (file) => fs.existsSync(file)
146
+ );
147
+ const prettierOptions = await getPrettierOptions();
148
+ for (const file of changedFiles) {
149
+ try {
150
+ const source = fs.readFileSync(file, "utf-8");
151
+ const formatted = await format(source, {
152
+ ...prettierOptions,
153
+ parser: "babel-ts"
154
+ });
155
+ fs.writeFileSync(file, formatted, "utf-8");
156
+ } catch {
157
+ task.output = "Couldn't prettify the changes. Please reformat the files manually if needed.";
158
+ }
159
+ }
160
+ try {
161
+ runBinSync("eslint", ["--fix", ...changedFiles], {
162
+ cwd: paths.base
163
+ });
164
+ } catch {
165
+ task.output = `Some of the changed files still have lint errors. Run ${c.highlight(
166
+ formatCedarCommand(["lint"])
167
+ )} to see them.`;
168
+ }
169
+ }
170
+ },
171
+ {
172
+ title: "Next steps...",
173
+ task: (ctx, task) => {
174
+ const rolesText = roles.length > 0 ? `It has an entry, with no permissions yet, for each role
175
+ your role lists name: ${roles.map((role) => c.highlight(role)).join(", ")}.` : `It has an example map, as no role lists name a role.`;
176
+ task.title = `Next steps...
177
+
178
+ ${c.success("\nPermissions configured!\n")}
179
+ 1. Your permissions, and what each role may do, are in
180
+ ${c.highlight("packages/permissions")}, shared by the api and web sides${ctx.packagesWorkspaceTurnedOn ? ` (this
181
+ turned on ${c.highlight("experimental.packagesWorkspace")} in cedar.toml)` : ""}.
182
+ ${rolesText}
183
+ A role is a bundle of permissions: list what each one may do.
184
+ 2. A user may do what any of their roles, in ${c.highlight("currentUser.roles")},
185
+ may do, so ${c.highlight("getCurrentUser")} returns ${c.highlight("roles")}: this selects
186
+ them when your ${c.highlight("User")} model has a ${c.highlight("roles")} field. Without one,
187
+ return the user's roles from where they're kept. It returns the
188
+ user through ${c.highlight("withPermissions")}, which adds
189
+ ${c.highlight("currentUser.permissions")} for the web.
190
+ 3. Guard fields with ${c.highlight('@requirePermission(permission: "...")')}, and
191
+ services with ${c.highlight("requirePermission('...')")} from src/lib/permissions.
192
+ A field with a ${c.highlight("roles")} list checks those roles; move each to a
193
+ permission that names what it allows, so a new role can do
194
+ only what its entry in the map says.
195
+ 4. On the web, check permissions with ${c.highlight("useAuth().hasPermission()")}, and
196
+ guard routes with ${c.highlight('<PrivateSet permission="...">')}. A user without
197
+ it is sent to ${c.highlight("unauthorized")}. ${c.highlight(formatCedarCommand(["generate", "types"]))}
198
+ types the names.
199
+ 5. In tests, ${c.highlight("mockCurrentUser({ roles: ['admin'] })")} gives the user
200
+ the permissions the map gives ${c.highlight("admin")}.
201
+ ${keptFiles.length > 0 ? `
202
+ Kept these existing files rather than overwriting them (run with
203
+ ${c.highlight("--force")} to overwrite):
204
+ ${keptFiles.map((file) => ` ${path.relative(paths.base, file)}`).join("\n")}
205
+ ` : ""}
206
+ Check out the docs for more info:
207
+ ${terminalLink("", "https://cedarjs.com/docs/permissions")}
208
+
209
+ `;
210
+ }
211
+ }
212
+ ],
213
+ {
214
+ rendererOptions: { collapseSubtasks: false }
215
+ }
216
+ );
217
+ try {
218
+ await tasks.run();
219
+ } catch (e) {
220
+ const message = e instanceof Error ? e.message : String(e);
221
+ const exitCode = e instanceof Error && "exitCode" in e && typeof e.exitCode === "number" ? e.exitCode : 1;
222
+ errorTelemetry(process.argv, message);
223
+ console.error(c.error(message));
224
+ process.exit(exitCode);
225
+ }
226
+ };
227
+ export {
228
+ handler
229
+ };
@@ -28,7 +28,7 @@ function enablePackagesWorkspace(tomlContent) {
28
28
  if (headerIndex === -1) {
29
29
  if (packagesWorkspace) {
30
30
  throw new Error(
31
- "CEDAR_TENANCY_ERR_PACKAGES_WORKSPACE: set `experimental.packagesWorkspace.enabled = true` in cedar.toml and run this command again."
31
+ "CEDAR_PERMISSIONS_ERR_PACKAGES_WORKSPACE: set `experimental.packagesWorkspace.enabled = true` in cedar.toml and run this command again."
32
32
  );
33
33
  }
34
34
  return tomlContent.replace(/\n*$/, "") + `
@@ -0,0 +1,25 @@
1
+ import { formatCedarCommand } from "@cedarjs/cli-helpers/packageManager/display";
2
+ function authNotSetUpMessage() {
3
+ return [
4
+ "Auth is not set up in this project.",
5
+ "",
6
+ "Permissions build on your app's auth: a user may do what their roles,",
7
+ "in `currentUser.roles`, may do. Set up auth first, then run this",
8
+ "command again:",
9
+ "",
10
+ ` ${formatCedarCommand(["setup", "auth", "dbAuth"])}`,
11
+ "",
12
+ "Any provider works. See https://cedarjs.com/docs/authentication"
13
+ ].join("\n");
14
+ }
15
+ function hasTenancyMessage() {
16
+ return [
17
+ "This app has tenancy, which includes permissions: they are in",
18
+ "packages/permissions, and api/src/lib/permissions checks them against",
19
+ "the user's role in the current organization."
20
+ ].join("\n");
21
+ }
22
+ export {
23
+ authNotSetUpMessage,
24
+ hasTenancyMessage
25
+ };
@@ -0,0 +1,80 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ const SOURCE_FILE = /\.(?:[jt]sx?)$/;
4
+ const NOT_APP_CODE = /\.(?:test|spec|scenarios|mock|mocks|stories)\.[jt]sx?$/;
5
+ const LIST = String.raw`(\[[^\]]*\]|'[^'\n]*'|"[^"\n]*")`;
6
+ const ROLE_LISTS = [
7
+ // `@requireAuth(roles: ["admin"])` in an SDL, `requireAuth({ roles: 'admin' })`
8
+ new RegExp(String.raw`\broles\s*:\s*` + LIST, "g"),
9
+ // `<PrivateSet roles="admin">`, `<PrivateSet roles={['admin', 'editor']}>`
10
+ new RegExp(String.raw`\broles\s*=\s*\{?\s*` + LIST, "g"),
11
+ // `hasRole('admin')`, `hasRole(['admin', 'editor'])`
12
+ new RegExp(String.raw`\bhasRole\(\s*` + LIST, "g")
13
+ ];
14
+ const STRING = /'([^'\n]*)'|"([^"\n]*)"/g;
15
+ function rolesInSource(source) {
16
+ const roles = /* @__PURE__ */ new Set();
17
+ for (const pattern of ROLE_LISTS) {
18
+ for (const [, list] of source.matchAll(pattern)) {
19
+ for (const [, single, double] of list.matchAll(STRING)) {
20
+ const role = (single ?? double).trim();
21
+ if (role) {
22
+ roles.add(role);
23
+ }
24
+ }
25
+ }
26
+ }
27
+ return [...roles];
28
+ }
29
+ function sourceFiles(dir) {
30
+ if (!fs.existsSync(dir)) {
31
+ return [];
32
+ }
33
+ return fs.readdirSync(dir, { recursive: true, encoding: "utf-8" }).filter(
34
+ (file) => SOURCE_FILE.test(file) && !NOT_APP_CODE.test(file) && !file.split(path.sep).includes("node_modules")
35
+ ).map((file) => path.join(dir, file));
36
+ }
37
+ function findRoles(dirs) {
38
+ const roles = /* @__PURE__ */ new Set();
39
+ for (const file of dirs.flatMap(sourceFiles)) {
40
+ for (const role of rolesInSource(fs.readFileSync(file, "utf-8"))) {
41
+ roles.add(role);
42
+ }
43
+ }
44
+ return [...roles].sort();
45
+ }
46
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
47
+ const key = (role) => IDENTIFIER.test(role) ? role : JSON.stringify(role);
48
+ function permissionsIndexSource(template, roles) {
49
+ const rolesComment = roles.length > 0 ? [
50
+ " * These are the roles the app's role lists name, with no",
51
+ " * permissions yet. Add the permissions each one needs, then guard",
52
+ " * fields with them instead of `roles`."
53
+ ].join("\n") : [
54
+ " * An example map: replace these permissions and roles with the",
55
+ " * app's own."
56
+ ].join("\n");
57
+ const definitions = roles.length > 0 ? [
58
+ " permissions: [],",
59
+ " roles: {",
60
+ ...roles.map((role) => ` ${key(role)}: [],`),
61
+ " },"
62
+ ] : [
63
+ " permissions: ['posts.read', 'posts.edit'],",
64
+ " roles: {",
65
+ " editor: ['posts.read', 'posts.edit'],",
66
+ " reader: ['posts.read'],",
67
+ " },"
68
+ ];
69
+ return template.replace("__ROLES_COMMENT__", rolesComment).replace("__DEFINITIONS__", definitions.join("\n"));
70
+ }
71
+ function userModelHasRoles(schema) {
72
+ const userModel = schema.match(/^model\s+User\s*\{([\s\S]*?)^\}/m)?.[1];
73
+ return !!userModel && /^\s*roles\s+\S/m.test(userModel);
74
+ }
75
+ export {
76
+ findRoles,
77
+ permissionsIndexSource,
78
+ rolesInSource,
79
+ userModelHasRoles
80
+ };
@@ -0,0 +1,23 @@
1
+ import { permissions } from '__PERMISSIONS_PACKAGE__'
2
+
3
+ import { createPermissions } from '@cedarjs/permissions/api'
4
+
5
+ /**
6
+ * Checks the permissions defined in __PERMISSIONS_PACKAGE__
7
+ * (packages/permissions): a user may do what any of their roles, in
8
+ * `currentUser.roles`, may do. `getCurrentUser` adds the user's permissions
9
+ * with `withPermissions`, so the web can check them with
10
+ * `useAuth().hasPermission()` and `<PrivateSet permission>`. The web only
11
+ * uses them to decide what to show. These checks are the guard.
12
+ *
13
+ * `canGrantRole(role)` is whether the signed-in user may give someone
14
+ * `role`, or take it away. They may if they hold every permission the role
15
+ * has, so nobody can hand out more than they may do themselves.
16
+ */
17
+ export const {
18
+ hasPermission,
19
+ requirePermission,
20
+ canGrantRole,
21
+ permissionsOf,
22
+ withPermissions,
23
+ } = createPermissions(permissions)
@@ -0,0 +1,28 @@
1
+ import { definePermissions } from '@cedarjs/permissions'
2
+ import type { PermissionName } from '@cedarjs/permissions'
3
+
4
+ /**
5
+ * Every permission the app checks, and what each role may do. A role is a
6
+ * named bundle of permissions.
7
+ *
8
+ * Fields and services ask for a permission, never a role:
9
+ * `@requirePermission(permission: "posts.edit")` on a field, and
10
+ * `requirePermission('posts.edit')` in a service. So a role can do exactly
11
+ * what its list says. A field added later is closed to a role until its
12
+ * permission is on that role's list.
13
+ *
14
+ * A user's roles are in `currentUser.roles` (one role, or a list of them),
15
+ * and they may do what any of their roles may do. A role that isn't listed
16
+ * here may do nothing.
17
+ *
18
+ __ROLES_COMMENT__
19
+ *
20
+ * This package is shared: the api checks these permissions
21
+ * (api/src/lib/permissions), and the web can import the names. The web gets
22
+ * the user's permissions from the api, through `getCurrentUser`.
23
+ */
24
+ export const permissions = definePermissions({
25
+ __DEFINITIONS__
26
+ })
27
+
28
+ export type Permission = PermissionName<typeof permissions>
@@ -0,0 +1,60 @@
1
+ import { permissions } from '__PERMISSIONS_PACKAGE__'
2
+
3
+ import { mockRedwoodDirective, getDirectiveName } from '@cedarjs/testing/api'
4
+
5
+ import requirePermission from './requirePermission.js'
6
+
7
+ /** A role in packages/permissions with a permission, and that permission */
8
+ const granted = Object.entries(permissions.roles)
9
+ .flatMap(([role, names]) => names.map((permission) => ({ role, permission })))
10
+ .at(0)
11
+
12
+ describe('requirePermission directive', () => {
13
+ it('declares the directive sdl as schema, with the correct name', () => {
14
+ expect(requirePermission.schema).toBeTruthy()
15
+ expect(getDirectiveName(requirePermission.schema)).toBe('requirePermission')
16
+ })
17
+
18
+ it('throws when there is no current user', () => {
19
+ const mockExecution = mockRedwoodDirective(requirePermission, {
20
+ context: {},
21
+ directiveArgs: { permission: granted?.permission },
22
+ })
23
+
24
+ expect(mockExecution).toThrowError()
25
+ })
26
+
27
+ it('throws for a permission that packages/permissions does not define', () => {
28
+ const mockExecution = mockRedwoodDirective(requirePermission, {
29
+ context: { currentUser: {} },
30
+ directiveArgs: { permission: 'not.defined' },
31
+ })
32
+
33
+ expect(mockExecution).toThrowError('Unknown permission "not.defined"')
34
+ })
35
+
36
+ if (granted) {
37
+ it('throws for a user without roles', () => {
38
+ const mockExecution = mockRedwoodDirective(requirePermission, {
39
+ context: { currentUser: {} },
40
+ directiveArgs: { permission: granted.permission },
41
+ })
42
+
43
+ expect(mockExecution).toThrowError()
44
+ })
45
+
46
+ it("passes when one of the user's roles has it", () => {
47
+ const mockExecution = mockRedwoodDirective(requirePermission, {
48
+ // One role, as a string. A list of roles works too.
49
+ context: { currentUser: { roles: granted.role } },
50
+ directiveArgs: { permission: granted.permission },
51
+ })
52
+
53
+ expect(mockExecution).not.toThrowError()
54
+ })
55
+ } else {
56
+ it.todo(
57
+ "passes when one of the user's roles has it, once a role in packages/permissions has a permission",
58
+ )
59
+ }
60
+ })
@@ -8,21 +8,26 @@ import { requirePermission as applicationRequirePermission } from 'src/lib/permi
8
8
 
9
9
  export const schema = gql`
10
10
  """
11
- Use to check that the signed-in user may do something: that their role in
12
- the current organization has the permission (see packages/permissions).
11
+ Use to check that the signed-in user may do something: that they have the
12
+ permission (see packages/permissions and api/src/lib/permissions).
13
13
  """
14
14
  directive @requirePermission(permission: String!) on FIELD_DEFINITION
15
15
  `
16
16
 
17
- // Optional to fit `ValidatorDirectiveFunc`; the SDL makes it required
17
+ // Optional to fit `ValidatorDirectiveFunc`; the SDL makes it required. Any
18
+ // name can reach it from an SDL, which `requirePermission` checks.
18
19
  type RequirePermissionValidate = ValidatorDirectiveFunc<{
19
- permission?: string
20
+ permission?: Permission
20
21
  }>
21
22
 
22
23
  const validate: RequirePermissionValidate = ({ directiveArgs }) => {
24
+ if (directiveArgs.permission === undefined) {
25
+ throw new Error('@requirePermission needs a `permission`')
26
+ }
27
+
23
28
  // A name the permissions map doesn't know throws, so a typo in the SDL is
24
29
  // an error rather than a field nobody can use
25
- applicationRequirePermission(directiveArgs.permission as Permission)
30
+ applicationRequirePermission(directiveArgs.permission)
26
31
  }
27
32
 
28
33
  const requirePermission = createValidatorDirective(schema, validate)
@@ -6,12 +6,14 @@ import { createPermissions } from '@cedarjs/tenancy'
6
6
  * Checks the permissions defined in __PERMISSIONS_PACKAGE__
7
7
  * (packages/permissions). `getCurrentUser` adds each membership's
8
8
  * permissions with `withPermissions`, so the web can check them with
9
- * `useAuth().hasPermission()`. The web only decides what to show; these
10
- * checks are the guard.
9
+ * `useAuth().hasPermission()` and `<PrivateSet permission>`. The web only
10
+ * uses them to decide what to show. These checks are the guard.
11
11
  *
12
- * `canGrantRole` is who may give someone a role, or take it away: whoever
13
- * holds every permission it has (`inviteMember` and `removeMember` in
14
- * api/src/services/organizations).
12
+ * `canGrantRole(role)` is whether the signed-in user may give someone
13
+ * `role`, or take it away. They may if they hold every permission the role
14
+ * has, so nobody can hand out more than they may do themselves.
15
+ * `inviteMember` and `removeMember` in api/src/services/organizations use
16
+ * it.
15
17
  */
16
18
  export const {
17
19
  hasPermission,
@@ -2,12 +2,15 @@ import { definePermissions } from '@cedarjs/permissions'
2
2
  import type { PermissionName } from '@cedarjs/permissions'
3
3
 
4
4
  /**
5
- * Every permission the app checks, and what each role may do: a role is a
6
- * named bundle of permissions. Fields ask for a permission
7
- * (`@requirePermission(permission: "members.manage")`, or
8
- * `requirePermission('members.manage')` in a service), never a role, so a
9
- * new role is one entry here: it can do exactly what its list says, and a
10
- * field added later is closed to it unless its permission is on the list.
5
+ * Every permission the app checks, and what each role may do. A role is a
6
+ * named bundle of permissions.
7
+ *
8
+ * Fields and services ask for a permission, never a role:
9
+ * `@requirePermission(permission: "members.manage")` on a field, and
10
+ * `requirePermission('members.manage')` in a service. So a role can do
11
+ * exactly what its list says, and adding a role is one entry here. A field
12
+ * added later is closed to a role until its permission is on that role's
13
+ * list.
11
14
  *
12
15
  * `organization.read` is "any member": every role lists it, and fields any
13
16
  * member may use ask for it. `organization.manage` is the owner's alone,
@@ -23,7 +23,7 @@ import { runTransform } from "../../../lib/runTransform.js";
23
23
  import {
24
24
  addPermissionsPackageTasks,
25
25
  permissionsPackageName
26
- } from "./permissionsPackage.js";
26
+ } from "../permissions/permissionsPackage.js";
27
27
  import {
28
28
  authNotSetUpMessage,
29
29
  hasWebAuthFile,
@@ -97,21 +97,21 @@ const handler = async ({ tenantField, force }) => {
97
97
  const isDbAuthProject = fs.existsSync(functionsAuthPath) && fs.readFileSync(functionsAuthPath, "utf-8").includes("DbAuthHandler");
98
98
  const writtenFiles = [];
99
99
  const keptFiles = [];
100
- const writeTemplateFile = async (templateFile, outputPath, replacements = {}, transform = (content) => content) => {
100
+ const writeTemplateFile = async (templateFile, outputPath, replacements = {}, transform = (content) => content, replaces = () => false) => {
101
101
  const dirname = import.meta.dirname;
102
- const templatePath = path.resolve(dirname, "templates", templateFile);
102
+ const templatePath = templateFile.startsWith("requirePermission.ts") ? path.resolve(dirname, "../permissions/templates", templateFile) : path.resolve(dirname, "templates", templateFile);
103
103
  let templateContent = fs.readFileSync(templatePath, "utf-8");
104
104
  for (const [token, value] of Object.entries(replacements)) {
105
105
  templateContent = templateContent.replaceAll(token, value);
106
106
  }
107
107
  templateContent = transform(templateContent);
108
108
  const content = projectIsTypescript ? templateContent : await transformTSToJS(outputPath, templateContent);
109
- if (fs.existsSync(outputPath) && !force) {
109
+ if (fs.existsSync(outputPath) && !force && !replaces(fs.readFileSync(outputPath, "utf-8"))) {
110
110
  keptFiles.push(outputPath);
111
111
  return;
112
112
  }
113
113
  writtenFiles.push(outputPath);
114
- return writeFile(outputPath, content, { overwriteExisting: force });
114
+ return writeFile(outputPath, content, { overwriteExisting: true });
115
115
  };
116
116
  const tasks = new Listr(
117
117
  [
@@ -257,30 +257,65 @@ ${SIGNUP_SNIPPET}`;
257
257
  },
258
258
  {
259
259
  title: "Adding the permissions package...",
260
- task: () => addPermissionsPackageTasks({
261
- cedarVersion,
262
- indexSource: fs.readFileSync(
263
- path.resolve(
264
- import.meta.dirname,
265
- "templates",
266
- "permissionsPackage.index.ts.template"
260
+ task: (ctx) => {
261
+ const indexPath = path.join(
262
+ paths.packages,
263
+ "permissions",
264
+ "src",
265
+ "index.ts"
266
+ );
267
+ ctx.mapNeedsOrgPermissions = !force && fs.existsSync(indexPath) && !fs.readFileSync(indexPath, "utf-8").includes("organization.read");
268
+ return addPermissionsPackageTasks({
269
+ cedarVersion,
270
+ indexSource: fs.readFileSync(
271
+ path.resolve(
272
+ import.meta.dirname,
273
+ "templates",
274
+ "permissionsPackage.index.ts.template"
275
+ ),
276
+ "utf-8"
267
277
  ),
268
- "utf-8"
269
- ),
270
- force
271
- })
278
+ force
279
+ });
280
+ }
272
281
  },
273
282
  {
274
283
  title: "Adding permission checks and the requirePermission directive...",
275
- task: async () => {
284
+ task: async (ctx) => {
276
285
  const replacements = {
277
286
  __PERMISSIONS_PACKAGE__: permissionsPackageName()
278
287
  };
288
+ const libPermissionsPath = path.join(
289
+ paths.api.lib,
290
+ `permissions.${ext}`
291
+ );
292
+ const asCode = (source) => source.replace(/\s+|;/g, "").replaceAll('"', "'");
293
+ const isSetupPermissionsFile = async (templateFile, outputPath) => {
294
+ let source = fs.readFileSync(
295
+ path.resolve(
296
+ import.meta.dirname,
297
+ "../permissions/templates",
298
+ templateFile
299
+ ),
300
+ "utf-8"
301
+ );
302
+ for (const [token, value] of Object.entries(replacements)) {
303
+ source = source.replaceAll(token, value);
304
+ }
305
+ const generated = projectIsTypescript ? source : await transformTSToJS(outputPath, source);
306
+ return (existing) => asCode(existing) === asCode(generated);
307
+ };
279
308
  await writeTemplateFile(
280
309
  "permissions.ts.template",
281
- path.join(paths.api.lib, `permissions.${ext}`),
282
- replacements
310
+ libPermissionsPath,
311
+ replacements,
312
+ void 0,
313
+ await isSetupPermissionsFile(
314
+ "permissions.ts.template",
315
+ libPermissionsPath
316
+ )
283
317
  );
318
+ ctx.libPermissionsNeedsMembership = !force && fs.readFileSync(libPermissionsPath, "utf-8").includes("@cedarjs/permissions/api");
284
319
  const directiveDir = path.join(
285
320
  paths.api.directives,
286
321
  "requirePermission"
@@ -290,10 +325,21 @@ ${SIGNUP_SNIPPET}`;
290
325
  path.join(directiveDir, `requirePermission.${ext}`),
291
326
  replacements
292
327
  );
328
+ const directiveTestPath = path.join(
329
+ directiveDir,
330
+ `requirePermission.test.${ext}`
331
+ );
293
332
  await writeTemplateFile(
294
333
  "requirePermission.test.ts.template",
295
- path.join(directiveDir, `requirePermission.test.${ext}`)
334
+ directiveTestPath,
335
+ {},
336
+ void 0,
337
+ await isSetupPermissionsFile(
338
+ "requirePermission.test.ts.template",
339
+ directiveTestPath
340
+ )
296
341
  );
342
+ ctx.directiveTestMocksRoles = !force && fs.readFileSync(directiveTestPath, "utf-8").includes("one of the user's roles has it");
297
343
  }
298
344
  },
299
345
  {
@@ -522,7 +568,33 @@ ${ctx.routerNeedsTenancy ? `
522
568
  <Route path="/org/{orgSlug}/settings" page={SettingsPage} name="settings" />
523
569
  </PrivateSet>
524
570
 
525
- ${keptFiles.length > 0 ? `
571
+ ${ctx.libPermissionsNeedsMembership ? `
572
+ ${c.warning("Kept your api/src/lib/permissions.")}
573
+ Its checks use ${c.highlight("createPermissions")} from ${c.highlight("@cedarjs/permissions/api")},
574
+ which checks ${c.highlight("currentUser.roles")}. In an organization, a role belongs to
575
+ the membership: import ${c.highlight("createPermissions")} from ${c.highlight("@cedarjs/tenancy")} instead.
576
+
577
+ ` : ""}${ctx.directiveTestMocksRoles ? `
578
+ ${c.warning("Kept your api/src/directives/requirePermission test.")}
579
+ It mocks users with ${c.highlight("roles")}. With tenancy, permissions come from
580
+ the user's membership in the current organization: mock
581
+ ${c.highlight("memberships")} and ${c.highlight("context.currentOrg")} instead.
582
+
583
+ ` : ""}${ctx.mapNeedsOrgPermissions ? `
584
+ ${c.warning("Kept your permission map in packages/permissions.")}
585
+ Its roles are now roles in an organization. Add the permissions the
586
+ generated organizations service checks, and give its member roles
587
+ these permissions, next to any they already have:
588
+
589
+ permissions: [...yours, 'organization.read', 'organization.manage', 'members.manage'],
590
+ roles: {
591
+ ...yours,
592
+ owner: [...(yours.owner ?? []), 'organization.read', 'organization.manage', 'members.manage'],
593
+ admin: [...(yours.admin ?? []), 'organization.read', 'members.manage'],
594
+ member: [...(yours.member ?? []), 'organization.read'],
595
+ },
596
+
597
+ ` : ""}${keptFiles.length > 0 ? `
526
598
  Kept these existing files rather than overwriting them (run with
527
599
  ${c.highlight("--force")} to overwrite):
528
600
  ${keptFiles.map((file) => ` ${path.relative(paths.base, file)}`).join("\n")}
@@ -14,6 +14,7 @@ import * as setupMiddleware from "./setup/middleware/middleware.js";
14
14
  import * as setupMonitoring from "./setup/monitoring/monitoring.js";
15
15
  import * as setupNeon from "./setup/neon/neon.js";
16
16
  import * as setupPackage from "./setup/package/package.js";
17
+ import * as setupPermissions from "./setup/permissions/permissions.js";
17
18
  import * as setupRealtime from "./setup/realtime/realtime.js";
18
19
  import * as setupServerFile from "./setup/server-file/serverFile.js";
19
20
  import * as setupTenancy from "./setup/tenancy/tenancy.js";
@@ -21,7 +22,7 @@ import * as setupUi from "./setup/ui/ui.js";
21
22
  import * as setupUploads from "./setup/uploads/uploads.js";
22
23
  const command = "setup <command>";
23
24
  const description = "Initialize project config and install packages";
24
- const builder = (yargs) => yargs.command(setupAuth).command(setupCache).command(setupDatabase).command(setupDeploy).command(setupDocker).command(setupGenerator).command(setupGraphql).command(setupI18n).command(setupJobs).command(setupMailer).command(setupMiddleware).command(setupMonitoring).command(setupNeon).command(setupPackage).command(setupRealtime).command(setupServerFile).command(setupTenancy).command(setupUi).command(setupUploads).demandCommand().middleware(detectCedarVersion).epilogue(
25
+ const builder = (yargs) => yargs.command(setupAuth).command(setupCache).command(setupDatabase).command(setupDeploy).command(setupDocker).command(setupGenerator).command(setupGraphql).command(setupI18n).command(setupJobs).command(setupMailer).command(setupMiddleware).command(setupMonitoring).command(setupNeon).command(setupPackage).command(setupPermissions).command(setupRealtime).command(setupServerFile).command(setupTenancy).command(setupUi).command(setupUploads).demandCommand().middleware(detectCedarVersion).epilogue(
25
26
  `Also see the ${terminalLink(
26
27
  "CedarJS CLI Reference",
27
28
  "https://cedarjs.com/docs/cli-commands#setup"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cedarjs/cli",
3
- "version": "8.0.0-canary.3570",
3
+ "version": "8.0.0-canary.3571",
4
4
  "description": "The CedarJS Command Line",
5
5
  "repository": {
6
6
  "type": "git",
@@ -36,17 +36,17 @@
36
36
  "@babel/preset-typescript": "7.29.7",
37
37
  "@babel/traverse": "7.29.10",
38
38
  "@babel/types": "7.29.11",
39
- "@cedarjs/api-server": "8.0.0-canary.3570",
40
- "@cedarjs/babel-config": "8.0.0-canary.3570",
41
- "@cedarjs/cli-helpers": "8.0.0-canary.3570",
42
- "@cedarjs/internal": "8.0.0-canary.3570",
43
- "@cedarjs/prerender": "8.0.0-canary.3570",
44
- "@cedarjs/project-config": "8.0.0-canary.3570",
45
- "@cedarjs/structure": "8.0.0-canary.3570",
46
- "@cedarjs/telemetry": "8.0.0-canary.3570",
47
- "@cedarjs/utils": "8.0.0-canary.3570",
48
- "@cedarjs/vite": "8.0.0-canary.3570",
49
- "@cedarjs/web-server": "8.0.0-canary.3570",
39
+ "@cedarjs/api-server": "8.0.0-canary.3571",
40
+ "@cedarjs/babel-config": "8.0.0-canary.3571",
41
+ "@cedarjs/cli-helpers": "8.0.0-canary.3571",
42
+ "@cedarjs/internal": "8.0.0-canary.3571",
43
+ "@cedarjs/prerender": "8.0.0-canary.3571",
44
+ "@cedarjs/project-config": "8.0.0-canary.3571",
45
+ "@cedarjs/structure": "8.0.0-canary.3571",
46
+ "@cedarjs/telemetry": "8.0.0-canary.3571",
47
+ "@cedarjs/utils": "8.0.0-canary.3571",
48
+ "@cedarjs/vite": "8.0.0-canary.3571",
49
+ "@cedarjs/web-server": "8.0.0-canary.3571",
50
50
  "@listr2/prompt-adapter-enquirer": "4.4.3",
51
51
  "@opentelemetry/api": "1.9.1",
52
52
  "@opentelemetry/core": "2.12.0",
@@ -93,7 +93,7 @@
93
93
  "yargs": "18.2.0"
94
94
  },
95
95
  "devDependencies": {
96
- "@cedarjs/framework-tools": "8.0.0-canary.3570",
96
+ "@cedarjs/framework-tools": "8.0.0-canary.3571",
97
97
  "@prisma/dmmf": "7.10.1",
98
98
  "@types/archiver": "^7.0.0",
99
99
  "memfs": "4.80.0",