@cedarjs/cli 8.0.0-canary.3569 → 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.
- package/dist/commands/setup/permissions/authCodemod.js +125 -0
- package/dist/commands/setup/permissions/permissions.js +25 -0
- package/dist/commands/setup/permissions/permissionsHandler.js +229 -0
- package/dist/commands/setup/{tenancy → permissions}/permissionsPackage.js +2 -2
- package/dist/commands/setup/permissions/preflight.js +25 -0
- package/dist/commands/setup/permissions/roles.js +80 -0
- package/dist/commands/setup/permissions/templates/permissions.ts.template +23 -0
- package/dist/commands/setup/permissions/templates/permissionsPackage.index.ts.template +28 -0
- package/dist/commands/setup/permissions/templates/requirePermission.test.ts.template +60 -0
- package/dist/commands/setup/{tenancy → permissions}/templates/requirePermission.ts.template +10 -5
- package/dist/commands/setup/tenancy/templates/permissions.ts.template +7 -5
- package/dist/commands/setup/tenancy/templates/permissionsPackage.index.ts.template +11 -8
- package/dist/commands/setup/tenancy/templates/tenancy.tsx.template +2 -2
- package/dist/commands/setup/tenancy/tenancyHandler.js +96 -24
- package/dist/commands/setup.js +2 -1
- package/package.json +13 -13
|
@@ -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
|
-
"
|
|
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*$/, "") + `
|
|
@@ -98,7 +98,7 @@ function permissionsPackageFiles({
|
|
|
98
98
|
watch: "tsc --watch"
|
|
99
99
|
},
|
|
100
100
|
dependencies: {
|
|
101
|
-
"@cedarjs/
|
|
101
|
+
"@cedarjs/permissions": cedarVersion
|
|
102
102
|
},
|
|
103
103
|
devDependencies: {
|
|
104
104
|
typescript: "5.9.3"
|
|
@@ -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
|
|
12
|
-
|
|
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?:
|
|
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
|
|
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()
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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,
|
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
import { definePermissions } from '@cedarjs/
|
|
2
|
-
import type { PermissionName } from '@cedarjs/
|
|
1
|
+
import { definePermissions } from '@cedarjs/permissions'
|
|
2
|
+
import type { PermissionName } from '@cedarjs/permissions'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Every permission the app checks, and what each role may do
|
|
6
|
-
* named bundle of permissions.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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,
|
|
@@ -16,10 +16,10 @@ import { createTenancy } from '@cedarjs/tenancy/web'
|
|
|
16
16
|
*
|
|
17
17
|
* A user who isn't signed in is sent to log in, and one who isn't a member
|
|
18
18
|
* of the organization sees `notAMember`. Pages only some members may open
|
|
19
|
-
* take `
|
|
19
|
+
* take a `permission` (see packages/permissions), and the one a member
|
|
20
20
|
* without it is sent to is `unauthorized`:
|
|
21
21
|
*
|
|
22
|
-
* <PrivateSet unauthenticated="login" unauthorized="projects"
|
|
22
|
+
* <PrivateSet unauthenticated="login" unauthorized="projects" permission="members.manage">
|
|
23
23
|
* <Route path="/org/{orgSlug}/members" page={MembersPage} name="members" />
|
|
24
24
|
* </PrivateSet>
|
|
25
25
|
*
|
|
@@ -23,7 +23,7 @@ import { runTransform } from "../../../lib/runTransform.js";
|
|
|
23
23
|
import {
|
|
24
24
|
addPermissionsPackageTasks,
|
|
25
25
|
permissionsPackageName
|
|
26
|
-
} from "
|
|
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:
|
|
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: () =>
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
269
|
-
)
|
|
270
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
{
|
|
@@ -514,15 +560,41 @@ ${ctx.routerNeedsTenancy ? `
|
|
|
514
560
|
what the user's role there may do.
|
|
515
561
|
7. Roles are the user's own: ${c.highlight("hasRole")} and ${c.highlight("<PrivateSet roles>")}
|
|
516
562
|
check ${c.highlight("currentUser.roles")}. In an organization, check a
|
|
517
|
-
permission instead: guard routes with ${c.highlight("<PrivateSet
|
|
518
|
-
|
|
563
|
+
permission instead: guard routes with ${c.highlight("<PrivateSet permission>")},
|
|
564
|
+
which checks their membership in the route's organization.
|
|
519
565
|
A member who lacks it is sent to ${c.highlight("unauthorized")}:
|
|
520
566
|
|
|
521
|
-
<PrivateSet unauthenticated="login" unauthorized="projects"
|
|
567
|
+
<PrivateSet unauthenticated="login" unauthorized="projects" permission="organization.manage">
|
|
522
568
|
<Route path="/org/{orgSlug}/settings" page={SettingsPage} name="settings" />
|
|
523
569
|
</PrivateSet>
|
|
524
570
|
|
|
525
|
-
${
|
|
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")}
|
package/dist/commands/setup.js
CHANGED
|
@@ -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.
|
|
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.
|
|
40
|
-
"@cedarjs/babel-config": "8.0.0-canary.
|
|
41
|
-
"@cedarjs/cli-helpers": "8.0.0-canary.
|
|
42
|
-
"@cedarjs/internal": "8.0.0-canary.
|
|
43
|
-
"@cedarjs/prerender": "8.0.0-canary.
|
|
44
|
-
"@cedarjs/project-config": "8.0.0-canary.
|
|
45
|
-
"@cedarjs/structure": "8.0.0-canary.
|
|
46
|
-
"@cedarjs/telemetry": "8.0.0-canary.
|
|
47
|
-
"@cedarjs/utils": "8.0.0-canary.
|
|
48
|
-
"@cedarjs/vite": "8.0.0-canary.
|
|
49
|
-
"@cedarjs/web-server": "8.0.0-canary.
|
|
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.
|
|
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",
|