Skip to content

Commit b7c5d3a

Browse files
authored
Merge pull request #1473 from CodeForAfrica/trustlab-rbac-access
fix(trustlab): enforce role-based Payload content access
2 parents e497842 + fc17e8c commit b7c5d3a

19 files changed

Lines changed: 306 additions & 69 deletions

File tree

apps/trustlab/src/app/preview/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { redirect } from "next/navigation";
66
import type { NextRequest } from "next/server";
77

88
import configPromise from "@payload-config";
9-
import { canManageContent } from "@/trustlab/payload/access/abilities";
9+
import { isAuthor } from "@/trustlab/payload/access/abilities";
1010

1111
export async function GET(req: NextRequest): Promise<Response> {
1212
const payload = await getPayload({ config: configPromise });
@@ -37,7 +37,7 @@ export async function GET(req: NextRequest): Promise<Response> {
3737
"Error verifying token for live preview",
3838
);
3939
}
40-
if (!(authResult && canManageContent(authResult?.user))) {
40+
if (!(authResult && isAuthor(authResult?.user))) {
4141
return new Response("You are not allowed to preview this page", {
4242
status: 403,
4343
});

apps/trustlab/src/lib/data/rest/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ export const api = {
5555
findPage,
5656
};
5757

58+
// pages/_error getInitialProps can run on the server or in the browser.
59+
// Keep this path on REST so it does not import Payload's server-only local API.
5860
export async function getErrorPageProps(context) {
5961
const slug = context?.params?.slugs?.[0] || "404";
6062
const props = await getProps(api, slug);
Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,57 @@
11
import { checkRole } from "./checkRole";
2-
import { ROLE_ADMIN, ROLE_AUTHOR, ROLE_EDITOR } from "./roles";
2+
import { hasValidRole, ROLE_ADMIN, ROLE_AUTHOR, ROLE_EDITOR } from "./roles";
33

4-
export const canManageContent = (user) => {
5-
if (!user) {
4+
export const isLoggedIn = (user) => hasValidRole(user);
5+
6+
export const hasLoggedInAccess = ({ req } = {}) => isLoggedIn(req?.user);
7+
8+
// Admins and editors can manage any content
9+
export const isEditor = (user) => checkRole([ROLE_ADMIN, ROLE_EDITOR], user);
10+
11+
export const hasEditorAccess = ({ req } = {}) => isEditor(req?.user);
12+
13+
export const isAuthor = (user) => {
14+
// Any valid CMS user has at least author-level capability.
15+
return isLoggedIn(user);
16+
};
17+
18+
export const canAuthor = (user) => {
19+
if (!isLoggedIn(user)) {
620
return false;
721
}
8-
// Admin and editors can manage any content
9-
if (checkRole([ROLE_ADMIN, ROLE_EDITOR], user)) {
22+
if (isEditor(user)) {
1023
return true;
1124
}
12-
13-
// Everyone else can only manage their own content
25+
if (!user.id) {
26+
return false;
27+
}
28+
// Authors can manage own content only
1429
return {
1530
createdBy: {
1631
equals: user.id,
1732
},
1833
};
1934
};
2035

21-
export const canManagePages = (user) =>
22-
checkRole([ROLE_ADMIN, ROLE_EDITOR], user);
36+
export const hasAuthorAccess = ({ req } = {}) => canAuthor(req?.user);
2337

24-
export const canManageSiteSettings = ({ req: { user } }) =>
25-
checkRole([ROLE_ADMIN], user);
38+
export const isAdmin = (user) => checkRole([ROLE_ADMIN], user);
39+
40+
export const hasAdminAccess = ({ req } = {}) => isAdmin(req?.user);
2641

2742
// TODO(@kelvinkipruto): what happens on delete? cascade or not?
28-
export const canManageUsers = (user) => {
29-
if (!user) {
43+
export const canManageUser = (user) => {
44+
if (!isLoggedIn(user)) {
3045
return false;
3146
}
3247
// Admins can manage all users
3348
if (user.role === ROLE_ADMIN) {
3449
return true;
3550
}
3651
// All other users can manage their own accounts
52+
if (!user.id) {
53+
return false;
54+
}
3755
const orQuery = [
3856
{
3957
id: {
@@ -50,5 +68,8 @@ export const canManageUsers = (user) => {
5068
};
5169
};
5270

53-
export const canCreateAccounts = (user) =>
54-
checkRole([ROLE_ADMIN, ROLE_EDITOR], user);
71+
export const hasManageUserAccess = ({ req } = {}) => canManageUser(req?.user);
72+
73+
export const hasCreateUserAccess = (args) => hasEditorAccess(args);
74+
75+
export default undefined;
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1+
export * from "./abilities";
12
export * from "./anyone";
2-
export * from "./loggedIn";
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import {
2+
canAuthor,
3+
hasAdminAccess,
4+
hasAuthorAccess,
5+
hasCreateUserAccess,
6+
hasManageUserAccess,
7+
hasEditorAccess,
8+
hasLoggedInAccess,
9+
isAuthor,
10+
} from "./abilities";
11+
import { anyone } from "./anyone";
12+
import { ROLE_ADMIN, ROLE_AUTHOR, ROLE_EDITOR } from "./roles";
13+
14+
describe("payload.access", () => {
15+
describe("abilities.hasAdminAccess", () => {
16+
it("allows requests from administrators", () => {
17+
expect(hasAdminAccess({ req: { user: { role: ROLE_ADMIN } } })).toBe(
18+
true,
19+
);
20+
});
21+
22+
it("denies requests from authors, editors, and anonymous users", () => {
23+
expect(hasAdminAccess({ req: { user: { role: ROLE_AUTHOR } } })).toBe(
24+
false,
25+
);
26+
expect(hasAdminAccess({ req: { user: { role: ROLE_EDITOR } } })).toBe(
27+
false,
28+
);
29+
expect(hasAdminAccess({ req: {} })).toBe(false);
30+
expect(hasAdminAccess(undefined)).toBe(false);
31+
});
32+
});
33+
34+
describe("abilities.isAuthor", () => {
35+
it("allows users with administrators, authors and editors roles", () => {
36+
expect(isAuthor({ role: ROLE_ADMIN })).toBe(true);
37+
expect(isAuthor({ role: ROLE_AUTHOR })).toBe(true);
38+
expect(isAuthor({ role: ROLE_EDITOR })).toBe(true);
39+
});
40+
41+
it("denies users without a valid role", () => {
42+
expect(isAuthor({ role: "unknown" })).toBe(false);
43+
expect(isAuthor(1)).toBe(false);
44+
expect(isAuthor(undefined)).toBe(false);
45+
});
46+
});
47+
48+
describe("abilities.canAuthor", () => {
49+
it("allows administrators and editors to author any content", () => {
50+
expect(canAuthor({ role: ROLE_ADMIN })).toBe(true);
51+
expect(canAuthor({ role: ROLE_EDITOR })).toBe(true);
52+
});
53+
54+
it("limits authors to their own content", () => {
55+
expect(canAuthor({ id: 1, role: ROLE_AUTHOR })).toEqual({
56+
createdBy: { equals: 1 },
57+
});
58+
});
59+
60+
it("denies authors without a user id for ownership checks", () => {
61+
expect(canAuthor({ role: ROLE_AUTHOR })).toBe(false);
62+
});
63+
64+
it("denies users without a valid role", () => {
65+
expect(canAuthor({ role: "unknown" })).toBe(false);
66+
expect(canAuthor(1)).toBe(false);
67+
expect(canAuthor(undefined)).toBe(false);
68+
});
69+
});
70+
71+
describe("abilities.hasAuthorAccess", () => {
72+
it("allows requests from administrators, authors and editors", () => {
73+
expect(hasAuthorAccess({ req: { user: { role: ROLE_ADMIN } } })).toBe(
74+
true,
75+
);
76+
expect(
77+
hasAuthorAccess({ req: { user: { id: 1, role: ROLE_AUTHOR } } }),
78+
).toEqual({ createdBy: { equals: 1 } });
79+
expect(hasAuthorAccess({ req: { user: { role: ROLE_EDITOR } } })).toBe(
80+
true,
81+
);
82+
});
83+
84+
it("denies requests from anonymous users", () => {
85+
expect(hasAuthorAccess({ req: {} })).toBe(false);
86+
expect(hasAuthorAccess(undefined)).toBe(false);
87+
});
88+
});
89+
90+
describe("abilities.hasCreateUserAccess", () => {
91+
it("allows requests from administrators and editors", () => {
92+
expect(hasCreateUserAccess({ req: { user: { role: ROLE_ADMIN } } })).toBe(
93+
true,
94+
);
95+
expect(
96+
hasCreateUserAccess({ req: { user: { role: ROLE_EDITOR } } }),
97+
).toBe(true);
98+
});
99+
100+
it("denies requests from authors and anonymous users", () => {
101+
expect(
102+
hasCreateUserAccess({ req: { user: { role: ROLE_AUTHOR } } }),
103+
).toBe(false);
104+
expect(hasCreateUserAccess({ req: {} })).toBe(false);
105+
expect(hasCreateUserAccess(undefined)).toBe(false);
106+
});
107+
});
108+
109+
describe("abilities.hasManageUserAccess", () => {
110+
it("allows requests from administrators, authors and editors", () => {
111+
expect(hasManageUserAccess({ req: { user: { role: ROLE_ADMIN } } })).toBe(
112+
true,
113+
);
114+
expect(
115+
hasManageUserAccess({ req: { user: { id: 1, role: ROLE_AUTHOR } } }),
116+
).toEqual({ or: [{ id: { equals: 1 } }] });
117+
expect(
118+
hasManageUserAccess({ req: { user: { id: 1, role: ROLE_EDITOR } } }),
119+
).toEqual({
120+
or: [{ id: { equals: 1 } }, { role: { equals: ROLE_AUTHOR } }],
121+
});
122+
});
123+
124+
it("denies requests from invalid-role and anonymous users", () => {
125+
expect(
126+
hasManageUserAccess({ req: { user: { id: 1, role: "unknown" } } }),
127+
).toBe(false);
128+
expect(hasManageUserAccess({ req: {} })).toBe(false);
129+
expect(hasManageUserAccess(undefined)).toBe(false);
130+
});
131+
132+
it("denies requests from non-admin users without a user id for ownership checks", () => {
133+
expect(
134+
hasManageUserAccess({ req: { user: { role: ROLE_AUTHOR } } }),
135+
).toBe(false);
136+
expect(
137+
hasManageUserAccess({ req: { user: { role: ROLE_EDITOR } } }),
138+
).toBe(false);
139+
});
140+
});
141+
142+
describe("abilities.hasEditorAccess", () => {
143+
it("allows requests from administrators and editors", () => {
144+
expect(hasEditorAccess({ req: { user: { role: ROLE_ADMIN } } })).toBe(
145+
true,
146+
);
147+
expect(hasEditorAccess({ req: { user: { role: ROLE_EDITOR } } })).toBe(
148+
true,
149+
);
150+
});
151+
152+
it("denies requests from authors and anonymous users", () => {
153+
expect(hasEditorAccess({ req: { user: { role: ROLE_AUTHOR } } })).toBe(
154+
false,
155+
);
156+
expect(hasEditorAccess({ req: {} })).toBe(false);
157+
expect(hasEditorAccess(undefined)).toBe(false);
158+
});
159+
});
160+
161+
describe("abilities.hasLoggedInAccess", () => {
162+
it("allows requests with users that have valid roles", () => {
163+
expect(hasLoggedInAccess({ req: { user: { role: ROLE_ADMIN } } })).toBe(
164+
true,
165+
);
166+
expect(hasLoggedInAccess({ req: { user: { role: ROLE_AUTHOR } } })).toBe(
167+
true,
168+
);
169+
expect(hasLoggedInAccess({ req: { user: { role: ROLE_EDITOR } } })).toBe(
170+
true,
171+
);
172+
});
173+
174+
it("denies requests without a user that has a valid role", () => {
175+
expect(hasLoggedInAccess({ req: { user: { role: "unknown" } } })).toBe(
176+
false,
177+
);
178+
expect(hasLoggedInAccess({ req: { user: 1 } })).toBe(false);
179+
expect(hasLoggedInAccess({ req: {} })).toBe(false);
180+
expect(hasLoggedInAccess(undefined)).toBe(false);
181+
});
182+
});
183+
184+
describe("anyone.anyone", () => {
185+
it("allows requests from administrators, authors, editors and anonymous users", () => {
186+
expect(anyone({ req: { user: { role: ROLE_ADMIN } } })).toBe(true);
187+
expect(anyone({ req: { user: { role: ROLE_AUTHOR } } })).toBe(true);
188+
expect(anyone({ req: { user: { role: ROLE_EDITOR } } })).toBe(true);
189+
expect(anyone({ req: { user: 1 } })).toBe(true);
190+
expect(anyone({ req: {} })).toBe(true);
191+
expect(anyone(undefined)).toBe(true);
192+
});
193+
});
194+
});

apps/trustlab/src/payload/access/loggedIn.js

Lines changed: 0 additions & 3 deletions
This file was deleted.

apps/trustlab/src/payload/access/roles.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,8 @@ export const ROLE_OPTIONS = [
66
{ label: "Editor", value: ROLE_EDITOR },
77
{ label: "Author", value: ROLE_AUTHOR },
88
];
9+
10+
export const hasValidRole = (user) =>
11+
ROLE_OPTIONS.some(({ value }) => user?.role === value);
12+
13+
export default undefined;

apps/trustlab/src/payload/collections/Donors.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import {
66
linkGroup,
77
} from "@commons-ui/payload";
88

9+
import { anyone, hasEditorAccess } from "@/trustlab/payload/access";
10+
911
const Donors = {
1012
slug: "donors",
1113
labels: {
@@ -23,9 +25,11 @@ const Donors = {
2325
useAsTitle: "name",
2426
},
2527
access: {
26-
read: () => true,
28+
read: anyone,
29+
create: hasEditorAccess,
30+
update: hasEditorAccess,
31+
delete: hasEditorAccess,
2732
},
28-
2933
fields: [
3034
{
3135
name: "name",

apps/trustlab/src/payload/collections/Media.js

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ import path from "path";
22

33
import { createdBy } from "@commons-ui/payload";
44

5-
import { canManageContent } from "@/trustlab/payload/access/abilities";
6-
import { anyone } from "@/trustlab/payload/access/anyone";
5+
import { anyone, hasAuthorAccess } from "@/trustlab/payload/access";
76
import { hideAPIURL, slugify } from "@/trustlab/payload/utils";
87

98
function slugifyFilename(filename) {
@@ -19,9 +18,9 @@ const Media = {
1918
slug: "media",
2019
access: {
2120
read: anyone,
22-
create: ({ req: { user } }) => canManageContent(user),
23-
update: ({ req: { user } }) => canManageContent(user),
24-
delete: ({ req: { user } }) => canManageContent(user),
21+
create: hasAuthorAccess,
22+
update: hasAuthorAccess,
23+
delete: hasAuthorAccess,
2524
},
2625
admin: {
2726
group: "Publication",

apps/trustlab/src/payload/collections/Opportunities.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { image, appendPathnameToCollection, slug } from "@commons-ui/payload";
22

3+
import { anyone, hasEditorAccess } from "@/trustlab/payload/access";
34
import blocks from "@/trustlab/payload/blocks";
45

56
const pageByType = {
@@ -25,7 +26,10 @@ const Opportunities = {
2526
defaultColumns: ["title", "type"],
2627
},
2728
access: {
28-
read: () => true,
29+
read: anyone,
30+
create: hasEditorAccess,
31+
update: hasEditorAccess,
32+
delete: hasEditorAccess,
2933
},
3034
fields: [
3135
{

0 commit comments

Comments
 (0)