@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.
@@ -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
- return changed ? root.toSource() : fileInfo.source;
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
- return root.toSource();
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
- * 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.
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` 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):
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 OwnersOnly = ({ children }: { children: ReactNode }) => (
35
- * <OrgScope roles="owner" unauthorized="projects">
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={OwnersOnly}>
41
- * <Route path="/org/{orgSlug}/settings" page={SettingsPage} name="settings" />
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 @requireOrgAuth
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
- @requireOrgAuth(roles: ["owner", "admin"])
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
- @requireOrgAuth(roles: ["owner", "admin"])
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: `@requireOrgAuth` checks the
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 `@requireOrgAuth(roles: ["owner", "admin"])` lets an admin call
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. ${c.highlight("@requireAuth")} is gone. Your fields use ${c.highlight("@requireUserAuth")} now,
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.3539",
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.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",
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.3539",
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",