From 2623f199e3e4af49238b726c4aef2a0dbd23a34b Mon Sep 17 00:00:00 2001 From: Milan Gruner Date: Mon, 11 Mar 2024 11:27:58 +0100 Subject: [PATCH 01/10] Implement create-admin.ts script to create default admin account --- app/scripts/create-admin.ts | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 app/scripts/create-admin.ts diff --git a/app/scripts/create-admin.ts b/app/scripts/create-admin.ts new file mode 100644 index 000000000..191549298 --- /dev/null +++ b/app/scripts/create-admin.ts @@ -0,0 +1,37 @@ +import { db } from "@/models"; +import env from "@next/env"; +import { randomUUID } from "node:crypto"; +import { logger } from "@/services/logger"; +import bcrypt from "bcrypt"; +import { Roles } from "@/lib/auth"; + +async function createAdmin() { + const projectDir = process.cwd(); + env.loadEnvConfig(projectDir); + + if (!db.initialized) { + await db.initialize(); + } + + if (!process.env.DEFAULT_ADMIN_EMAIL || !process.env.DEFAULT_ADMIN_PASSWORD) { + throw new Error( + "Missing default admin credentials DEFAULT_ADMIN_EMAIL and DEFAULT_ADMIN_PASSWORD in env!", + ); + } + + const passwordHash = await bcrypt.hash( + process.env.DEFAULT_ADMIN_PASSWORD, + 12, + ); + const user = await db.models.User.create({ + userId: randomUUID(), + name: "Admin", + email: process.env.DEFAULT_ADMIN_EMAIL.toLowerCase(), + passwordHash, + role: Roles.Admin, + }); + + logger.info("Created admin user", user.userId); +} + +createAdmin(); From b0522196934aee91e90851847da1efcdc8fe8e7d Mon Sep 17 00:00:00 2001 From: Milan Gruner Date: Mon, 11 Mar 2024 11:28:15 +0100 Subject: [PATCH 02/10] Implement change role admin route and tests for it --- app/src/app/api/v0/auth/role/route.ts | 31 +++++++++++ app/tests/api/admin.test.ts | 78 +++++++++++++++++++++++++++ app/tests/helpers.ts | 20 ++++--- 3 files changed, 121 insertions(+), 8 deletions(-) create mode 100644 app/src/app/api/v0/auth/role/route.ts create mode 100644 app/tests/api/admin.test.ts diff --git a/app/src/app/api/v0/auth/role/route.ts b/app/src/app/api/v0/auth/role/route.ts new file mode 100644 index 000000000..97fb865ac --- /dev/null +++ b/app/src/app/api/v0/auth/role/route.ts @@ -0,0 +1,31 @@ +import { Roles } from "@/lib/auth"; +import { db } from "@/models"; +import { apiHandler } from "@/util/api"; +import createHttpError from "http-errors"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +const changeRoleRequest = z.object({ + email: z.string().email(), + role: z.nativeEnum(Roles), +}); + +export const POST = apiHandler(async (req, { session }) => { + if (session?.user.role !== Roles.Admin) { + throw new createHttpError.Forbidden("Can only be used by admin accounts"); + } + const body = changeRoleRequest.parse(await req.json()); + const user = await db.models.User.findOne({ where: { email: body.email } }); + + if (!user) { + throw new createHttpError.NotFound("User not found"); + } + if (user.role === body.role) { + throw new createHttpError.BadRequest("User already has role " + body.role); + } + + user.role = body.role; + await user.save(); + + return NextResponse.json({ success: true }); +}); diff --git a/app/tests/api/admin.test.ts b/app/tests/api/admin.test.ts new file mode 100644 index 000000000..903850336 --- /dev/null +++ b/app/tests/api/admin.test.ts @@ -0,0 +1,78 @@ +import { POST as changeRole } from "@/app/api/v0/auth/role/route"; +import { db } from "@/models"; +import assert from "node:assert"; +import { after, before, beforeEach, describe, it, mock } from "node:test"; +import { mockRequest, setupTests, testUserData, testUserID } from "../helpers"; +import { AppSession, Auth, Roles } from "@/lib/auth"; + +const mockSession: AppSession = { + user: { id: testUserID, role: "user" }, + expires: "1h", +}; +const mockAdminSession: AppSession = { + user: { id: testUserID, role: "admin" }, + expires: "1h", +}; + +describe("Admin API", () => { + let prevGetServerSession = Auth.getServerSession; + + before(async () => { + setupTests(); + await db.initialize(); + }); + + after(async () => { + Auth.getServerSession = prevGetServerSession; + if (db.sequelize) await db.sequelize.close(); + }); + + beforeEach(async () => { + Auth.getServerSession = mock.fn(() => Promise.resolve(mockSession)); + await db.models.User.upsert({ + userId: testUserID, + name: testUserData.name, + email: testUserData.email, + role: Roles.User, + }); + }); + + it("should change the user role when logged in as admin", async () => { + const req = mockRequest({ email: testUserData.email, role: Roles.Admin }); + Auth.getServerSession = mock.fn(() => Promise.resolve(mockAdminSession)); + const res = await changeRole(req, { params: {} }); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.success, true); + + const user = await db.models.User.findOne({ + where: { email: testUserData.email }, + }); + assert.equal(user?.role, Roles.Admin); + }); + + it("should not change the user role when logged in as normal user", async () => { + const req = mockRequest({ email: testUserData.email, role: Roles.Admin }); + const res = await changeRole(req, { params: {} }); + assert.equal(res.status, 403); + + const user = await db.models.User.findOne({ + where: { email: testUserData.email }, + }); + assert.equal(user?.role, Roles.User); + }); + + it("should validate the request", async () => { + Auth.getServerSession = mock.fn(() => Promise.resolve(mockAdminSession)); + const req = mockRequest({ email: testUserData.email, role: "invalid" }); + const res = await changeRole(req, { params: {} }); + assert.equal(res.status, 400); + console.log(await res.text()); + const req2 = mockRequest({ email: "not-an-email", role: "Admin" }); + const res2 = await changeRole(req2, { params: {} }); + assert.equal(res2.status, 400); + const req3 = mockRequest({}); + const res3 = await changeRole(req3, { params: {} }); + assert.equal(res3.status, 400); + }); +}); diff --git a/app/tests/helpers.ts b/app/tests/helpers.ts index 5dafdc6a7..5bc1c0d54 100644 --- a/app/tests/helpers.ts +++ b/app/tests/helpers.ts @@ -17,7 +17,10 @@ export function createRequest(url: string, body?: any) { return request; } -export function mockRequest(body?: any, searchParams?: Record): NextRequest { +export function mockRequest( + body?: any, + searchParams?: Record, +): NextRequest { const request = new NextRequest(new URL(mockUrl)); request.json = mock.fn(() => Promise.resolve(body)); for (const param in searchParams) { @@ -72,6 +75,13 @@ export const testFileFormat = { }; export const testUserID = "beb9634a-b68c-4c1b-a20b-2ab0ced5e3c2"; +export const testUserData = { + id: testUserID, + name: "Test User", + email: "test@example.com", + image: null, + role: "user", +}; export function setupTests() { const projectDir = process.cwd(); @@ -82,13 +92,7 @@ export function setupTests() { const expires = new Date(); expires.setDate(expires.getDate() + 1); return { - user: { - id: testUserID, - name: "Test User", - email: "test@example.com", - image: null, - role: "user", - }, + user: testUserData, expires: expires.toISOString(), }; }); From e7a45a43cd3333599029eae81df5fad1ea2e0a0a Mon Sep 17 00:00:00 2001 From: Milan Gruner Date: Mon, 11 Mar 2024 11:31:01 +0100 Subject: [PATCH 03/10] Print error and return instead of throwing error in create-admin.ts --- app/scripts/create-admin.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/scripts/create-admin.ts b/app/scripts/create-admin.ts index 191549298..9cb93cf10 100644 --- a/app/scripts/create-admin.ts +++ b/app/scripts/create-admin.ts @@ -14,9 +14,10 @@ async function createAdmin() { } if (!process.env.DEFAULT_ADMIN_EMAIL || !process.env.DEFAULT_ADMIN_PASSWORD) { - throw new Error( - "Missing default admin credentials DEFAULT_ADMIN_EMAIL and DEFAULT_ADMIN_PASSWORD in env!", + logger.error( + "create-admin.ts: Missing default admin credentials DEFAULT_ADMIN_EMAIL and DEFAULT_ADMIN_PASSWORD in env!", ); + return; } const passwordHash = await bcrypt.hash( From 740cc5487ad558542c116b72f8a2836282e9e817 Mon Sep 17 00:00:00 2001 From: Milan Gruner Date: Mon, 11 Mar 2024 11:34:08 +0100 Subject: [PATCH 04/10] Log more output in success message and close DB connection in create-admin.ts --- app/scripts/create-admin.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/scripts/create-admin.ts b/app/scripts/create-admin.ts index 9cb93cf10..684d5bbab 100644 --- a/app/scripts/create-admin.ts +++ b/app/scripts/create-admin.ts @@ -32,7 +32,12 @@ async function createAdmin() { role: Roles.Admin, }); - logger.info("Created admin user", user.userId); + logger.info( + "Created admin user with email %s and ID %s", + user.email, + user.userId, + ); + await db.sequelize?.close(); } createAdmin(); From 0a51f31c3642c104fd2becccc196ad77a3b39a51 Mon Sep 17 00:00:00 2001 From: Milan Gruner Date: Mon, 11 Mar 2024 11:51:39 +0100 Subject: [PATCH 05/10] Cover user not found case in admin role route tests --- app/tests/api/admin.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/tests/api/admin.test.ts b/app/tests/api/admin.test.ts index 903850336..78231ccd8 100644 --- a/app/tests/api/admin.test.ts +++ b/app/tests/api/admin.test.ts @@ -62,6 +62,16 @@ describe("Admin API", () => { assert.equal(user?.role, Roles.User); }); + it("should return a 404 error when user does not exist", async () => { + const req = mockRequest({ + email: "not-existing@example.com", + role: Roles.Admin, + }); + Auth.getServerSession = mock.fn(() => Promise.resolve(mockAdminSession)); + const res = await changeRole(req, { params: {} }); + assert.equal(res.status, 404); + }); + it("should validate the request", async () => { Auth.getServerSession = mock.fn(() => Promise.resolve(mockAdminSession)); const req = mockRequest({ email: testUserData.email, role: "invalid" }); From 8a2182c776879e29d31303ca27b6c8e8509673ac Mon Sep 17 00:00:00 2001 From: Milan Gruner Date: Mon, 11 Mar 2024 12:00:55 +0100 Subject: [PATCH 06/10] Remove logging from role route validation test --- app/tests/api/admin.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/tests/api/admin.test.ts b/app/tests/api/admin.test.ts index 78231ccd8..d8e47f731 100644 --- a/app/tests/api/admin.test.ts +++ b/app/tests/api/admin.test.ts @@ -74,13 +74,15 @@ describe("Admin API", () => { it("should validate the request", async () => { Auth.getServerSession = mock.fn(() => Promise.resolve(mockAdminSession)); + const req = mockRequest({ email: testUserData.email, role: "invalid" }); const res = await changeRole(req, { params: {} }); assert.equal(res.status, 400); - console.log(await res.text()); + const req2 = mockRequest({ email: "not-an-email", role: "Admin" }); const res2 = await changeRole(req2, { params: {} }); assert.equal(res2.status, 400); + const req3 = mockRequest({}); const res3 = await changeRole(req3, { params: {} }); assert.equal(res3.status, 400); From 73cc54ffa52dcaf7a520e5f89357cfc38e13def7 Mon Sep 17 00:00:00 2001 From: Milan Gruner Date: Mon, 11 Mar 2024 17:14:56 +0100 Subject: [PATCH 07/10] Add DEFAULT_ADMIN_EMAIL and DEFAULT_ADMIN_PASSWORD from Github Secrets to env --- .github/workflows/web-actions.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/web-actions.yml b/.github/workflows/web-actions.yml index 45970672f..4a8048cfe 100644 --- a/.github/workflows/web-actions.yml +++ b/.github/workflows/web-actions.yml @@ -122,6 +122,8 @@ jobs: kubectl apply -f k8s/cc-sync-catalogue.yml -n default # kubectl create job --from=cronjob/cc-sync-catalogue cc-sync-catalogue-manual -n default kubectl apply -f k8s/cc-web-deploy.yml -n default + kubectl set env deployment/cc-web-deploy DEFAULT_ADMIN_EMAIL=${{secrets.DEFAULT_ADMIN_EMAIL}} + kubectl set env deployment/cc-web-deploy DEFAULT_ADMIN_PASSWORD=${{secrets.DEFAULT_ADMIN_PASSWORD}} kubectl set env deployment/cc-web-deploy SMTP_USER=${{secrets.SMTP_USER}} kubectl set env deployment/cc-web-deploy SMTP_PASSWORD=${{secrets.SMTP_PASSWORD}} kubectl set env deployment/cc-web-deploy NEXTAUTH_SECRET=${{secrets.NEXTAUTH_SECRET}} From 2780aaa5f9a3dfc85057148d9f48f3dc0722f4f6 Mon Sep 17 00:00:00 2001 From: Milan Gruner Date: Mon, 11 Mar 2024 17:21:57 +0100 Subject: [PATCH 08/10] Create k8s job for create-admin script and run in CI --- .github/workflows/web-actions.yml | 5 +++-- app/package.json | 3 ++- k8s/cc-create-admin.yml | 29 +++++++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 k8s/cc-create-admin.yml diff --git a/.github/workflows/web-actions.yml b/.github/workflows/web-actions.yml index 4a8048cfe..624e71b26 100644 --- a/.github/workflows/web-actions.yml +++ b/.github/workflows/web-actions.yml @@ -122,8 +122,6 @@ jobs: kubectl apply -f k8s/cc-sync-catalogue.yml -n default # kubectl create job --from=cronjob/cc-sync-catalogue cc-sync-catalogue-manual -n default kubectl apply -f k8s/cc-web-deploy.yml -n default - kubectl set env deployment/cc-web-deploy DEFAULT_ADMIN_EMAIL=${{secrets.DEFAULT_ADMIN_EMAIL}} - kubectl set env deployment/cc-web-deploy DEFAULT_ADMIN_PASSWORD=${{secrets.DEFAULT_ADMIN_PASSWORD}} kubectl set env deployment/cc-web-deploy SMTP_USER=${{secrets.SMTP_USER}} kubectl set env deployment/cc-web-deploy SMTP_PASSWORD=${{secrets.SMTP_PASSWORD}} kubectl set env deployment/cc-web-deploy NEXTAUTH_SECRET=${{secrets.NEXTAUTH_SECRET}} @@ -132,4 +130,7 @@ jobs: kubectl set env deployment/cc-web-deploy CHAT_PROVIDER=huggingface kubectl set env deployment/cc-web-deploy OPENAI_API_KEY=${{secrets.OPENAI_API_KEY}} kubectl set env deployment/cc-web-deploy HUGGINGFACE_API_KEY=${{secrets.HUGGINGFACE_API_KEY}} + kubectl set env deployment/cc-web-deploy DEFAULT_ADMIN_EMAIL=${{secrets.DEFAULT_ADMIN_EMAIL}} + kubectl set env deployment/cc-web-deploy DEFAULT_ADMIN_PASSWORD=${{secrets.DEFAULT_ADMIN_PASSWORD}} + kubectl create -f k8s/cc-create-admin.yml -n default kubectl rollout restart deployment cc-web-deploy -n default diff --git a/app/package.json b/app/package.json index 4e9ac567b..261783620 100644 --- a/app/package.json +++ b/app/package.json @@ -26,7 +26,8 @@ "cy:run": "cypress run", "cy:test": "start-server-and-test start http://localhost:3000/en/auth/login cy:run", "prettier": "npx prettier . --write", - "email": "email dev --dir src/lib/emails" + "email": "email dev --dir src/lib/emails", + "create-admin": "tsx scripts/create-admin.ts" }, "dependencies": { "@chakra-ui/icons": "^2.1.0", diff --git a/k8s/cc-create-admin.yml b/k8s/cc-create-admin.yml new file mode 100644 index 000000000..208719a27 --- /dev/null +++ b/k8s/cc-create-admin.yml @@ -0,0 +1,29 @@ +apiVersion: batch/v1 +kind: Job +metadata: + generateName: cc-create-admin- +spec: + ttlSecondsAfterFinished: 86400 + template: + spec: + restartPolicy: OnFailure + containers: + - name: cc-migrate + image: ghcr.io/open-earth-foundation/citycatalyst:latest + imagePullPolicy: Always + env: + - name: NODE_ENV + value: development + - name: DATABASE_NAME + value: "citycatalyst" + - name: DATABASE_HOST + value: "cc-db" + - name: DATABASE_USER + value: "citycatalyst" + - name: DATABASE_PASSWORD + value: "development" + command: ["npm", "run", "create-admin"] + resources: + limits: + memory: "1024Mi" + cpu: "1000m" From 0286daa7ab1cbc5cc9d9ea20aea8682160facbbe Mon Sep 17 00:00:00 2001 From: Milan Gruner Date: Tue, 19 Mar 2024 11:41:34 +0100 Subject: [PATCH 09/10] Make create-admin.ts script idempotent (skip when account exists already) --- app/scripts/create-admin.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/app/scripts/create-admin.ts b/app/scripts/create-admin.ts index 684d5bbab..e63090772 100644 --- a/app/scripts/create-admin.ts +++ b/app/scripts/create-admin.ts @@ -17,6 +17,16 @@ async function createAdmin() { logger.error( "create-admin.ts: Missing default admin credentials DEFAULT_ADMIN_EMAIL and DEFAULT_ADMIN_PASSWORD in env!", ); + await db.sequelize?.close(); + return; + } + + const email = process.env.DEFAULT_ADMIN_EMAIL.toLowerCase(); + const user = await db.models.User.findOne({ where: { email } }); + + if (user) { + logger.info("Admin user already exists. Exiting."); + await db.sequelize?.close(); return; } @@ -24,18 +34,18 @@ async function createAdmin() { process.env.DEFAULT_ADMIN_PASSWORD, 12, ); - const user = await db.models.User.create({ + const newUser = await db.models.User.create({ userId: randomUUID(), name: "Admin", - email: process.env.DEFAULT_ADMIN_EMAIL.toLowerCase(), + email: email, passwordHash, role: Roles.Admin, }); logger.info( "Created admin user with email %s and ID %s", - user.email, - user.userId, + newUser.email, + newUser.userId, ); await db.sequelize?.close(); } From 31a38eef6e26b4f157c517b990351a35dcf9ebae Mon Sep 17 00:00:00 2001 From: Milan Gruner Date: Tue, 19 Mar 2024 11:44:06 +0100 Subject: [PATCH 10/10] Add tsx to runtime dependencies to execute scripts --- app/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/package.json b/app/package.json index 261783620..188bb8d4c 100644 --- a/app/package.json +++ b/app/package.json @@ -92,6 +92,7 @@ "sequelize": "^6.35.2", "sequelize-cli": "^6.6.2", "tailwindcss": "3.4.1", + "tsx": "^4.7.0", "typescript": "5.3.3", "uuid": "^9.0.1", "wellknown": "^0.5.0", @@ -109,8 +110,7 @@ "prettier": "3.2.5", "sequelize-auto": "^0.8.8", "start-server-and-test": "^2.0.3", - "storybook": "^7.6.12", - "tsx": "^4.7.0" + "storybook": "^7.6.12" }, "engines": { "node": ">=20.5.0"