@cedarjs/cli 8.0.0-canary.3536 → 8.0.0-canary.3539
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/generate/realtime/realtimeHandler.js +13 -5
- package/dist/commands/generate/sdl/sdlHandler.js +10 -2
- package/dist/commands/setup/realtime/realtimeHandler.js +43 -18
- package/dist/commands/setup/tenancy/authCodemod.js +93 -17
- package/dist/commands/setup/tenancy/authDirectives.js +101 -0
- package/dist/commands/setup/tenancy/templates/OrgScope.tsx.template +30 -1
- package/dist/commands/setup/tenancy/templates/organizations.sdl.ts.template +7 -6
- package/dist/commands/setup/tenancy/templates/organizations.test.ts.template +24 -28
- package/dist/commands/setup/tenancy/templates/organizations.ts.template +6 -6
- package/dist/commands/setup/tenancy/templates/requireOrgAuth.test.ts.template +56 -0
- package/dist/commands/setup/tenancy/templates/requireOrgAuth.ts.template +25 -0
- package/dist/commands/setup/tenancy/templates/requireUserAuth.test.ts.template +26 -0
- package/dist/commands/setup/tenancy/templates/requireUserAuth.ts.template +26 -0
- package/dist/commands/setup/tenancy/tenancyHandler.js +78 -17
- package/dist/commands/setup/tenancy/webHasRole.js +45 -0
- package/dist/commands/setup/uploads/uploadsHandler.js +4 -0
- package/dist/lib/authDirective.js +37 -0
- package/package.json +15 -15
- package/dist/commands/setup/tenancy/templates/requireMembership.test.ts.template +0 -35
- package/dist/commands/setup/tenancy/templates/requireMembership.ts.template +0 -25
|
@@ -7,6 +7,7 @@ import prompts from "prompts";
|
|
|
7
7
|
import { colors as c } from "@cedarjs/cli-helpers/colors";
|
|
8
8
|
import { generate as generateTypes } from "@cedarjs/internal/dist/generate/generate";
|
|
9
9
|
import { errorTelemetry } from "@cedarjs/telemetry";
|
|
10
|
+
import { withAuthDirective } from "../../../lib/authDirective.js";
|
|
10
11
|
import {
|
|
11
12
|
generateTemplate,
|
|
12
13
|
getPaths,
|
|
@@ -119,14 +120,18 @@ async function handler({
|
|
|
119
120
|
exampleFile,
|
|
120
121
|
exampleSubscriptionTemplateContent
|
|
121
122
|
);
|
|
122
|
-
const blankTemplateContent =
|
|
123
|
-
setupScriptContent,
|
|
124
|
-
|
|
123
|
+
const blankTemplateContent = withAuthDirective(
|
|
124
|
+
await generateTemplate(setupScriptContent, templateVariables(name)),
|
|
125
|
+
// Not about a model, so the safe default
|
|
126
|
+
{ belongsToOrg: true }
|
|
125
127
|
);
|
|
126
128
|
return [
|
|
127
129
|
writeFile(
|
|
128
130
|
sdlFile,
|
|
129
|
-
|
|
131
|
+
withAuthDirective(
|
|
132
|
+
await generateTemplate(sdlContent, templateVariables(name)),
|
|
133
|
+
{ belongsToOrg: true }
|
|
134
|
+
),
|
|
130
135
|
{
|
|
131
136
|
overwriteExisting: force
|
|
132
137
|
}
|
|
@@ -176,7 +181,10 @@ async function handler({
|
|
|
176
181
|
return [
|
|
177
182
|
writeFile(
|
|
178
183
|
sdlFile,
|
|
179
|
-
|
|
184
|
+
withAuthDirective(
|
|
185
|
+
await generateTemplate(sdlContent, templateVariables(name)),
|
|
186
|
+
{ belongsToOrg: true }
|
|
187
|
+
),
|
|
180
188
|
{
|
|
181
189
|
overwriteExisting: force
|
|
182
190
|
}
|
|
@@ -9,6 +9,10 @@ import { generate as generateTypes } from "@cedarjs/internal/dist/generate/gener
|
|
|
9
9
|
import { getConfig } from "@cedarjs/project-config";
|
|
10
10
|
import { errorTelemetry } from "@cedarjs/telemetry";
|
|
11
11
|
import { pluralize } from "@cedarjs/utils/cedarPluralize";
|
|
12
|
+
import {
|
|
13
|
+
belongsToOrganization,
|
|
14
|
+
withAuthDirective
|
|
15
|
+
} from "../../../lib/authDirective.js";
|
|
12
16
|
import { transformTSToJS } from "../../../lib/index.js";
|
|
13
17
|
import {
|
|
14
18
|
prepareForRollback,
|
|
@@ -159,7 +163,8 @@ const sdlFromSchemaModel = async (name, crud, docs = false) => {
|
|
|
159
163
|
idType: idType(model, crud),
|
|
160
164
|
idName: idName(model, crud),
|
|
161
165
|
relations: relationsForModel(model),
|
|
162
|
-
enums
|
|
166
|
+
enums,
|
|
167
|
+
belongsToOrg: belongsToOrganization(model)
|
|
163
168
|
};
|
|
164
169
|
};
|
|
165
170
|
const files = async ({
|
|
@@ -180,7 +185,10 @@ const files = async ({
|
|
|
180
185
|
templateVars: { docs, name, crud, ...sdlData },
|
|
181
186
|
outputPath: `${camelcase(pluralize(name))}.sdl.${extension}`
|
|
182
187
|
});
|
|
183
|
-
const
|
|
188
|
+
const sdl = withAuthDirective(content, {
|
|
189
|
+
belongsToOrg: sdlData.belongsToOrg
|
|
190
|
+
});
|
|
191
|
+
const template = typescript ? sdl : await transformTSToJS(outputPath, sdl);
|
|
184
192
|
return {
|
|
185
193
|
[outputPath]: template,
|
|
186
194
|
...await serviceFiles({
|
|
@@ -7,6 +7,7 @@ import { addApiPackages } from "@cedarjs/cli-helpers/installHelpers";
|
|
|
7
7
|
import { runBinSync } from "@cedarjs/cli-helpers/packageManager/exec";
|
|
8
8
|
import { generate as generateTypes } from "@cedarjs/internal/dist/generate/generate";
|
|
9
9
|
import { errorTelemetry } from "@cedarjs/telemetry";
|
|
10
|
+
import { withAuthDirective } from "../../../lib/authDirective.js";
|
|
10
11
|
import { getPaths, transformTSToJS, writeFile } from "../../../lib/index.js";
|
|
11
12
|
import { isTypeScriptProject, serverFileExists } from "../../../lib/project.js";
|
|
12
13
|
import { setupServerFileTasks } from "../server-file/serverFileHandler.js";
|
|
@@ -97,9 +98,13 @@ async function handler(args) {
|
|
|
97
98
|
exampleSubscriptionTemplateContent
|
|
98
99
|
);
|
|
99
100
|
return [
|
|
100
|
-
writeFile(
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
writeFile(
|
|
102
|
+
exampleFile,
|
|
103
|
+
withAuthDirective(setupScriptContent, { belongsToOrg: false }),
|
|
104
|
+
{
|
|
105
|
+
overwriteExisting: force
|
|
106
|
+
}
|
|
107
|
+
)
|
|
103
108
|
];
|
|
104
109
|
}
|
|
105
110
|
},
|
|
@@ -158,15 +163,23 @@ async function handler(args) {
|
|
|
158
163
|
exampleSubscriptionTemplateContent
|
|
159
164
|
);
|
|
160
165
|
return [
|
|
161
|
-
writeFile(
|
|
162
|
-
|
|
163
|
-
|
|
166
|
+
writeFile(
|
|
167
|
+
sdlFile,
|
|
168
|
+
withAuthDirective(sdlContent, { belongsToOrg: false }),
|
|
169
|
+
{
|
|
170
|
+
overwriteExisting: force
|
|
171
|
+
}
|
|
172
|
+
),
|
|
164
173
|
writeFile(serviceFile, serviceContent, {
|
|
165
174
|
overwriteExisting: force
|
|
166
175
|
}),
|
|
167
|
-
writeFile(
|
|
168
|
-
|
|
169
|
-
|
|
176
|
+
writeFile(
|
|
177
|
+
exampleFile,
|
|
178
|
+
withAuthDirective(setupScriptContent, { belongsToOrg: false }),
|
|
179
|
+
{
|
|
180
|
+
overwriteExisting: force
|
|
181
|
+
}
|
|
182
|
+
)
|
|
170
183
|
];
|
|
171
184
|
}
|
|
172
185
|
},
|
|
@@ -206,9 +219,13 @@ async function handler(args) {
|
|
|
206
219
|
);
|
|
207
220
|
const serviceContent = ts ? exampleServiceTemplateContent : await transformTSToJS(serviceFile, exampleServiceTemplateContent);
|
|
208
221
|
return [
|
|
209
|
-
writeFile(
|
|
210
|
-
|
|
211
|
-
|
|
222
|
+
writeFile(
|
|
223
|
+
sdlFile,
|
|
224
|
+
withAuthDirective(sdlContent, { belongsToOrg: false }),
|
|
225
|
+
{
|
|
226
|
+
overwriteExisting: force
|
|
227
|
+
}
|
|
228
|
+
),
|
|
212
229
|
writeFile(serviceFile, serviceContent, {
|
|
213
230
|
overwriteExisting: force
|
|
214
231
|
})
|
|
@@ -251,9 +268,13 @@ async function handler(args) {
|
|
|
251
268
|
);
|
|
252
269
|
const serviceContent = ts ? exampleServiceTemplateContent : await transformTSToJS(serviceFile, exampleServiceTemplateContent);
|
|
253
270
|
return [
|
|
254
|
-
writeFile(
|
|
255
|
-
|
|
256
|
-
|
|
271
|
+
writeFile(
|
|
272
|
+
sdlFile,
|
|
273
|
+
withAuthDirective(sdlContent, { belongsToOrg: false }),
|
|
274
|
+
{
|
|
275
|
+
overwriteExisting: force
|
|
276
|
+
}
|
|
277
|
+
),
|
|
257
278
|
writeFile(serviceFile, serviceContent, {
|
|
258
279
|
overwriteExisting: force
|
|
259
280
|
})
|
|
@@ -296,9 +317,13 @@ async function handler(args) {
|
|
|
296
317
|
);
|
|
297
318
|
const serviceContent = ts ? exampleServiceTemplateContent : await transformTSToJS(serviceFile, exampleServiceTemplateContent);
|
|
298
319
|
return [
|
|
299
|
-
writeFile(
|
|
300
|
-
|
|
301
|
-
|
|
320
|
+
writeFile(
|
|
321
|
+
sdlFile,
|
|
322
|
+
withAuthDirective(sdlContent, { belongsToOrg: false }),
|
|
323
|
+
{
|
|
324
|
+
overwriteExisting: force
|
|
325
|
+
}
|
|
326
|
+
),
|
|
302
327
|
writeFile(serviceFile, serviceContent, {
|
|
303
328
|
overwriteExisting: force
|
|
304
329
|
})
|
|
@@ -18,6 +18,59 @@ function buildMembershipsSelectProperty() {
|
|
|
18
18
|
}
|
|
19
19
|
return property;
|
|
20
20
|
}
|
|
21
|
+
const TENANCY_AUTH_EXPORT = `export const { hasUserRole, hasOrgRole, requireUserAuth, requireOrgAuth } =
|
|
22
|
+
createTenancyAuth()
|
|
23
|
+
`;
|
|
24
|
+
const DEFAULT_REQUIRE_AUTH_STATEMENTS = [
|
|
25
|
+
/^if \(!isAuthenticated\(\)\) \{ throw new AuthenticationError\([^)]*\);? \}$/,
|
|
26
|
+
/^if \(roles && !hasRole\(roles\)\) \{ throw new ForbiddenError\([^)]*\);? \}$/,
|
|
27
|
+
/^return isAuthenticated\(\);?$/
|
|
28
|
+
];
|
|
29
|
+
const DEFAULT_HAS_ROLE_NAMES = /* @__PURE__ */ new Set([
|
|
30
|
+
"AllowedRoles",
|
|
31
|
+
"Array",
|
|
32
|
+
"allowedRole",
|
|
33
|
+
"allowedRoles",
|
|
34
|
+
"context",
|
|
35
|
+
"currentUser",
|
|
36
|
+
"currentUserRoles",
|
|
37
|
+
"hasRole",
|
|
38
|
+
"includes",
|
|
39
|
+
"isArray",
|
|
40
|
+
"isAuthenticated",
|
|
41
|
+
"role",
|
|
42
|
+
"roles",
|
|
43
|
+
"some",
|
|
44
|
+
"undefined"
|
|
45
|
+
]);
|
|
46
|
+
function exportedValue(declaration) {
|
|
47
|
+
const node = declaration.nodes()[0];
|
|
48
|
+
return j.VariableDeclaration.check(node?.declaration) ? node.declaration.declarations[0] : void 0;
|
|
49
|
+
}
|
|
50
|
+
function isDefaultHasRole(hasRole) {
|
|
51
|
+
return hasRole.find(j.Identifier).nodes().every((identifier) => DEFAULT_HAS_ROLE_NAMES.has(identifier.name));
|
|
52
|
+
}
|
|
53
|
+
function isDefaultRequireAuth(requireAuth) {
|
|
54
|
+
const declarator = exportedValue(requireAuth);
|
|
55
|
+
const fn = j.VariableDeclarator.check(declarator) ? declarator.init : null;
|
|
56
|
+
if (!j.ArrowFunctionExpression.check(fn) || !j.BlockStatement.check(fn.body)) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
return fn.body.body.every((statement) => {
|
|
60
|
+
const source = j(statement).toSource().replace(/\/\/[^\n]*|\/\*[\s\S]*?\*\//g, "").replace(/\s+/g, " ").trim();
|
|
61
|
+
return DEFAULT_REQUIRE_AUTH_STATEMENTS.some(
|
|
62
|
+
(pattern) => pattern.test(source)
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
function findExportedConst(root, name) {
|
|
67
|
+
return root.find(j.ExportNamedDeclaration, {
|
|
68
|
+
declaration: {
|
|
69
|
+
type: "VariableDeclaration",
|
|
70
|
+
declarations: [{ id: { type: "Identifier", name } }]
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
|
21
74
|
function transform(fileInfo) {
|
|
22
75
|
const root = j.withParser("tsx")(fileInfo.source);
|
|
23
76
|
let selectFound = false;
|
|
@@ -55,29 +108,52 @@ function transform(fileInfo) {
|
|
|
55
108
|
if (!selectFound) {
|
|
56
109
|
throw new Error("CEDAR_CODEMOD_ERR_AUTH_SHAPE_NOT_FOUND");
|
|
57
110
|
}
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
111
|
+
const alreadyWired = root.find(j.ImportSpecifier, { imported: { name: "createTenancyAuth" } }).length > 0;
|
|
112
|
+
if (alreadyWired) {
|
|
113
|
+
return changed ? root.toSource() : fileInfo.source;
|
|
114
|
+
}
|
|
115
|
+
const hasRole = findExportedConst(root, "hasRole");
|
|
116
|
+
const requireAuth = findExportedConst(root, "requireAuth");
|
|
117
|
+
if (hasRole.length !== 1 || requireAuth.length !== 1) {
|
|
118
|
+
throw new Error("CEDAR_CODEMOD_ERR_AUTH_CHECKS_NOT_FOUND");
|
|
119
|
+
}
|
|
120
|
+
if (!isDefaultRequireAuth(requireAuth) || !isDefaultHasRole(hasRole)) {
|
|
121
|
+
throw new Error("CEDAR_CODEMOD_ERR_AUTH_CHECKS_CHANGED");
|
|
122
|
+
}
|
|
123
|
+
const tenancyAuth = j.withParser("ts")(TENANCY_AUTH_EXPORT).find(j.ExportNamedDeclaration).nodes()[0];
|
|
124
|
+
requireAuth.replaceWith(tenancyAuth);
|
|
125
|
+
hasRole.remove();
|
|
126
|
+
root.find(j.Identifier, { name: "hasRole" }).filter(
|
|
127
|
+
(path) => !(j.MemberExpression.check(path.parent.node) && path.parent.node.property === path.node)
|
|
128
|
+
).forEach((path) => {
|
|
129
|
+
path.node.name = "hasUserRole";
|
|
65
130
|
});
|
|
66
|
-
const
|
|
67
|
-
(
|
|
131
|
+
const isUnused = (name) => root.find(j.Identifier, { name }).filter(
|
|
132
|
+
(path) => !j.ImportSpecifier.check(path.parent.node) && !j.TSTypeAliasDeclaration.check(path.parent.node)
|
|
133
|
+
).length === 0;
|
|
134
|
+
root.find(j.TSTypeAliasDeclaration, { id: { name: "AllowedRoles" } }).filter(() => isUnused("AllowedRoles")).remove();
|
|
135
|
+
root.find(j.ImportDeclaration, { source: { value: "@cedarjs/graphql-server" } }).forEach((path) => {
|
|
136
|
+
path.node.specifiers = (path.node.specifiers ?? []).filter(
|
|
137
|
+
(specifier) => !j.ImportSpecifier.check(specifier) || !j.Identifier.check(specifier.local) || !isUnused(specifier.local.name)
|
|
138
|
+
);
|
|
139
|
+
}).filter((path) => (path.node.specifiers ?? []).length === 0).remove();
|
|
140
|
+
const tenancyImport = j.withParser("ts")(`import { createTenancyAuth } from '@cedarjs/tenancy'
|
|
141
|
+
`).find(j.ImportDeclaration).nodes()[0];
|
|
142
|
+
const imports = root.find(j.ImportDeclaration);
|
|
143
|
+
const cedarImports = imports.filter(
|
|
144
|
+
(path) => String(path.node.source.value).startsWith("@cedarjs/")
|
|
68
145
|
);
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
).find(j.ExportNamedDeclaration).nodes()[0];
|
|
146
|
+
const anchor = cedarImports.length > 0 ? cedarImports : imports;
|
|
147
|
+
if (anchor.length > 0) {
|
|
148
|
+
anchor.at(anchor.length - 1).insertAfter(tenancyImport);
|
|
149
|
+
} else {
|
|
74
150
|
root.find(j.Program).forEach((path) => {
|
|
75
|
-
path.node.body.
|
|
151
|
+
path.node.body.unshift(tenancyImport);
|
|
76
152
|
});
|
|
77
|
-
changed = true;
|
|
78
153
|
}
|
|
79
|
-
return
|
|
154
|
+
return root.toSource();
|
|
80
155
|
}
|
|
81
156
|
export {
|
|
157
|
+
TENANCY_AUTH_EXPORT,
|
|
82
158
|
transform as default
|
|
83
159
|
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
function renameRequireAuthDirective(source) {
|
|
2
|
+
return source.replace(/@requireAuth(?![\w$])/g, "@requireUserAuth");
|
|
3
|
+
}
|
|
4
|
+
const LIB_AUTH_IMPORT = /import\s*\{([^}]*)\}\s*from\s*(['"])((?:src|\.{1,2})(?:\/[^'"]*)?\/lib\/auth(?:\.[jt]s)?)\2/g;
|
|
5
|
+
const LIB_AUTH_RENAMES = {
|
|
6
|
+
requireAuth: "requireUserAuth",
|
|
7
|
+
hasRole: "hasUserRole"
|
|
8
|
+
};
|
|
9
|
+
function renameLibAuthImports(source) {
|
|
10
|
+
let result = source;
|
|
11
|
+
for (const [from, to] of Object.entries(LIB_AUTH_RENAMES)) {
|
|
12
|
+
let renameUses = false;
|
|
13
|
+
result = result.replace(
|
|
14
|
+
LIB_AUTH_IMPORT,
|
|
15
|
+
(statement, specifiers) => {
|
|
16
|
+
const renamed = specifiers.replace(
|
|
17
|
+
new RegExp(`(?<![\\w$])${from}(?![\\w$])(\\s+as\\s+[\\w$]+)?`, "g"),
|
|
18
|
+
(_specifier, alias) => {
|
|
19
|
+
if (!alias) {
|
|
20
|
+
renameUses = true;
|
|
21
|
+
}
|
|
22
|
+
return `${to}${alias ?? ""}`;
|
|
23
|
+
}
|
|
24
|
+
);
|
|
25
|
+
return statement.replace(specifiers, renamed);
|
|
26
|
+
}
|
|
27
|
+
);
|
|
28
|
+
if (renameUses) {
|
|
29
|
+
result = result.replace(
|
|
30
|
+
new RegExp(`(?<![\\w$@.])${from}(?![\\w$])`, "g"),
|
|
31
|
+
to
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
const DEFAULT_REQUIRE_AUTH_DIRECTIVES = [
|
|
38
|
+
`import { gql } from 'graphql-tag'
|
|
39
|
+
|
|
40
|
+
import type { ValidatorDirectiveFunc } from '@cedarjs/graphql-server'
|
|
41
|
+
import { createValidatorDirective } from '@cedarjs/graphql-server'
|
|
42
|
+
|
|
43
|
+
import { requireAuth as applicationRequireAuth } from 'src/lib/auth'
|
|
44
|
+
|
|
45
|
+
export const schema = gql\`
|
|
46
|
+
"""
|
|
47
|
+
Use to check whether or not a user is authenticated and is associated
|
|
48
|
+
with an optional set of roles.
|
|
49
|
+
"""
|
|
50
|
+
directive @requireAuth(roles: [String]) on FIELD_DEFINITION
|
|
51
|
+
\`
|
|
52
|
+
|
|
53
|
+
type RequireAuthValidate = ValidatorDirectiveFunc<{ roles?: string[] }>
|
|
54
|
+
|
|
55
|
+
const validate: RequireAuthValidate = ({ directiveArgs }) => {
|
|
56
|
+
const { roles } = directiveArgs
|
|
57
|
+
applicationRequireAuth({ roles })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const requireAuth = createValidatorDirective(schema, validate)
|
|
61
|
+
|
|
62
|
+
export default requireAuth
|
|
63
|
+
`,
|
|
64
|
+
`import { gql } from 'graphql-tag'
|
|
65
|
+
|
|
66
|
+
import { createValidatorDirective } from '@cedarjs/graphql-server'
|
|
67
|
+
|
|
68
|
+
import { requireAuth as applicationRequireAuth } from 'src/lib/auth'
|
|
69
|
+
|
|
70
|
+
export const schema = gql\`
|
|
71
|
+
"""
|
|
72
|
+
Use to check whether or not a user is authenticated and is associated
|
|
73
|
+
with an optional set of roles.
|
|
74
|
+
"""
|
|
75
|
+
directive @requireAuth(roles: [String]) on FIELD_DEFINITION
|
|
76
|
+
\`
|
|
77
|
+
|
|
78
|
+
const validate = ({ directiveArgs }) => {
|
|
79
|
+
const { roles } = directiveArgs
|
|
80
|
+
applicationRequireAuth({ roles })
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const requireAuth = createValidatorDirective(schema, validate)
|
|
84
|
+
|
|
85
|
+
export default requireAuth
|
|
86
|
+
`
|
|
87
|
+
];
|
|
88
|
+
function withoutFormatting(source) {
|
|
89
|
+
return source.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, "").replace(/["']/g, "'").replace(/[;\s]/g, "").replace(/,([)\]}>])/g, "$1");
|
|
90
|
+
}
|
|
91
|
+
function isDefaultRequireAuthDirective(source) {
|
|
92
|
+
return DEFAULT_REQUIRE_AUTH_DIRECTIVES.some(
|
|
93
|
+
(directive) => withoutFormatting(directive) === withoutFormatting(source)
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
export {
|
|
97
|
+
DEFAULT_REQUIRE_AUTH_DIRECTIVES,
|
|
98
|
+
isDefaultRequireAuthDirective,
|
|
99
|
+
renameLibAuthImports,
|
|
100
|
+
renameRequireAuthDirective
|
|
101
|
+
};
|
|
@@ -7,6 +7,13 @@ import { useAuth } from 'src/auth'
|
|
|
7
7
|
export interface OrgScopeProps {
|
|
8
8
|
/** Overrides the `orgSlug` route param. */
|
|
9
9
|
orgSlug?: string
|
|
10
|
+
/** Only members with one of these roles in the organization get in. */
|
|
11
|
+
roles?: string | string[]
|
|
12
|
+
/**
|
|
13
|
+
* The name of the route a member without one of `roles` is sent to. It
|
|
14
|
+
* gets the `orgSlug` param when its path has one.
|
|
15
|
+
*/
|
|
16
|
+
unauthorized?: string
|
|
10
17
|
children: ReactNode
|
|
11
18
|
}
|
|
12
19
|
|
|
@@ -19,15 +26,37 @@ export interface OrgScopeProps {
|
|
|
19
26
|
* <Route path="/org/{orgSlug}/projects" page={ProjectsPage} name="projects" />
|
|
20
27
|
* </Set>
|
|
21
28
|
*
|
|
29
|
+
* `useCurrentOrg().hasOrgRole` checks the user's role in the organization,
|
|
30
|
+
* and `useAuth().hasUserRole` checks `currentUser.roles`. For pages only some
|
|
31
|
+
* roles may open, wrap them in a component that passes `roles` (there's no
|
|
32
|
+
* `<PrivateSet roles>` with tenancy):
|
|
33
|
+
*
|
|
34
|
+
* const OwnersOnly = ({ children }: { children: ReactNode }) => (
|
|
35
|
+
* <OrgScope roles="owner" unauthorized="projects">
|
|
36
|
+
* {children}
|
|
37
|
+
* </OrgScope>
|
|
38
|
+
* )
|
|
39
|
+
*
|
|
40
|
+
* <Set wrap={OwnersOnly}>
|
|
41
|
+
* <Route path="/org/{orgSlug}/settings" page={SettingsPage} name="settings" />
|
|
42
|
+
* </Set>
|
|
43
|
+
*
|
|
22
44
|
* Routes under `OrgScope` must not carry `prerender`: `OrgScope`'s nested
|
|
23
45
|
* Apollo client shadows the per-route client the prerenderer injects above
|
|
24
46
|
* `App`, so a prerendered query under here would not go through it.
|
|
25
47
|
*/
|
|
26
|
-
export const OrgScope = ({
|
|
48
|
+
export const OrgScope = ({
|
|
49
|
+
orgSlug,
|
|
50
|
+
roles,
|
|
51
|
+
unauthorized,
|
|
52
|
+
children,
|
|
53
|
+
}: OrgScopeProps) => {
|
|
27
54
|
return (
|
|
28
55
|
<TenancyOrgScope
|
|
29
56
|
useAuth={useAuth}
|
|
30
57
|
orgSlug={orgSlug}
|
|
58
|
+
roles={roles}
|
|
59
|
+
unauthorized={unauthorized}
|
|
31
60
|
notAMember={
|
|
32
61
|
<div>
|
|
33
62
|
<p>You don't have access to this organization.</p>
|
|
@@ -43,16 +43,16 @@ export const schema = gql`
|
|
|
43
43
|
|
|
44
44
|
type Query {
|
|
45
45
|
"Every membership belonging to the signed-in user, across all organizations they belong to."
|
|
46
|
-
myMemberships: [Membership!]! @
|
|
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 @requireOrgAuth
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
type Mutation {
|
|
53
53
|
"Creates a new organization with the caller as its owner."
|
|
54
54
|
createOrganization(input: CreateOrganizationInput!): Organization!
|
|
55
|
-
@
|
|
55
|
+
@requireUserAuth
|
|
56
56
|
|
|
57
57
|
"""
|
|
58
58
|
Creates a pending membership (an invitation) in the current organization,
|
|
@@ -64,7 +64,7 @@ export const schema = gql`
|
|
|
64
64
|
the invite shows up if the inviter is also the invitee.
|
|
65
65
|
"""
|
|
66
66
|
inviteMember(input: InviteMemberInput!): Invitation!
|
|
67
|
-
@
|
|
67
|
+
@requireOrgAuth(roles: ["owner", "admin"])
|
|
68
68
|
|
|
69
69
|
"""
|
|
70
70
|
Attaches the pending membership matching token to the signed-in user, or
|
|
@@ -73,7 +73,8 @@ export const schema = gql`
|
|
|
73
73
|
Web callers must call reauthenticate() from useAuth() in onCompleted so
|
|
74
74
|
the new membership is reflected in currentUser.memberships.
|
|
75
75
|
"""
|
|
76
|
-
acceptInvitation(token: String!): Organization!
|
|
76
|
+
acceptInvitation(token: String!): Organization!
|
|
77
|
+
@requireUserAuth
|
|
77
78
|
|
|
78
79
|
"""
|
|
79
80
|
Removes a membership from the current organization, for a role the
|
|
@@ -84,6 +85,6 @@ export const schema = gql`
|
|
|
84
85
|
removing their own membership.
|
|
85
86
|
"""
|
|
86
87
|
removeMember(membershipId: String!): Membership!
|
|
87
|
-
@
|
|
88
|
+
@requireOrgAuth(roles: ["owner", "admin"])
|
|
88
89
|
}
|
|
89
90
|
`
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { setContext } from '@cedarjs/context'
|
|
2
|
-
|
|
3
1
|
import { db } from 'src/lib/db'
|
|
4
2
|
|
|
5
3
|
import {
|
|
@@ -18,39 +16,37 @@ import type { StandardScenario } from './organizations.scenarios.js'
|
|
|
18
16
|
// CedarJS Testing Docs:
|
|
19
17
|
// https://cedarjs.com/docs/testing#testing-services
|
|
20
18
|
|
|
21
|
-
/**
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
19
|
+
/** The id of the user a scenario membership belongs to */
|
|
20
|
+
const userIdOf = (membership: { userId: string | null }) => {
|
|
21
|
+
if (!membership.userId) {
|
|
22
|
+
throw new Error('Expected the scenario membership to have a user')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return membership.userId
|
|
26
|
+
}
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
|
-
*
|
|
30
|
-
* `context` function does for an
|
|
29
|
+
* Signs in as `membership`'s user, in its organization, the way the GraphQL
|
|
30
|
+
* `context` function does for an in-organization request
|
|
31
31
|
*/
|
|
32
32
|
const asMember = (
|
|
33
33
|
scenario: StandardScenario,
|
|
34
34
|
membership: { id: string; userId: string | null; role: string },
|
|
35
35
|
) => {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
},
|
|
44
|
-
})
|
|
36
|
+
mockCurrentUser({ id: userIdOf(membership) })
|
|
37
|
+
context.currentOrg = {
|
|
38
|
+
id: scenario.organization.acme.id,
|
|
39
|
+
slug: scenario.organization.acme.slug,
|
|
40
|
+
role: membership.role,
|
|
41
|
+
membershipId: membership.id,
|
|
42
|
+
}
|
|
45
43
|
}
|
|
46
44
|
|
|
47
45
|
describe('organizations service', () => {
|
|
48
46
|
scenario(
|
|
49
47
|
"myMemberships returns the signed-in user's memberships",
|
|
50
48
|
async (scenario: StandardScenario) => {
|
|
51
|
-
|
|
52
|
-
currentUser: asCurrentUser({ id: scenario.membership.owner.userId! }),
|
|
53
|
-
})
|
|
49
|
+
mockCurrentUser({ id: userIdOf(scenario.membership.owner) })
|
|
54
50
|
|
|
55
51
|
const result = await myMemberships()
|
|
56
52
|
|
|
@@ -235,7 +231,7 @@ describe('organizations service', () => {
|
|
|
235
231
|
},
|
|
236
232
|
})
|
|
237
233
|
|
|
238
|
-
|
|
234
|
+
mockCurrentUser({ id: invitee.id })
|
|
239
235
|
|
|
240
236
|
const result = await acceptInvitation({
|
|
241
237
|
token: scenario.membership.pending.invitationToken!,
|
|
@@ -256,7 +252,7 @@ describe('organizations service', () => {
|
|
|
256
252
|
},
|
|
257
253
|
})
|
|
258
254
|
|
|
259
|
-
|
|
255
|
+
mockCurrentUser({ id: invitee.id })
|
|
260
256
|
|
|
261
257
|
const token = scenario.membership.pending.invitationToken!
|
|
262
258
|
|
|
@@ -287,10 +283,10 @@ describe('organizations service', () => {
|
|
|
287
283
|
|
|
288
284
|
const token = scenario.membership.pending.invitationToken!
|
|
289
285
|
|
|
290
|
-
|
|
286
|
+
mockCurrentUser({ id: firstUser.id })
|
|
291
287
|
await acceptInvitation({ token })
|
|
292
288
|
|
|
293
|
-
|
|
289
|
+
mockCurrentUser({ id: secondUser.id })
|
|
294
290
|
await expect(acceptInvitation({ token })).rejects.toThrow()
|
|
295
291
|
},
|
|
296
292
|
)
|
|
@@ -308,7 +304,7 @@ describe('organizations service', () => {
|
|
|
308
304
|
|
|
309
305
|
const token = scenario.membership.pending.invitationToken!
|
|
310
306
|
|
|
311
|
-
|
|
307
|
+
mockCurrentUser({ id: invitee.id })
|
|
312
308
|
await acceptInvitation({ token })
|
|
313
309
|
|
|
314
310
|
const claimed = await db.membership.findUnique({
|
|
@@ -321,7 +317,7 @@ describe('organizations service', () => {
|
|
|
321
317
|
)
|
|
322
318
|
|
|
323
319
|
it('rejects a token that never matched a pending invitation', async () => {
|
|
324
|
-
|
|
320
|
+
mockCurrentUser({ id: 'some-user-id' })
|
|
325
321
|
|
|
326
322
|
await expect(
|
|
327
323
|
acceptInvitation({ token: 'not-a-real-token' }),
|
|
@@ -62,7 +62,7 @@ const canGrantRole = (granterRole: string, targetRole: string): boolean =>
|
|
|
62
62
|
* organizations. `Membership` is a global model, so this reads plain `db`.
|
|
63
63
|
*/
|
|
64
64
|
export const myMemberships: QueryResolvers['myMemberships'] = () => {
|
|
65
|
-
// @
|
|
65
|
+
// @requireUserAuth guarantees a signed-in user before this resolver runs.
|
|
66
66
|
return db.membership.findMany({
|
|
67
67
|
where: { userId: context.currentUser!.id },
|
|
68
68
|
orderBy: { createdAt: 'asc' },
|
|
@@ -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
|
-
*
|
|
75
|
+
* but it must match the current organization: `@requireOrgAuth` checks the
|
|
76
|
+
* 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()
|
|
@@ -89,7 +89,7 @@ export const organization: QueryResolvers['organization'] = async ({ id }) => {
|
|
|
89
89
|
export const createOrganization: MutationResolvers['createOrganization'] =
|
|
90
90
|
async ({ input }) => {
|
|
91
91
|
const baseSlug = slugify(input.name)
|
|
92
|
-
// @
|
|
92
|
+
// @requireUserAuth guarantees a signed-in user before this resolver runs.
|
|
93
93
|
const userId = context.currentUser!.id
|
|
94
94
|
|
|
95
95
|
let slug = baseSlug
|
|
@@ -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 `@requireOrgAuth(roles: ["owner", "admin"])` 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.
|
|
@@ -232,7 +232,7 @@ const claimInvitation = async (
|
|
|
232
232
|
export const acceptInvitation: MutationResolvers['acceptInvitation'] = async ({
|
|
233
233
|
token,
|
|
234
234
|
}) => {
|
|
235
|
-
// @
|
|
235
|
+
// @requireUserAuth guarantees a signed-in user before this resolver runs.
|
|
236
236
|
const userId = context.currentUser!.id
|
|
237
237
|
|
|
238
238
|
const claim = await claimInvitation(token, userId)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { mockRedwoodDirective, getDirectiveName } from '@cedarjs/testing/api'
|
|
2
|
+
|
|
3
|
+
import requireOrgAuth from './requireOrgAuth.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('requireOrgAuth directive', () => {
|
|
13
|
+
it('declares the directive sdl as schema, with the correct name', () => {
|
|
14
|
+
expect(requireOrgAuth.schema).toBeTruthy()
|
|
15
|
+
expect(getDirectiveName(requireOrgAuth.schema)).toBe('requireOrgAuth')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('throws when there is no current user', () => {
|
|
19
|
+
const mockExecution = mockRedwoodDirective(requireOrgAuth, { context: {} })
|
|
20
|
+
|
|
21
|
+
expect(mockExecution).toThrowError("You don't have permission to do that.")
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('throws when there is no current organization', () => {
|
|
25
|
+
const mockExecution = mockRedwoodDirective(requireOrgAuth, {
|
|
26
|
+
context: { currentUser: { id: 'user-1' } },
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
expect(mockExecution).toThrowError(
|
|
30
|
+
'You must be a member of an organization to do that.',
|
|
31
|
+
)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('does not throw for a member of the current organization', () => {
|
|
35
|
+
const mockExecution = mockRedwoodDirective(requireOrgAuth, {
|
|
36
|
+
context: {
|
|
37
|
+
currentUser: { id: 'user-1' },
|
|
38
|
+
currentOrg: currentOrg('member'),
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
expect(mockExecution).not.toThrowError()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("throws when the role in the current organization isn't listed", () => {
|
|
46
|
+
const mockExecution = mockRedwoodDirective(requireOrgAuth, {
|
|
47
|
+
context: {
|
|
48
|
+
currentUser: { id: 'user-1' },
|
|
49
|
+
currentOrg: currentOrg('member'),
|
|
50
|
+
},
|
|
51
|
+
directiveArgs: { roles: ['owner'] },
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
expect(mockExecution).toThrowError("You don't have access to do that.")
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { gql } from 'graphql-tag'
|
|
2
|
+
|
|
3
|
+
import type { ValidatorDirectiveFunc } from '@cedarjs/graphql-server'
|
|
4
|
+
import { createValidatorDirective } from '@cedarjs/graphql-server'
|
|
5
|
+
|
|
6
|
+
import { requireOrgAuth as applicationRequireOrgAuth } from 'src/lib/auth'
|
|
7
|
+
|
|
8
|
+
export const schema = gql`
|
|
9
|
+
"""
|
|
10
|
+
Use to check that the signed-in user is a member of the current
|
|
11
|
+
organization, with one of an optional set of roles there.
|
|
12
|
+
"""
|
|
13
|
+
directive @requireOrgAuth(roles: [String]) on FIELD_DEFINITION
|
|
14
|
+
`
|
|
15
|
+
|
|
16
|
+
type RequireOrgAuthValidate = ValidatorDirectiveFunc<{ roles?: string[] }>
|
|
17
|
+
|
|
18
|
+
const validate: RequireOrgAuthValidate = ({ directiveArgs }) => {
|
|
19
|
+
const { roles } = directiveArgs
|
|
20
|
+
applicationRequireOrgAuth({ roles })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const requireOrgAuth = createValidatorDirective(schema, validate)
|
|
24
|
+
|
|
25
|
+
export default requireOrgAuth
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { mockRedwoodDirective, getDirectiveName } from '@cedarjs/testing/api'
|
|
2
|
+
|
|
3
|
+
import requireUserAuth from './requireUserAuth.js'
|
|
4
|
+
|
|
5
|
+
describe('requireUserAuth directive', () => {
|
|
6
|
+
it('declares the directive sdl as schema, with the correct name', () => {
|
|
7
|
+
expect(requireUserAuth.schema).toBeTruthy()
|
|
8
|
+
expect(getDirectiveName(requireUserAuth.schema)).toBe('requireUserAuth')
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('throws when there is no current user', () => {
|
|
12
|
+
const mockExecution = mockRedwoodDirective(requireUserAuth, {
|
|
13
|
+
context: {},
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
expect(mockExecution).toThrowError("You don't have permission to do that.")
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('does not throw for a signed-in user, with no organization', () => {
|
|
20
|
+
const mockExecution = mockRedwoodDirective(requireUserAuth, {
|
|
21
|
+
context: { currentUser: { id: 'user-1' } },
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
expect(mockExecution).not.toThrowError()
|
|
25
|
+
})
|
|
26
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { gql } from 'graphql-tag'
|
|
2
|
+
|
|
3
|
+
import type { ValidatorDirectiveFunc } from '@cedarjs/graphql-server'
|
|
4
|
+
import { createValidatorDirective } from '@cedarjs/graphql-server'
|
|
5
|
+
|
|
6
|
+
import { requireUserAuth as applicationRequireUserAuth } from 'src/lib/auth'
|
|
7
|
+
|
|
8
|
+
export const schema = gql`
|
|
9
|
+
"""
|
|
10
|
+
Use to check whether or not a user is authenticated and is associated
|
|
11
|
+
with an optional set of roles, in any organization or none. For fields
|
|
12
|
+
about the user rather than an organization.
|
|
13
|
+
"""
|
|
14
|
+
directive @requireUserAuth(roles: [String]) on FIELD_DEFINITION
|
|
15
|
+
`
|
|
16
|
+
|
|
17
|
+
type RequireUserAuthValidate = ValidatorDirectiveFunc<{ roles?: string[] }>
|
|
18
|
+
|
|
19
|
+
const validate: RequireUserAuthValidate = ({ directiveArgs }) => {
|
|
20
|
+
const { roles } = directiveArgs
|
|
21
|
+
applicationRequireUserAuth({ roles })
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const requireUserAuth = createValidatorDirective(schema, validate)
|
|
25
|
+
|
|
26
|
+
export default requireUserAuth
|
|
@@ -20,6 +20,12 @@ import { errorTelemetry } from "@cedarjs/telemetry";
|
|
|
20
20
|
import { getPaths, transformTSToJS, writeFile } from "../../../lib/index.js";
|
|
21
21
|
import { isTypeScriptProject } from "../../../lib/project.js";
|
|
22
22
|
import { runTransform } from "../../../lib/runTransform.js";
|
|
23
|
+
import { TENANCY_AUTH_EXPORT } from "./authCodemod.js";
|
|
24
|
+
import {
|
|
25
|
+
isDefaultRequireAuthDirective,
|
|
26
|
+
renameRequireAuthDirective,
|
|
27
|
+
renameLibAuthImports
|
|
28
|
+
} from "./authDirectives.js";
|
|
23
29
|
import {
|
|
24
30
|
authNotSetUpMessage,
|
|
25
31
|
hasWebAuthFile,
|
|
@@ -33,8 +39,11 @@ import {
|
|
|
33
39
|
ORGANIZATION_MODEL,
|
|
34
40
|
requiredUserFields
|
|
35
41
|
} from "./schemaPrisma.js";
|
|
42
|
+
import { renameUseAuthHasRole } from "./webHasRole.js";
|
|
36
43
|
const DEFAULT_TENANT_FIELD = "organizationId";
|
|
37
44
|
const AUTH_SNIPPET = `
|
|
45
|
+
import { createTenancyAuth } from '@cedarjs/tenancy'
|
|
46
|
+
|
|
38
47
|
export const getCurrentUser = async (session) => {
|
|
39
48
|
return await db.user.findUnique({
|
|
40
49
|
where: { id: session.id },
|
|
@@ -52,8 +61,8 @@ const AUTH_SNIPPET = `
|
|
|
52
61
|
})
|
|
53
62
|
}
|
|
54
63
|
|
|
55
|
-
|
|
56
|
-
`;
|
|
64
|
+
// In place of your hasRole and requireAuth
|
|
65
|
+
${TENANCY_AUTH_EXPORT}`;
|
|
57
66
|
const SIGNUP_SNIPPET = `
|
|
58
67
|
handler: async ({ username, hashedPassword, salt, userAttributes }) => {
|
|
59
68
|
const user = await db.user.create({
|
|
@@ -202,7 +211,7 @@ Re-run with --force to have this command append its own versions beside them ins
|
|
|
202
211
|
}
|
|
203
212
|
},
|
|
204
213
|
{
|
|
205
|
-
title: "Adding memberships to getCurrentUser and
|
|
214
|
+
title: "Adding memberships to getCurrentUser and the tenancy auth checks to api/src/lib/auth...",
|
|
206
215
|
task: async (_ctx, task) => {
|
|
207
216
|
if (!fs.existsSync(authPath)) {
|
|
208
217
|
task.skip("No api/src/lib/auth file found; skipping.");
|
|
@@ -212,7 +221,10 @@ Re-run with --force to have this command append its own versions beside them ins
|
|
|
212
221
|
transformPath: path.join(import.meta.dirname, "authCodemod.js"),
|
|
213
222
|
targetPaths: [authPath]
|
|
214
223
|
});
|
|
215
|
-
if (transformResult.error) {
|
|
224
|
+
if (transformResult.error?.includes("AUTH_CHECKS_CHANGED")) {
|
|
225
|
+
task.output = `api/src/lib/auth's requireAuth or hasRole has checks of its own, so it was left as it is. Please add this by hand, keeping your checks:
|
|
226
|
+
${AUTH_SNIPPET}`;
|
|
227
|
+
} else if (transformResult.error) {
|
|
216
228
|
task.output = `Could not update api/src/lib/auth automatically. Please add this by hand:
|
|
217
229
|
${AUTH_SNIPPET}`;
|
|
218
230
|
}
|
|
@@ -253,20 +265,54 @@ ${SIGNUP_SNIPPET}`;
|
|
|
253
265
|
skip: () => isDbAuthProject ? false : "Not a dbAuth project; skipping."
|
|
254
266
|
},
|
|
255
267
|
{
|
|
256
|
-
title: "
|
|
257
|
-
task: async () => {
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
"requireMembership.ts.template",
|
|
264
|
-
path.join(directiveDir, `requireMembership.${ext}`)
|
|
268
|
+
title: "Switching @requireAuth to @requireOrgAuth and @requireUserAuth, and hasRole to hasUserRole...",
|
|
269
|
+
task: async (ctx) => {
|
|
270
|
+
const oldDirectiveDir = path.join(paths.api.directives, "requireAuth");
|
|
271
|
+
const isInOldDirective = (file) => file.startsWith(oldDirectiveDir + path.sep);
|
|
272
|
+
const oldDirectivePath = path.join(
|
|
273
|
+
oldDirectiveDir,
|
|
274
|
+
`requireAuth.${ext}`
|
|
265
275
|
);
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
path.join(directiveDir, `requireMembership.test.${ext}`)
|
|
276
|
+
const keepOldDirective = fs.existsSync(oldDirectivePath) && !isDefaultRequireAuthDirective(
|
|
277
|
+
fs.readFileSync(oldDirectivePath, "utf-8")
|
|
269
278
|
);
|
|
279
|
+
const sourceFilesIn = (dir) => fs.readdirSync(dir, { recursive: true, withFileTypes: true }).filter(
|
|
280
|
+
(entry) => entry.isFile() && /\.[jt]sx?$/.test(entry.name)
|
|
281
|
+
).map((entry) => path.join(entry.parentPath, entry.name));
|
|
282
|
+
for (const file of sourceFilesIn(paths.api.src)) {
|
|
283
|
+
const source = fs.readFileSync(file, "utf-8");
|
|
284
|
+
const renamed = renameLibAuthImports(
|
|
285
|
+
keepOldDirective || isInOldDirective(file) ? source : renameRequireAuthDirective(source)
|
|
286
|
+
);
|
|
287
|
+
if (renamed !== source) {
|
|
288
|
+
fs.writeFileSync(file, renamed);
|
|
289
|
+
writtenFiles.push(file);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
for (const file of sourceFilesIn(paths.web.src)) {
|
|
293
|
+
const source = fs.readFileSync(file, "utf-8");
|
|
294
|
+
const renamed = renameUseAuthHasRole(source);
|
|
295
|
+
if (renamed !== source) {
|
|
296
|
+
fs.writeFileSync(file, renamed);
|
|
297
|
+
writtenFiles.push(file);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (keepOldDirective) {
|
|
301
|
+
ctx.keptRequireAuthDirective = true;
|
|
302
|
+
} else if (fs.existsSync(oldDirectivePath)) {
|
|
303
|
+
fs.rmSync(oldDirectiveDir, { recursive: true, force: true });
|
|
304
|
+
}
|
|
305
|
+
for (const name of ["requireOrgAuth", "requireUserAuth"]) {
|
|
306
|
+
const directiveDir = path.join(paths.api.directives, name);
|
|
307
|
+
await writeTemplateFile(
|
|
308
|
+
`${name}.ts.template`,
|
|
309
|
+
path.join(directiveDir, `${name}.${ext}`)
|
|
310
|
+
);
|
|
311
|
+
await writeTemplateFile(
|
|
312
|
+
`${name}.test.ts.template`,
|
|
313
|
+
path.join(directiveDir, `${name}.test.${ext}`)
|
|
314
|
+
);
|
|
315
|
+
}
|
|
270
316
|
}
|
|
271
317
|
},
|
|
272
318
|
{
|
|
@@ -424,7 +470,8 @@ ${SIGNUP_SNIPPET}`;
|
|
|
424
470
|
User/Organization/Membership. For each existing model (e.g.
|
|
425
471
|
Contact) whose rows belong to one organization, add
|
|
426
472
|
${c.highlight("organizationId String")} plus ${c.highlight("@@index([organizationId])")} to it in
|
|
427
|
-
schema.prisma
|
|
473
|
+
schema.prisma, and switch its fields from ${c.highlight("@requireUserAuth")}
|
|
474
|
+
to ${c.highlight("@requireOrgAuth")}. For each one that stays global, add it
|
|
428
475
|
to the ${c.highlight("allExcept")} list in api/src/lib/db instead.
|
|
429
476
|
3. Wrap your organization routes with the generated OrgScope in
|
|
430
477
|
web/src/Routes.tsx:
|
|
@@ -441,6 +488,20 @@ ${SIGNUP_SNIPPET}`;
|
|
|
441
488
|
tenant-owned models with ${c.highlight("withTenancy")} from ${c.highlight(
|
|
442
489
|
"@cedarjs/tenancy"
|
|
443
490
|
)}.
|
|
491
|
+
5. ${c.highlight("@requireAuth")} is gone. Your fields use ${c.highlight("@requireUserAuth")} now,
|
|
492
|
+
which checks what ${c.highlight("@requireAuth")} did: there were no organizations
|
|
493
|
+
before. ${c.highlight("@requireOrgAuth")} needs a current organization, and checks
|
|
494
|
+
the user's role in it.${ctx.keptRequireAuthDirective ? `
|
|
495
|
+
api/src/directives/requireAuth was changed from Cedar's, so
|
|
496
|
+
it and your @requireAuth fields were left as they are: move
|
|
497
|
+
its checks to requireUserAuth or requireOrgAuth, switch the
|
|
498
|
+
fields over, then delete it.` : ""}
|
|
499
|
+
6. Every role check names its kind now. ${c.highlight("hasRole")} is
|
|
500
|
+
${c.highlight("hasUserRole")} on both sides (your existing uses were renamed),
|
|
501
|
+
checking ${c.highlight("currentUser.roles")}; ${c.highlight("hasOrgRole")} checks the role in
|
|
502
|
+
the organization. ${c.highlight("<PrivateSet roles>")} isn't available with
|
|
503
|
+
tenancy: guard routes by role with ${c.highlight("OrgScope")}'s ${c.highlight("roles")} and
|
|
504
|
+
${c.highlight("unauthorized")}, from a small wrapper component.
|
|
444
505
|
|
|
445
506
|
${keptFiles.length > 0 ? `
|
|
446
507
|
Kept these existing files rather than overwriting them (run with
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const HAS_ROLE = /(?<![\w$])hasRole(?![\w$])/;
|
|
2
|
+
const USE_AUTH_DESTRUCTURING = /\{([^{}]*)\}(\s*=\s*useAuth\(\))/g;
|
|
3
|
+
const USE_AUTH_VARIABLE = /(?:const|let|var)\s+([\w$]+)\s*=\s*useAuth\(\)/g;
|
|
4
|
+
const HAS_ROLE_ON_VARIABLE = /(?<![\w$.])([\w$]+)(\s*\??\.\s*)hasRole(?![\w$])/g;
|
|
5
|
+
function renameUseAuthHasRole(source) {
|
|
6
|
+
if (!source.includes("useAuth()") || !HAS_ROLE.test(source)) {
|
|
7
|
+
return source;
|
|
8
|
+
}
|
|
9
|
+
let renameUses = false;
|
|
10
|
+
let result = source.replace(
|
|
11
|
+
/(useAuth\(\)\s*\??\.\s*)hasRole(?![\w$])/g,
|
|
12
|
+
"$1hasUserRole"
|
|
13
|
+
);
|
|
14
|
+
result = result.replace(
|
|
15
|
+
USE_AUTH_DESTRUCTURING,
|
|
16
|
+
(_statement, specifiers, rest) => {
|
|
17
|
+
const renamed = specifiers.replace(
|
|
18
|
+
/(?<![\w$])hasRole(?![\w$])(\s*:)?/g,
|
|
19
|
+
(_specifier, alias) => {
|
|
20
|
+
if (!alias) {
|
|
21
|
+
renameUses = true;
|
|
22
|
+
}
|
|
23
|
+
return `hasUserRole${alias ?? ""}`;
|
|
24
|
+
}
|
|
25
|
+
);
|
|
26
|
+
return `{${renamed}}${rest}`;
|
|
27
|
+
}
|
|
28
|
+
);
|
|
29
|
+
if (renameUses) {
|
|
30
|
+
result = result.replace(/(?<![\w$.])hasRole(?![\w$])/g, "hasUserRole");
|
|
31
|
+
}
|
|
32
|
+
const authVariables = new Set(
|
|
33
|
+
Array.from(source.matchAll(USE_AUTH_VARIABLE), ([, name]) => name)
|
|
34
|
+
);
|
|
35
|
+
if (authVariables.size === 0) {
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
return result.replace(
|
|
39
|
+
HAS_ROLE_ON_VARIABLE,
|
|
40
|
+
(match, name, access) => authVariables.has(name) ? `${name}${access}hasUserRole` : match
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
export {
|
|
44
|
+
renameUseAuthHasRole
|
|
45
|
+
};
|
|
@@ -14,6 +14,7 @@ import { formatCedarCommand } from "@cedarjs/cli-helpers/packageManager/display"
|
|
|
14
14
|
import { addEnvVar } from "@cedarjs/cli-helpers/project";
|
|
15
15
|
import { getSchemaPath } from "@cedarjs/project-config";
|
|
16
16
|
import { errorTelemetry } from "@cedarjs/telemetry";
|
|
17
|
+
import { withAuthDirective } from "../../../lib/authDirective.js";
|
|
17
18
|
import { getPaths, transformTSToJS, writeFile } from "../../../lib/index.js";
|
|
18
19
|
import { isTypeScriptProject } from "../../../lib/project.js";
|
|
19
20
|
import { generateSecret } from "../../generate/secret/secret.js";
|
|
@@ -112,6 +113,9 @@ const handler = async ({ targets, force }) => {
|
|
|
112
113
|
for (const [token, value] of Object.entries(replacements)) {
|
|
113
114
|
templateContent = templateContent.replaceAll(token, value);
|
|
114
115
|
}
|
|
116
|
+
templateContent = withAuthDirective(templateContent, {
|
|
117
|
+
belongsToOrg: false
|
|
118
|
+
});
|
|
115
119
|
const content = projectIsTypescript ? templateContent : await transformTSToJS(outputPath, templateContent);
|
|
116
120
|
return writeFile(outputPath, content, { overwriteExisting: force });
|
|
117
121
|
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { getPaths } from "./index.js";
|
|
4
|
+
function hasTenancyAuthDirectives() {
|
|
5
|
+
return fs.existsSync(path.join(getPaths().api.directives, "requireOrgAuth"));
|
|
6
|
+
}
|
|
7
|
+
function tenantField() {
|
|
8
|
+
for (const ext of ["ts", "js"]) {
|
|
9
|
+
const dbPath = path.join(getPaths().api.lib, `db.${ext}`);
|
|
10
|
+
if (fs.existsSync(dbPath)) {
|
|
11
|
+
const match = fs.readFileSync(dbPath, "utf-8").match(/\btenantField:\s*['"](\w+)['"]/);
|
|
12
|
+
return match?.[1] ?? "organizationId";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return "organizationId";
|
|
16
|
+
}
|
|
17
|
+
function belongsToOrganization(model) {
|
|
18
|
+
const field = tenantField();
|
|
19
|
+
return model.fields.some(
|
|
20
|
+
({ name, type }) => name === field || type === "Organization"
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
function withAuthDirective(source, { belongsToOrg }) {
|
|
24
|
+
if (!hasTenancyAuthDirectives()) {
|
|
25
|
+
return source;
|
|
26
|
+
}
|
|
27
|
+
return source.replace(
|
|
28
|
+
/@requireAuth(?![\w$])/g,
|
|
29
|
+
belongsToOrg ? "@requireOrgAuth" : "@requireUserAuth"
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
export {
|
|
33
|
+
belongsToOrganization,
|
|
34
|
+
hasTenancyAuthDirectives,
|
|
35
|
+
tenantField,
|
|
36
|
+
withAuthDirective
|
|
37
|
+
};
|
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.3539",
|
|
4
4
|
"description": "The CedarJS Command Line",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -32,21 +32,21 @@
|
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@babel/core": "^7.26.10",
|
|
35
|
-
"@babel/parser": "7.29.
|
|
35
|
+
"@babel/parser": "7.29.11",
|
|
36
36
|
"@babel/preset-typescript": "7.29.7",
|
|
37
37
|
"@babel/traverse": "7.29.10",
|
|
38
|
-
"@babel/types": "7.29.
|
|
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.
|
|
38
|
+
"@babel/types": "7.29.11",
|
|
39
|
+
"@cedarjs/api-server": "8.0.0-canary.3539",
|
|
40
|
+
"@cedarjs/babel-config": "8.0.0-canary.3539",
|
|
41
|
+
"@cedarjs/cli-helpers": "8.0.0-canary.3539",
|
|
42
|
+
"@cedarjs/internal": "8.0.0-canary.3539",
|
|
43
|
+
"@cedarjs/prerender": "8.0.0-canary.3539",
|
|
44
|
+
"@cedarjs/project-config": "8.0.0-canary.3539",
|
|
45
|
+
"@cedarjs/structure": "8.0.0-canary.3539",
|
|
46
|
+
"@cedarjs/telemetry": "8.0.0-canary.3539",
|
|
47
|
+
"@cedarjs/utils": "8.0.0-canary.3539",
|
|
48
|
+
"@cedarjs/vite": "8.0.0-canary.3539",
|
|
49
|
+
"@cedarjs/web-server": "8.0.0-canary.3539",
|
|
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.3539",
|
|
97
97
|
"@prisma/dmmf": "7.10.1",
|
|
98
98
|
"@types/archiver": "^7.0.0",
|
|
99
99
|
"memfs": "4.80.0",
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import { mockRedwoodDirective, getDirectiveName } from '@cedarjs/testing/api'
|
|
2
|
-
|
|
3
|
-
import requireMembership from './requireMembership.js'
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* `context.currentUser` is typed from this app's `getCurrentUser`, which
|
|
7
|
-
* selects more fields than these tests need.
|
|
8
|
-
*/
|
|
9
|
-
const asCurrentUser = (user: { id: string; memberships?: unknown[] }) =>
|
|
10
|
-
user as unknown as NonNullable<typeof context.currentUser>
|
|
11
|
-
|
|
12
|
-
describe('requireMembership directive', () => {
|
|
13
|
-
it('declares the directive sdl as schema, with the correct name', () => {
|
|
14
|
-
expect(requireMembership.schema).toBeTruthy()
|
|
15
|
-
expect(getDirectiveName(requireMembership.schema)).toBe(
|
|
16
|
-
'requireMembership',
|
|
17
|
-
)
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
it('throws when there is no current user', () => {
|
|
21
|
-
const mockExecution = mockRedwoodDirective(requireMembership, {
|
|
22
|
-
context: {},
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
expect(mockExecution).toThrowError()
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
it('throws when there is a current user but no current organization', () => {
|
|
29
|
-
const mockExecution = mockRedwoodDirective(requireMembership, {
|
|
30
|
-
context: { currentUser: asCurrentUser({ id: '1' }) },
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
expect(mockExecution).toThrowError()
|
|
34
|
-
})
|
|
35
|
-
})
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { gql } from 'graphql-tag'
|
|
2
|
-
|
|
3
|
-
import type { ValidatorDirectiveFunc } from '@cedarjs/graphql-server'
|
|
4
|
-
import { createValidatorDirective } from '@cedarjs/graphql-server'
|
|
5
|
-
|
|
6
|
-
import { requireMembership as applicationRequireMembership } from 'src/lib/auth'
|
|
7
|
-
|
|
8
|
-
export const schema = gql`
|
|
9
|
-
"""
|
|
10
|
-
Use to check whether or not the current user is a member of the current
|
|
11
|
-
organization, optionally restricted to a set of roles.
|
|
12
|
-
"""
|
|
13
|
-
directive @requireMembership(roles: [String]) on FIELD_DEFINITION
|
|
14
|
-
`
|
|
15
|
-
|
|
16
|
-
type RequireMembershipValidate = ValidatorDirectiveFunc<{ roles?: string[] }>
|
|
17
|
-
|
|
18
|
-
const validate: RequireMembershipValidate = ({ directiveArgs }) => {
|
|
19
|
-
const { roles } = directiveArgs
|
|
20
|
-
applicationRequireMembership({ roles })
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const requireMembership = createValidatorDirective(schema, validate)
|
|
24
|
-
|
|
25
|
-
export default requireMembership
|