Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Enrich /me endpoint with user roles #2224

Merged
merged 10 commits into from
Sep 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api.planx.uk/admin/feedback/downloadFeedbackCSV.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ type ParsedFeedback = Feedback & {
* tags:
* - admin
* security:
* - userJWT: []
* - bearerAuth: []
*/
export const downloadFeedbackCSV = async (
req: Request,
Expand Down
2 changes: 1 addition & 1 deletion api.planx.uk/admin/session/bops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { getClient } from "../../client";
* parameters:
* - $ref: '#/components/parameters/sessionId'
* security:
* - userJWT: []
* - bearerAuth: []
*/
export const getBOPSPayload = async (
req: Request,
Expand Down
4 changes: 2 additions & 2 deletions api.planx.uk/admin/session/csv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { getClient } from "../../client";
* required: false
* description: If a CSV file should be downloaded, or its raw data returned
* security:
* - userJWT: []
* - bearerAuth: []
*/
export async function getCSVData(
req: Request,
Expand Down Expand Up @@ -62,7 +62,7 @@ export async function getCSVData(
* required: false
* description: If a CSV file should be downloaded, or its raw data returned
* security:
* - userJWT: []
* - bearerAuth: []
*/
export async function getRedactedCSVData(
req: Request,
Expand Down
4 changes: 2 additions & 2 deletions api.planx.uk/admin/session/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ type HTMLExportHandler = RequestHandler<{ sessionId: string }, string>;
* parameters:
* - $ref: '#/components/parameters/sessionId'
* security:
* - userJWT: []
* - bearerAuth: []
*/
export const getHTMLExport: HTMLExportHandler = async (req, res, next) => {
try {
Expand Down Expand Up @@ -53,7 +53,7 @@ export const getHTMLExport: HTMLExportHandler = async (req, res, next) => {
* parameters:
* - $ref: '#/components/parameters/sessionId'
* security:
* - userJWT: []
* - bearerAuth: []
*/
export const getRedactedHTMLExport: HTMLExportHandler = async (
req,
Expand Down
2 changes: 1 addition & 1 deletion api.planx.uk/admin/session/oneAppXML.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { adminGraphQLClient as client } from "../../hasura";
* parameters:
* - $ref: '#/components/parameters/sessionId'
* security:
* - userJWT: []
* - bearerAuth: []
*/
export const getOneAppXML = async (
req: Request,
Expand Down
2 changes: 1 addition & 1 deletion api.planx.uk/admin/session/summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { Breadcrumb, Flow, LowCalSession, Passport, Team } from "../../types";
* parameters:
* - $ref: '#/components/parameters/sessionId'
* security:
* - userJWT: []
* - bearerAuth: []
*/
export async function getSessionSummary(
req: Request,
Expand Down
2 changes: 1 addition & 1 deletion api.planx.uk/admin/session/zip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { buildSubmissionExportZip } from "../../send/exportZip";
* required: false
* description: If the OneApp XML file should be included in the zip
* security:
* - userJWT: []
* - bearerAuth: []
*/
export async function generateZip(
req: Request,
Expand Down
25 changes: 24 additions & 1 deletion api.planx.uk/modules/auth/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,11 +171,34 @@ export const useRoleAuth: UseRoleAuth =
});
};

// Convenience methods
// Convenience methods for role-based access
export const useTeamViewerAuth = useRoleAuth([
"teamViewer",
"teamEditor",
"platformAdmin",
]);
export const useTeamEditorAuth = useRoleAuth(["teamEditor", "platformAdmin"]);
export const usePlatformAdminAuth = useRoleAuth(["platformAdmin"]);

/**
* Allow any logged in user to access route, without checking roles
*/
export const useLoginAuth: RequestHandler = (req, res, next) =>
useJWT(req, res, () => {
if (req?.user?.sub) {
userContext.run(
{
user: {
...req.user,
jwt: req.cookies.jwt,
},
},
() => next(),
);
} else {
return next({
status: 401,
message: "No authorization token was found",
});
}
});
1 change: 1 addition & 0 deletions api.planx.uk/modules/auth/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const buildJWT = async (email: string): Promise<string | undefined> => {

const data = {
sub: user.id.toString(),
email,
"https://hasura.io/jwt/claims": generateHasuraClaimsForUser(user),
};

Expand Down
35 changes: 35 additions & 0 deletions api.planx.uk/modules/misc/controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { RequestHandler } from "express";
import { getClient } from "../../client";
import { userContext } from "../auth/middleware";
import { ServerError } from "../../errors";

export const getLoggedInUserDetails: RequestHandler = async (
_req,
res,
next,
) => {
try {
const $client = getClient();

const email = userContext.getStore()?.user.email;
if (!email)
throw new ServerError({
message: "User email missing from request",
status: 400,
});

const user = await $client.user.getByEmail(email);
if (!user)
throw new ServerError({
message: `Unable to locate user with email ${email}`,
status: 400,
});

res.json(user);
} catch (error) {
next(error);
}
};

export const healthCheck: RequestHandler = (_req, res) =>
res.json({ hello: "world" });
81 changes: 81 additions & 0 deletions api.planx.uk/modules/misc/docs.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
openapi: 3.1.0
info:
title: Plan✕ API
version: 0.1.0
tags:
- name: misc
description: Miscellaneous
paths:
/:
get:
summary: Health check
description: Confirms the API is healthy
tags:
- misc
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
hello:
type: string
example:
hello: world
/me:
get:
summary: Get information about currently logged in user
tags:
- misc
security:
- bearerAuth: []
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
id:
type: integer
format: int32
example: 123
firstName:
type: string
example: Albert
lastName:
type: string
example: Einstein
email:
type: string
example: [email protected]
isPlatformAdmin:
type: boolean
example: true
teams:
type: array
items:
type: object
properties:
team:
type: object
properties:
id:
type: integer
format: int32
example: 123
slug:
type: string
example: opensystemslab
name:
type: string
example: Open Systems Lab
role:
type: string
enum: ["teamEditor", "teamViewer"]
example: "teamEditor"
"401":
$ref: "#/components/responses/Unauthorised"
111 changes: 111 additions & 0 deletions api.planx.uk/modules/misc/routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import supertest from "supertest";
import app from "../../server";
import { authHeader, getJWT } from "../../tests/mockJWT";
import { userContext } from "../auth/middleware";

const getStoreMock = jest.spyOn(userContext, "getStore");

const mockGetByEmail = jest.fn().mockResolvedValue({
id: 36,
firstName: "Albert",
lastName: "Einstein",
email: "[email protected]",
isPlatformAdmin: true,
teams: [
{
teamId: 1,
role: "teamEditor",
},
{
teamId: 24,
role: "teamEditor",
},
],
});

jest.mock("@opensystemslab/planx-core", () => {
return {
CoreDomainClient: jest.fn().mockImplementation(() => ({
user: {
getByEmail: () => mockGetByEmail(),
},
})),
};
});

describe("/me endpoint", () => {
beforeEach(() => {
getStoreMock.mockReturnValue({
user: {
sub: "123",
email: "[email protected]",
jwt: getJWT({ role: "teamEditor" }),
},
});
});

it("returns an error if authorization headers are not set", async () => {
await supertest(app)
.get("/me")
.expect(401)
.then((res) => {
expect(res.body).toEqual({
error: "No authorization token was found",
});
});
});

it("returns an error for invalid user context", async () => {
getStoreMock.mockReturnValue({
user: {
sub: "123",
email: undefined,
jwt: getJWT({ role: "teamEditor" }),
},
});

await supertest(app)
.get("/me")
.set(authHeader({ role: "teamEditor" }))
.expect(400)
.then((res) => {
expect(res.body).toEqual({
error: "User email missing from request",
});
});
});

it("returns an error for an invalid email address", async () => {
mockGetByEmail.mockResolvedValueOnce(null);

await supertest(app)
.get("/me")
.set(authHeader({ role: "teamEditor" }))
.expect(400)
.then((res) => {
expect(res.body).toEqual({
error: "Unable to locate user with email [email protected]",
});
});
});

it("returns user details for a logged in user", async () => {
await supertest(app)
.get("/me")
.set(authHeader({ role: "teamEditor" }))
.expect(200)
.then((res) => {
expect(res.body).toHaveProperty("email", "[email protected]");
expect(res.body.teams).toHaveLength(2);
});
});
});

describe("healthcheck endpoint", () => {
it("always returns a 200", async () => {
await supertest(app)
.get("/")
.expect(200)
.then((res) => expect(res.body).toHaveProperty("hello", "world"));
});
});
10 changes: 10 additions & 0 deletions api.planx.uk/modules/misc/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Router } from "express";
import { useLoginAuth } from "../auth/middleware";
import { getLoggedInUserDetails, healthCheck } from "./controller";

const router = Router();

router.get("/", healthCheck);
router.get("/me", useLoginAuth, getLoggedInUserDetails);

export default router;
2 changes: 1 addition & 1 deletion api.planx.uk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"private": true,
"dependencies": {
"@airbrake/node": "^2.1.8",
"@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#17f3bf5",
"@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#7e31b2e",
"@types/isomorphic-fetch": "^0.0.36",
"adm-zip": "^0.5.10",
"aws-sdk": "^2.1441.0",
Expand Down
Loading
Loading