Skip to content

Commit b5f362a

Browse files
feat(admin): backoffice access control (isAdmin role) (#3187)
1 parent f4fc5aa commit b5f362a

22 files changed

Lines changed: 485 additions & 7 deletions

File tree

.github/workflows/e2e.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ jobs:
3131
S3_BUCKET_NAME: egapro-dev-app
3232
CLAMAV_HOST: localhost
3333
CLAMAV_PORT: 3310
34+
ADMIN_EMAILS: test@fia1.fr
3435
# Required by env.js but never exercised by E2E tests (the audit
3536
# cleanup route is only called by the K8s CronJob). A dummy value
3637
# that satisfies the `.min(32)` Zod check is enough.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
kind: ConfigMap
2+
apiVersion: v1
3+
metadata:
4+
name: admin
5+
data:
6+
# Test ProConnect identity used by the E2E suite and manual QA on
7+
# review apps — gives admin access on dev/alpha environments only.
8+
ADMIN_EMAILS: "test@fia1.fr"
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
kind: ConfigMap
2+
apiVersion: v1
3+
metadata:
4+
name: admin
5+
data:
6+
# Test ProConnect identity used by manual QA on alpha/preprod — gives
7+
# admin access on non-production environments only.
8+
ADMIN_EMAILS: "test@fia1.fr"
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
apiVersion: bitnami.com/v1alpha1
2+
kind: SealedSecret
3+
metadata:
4+
annotations:
5+
sealedsecrets.bitnami.com/namespace-wide: 'true'
6+
name: admin
7+
namespace: egapro
8+
spec:
9+
encryptedData:
10+
ADMIN_EMAILS: AgAPO/KHlIJDVCnAYm7q2u2g6c/U/bTblJtWxEc3u+D85388le3uBUOGwv4Fbn32RBXXa/FX+VVvAAQ+k0i8+TuuEFVT74/A+UnqdL+NrjRKv/NjpYqtfD75reeFnbgGZ7Z7m9i0Kb888TCX2H72LGDBC6vgJe5650FM2Fs0wSTc0Lnl7/q4wO0jJn5YOjAxMvqGm/V6fk/rtdFgTabwHVFYQHDdxorRfMcwJH0+V7FmGOCeH2ye2AUyAUUoDgdPEqwmYs3QyOQVTDKSG7KbvioY3/Jh1SwxLJxmvMm2BpBdl9aYtjDOM1vKgVcfD6kAenrB4gKFUzf0L91wfmgnXwhb19r/sT6AL4Bsf8CLow2wZZ5gecVF3MRKgaBJJyDY6pRshuVT2QUSYrOkTV0kC0U08zJv0shsUinyfBWv4XYgmUHslqtLtJac9cvQnbkQU8uT6zTRNJjdswxd44G586bVmQguop37RiPzl4tNeuN1Z/YpvwJ5V0JSRuZ94wmP254njB72GW7azKULrbSEi9nL3aMhKJnYLLJg10XleSkT45kK03+kzNDZD51ZBxLCD/GNWkqsiLqaHpKDtHWrydrlTxEJk6LSnGlhwDiVNjhPUEo9/HKiN2CBqlj2K2sQ24294M7CB5SFXv2iJSDPEVxjTphYMQblvSqj8keY/MTGTIDjzb4NpXPsdWpX1spP4CaPgmIQm/TOQu5oJ1b5z/Zp9Z3yBtj/obXNDkQp2TuyHxlN
11+
template:
12+
metadata:
13+
annotations:
14+
sealedsecrets.bitnami.com/namespace-wide: 'true'
15+
name: admin
16+
type: Opaque

.kontinuous/values.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ app:
3535
- secretRef:
3636
name: "gip-mds-import"
3737
optional: true
38+
- configMapRef:
39+
name: "admin"
40+
optional: true
41+
- secretRef:
42+
name: "admin"
43+
optional: true
3844
- secretRef:
3945
name: "audit-cleanup"
4046
optional: true

packages/app/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,4 @@ S3_SECRET_ACCESS_KEY="minioadmin"
2222
S3_BUCKET_NAME="egapro-dev-app"
2323
CLAMAV_HOST="localhost"
2424
CLAMAV_PORT="3310"
25+
ADMIN_EMAILS="test@fia1.fr"
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import type { NextRequest } from "next/server";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
const { mockGetToken } = vi.hoisted(() => ({
5+
mockGetToken: vi.fn(),
6+
}));
7+
8+
vi.mock("next-auth/jwt", () => ({ getToken: mockGetToken }));
9+
vi.mock("~/env", () => ({ env: { AUTH_SECRET: "test-secret" } }));
10+
11+
import { middleware } from "~/middleware";
12+
13+
function makeRequest(pathname = "/admin"): NextRequest {
14+
const url = `http://localhost${pathname}`;
15+
return {
16+
url,
17+
nextUrl: { pathname },
18+
} as unknown as NextRequest;
19+
}
20+
21+
describe("admin middleware", () => {
22+
beforeEach(() => {
23+
mockGetToken.mockReset();
24+
});
25+
26+
it("redirects to /login with callbackUrl when there is no token", async () => {
27+
mockGetToken.mockResolvedValue(null);
28+
const res = await middleware(makeRequest("/admin/users"));
29+
expect(res.headers.get("location")).toBe(
30+
"http://localhost/login?callbackUrl=%2Fadmin%2Fusers",
31+
);
32+
});
33+
34+
it("forces re-login when the token has no isAdmin field (pre-PR token)", async () => {
35+
mockGetToken.mockResolvedValue({ id: "u1" });
36+
const res = await middleware(makeRequest("/admin"));
37+
expect(res.headers.get("location")).toBe(
38+
"http://localhost/login?callbackUrl=%2Fadmin",
39+
);
40+
});
41+
42+
it("redirects non-admin users to /mon-espace", async () => {
43+
mockGetToken.mockResolvedValue({ id: "u1", isAdmin: false });
44+
const res = await middleware(makeRequest("/admin"));
45+
expect(res.headers.get("location")).toBe("http://localhost/mon-espace");
46+
});
47+
48+
it("lets admin users through", async () => {
49+
mockGetToken.mockResolvedValue({ id: "u1", isAdmin: true });
50+
const res = await middleware(makeRequest("/admin"));
51+
// NextResponse.next() does not set a redirect location
52+
expect(res.headers.get("location")).toBeNull();
53+
});
54+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { redirect } from "next/navigation";
2+
import type { ReactNode } from "react";
3+
4+
import { auth } from "~/server/auth";
5+
6+
export default async function AdminLayout({
7+
children,
8+
}: {
9+
children: ReactNode;
10+
}) {
11+
const session = await auth();
12+
13+
if (!session?.user) {
14+
redirect("/login");
15+
}
16+
17+
if (!session.user.isAdmin) {
18+
redirect("/mon-espace");
19+
}
20+
21+
return <>{children}</>;
22+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { AdminHomePage } from "~/modules/admin";
2+
import { auth } from "~/server/auth";
3+
4+
export default async function Page() {
5+
// Access control is handled by the edge middleware and the admin layout;
6+
// when this page renders we are guaranteed to have an admin session.
7+
const session = await auth();
8+
const user = session?.user;
9+
10+
return (
11+
<AdminHomePage
12+
userEmail={user?.email ?? ""}
13+
userName={user?.name ?? user?.email ?? ""}
14+
/>
15+
);
16+
}

packages/app/src/e2e/admin.e2e.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { expect, test } from "@playwright/test";
2+
3+
test("admin user can access /admin and sees backoffice page", async ({
4+
page,
5+
}) => {
6+
await page.goto("/admin");
7+
await expect(
8+
page.getByRole("heading", { name: "Backoffice", level: 1 }),
9+
).toBeVisible();
10+
await expect(page.getByText("administrateur")).toBeVisible();
11+
});

0 commit comments

Comments
 (0)