@cedarjs/cli 8.0.0-canary.3539 → 8.0.0-canary.3540
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/tenancy/authCodemod.js +91 -2
- package/dist/commands/setup/tenancy/permissionsPackage.js +240 -0
- package/dist/commands/setup/tenancy/templates/OrgScope.tsx.template +20 -10
- package/dist/commands/setup/tenancy/templates/organizations.sdl.ts.template +4 -3
- package/dist/commands/setup/tenancy/templates/organizations.ts.template +3 -3
- package/dist/commands/setup/tenancy/templates/permissions.ts.template +20 -0
- package/dist/commands/setup/tenancy/templates/permissionsPackage.index.ts.template +28 -0
- package/dist/commands/setup/tenancy/templates/requirePermission.test.ts.template +50 -0
- package/dist/commands/setup/tenancy/templates/requirePermission.ts.template +32 -0
- package/dist/commands/setup/tenancy/tenancyHandler.js +56 -3
- package/package.json +13 -13
|
@@ -71,9 +71,90 @@ function findExportedConst(root, name) {
|
|
|
71
71
|
}
|
|
72
72
|
});
|
|
73
73
|
}
|
|
74
|
+
const USER_LOOKUP = {
|
|
75
|
+
callee: {
|
|
76
|
+
type: "MemberExpression",
|
|
77
|
+
object: {
|
|
78
|
+
type: "MemberExpression",
|
|
79
|
+
object: { name: "db" },
|
|
80
|
+
property: { name: "user" }
|
|
81
|
+
},
|
|
82
|
+
property: { name: "findUnique" }
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
function addContextImport(root) {
|
|
86
|
+
const usesContext = root.find(j.Identifier, { name: "context" }).filter(
|
|
87
|
+
(path) => !(j.MemberExpression.check(path.parent.node) && path.parent.node.property === path.node && !path.parent.node.computed) && !(j.ObjectProperty.check(path.parent.node) && path.parent.node.key === path.node)
|
|
88
|
+
).length > 0;
|
|
89
|
+
const declaresContext = root.find(j.ImportDeclaration, {
|
|
90
|
+
source: { value: "@cedarjs/graphql-server" }
|
|
91
|
+
}).length > 0 || root.find(j.ImportSpecifier, { local: { name: "context" } }).length > 0 || root.find(j.VariableDeclarator, { id: { name: "context" } }).length > 0;
|
|
92
|
+
if (!usesContext || declaresContext) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
const contextImport = j.withParser("ts")(`import { context } from '@cedarjs/context'
|
|
96
|
+
`).find(j.ImportDeclaration).nodes()[0];
|
|
97
|
+
const cedarImports = root.find(j.ImportDeclaration).filter((path) => String(path.node.source.value).startsWith("@cedarjs/"));
|
|
98
|
+
const before = cedarImports.filter(
|
|
99
|
+
(path) => String(path.node.source.value) > "@cedarjs/context"
|
|
100
|
+
);
|
|
101
|
+
if (before.length > 0) {
|
|
102
|
+
before.at(0).insertBefore(contextImport);
|
|
103
|
+
} else if (cedarImports.length > 0) {
|
|
104
|
+
cedarImports.at(cedarImports.length - 1).insertAfter(contextImport);
|
|
105
|
+
} else {
|
|
106
|
+
root.find(j.Program).forEach((path) => {
|
|
107
|
+
path.node.body.unshift(contextImport);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
function addWithPermissions(root, lookups) {
|
|
113
|
+
const isWithPermissionsCall = (node) => j.CallExpression.check(node) && j.Identifier.check(node.callee) && node.callee.name === "withPermissions";
|
|
114
|
+
let wrapped = false;
|
|
115
|
+
root.find(j.CallExpression, USER_LOOKUP).forEach((path) => {
|
|
116
|
+
if (!lookups.has(path.node)) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const awaited = j.AwaitExpression.check(path.parent.node) ? path.parent : null;
|
|
120
|
+
const thenCall = path.parent.parent?.node;
|
|
121
|
+
const isThenWithPermissions = j.MemberExpression.check(path.parent.node) && j.Identifier.check(path.parent.node.property) && path.parent.node.property.name === "then" && j.CallExpression.check(thenCall) && j.Identifier.check(thenCall.arguments[0]) && thenCall.arguments[0].name === "withPermissions";
|
|
122
|
+
if (isWithPermissionsCall((awaited ?? path).parent.node) || isThenWithPermissions) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
wrapped = true;
|
|
126
|
+
if (awaited) {
|
|
127
|
+
awaited.replace(
|
|
128
|
+
j.callExpression(j.identifier("withPermissions"), [awaited.node])
|
|
129
|
+
);
|
|
130
|
+
} else {
|
|
131
|
+
path.replace(
|
|
132
|
+
j.callExpression(j.memberExpression(path.node, j.identifier("then")), [
|
|
133
|
+
j.identifier("withPermissions")
|
|
134
|
+
])
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
return wrapped;
|
|
139
|
+
}
|
|
140
|
+
function addPermissionsImport(source) {
|
|
141
|
+
if (/import \{[^}]*\bwithPermissions\b[^}]*\} from/.test(source)) {
|
|
142
|
+
return source;
|
|
143
|
+
}
|
|
144
|
+
const eol = source.includes("\r\n") ? "\r\n" : "\n";
|
|
145
|
+
const dbImport = /^import \{ db \} from (['"])(.*?)db(\.js)?\1;?\r?\n/m;
|
|
146
|
+
if (dbImport.test(source)) {
|
|
147
|
+
return source.replace(
|
|
148
|
+
dbImport,
|
|
149
|
+
(line, quote, dir, ext = "") => `${line}import { withPermissions } from ${quote}${dir}permissions${ext}${quote}${eol}`
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return `import { withPermissions } from 'src/lib/permissions'${eol}${source}`;
|
|
153
|
+
}
|
|
74
154
|
function transform(fileInfo) {
|
|
75
155
|
const root = j.withParser("tsx")(fileInfo.source);
|
|
76
156
|
let selectFound = false;
|
|
157
|
+
const membershipLookups = /* @__PURE__ */ new Set();
|
|
77
158
|
let changed = false;
|
|
78
159
|
root.find(j.CallExpression, {
|
|
79
160
|
callee: {
|
|
@@ -104,13 +185,20 @@ function transform(fileInfo) {
|
|
|
104
185
|
changed = true;
|
|
105
186
|
}
|
|
106
187
|
selectFound = true;
|
|
188
|
+
membershipLookups.add(path.node);
|
|
107
189
|
});
|
|
108
190
|
if (!selectFound) {
|
|
109
191
|
throw new Error("CEDAR_CODEMOD_ERR_AUTH_SHAPE_NOT_FOUND");
|
|
110
192
|
}
|
|
193
|
+
if (addWithPermissions(root, membershipLookups)) {
|
|
194
|
+
changed = true;
|
|
195
|
+
}
|
|
111
196
|
const alreadyWired = root.find(j.ImportSpecifier, { imported: { name: "createTenancyAuth" } }).length > 0;
|
|
112
197
|
if (alreadyWired) {
|
|
113
|
-
|
|
198
|
+
if (addContextImport(root)) {
|
|
199
|
+
changed = true;
|
|
200
|
+
}
|
|
201
|
+
return addPermissionsImport(changed ? root.toSource() : fileInfo.source);
|
|
114
202
|
}
|
|
115
203
|
const hasRole = findExportedConst(root, "hasRole");
|
|
116
204
|
const requireAuth = findExportedConst(root, "requireAuth");
|
|
@@ -151,7 +239,8 @@ function transform(fileInfo) {
|
|
|
151
239
|
path.node.body.unshift(tenancyImport);
|
|
152
240
|
});
|
|
153
241
|
}
|
|
154
|
-
|
|
242
|
+
addContextImport(root);
|
|
243
|
+
return addPermissionsImport(root.toSource());
|
|
155
244
|
}
|
|
156
245
|
export {
|
|
157
246
|
TENANCY_AUTH_EXPORT,
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { Listr } from "listr2";
|
|
4
|
+
import * as toml from "smol-toml";
|
|
5
|
+
import { runScript } from "@cedarjs/cli-helpers/packageManager/exec";
|
|
6
|
+
import { installPackages } from "@cedarjs/cli-helpers/packageManager/packages";
|
|
7
|
+
import { addWorkspaceDir } from "@cedarjs/cli-helpers/packageManager/workspaces";
|
|
8
|
+
import { getConfigPath } from "@cedarjs/project-config";
|
|
9
|
+
import { getPackageManager } from "@cedarjs/project-config/packageManager";
|
|
10
|
+
import { getPaths, writeFile } from "../../../lib/index.js";
|
|
11
|
+
import {
|
|
12
|
+
addDependencyToPackageJson,
|
|
13
|
+
nameVariants,
|
|
14
|
+
updateGitignore,
|
|
15
|
+
updateTsconfig,
|
|
16
|
+
updateWorkspaceTsconfigReferences
|
|
17
|
+
} from "../../generate/package/packageHandler.js";
|
|
18
|
+
const PERMISSIONS_PACKAGE_FOLDER = "permissions";
|
|
19
|
+
const TABLE_HEADER = "[experimental.packagesWorkspace]";
|
|
20
|
+
function enablePackagesWorkspace(tomlContent) {
|
|
21
|
+
const config = toml.parse(tomlContent);
|
|
22
|
+
const packagesWorkspace = config.experimental?.packagesWorkspace;
|
|
23
|
+
if (packagesWorkspace?.enabled === true) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
const lines = tomlContent.split("\n");
|
|
27
|
+
const headerIndex = lines.findIndex((line) => line.trim() === TABLE_HEADER);
|
|
28
|
+
if (headerIndex === -1) {
|
|
29
|
+
if (packagesWorkspace) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
"CEDAR_TENANCY_ERR_PACKAGES_WORKSPACE: set `experimental.packagesWorkspace.enabled = true` in cedar.toml and run this command again."
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
return tomlContent.replace(/\n*$/, "") + `
|
|
35
|
+
|
|
36
|
+
${TABLE_HEADER}
|
|
37
|
+
enabled = true
|
|
38
|
+
`;
|
|
39
|
+
}
|
|
40
|
+
const tableEnd = lines.findIndex(
|
|
41
|
+
(line, index) => index > headerIndex && /^\s*\[/.test(line)
|
|
42
|
+
);
|
|
43
|
+
const end = tableEnd === -1 ? lines.length : tableEnd;
|
|
44
|
+
const enabledIndex = lines.findIndex(
|
|
45
|
+
(line, index) => index > headerIndex && index < end && /^\s*enabled\s*=/.test(line)
|
|
46
|
+
);
|
|
47
|
+
if (enabledIndex === -1) {
|
|
48
|
+
lines.splice(headerIndex + 1, 0, " enabled = true");
|
|
49
|
+
} else {
|
|
50
|
+
lines[enabledIndex] = lines[enabledIndex].replace(
|
|
51
|
+
/enabled\s*=.*/,
|
|
52
|
+
"enabled = true"
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return lines.join("\n");
|
|
56
|
+
}
|
|
57
|
+
const packageNameIn = (packageDir) => {
|
|
58
|
+
try {
|
|
59
|
+
const { name } = JSON.parse(
|
|
60
|
+
fs.readFileSync(path.join(packageDir, "package.json"), "utf-8")
|
|
61
|
+
);
|
|
62
|
+
return typeof name === "string" ? name : void 0;
|
|
63
|
+
} catch {
|
|
64
|
+
return void 0;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
function permissionsPackageName(packagesDir = path.join(getPaths().base, "packages")) {
|
|
68
|
+
const existing = packageNameIn(
|
|
69
|
+
path.join(packagesDir, PERMISSIONS_PACKAGE_FOLDER)
|
|
70
|
+
);
|
|
71
|
+
if (existing) {
|
|
72
|
+
return existing;
|
|
73
|
+
}
|
|
74
|
+
const otherPackages = fs.existsSync(packagesDir) ? fs.readdirSync(packagesDir, { withFileTypes: true }) : [];
|
|
75
|
+
const scope = otherPackages.filter((entry) => entry.isDirectory()).map((entry) => packageNameIn(path.join(packagesDir, entry.name))).find((name) => name?.startsWith("@") && name.includes("/"))?.split("/")[0];
|
|
76
|
+
return scope ? `${scope}/${PERMISSIONS_PACKAGE_FOLDER}` : nameVariants(PERMISSIONS_PACKAGE_FOLDER).packageName;
|
|
77
|
+
}
|
|
78
|
+
function permissionsPackageFiles({
|
|
79
|
+
packageName,
|
|
80
|
+
cedarVersion,
|
|
81
|
+
indexSource
|
|
82
|
+
}) {
|
|
83
|
+
const packageJson = {
|
|
84
|
+
name: packageName,
|
|
85
|
+
version: "0.0.0",
|
|
86
|
+
private: true,
|
|
87
|
+
type: "module",
|
|
88
|
+
main: "dist/index.js",
|
|
89
|
+
types: "./dist/index.d.ts",
|
|
90
|
+
exports: {
|
|
91
|
+
".": {
|
|
92
|
+
types: "./dist/index.d.ts",
|
|
93
|
+
default: "./dist/index.js"
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
scripts: {
|
|
97
|
+
build: "tsc",
|
|
98
|
+
watch: "tsc --watch"
|
|
99
|
+
},
|
|
100
|
+
dependencies: {
|
|
101
|
+
"@cedarjs/tenancy": cedarVersion
|
|
102
|
+
},
|
|
103
|
+
devDependencies: {
|
|
104
|
+
typescript: "5.9.3"
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
const tsconfig = {
|
|
108
|
+
compilerOptions: {
|
|
109
|
+
composite: true,
|
|
110
|
+
target: "es2023",
|
|
111
|
+
module: "node20",
|
|
112
|
+
esModuleInterop: true,
|
|
113
|
+
strict: true,
|
|
114
|
+
skipLibCheck: true,
|
|
115
|
+
rootDir: "src",
|
|
116
|
+
outDir: "dist",
|
|
117
|
+
sourceMap: true,
|
|
118
|
+
declaration: true,
|
|
119
|
+
declarationMap: true
|
|
120
|
+
},
|
|
121
|
+
include: ["src"]
|
|
122
|
+
};
|
|
123
|
+
return {
|
|
124
|
+
"package.json": JSON.stringify(packageJson, null, 2) + "\n",
|
|
125
|
+
"tsconfig.json": JSON.stringify(tsconfig, null, 2) + "\n",
|
|
126
|
+
"src/index.ts": indexSource
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
function writePermissionsPackageFiles(packageDir, files, { force }) {
|
|
130
|
+
const kept = [];
|
|
131
|
+
for (const [file, content] of Object.entries(files)) {
|
|
132
|
+
const filePath = path.join(packageDir, file);
|
|
133
|
+
if (fs.existsSync(filePath) && !force) {
|
|
134
|
+
kept.push(filePath);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
writeFile(filePath, content, { overwriteExisting: force });
|
|
138
|
+
}
|
|
139
|
+
return kept;
|
|
140
|
+
}
|
|
141
|
+
function addPermissionsPackageTasks({
|
|
142
|
+
cedarVersion,
|
|
143
|
+
indexSource,
|
|
144
|
+
force
|
|
145
|
+
}) {
|
|
146
|
+
const paths = getPaths();
|
|
147
|
+
const packageDir = path.join(
|
|
148
|
+
paths.base,
|
|
149
|
+
"packages",
|
|
150
|
+
PERMISSIONS_PACKAGE_FOLDER
|
|
151
|
+
);
|
|
152
|
+
const packageName = permissionsPackageName();
|
|
153
|
+
return new Listr([
|
|
154
|
+
{
|
|
155
|
+
title: "Turning on the packages workspace in cedar.toml...",
|
|
156
|
+
task: (ctx, task) => {
|
|
157
|
+
const configPath = getConfigPath();
|
|
158
|
+
const updated = enablePackagesWorkspace(
|
|
159
|
+
fs.readFileSync(configPath, "utf-8")
|
|
160
|
+
);
|
|
161
|
+
if (updated === null) {
|
|
162
|
+
task.skip("Already on");
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
fs.writeFileSync(configPath, updated);
|
|
166
|
+
ctx.packagesWorkspaceTurnedOn = true;
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
title: "Updating workspace config...",
|
|
171
|
+
task: (_ctx, task) => {
|
|
172
|
+
const result = addWorkspaceDir(
|
|
173
|
+
paths.base,
|
|
174
|
+
`packages/${PERMISSIONS_PACKAGE_FOLDER}`,
|
|
175
|
+
getPackageManager()
|
|
176
|
+
);
|
|
177
|
+
if (result === "exists") {
|
|
178
|
+
task.skip("Workspaces already configured");
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
title: "Updating tsconfig files...",
|
|
184
|
+
task: (_ctx, task) => updateTsconfig(task)
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
title: "Updating .gitignore...",
|
|
188
|
+
task: (_ctx, task) => updateGitignore(task)
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
title: `Writing ${packageName}...`,
|
|
192
|
+
task: (_ctx, task) => {
|
|
193
|
+
const files = permissionsPackageFiles({
|
|
194
|
+
packageName,
|
|
195
|
+
cedarVersion,
|
|
196
|
+
indexSource
|
|
197
|
+
});
|
|
198
|
+
const kept = writePermissionsPackageFiles(packageDir, files, { force });
|
|
199
|
+
if (kept.length === Object.keys(files).length) {
|
|
200
|
+
task.skip("Already there (run with --force to overwrite)");
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
title: "Adding it to the api and web sides...",
|
|
206
|
+
task: async (_ctx, task) => {
|
|
207
|
+
for (const side of [paths.api.base, paths.web.base]) {
|
|
208
|
+
await addDependencyToPackageJson(
|
|
209
|
+
task,
|
|
210
|
+
path.join(side, "package.json"),
|
|
211
|
+
packageName
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
title: "Updating tsconfig references...",
|
|
218
|
+
task: (_ctx, task) => updateWorkspaceTsconfigReferences(
|
|
219
|
+
task,
|
|
220
|
+
PERMISSIONS_PACKAGE_FOLDER,
|
|
221
|
+
"both"
|
|
222
|
+
)
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
title: "Installing and building it...",
|
|
226
|
+
task: async () => {
|
|
227
|
+
await installPackages({ cwd: paths.base });
|
|
228
|
+
await runScript("build", [], { cwd: packageDir });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
]);
|
|
232
|
+
}
|
|
233
|
+
export {
|
|
234
|
+
PERMISSIONS_PACKAGE_FOLDER,
|
|
235
|
+
addPermissionsPackageTasks,
|
|
236
|
+
enablePackagesWorkspace,
|
|
237
|
+
permissionsPackageFiles,
|
|
238
|
+
permissionsPackageName,
|
|
239
|
+
writePermissionsPackageFiles
|
|
240
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ReactNode } from 'react'
|
|
2
2
|
|
|
3
3
|
import { OrgScope as TenancyOrgScope } from '@cedarjs/tenancy/web'
|
|
4
|
+
import type { OrgScopeProps as TenancyOrgScopeProps } from '@cedarjs/tenancy/web'
|
|
4
5
|
|
|
5
6
|
import { useAuth } from 'src/auth'
|
|
6
7
|
|
|
@@ -10,8 +11,14 @@ export interface OrgScopeProps {
|
|
|
10
11
|
/** Only members with one of these roles in the organization get in. */
|
|
11
12
|
roles?: string | string[]
|
|
12
13
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
14
|
+
* Only members whose role has this permission (api/src/lib/permissions)
|
|
15
|
+
* get in.
|
|
16
|
+
*/
|
|
17
|
+
permission?: TenancyOrgScopeProps['permission']
|
|
18
|
+
/**
|
|
19
|
+
* The name of the route a member without one of `roles`, or without
|
|
20
|
+
* `permission`, is sent to. It gets the `orgSlug` param when its path has
|
|
21
|
+
* one.
|
|
15
22
|
*/
|
|
16
23
|
unauthorized?: string
|
|
17
24
|
children: ReactNode
|
|
@@ -26,19 +33,20 @@ export interface OrgScopeProps {
|
|
|
26
33
|
* <Route path="/org/{orgSlug}/projects" page={ProjectsPage} name="projects" />
|
|
27
34
|
* </Set>
|
|
28
35
|
*
|
|
29
|
-
* `useCurrentOrg().hasOrgRole`
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
36
|
+
* Under it, `useAuth().hasPermission` and `useCurrentOrg().hasOrgRole`
|
|
37
|
+
* check the user's membership in the organization, and
|
|
38
|
+
* `useAuth().hasUserRole` checks `currentUser.roles`. For pages only some
|
|
39
|
+
* members may open, wrap them in a component that passes `permission` (or
|
|
40
|
+
* `roles`; there's no `<PrivateSet roles>` with tenancy):
|
|
33
41
|
*
|
|
34
|
-
* const
|
|
35
|
-
* <OrgScope
|
|
42
|
+
* const MemberManagers = ({ children }: { children: ReactNode }) => (
|
|
43
|
+
* <OrgScope permission="members.manage" unauthorized="projects">
|
|
36
44
|
* {children}
|
|
37
45
|
* </OrgScope>
|
|
38
46
|
* )
|
|
39
47
|
*
|
|
40
|
-
* <Set wrap={
|
|
41
|
-
* <Route path="/org/{orgSlug}/
|
|
48
|
+
* <Set wrap={MemberManagers}>
|
|
49
|
+
* <Route path="/org/{orgSlug}/members" page={MembersPage} name="members" />
|
|
42
50
|
* </Set>
|
|
43
51
|
*
|
|
44
52
|
* Routes under `OrgScope` must not carry `prerender`: `OrgScope`'s nested
|
|
@@ -48,6 +56,7 @@ export interface OrgScopeProps {
|
|
|
48
56
|
export const OrgScope = ({
|
|
49
57
|
orgSlug,
|
|
50
58
|
roles,
|
|
59
|
+
permission,
|
|
51
60
|
unauthorized,
|
|
52
61
|
children,
|
|
53
62
|
}: OrgScopeProps) => {
|
|
@@ -56,6 +65,7 @@ export const OrgScope = ({
|
|
|
56
65
|
useAuth={useAuth}
|
|
57
66
|
orgSlug={orgSlug}
|
|
58
67
|
roles={roles}
|
|
68
|
+
permission={permission}
|
|
59
69
|
unauthorized={unauthorized}
|
|
60
70
|
notAMember={
|
|
61
71
|
<div>
|
|
@@ -46,7 +46,8 @@ export const schema = gql`
|
|
|
46
46
|
myMemberships: [Membership!]! @requireUserAuth
|
|
47
47
|
|
|
48
48
|
"The current organization, resolved by resolveCurrentOrg for this request."
|
|
49
|
-
organization(id: String!): Organization
|
|
49
|
+
organization(id: String!): Organization
|
|
50
|
+
@requirePermission(permission: "organization.read")
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
type Mutation {
|
|
@@ -64,7 +65,7 @@ export const schema = gql`
|
|
|
64
65
|
the invite shows up if the inviter is also the invitee.
|
|
65
66
|
"""
|
|
66
67
|
inviteMember(input: InviteMemberInput!): Invitation!
|
|
67
|
-
@
|
|
68
|
+
@requirePermission(permission: "members.manage")
|
|
68
69
|
|
|
69
70
|
"""
|
|
70
71
|
Attaches the pending membership matching token to the signed-in user, or
|
|
@@ -85,6 +86,6 @@ export const schema = gql`
|
|
|
85
86
|
removing their own membership.
|
|
86
87
|
"""
|
|
87
88
|
removeMember(membershipId: String!): Membership!
|
|
88
|
-
@
|
|
89
|
+
@requirePermission(permission: "members.manage")
|
|
89
90
|
}
|
|
90
91
|
`
|
|
@@ -72,8 +72,8 @@ export const myMemberships: QueryResolvers['myMemberships'] = () => {
|
|
|
72
72
|
/**
|
|
73
73
|
* The current organization (the one `resolveCurrentOrg` resolved for this
|
|
74
74
|
* request). `id` is required so callers state which organization they mean,
|
|
75
|
-
* but it must match the current organization: `@
|
|
76
|
-
* caller's membership in the current organization, not in `id`.
|
|
75
|
+
* but it must match the current organization: `@requirePermission` checks
|
|
76
|
+
* the caller's membership in the current organization, not in `id`.
|
|
77
77
|
*/
|
|
78
78
|
export const organization: QueryResolvers['organization'] = async ({ id }) => {
|
|
79
79
|
const currentOrg = requireCurrentOrg()
|
|
@@ -135,7 +135,7 @@ export const createOrganization: MutationResolvers['createOrganization'] =
|
|
|
135
135
|
* Creates a pending membership (an invitation) in the current organization,
|
|
136
136
|
* for a role the caller's own role may grant per `ROLE_GRANTS` -- an
|
|
137
137
|
* `admin` can invite an `admin` or a `member`, but not an `owner`, even
|
|
138
|
-
* though `@
|
|
138
|
+
* though `@requirePermission(permission: "members.manage")` lets an admin call
|
|
139
139
|
* this mutation at all. A role that isn't in `ROLE_GRANTS` is rejected
|
|
140
140
|
* outright, so a typo in `input.role` can never create a membership nobody
|
|
141
141
|
* can check against.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { permissions } from '__PERMISSIONS_PACKAGE__'
|
|
2
|
+
|
|
3
|
+
import { createPermissions } from '@cedarjs/tenancy'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Checks the permissions defined in __PERMISSIONS_PACKAGE__
|
|
7
|
+
* (packages/permissions). `getCurrentUser` adds each membership's
|
|
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.
|
|
11
|
+
*
|
|
12
|
+
* Who may grant which role is `ROLE_GRANTS` in
|
|
13
|
+
* api/src/services/organizations/organizations.
|
|
14
|
+
*/
|
|
15
|
+
export const {
|
|
16
|
+
hasPermission,
|
|
17
|
+
requirePermission,
|
|
18
|
+
permissionsOf,
|
|
19
|
+
withPermissions,
|
|
20
|
+
} = createPermissions(permissions)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { definePermissions } from '@cedarjs/tenancy/permissions'
|
|
2
|
+
import type { PermissionName } from '@cedarjs/tenancy/permissions'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Every permission the app checks, and what each role may do. Fields ask
|
|
6
|
+
* for a permission (`@requirePermission(permission: "members.manage")`, or
|
|
7
|
+
* `requirePermission()` in a service) instead of listing roles, so a new
|
|
8
|
+
* role is one entry here: it can do exactly what its list says, and a
|
|
9
|
+
* field added later is closed to it unless its permission is on the list.
|
|
10
|
+
*
|
|
11
|
+
* `roles` are per organization: a membership's role decides what the user
|
|
12
|
+
* may do in that organization. Fields about the user rather than an
|
|
13
|
+
* organization (their own account, say) use `@requireUserAuth` instead.
|
|
14
|
+
*
|
|
15
|
+
* This package is shared: the api checks these permissions
|
|
16
|
+
* (api/src/lib/permissions), and the web can import the names. The web gets each
|
|
17
|
+
* membership's permissions from the api, through `getCurrentUser`.
|
|
18
|
+
*/
|
|
19
|
+
export const permissions = definePermissions({
|
|
20
|
+
permissions: ['organization.read', 'members.manage'],
|
|
21
|
+
roles: {
|
|
22
|
+
owner: ['organization.read', 'members.manage'],
|
|
23
|
+
admin: ['organization.read', 'members.manage'],
|
|
24
|
+
member: ['organization.read'],
|
|
25
|
+
},
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
export type Permission = PermissionName<typeof permissions>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { mockRedwoodDirective, getDirectiveName } from '@cedarjs/testing/api'
|
|
2
|
+
|
|
3
|
+
import requirePermission from './requirePermission.js'
|
|
4
|
+
|
|
5
|
+
const currentOrg = (role: string) => ({
|
|
6
|
+
id: 'org-1',
|
|
7
|
+
slug: 'org-1',
|
|
8
|
+
role,
|
|
9
|
+
membershipId: 'membership-1',
|
|
10
|
+
})
|
|
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: 'organization.read' },
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
expect(mockExecution).toThrowError()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("throws when the role in the current organization doesn't have it", () => {
|
|
28
|
+
const mockExecution = mockRedwoodDirective(requirePermission, {
|
|
29
|
+
context: {
|
|
30
|
+
currentUser: { id: 'user-1' },
|
|
31
|
+
currentOrg: currentOrg('member'),
|
|
32
|
+
},
|
|
33
|
+
directiveArgs: { permission: 'members.manage' },
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
expect(mockExecution).toThrowError()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('passes when the role in the current organization has it', () => {
|
|
40
|
+
const mockExecution = mockRedwoodDirective(requirePermission, {
|
|
41
|
+
context: {
|
|
42
|
+
currentUser: { id: 'user-1' },
|
|
43
|
+
currentOrg: currentOrg('owner'),
|
|
44
|
+
},
|
|
45
|
+
directiveArgs: { permission: 'members.manage' },
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
expect(mockExecution).not.toThrowError()
|
|
49
|
+
})
|
|
50
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Permission } from '__PERMISSIONS_PACKAGE__'
|
|
2
|
+
import { gql } from 'graphql-tag'
|
|
3
|
+
|
|
4
|
+
import type { ValidatorDirectiveFunc } from '@cedarjs/graphql-server'
|
|
5
|
+
import { createValidatorDirective } from '@cedarjs/graphql-server'
|
|
6
|
+
|
|
7
|
+
import { requirePermission as applicationRequirePermission } from 'src/lib/permissions'
|
|
8
|
+
|
|
9
|
+
export const schema = gql`
|
|
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).
|
|
13
|
+
"""
|
|
14
|
+
directive @requirePermission(permission: String!) on FIELD_DEFINITION
|
|
15
|
+
`
|
|
16
|
+
|
|
17
|
+
// Optional to fit `ValidatorDirectiveFunc`; the SDL makes it required
|
|
18
|
+
type RequirePermissionValidate = ValidatorDirectiveFunc<{
|
|
19
|
+
permission?: string
|
|
20
|
+
}>
|
|
21
|
+
|
|
22
|
+
const validate: RequirePermissionValidate = ({ directiveArgs }) => {
|
|
23
|
+
// A name the permissions map doesn't know throws, so a typo in the SDL is
|
|
24
|
+
// an error rather than a field nobody can use
|
|
25
|
+
applicationRequirePermission({
|
|
26
|
+
permission: directiveArgs.permission as Permission,
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const requirePermission = createValidatorDirective(schema, validate)
|
|
31
|
+
|
|
32
|
+
export default requirePermission
|
|
@@ -26,6 +26,10 @@ import {
|
|
|
26
26
|
renameRequireAuthDirective,
|
|
27
27
|
renameLibAuthImports
|
|
28
28
|
} from "./authDirectives.js";
|
|
29
|
+
import {
|
|
30
|
+
addPermissionsPackageTasks,
|
|
31
|
+
permissionsPackageName
|
|
32
|
+
} from "./permissionsPackage.js";
|
|
29
33
|
import {
|
|
30
34
|
authNotSetUpMessage,
|
|
31
35
|
hasWebAuthFile,
|
|
@@ -44,8 +48,10 @@ const DEFAULT_TENANT_FIELD = "organizationId";
|
|
|
44
48
|
const AUTH_SNIPPET = `
|
|
45
49
|
import { createTenancyAuth } from '@cedarjs/tenancy'
|
|
46
50
|
|
|
51
|
+
import { withPermissions } from 'src/lib/permissions'
|
|
52
|
+
|
|
47
53
|
export const getCurrentUser = async (session) => {
|
|
48
|
-
return await db.user.findUnique({
|
|
54
|
+
return withPermissions(await db.user.findUnique({
|
|
49
55
|
where: { id: session.id },
|
|
50
56
|
select: {
|
|
51
57
|
// ...your existing fields...
|
|
@@ -58,7 +64,7 @@ const AUTH_SNIPPET = `
|
|
|
58
64
|
},
|
|
59
65
|
},
|
|
60
66
|
},
|
|
61
|
-
})
|
|
67
|
+
}))
|
|
62
68
|
}
|
|
63
69
|
|
|
64
70
|
// In place of your hasRole and requireAuth
|
|
@@ -315,6 +321,47 @@ ${SIGNUP_SNIPPET}`;
|
|
|
315
321
|
}
|
|
316
322
|
}
|
|
317
323
|
},
|
|
324
|
+
{
|
|
325
|
+
title: "Adding the permissions package...",
|
|
326
|
+
task: () => addPermissionsPackageTasks({
|
|
327
|
+
cedarVersion,
|
|
328
|
+
indexSource: fs.readFileSync(
|
|
329
|
+
path.resolve(
|
|
330
|
+
import.meta.dirname,
|
|
331
|
+
"templates",
|
|
332
|
+
"permissionsPackage.index.ts.template"
|
|
333
|
+
),
|
|
334
|
+
"utf-8"
|
|
335
|
+
),
|
|
336
|
+
force
|
|
337
|
+
})
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
title: "Adding permission checks and the requirePermission directive...",
|
|
341
|
+
task: async () => {
|
|
342
|
+
const replacements = {
|
|
343
|
+
__PERMISSIONS_PACKAGE__: permissionsPackageName()
|
|
344
|
+
};
|
|
345
|
+
await writeTemplateFile(
|
|
346
|
+
"permissions.ts.template",
|
|
347
|
+
path.join(paths.api.lib, `permissions.${ext}`),
|
|
348
|
+
replacements
|
|
349
|
+
);
|
|
350
|
+
const directiveDir = path.join(
|
|
351
|
+
paths.api.directives,
|
|
352
|
+
"requirePermission"
|
|
353
|
+
);
|
|
354
|
+
await writeTemplateFile(
|
|
355
|
+
"requirePermission.ts.template",
|
|
356
|
+
path.join(directiveDir, `requirePermission.${ext}`),
|
|
357
|
+
replacements
|
|
358
|
+
);
|
|
359
|
+
await writeTemplateFile(
|
|
360
|
+
"requirePermission.test.ts.template",
|
|
361
|
+
path.join(directiveDir, `requirePermission.test.${ext}`)
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
},
|
|
318
365
|
{
|
|
319
366
|
title: "Adding the organizations service...",
|
|
320
367
|
task: async () => {
|
|
@@ -488,7 +535,13 @@ ${SIGNUP_SNIPPET}`;
|
|
|
488
535
|
tenant-owned models with ${c.highlight("withTenancy")} from ${c.highlight(
|
|
489
536
|
"@cedarjs/tenancy"
|
|
490
537
|
)}.
|
|
491
|
-
5.
|
|
538
|
+
5. Your permissions, and what each role may do, are in
|
|
539
|
+
${c.highlight("packages/permissions")}, shared by the api and web sides${ctx.packagesWorkspaceTurnedOn ? ` (this
|
|
540
|
+
turned on ${c.highlight("experimental.packagesWorkspace")} in cedar.toml)` : ""}. Guard
|
|
541
|
+
organization fields with ${c.highlight('@requirePermission(permission: "...")')}
|
|
542
|
+
(or ${c.highlight("@requireOrgAuth(roles: [...])")}), and check permissions on the
|
|
543
|
+
web with ${c.highlight("useAuth().hasPermission()")}.
|
|
544
|
+
6. ${c.highlight("@requireAuth")} is gone. Your fields use ${c.highlight("@requireUserAuth")} now,
|
|
492
545
|
which checks what ${c.highlight("@requireAuth")} did: there were no organizations
|
|
493
546
|
before. ${c.highlight("@requireOrgAuth")} needs a current organization, and checks
|
|
494
547
|
the user's role in it.${ctx.keptRequireAuthDirective ? `
|
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.3540",
|
|
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.3540",
|
|
40
|
+
"@cedarjs/babel-config": "8.0.0-canary.3540",
|
|
41
|
+
"@cedarjs/cli-helpers": "8.0.0-canary.3540",
|
|
42
|
+
"@cedarjs/internal": "8.0.0-canary.3540",
|
|
43
|
+
"@cedarjs/prerender": "8.0.0-canary.3540",
|
|
44
|
+
"@cedarjs/project-config": "8.0.0-canary.3540",
|
|
45
|
+
"@cedarjs/structure": "8.0.0-canary.3540",
|
|
46
|
+
"@cedarjs/telemetry": "8.0.0-canary.3540",
|
|
47
|
+
"@cedarjs/utils": "8.0.0-canary.3540",
|
|
48
|
+
"@cedarjs/vite": "8.0.0-canary.3540",
|
|
49
|
+
"@cedarjs/web-server": "8.0.0-canary.3540",
|
|
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.3540",
|
|
97
97
|
"@prisma/dmmf": "7.10.1",
|
|
98
98
|
"@types/archiver": "^7.0.0",
|
|
99
99
|
"memfs": "4.80.0",
|