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

Use Keycloak roles for endpoint permissions #45

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
20 changes: 10 additions & 10 deletions features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import { isLeft } from "fp-ts/lib/Either.js";
import { PathReporter } from "io-ts/lib/PathReporter.js";
import { Response } from "express";
import { Request as JwtRequest } from "express-jwt";
import { FindCursor, ModifyResult, ObjectId, WithId } from "mongodb";
import { FindCursor, ObjectId, WithId } from "mongodb";

import { State } from "./globals.js";
import { sceneToJson } from "./scenes.js";
import { makeRequireSuperuserMiddleware } from "./superuser.js";
import { makeRequireSuperuserOrRoleMiddleware } from "./permissions.js";

export interface MongoSceneFeature {
scene_id: ObjectId;
Expand Down Expand Up @@ -104,11 +104,11 @@ export function initializeFeatureEndpoints(state: State) {

type FeatureCreationT = t.TypeOf<typeof FeatureCreation>;

const requireSuperuser = makeRequireSuperuserMiddleware(state);
const requireManageFeatures = makeRequireSuperuserOrRoleMiddleware(state, 'manage-features');

state.app.post(
"/feature",
requireSuperuser,
requireManageFeatures,
async (req: JwtRequest, res: Response) => {

const maybe = FeatureCreation.decode(req.body);
Expand Down Expand Up @@ -152,7 +152,7 @@ export function initializeFeatureEndpoints(state: State) {

state.app.get(
"/features",
requireSuperuser,
requireManageFeatures,
async (req: JwtRequest, res: Response) => {
const startDate = new Date(Number(req.query.start_date));
const endDate = new Date(Number(req.query.end_date));
Expand Down Expand Up @@ -180,7 +180,7 @@ export function initializeFeatureEndpoints(state: State) {

state.app.get(
"/features/queue",
requireSuperuser,
requireManageFeatures,
async (req: JwtRequest, res: Response) => {
const queueDoc = await state.featureQueue.findOne();
const sceneIDs = queueDoc?.scene_ids ?? [];
Expand All @@ -203,7 +203,7 @@ export function initializeFeatureEndpoints(state: State) {

state.app.get(
"/features/:id",
requireSuperuser,
requireManageFeatures,
async (req: JwtRequest, res: Response) => {
const objectId = new ObjectId(req.params.id);
const feature = await state.features.findOne({ _id: objectId });
Expand All @@ -225,7 +225,7 @@ export function initializeFeatureEndpoints(state: State) {

state.app.patch(
"/features/:id",
requireSuperuser,
requireManageFeatures,
async (req: JwtRequest, res: Response) => {
const objectId = new ObjectId(req.params.id);
const feature = await state.features.findOne({ _id: objectId });
Expand Down Expand Up @@ -259,7 +259,7 @@ export function initializeFeatureEndpoints(state: State) {

state.app.delete(
"/features/:id",
requireSuperuser,
requireManageFeatures,
async (req: JwtRequest, res: Response) => {
const objectId = new ObjectId(req.params.id);
const feature = await state.features.findOne({ _id: objectId });
Expand Down Expand Up @@ -287,7 +287,7 @@ export function initializeFeatureEndpoints(state: State) {

state.app.post(
"/features/queue",
requireSuperuser,
requireManageFeatures,
async (req: JwtRequest, res: Response) => {
const maybe = QueueRequestBody.decode(req.body);
if (isLeft(maybe)) {
Expand Down
2 changes: 2 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { initializeSceneEndpoints } from "./scenes.js";
import { initializeSuperuserEndpoints } from "./superuser.js";
import { initializeSessionEndpoints } from "./session.js";
import { initializeTessellationEndpoints } from "./tessellation.js";
import { initializePermissionsEndpoints } from "./permissions.js";
import { createDailyFeatureUpdateJob } from "./cron.js";

import { setLogLevel } from "@azure/logger";
Expand Down Expand Up @@ -108,6 +109,7 @@ state.app.get("/", (_req: Request, res: Response) => {
initializeFeatureEndpoints(state);
initializeHandleEndpoints(state);
initializeImageEndpoints(state);
initializePermissionsEndpoints(state);
initializeSceneEndpoints(state);
initializeSuperuserEndpoints(state);
initializeSessionEndpoints(state);
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,11 @@
"@types/express-session": "^1.17.7",
"@types/geojson": "^7946.0.10",
"@types/jsdom": "^21.1.1",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^18.11.15",
"@types/node-schedule": "^2.1.6",
"@types/spdx-expression-parse": "^3.0.2",
"keycloak-js": "^24.0.1",
"typescript": "^4.9.4"
},
"license": "MIT",
Expand Down
68 changes: 68 additions & 0 deletions permissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { JwtPayload } from "jsonwebtoken";
import type { KeycloakTokenParsed } from "keycloak-js";
import { Request as JwtRequest } from "express-jwt";
import { NextFunction, RequestHandler, Response } from "express";

import { State } from "./globals.js";

export type KeycloakJwtRequest = JwtRequest<JwtPayload & KeycloakTokenParsed>;

export type ConstellationsRole = 'update-home-timeline' | 'update-global-tessellation' |
'manage-handles' | 'manage-features';

export function amISuperuser(req: JwtRequest, state: State): boolean {
return req.auth !== undefined && req.auth.sub === state.config.superuserAccountId;
}

export function hasRole(req: KeycloakJwtRequest, role: ConstellationsRole): boolean {
return req.auth !== undefined && !!req.auth.realm_access?.roles.includes(role);
}

export function makeRequireRoleMiddleware(role: ConstellationsRole): RequestHandler {
return (req: KeycloakJwtRequest, res: Response, next: NextFunction) => {
if (!hasRole(req, role)) {
res.status(403).json({
error: true,
message: "Forbidden",
});
}
next();
pkgw marked this conversation as resolved.
Show resolved Hide resolved
};
}

export function makeRequireSuperuserMiddleware(state: State): RequestHandler {
return (req: JwtRequest, res: Response, next: NextFunction) => {
if (!amISuperuser(req, state)) {
res.status(403).json({
error: true,
message: "Forbidden",
});
} else {
console.warn("executing superuser API call:", req.path);
next();
}
};
}

export function makeRequireSuperuserOrRoleMiddleware(state: State, role: ConstellationsRole): RequestHandler {
return (req: KeycloakJwtRequest, res: Response, next: NextFunction) => {
console.log(hasRole(req, role));
pkgw marked this conversation as resolved.
Show resolved Hide resolved
const allowed = amISuperuser(req, state) || hasRole(req, role);
if (!allowed) {
res.status(403).json({
error: true,
message: "Forbidden",
});
} else {
next();
}
};
}

export function initializePermissionsEndpoints(state: State) {
pkgw marked this conversation as resolved.
Show resolved Hide resolved
state.app.get("/misc/permissions", async (req: KeycloakJwtRequest, res: Response) => {
res.json({
result: req.auth?.realm_access?.roles ?? []
});
});
}
33 changes: 10 additions & 23 deletions superuser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,7 @@ import { State } from "./globals.js";
import { MongoScene } from "./scenes.js";
import { createGlobalTessellation } from "./tessellation.js";
import { getCurrentFeaturedSceneID } from "./features.js";

export function amISuperuser(req: JwtRequest, state: State): boolean {
return req.auth !== undefined && req.auth.sub === state.config.superuserAccountId;
}

export function makeRequireSuperuserMiddleware(state: State): RequestHandler {
return (req: JwtRequest, res: Response, next: NextFunction) => {
if (!amISuperuser(req, state)) {
res.status(403).json({
error: true,
message: "Forbidden"
});
} else {
console.warn("executing superuser API call:", req.path);
next();
}
};
}
import { amISuperuser, makeRequireSuperuserMiddleware, makeRequireSuperuserOrRoleMiddleware } from "./permissions.js";

export async function updateTimeline(state: State, initialSceneID: ObjectId | null): Promise<void> {
let initialScene: WithId<MongoScene> | null = null;
Expand Down Expand Up @@ -76,8 +59,12 @@ export function initializeSuperuserEndpoints(state: State) {
});
});

// A middleware to require that the request comes from the superuser account.
// Middlewares to check various permissions
// For all of these, we accept the superuser as well
const requireSuperuser = makeRequireSuperuserMiddleware(state);
const requireUpdateHomeTimeline = makeRequireSuperuserOrRoleMiddleware(state, "update-home-timeline");
const requireUpdateGlobalTessellation = makeRequireSuperuserOrRoleMiddleware(state, "update-global-tessellation");
const requireManageHandles = makeRequireSuperuserOrRoleMiddleware(state, "manage-handles");

// POST /misc/config-database - Set up some configuration of our backing
// database.
Expand Down Expand Up @@ -120,7 +107,7 @@ export function initializeSuperuserEndpoints(state: State) {

state.app.post(
"/handle/:handle",
requireSuperuser,
requireManageHandles,
async (req: JwtRequest, res: Response) => {
const handle = req.params.handle;

Expand Down Expand Up @@ -179,7 +166,7 @@ export function initializeSuperuserEndpoints(state: State) {

state.app.post(
"/handle/:handle/add-owner",
requireSuperuser,
requireManageHandles,
async (req: JwtRequest, res: Response) => {
const handle = req.params.handle;
const maybe = HandleOwnerAdd.decode(req.body);
Expand Down Expand Up @@ -214,7 +201,7 @@ export function initializeSuperuserEndpoints(state: State) {

state.app.post(
"/misc/update-timeline",
requireSuperuser,
requireUpdateHomeTimeline,
async (req: JwtRequest, res: Response) => {
const initialIDInput = req.query.initial_id;
let initialSceneID: ObjectId | null;
Expand Down Expand Up @@ -245,7 +232,7 @@ export function initializeSuperuserEndpoints(state: State) {

state.app.post(
"/misc/update-global-tessellation",
requireSuperuser,
requireUpdateGlobalTessellation,
async (_req: JwtRequest, res: Response) => {
const MIN_DISTANCE_RAD = 0.01; // about 0.6 deg
const tess = await createGlobalTessellation(state, MIN_DISTANCE_RAD);
Expand Down
35 changes: 35 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,15 @@ __metadata:
languageName: node
linkType: hard

"@types/jsonwebtoken@npm:^9.0.6":
version: 9.0.6
resolution: "@types/jsonwebtoken@npm:9.0.6"
dependencies:
"@types/node": "*"
checksum: a568e7cb1c703bcb015eff8bf5996e276e748d2b39ddc47edf5ddccd1378f5792179c43302a1c803e47a54b0220f9ecaae445ec444d28bf81b88856f899e85b9
languageName: node
linkType: hard

"@types/mime@npm:*":
version: 3.0.4
resolution: "@types/mime@npm:3.0.4"
Expand Down Expand Up @@ -388,6 +397,7 @@ __metadata:
"@types/express-session": ^1.17.7
"@types/geojson": ^7946.0.10
"@types/jsdom": ^21.1.1
"@types/jsonwebtoken": ^9.0.6
"@types/node": ^18.11.15
"@types/node-schedule": ^2.1.6
"@types/spdx-expression-parse": ^3.0.2
Expand All @@ -408,6 +418,7 @@ __metadata:
io-ts: ^2.2
jsdom: ^22.0.0
jwks-rsa: ^3.0
keycloak-js: ^24.0.1
mongodb: ^5.0.1
node-schedule: ^2.1.1
spdx-expression-parse: ^3.0.1
Expand Down Expand Up @@ -1230,6 +1241,13 @@ __metadata:
languageName: node
linkType: hard

"js-sha256@npm:^0.11.0":
version: 0.11.0
resolution: "js-sha256@npm:0.11.0"
checksum: 742d34a0c6eb15247309f1c74889b5a51df01a96e4307375b420fbe973f2f25585012b4d3c8fa52a1b18546153d12587e6463bb725dc1bc58686da03e892c334
languageName: node
linkType: hard

"js-yaml@npm:3.14.1":
version: 3.14.1
resolution: "js-yaml@npm:3.14.1"
Expand Down Expand Up @@ -1338,6 +1356,23 @@ __metadata:
languageName: node
linkType: hard

"jwt-decode@npm:^4.0.0":
version: 4.0.0
resolution: "jwt-decode@npm:4.0.0"
checksum: 390e2edcb31a92e86c8cbdd1edeea4c0d62acd371f8a8f0a8878e499390c0ecf4c658b365c4e941e4ef37d0170e4ca650aaa49f99a45c0b9695a235b210154b0
languageName: node
linkType: hard

"keycloak-js@npm:^24.0.1":
version: 24.0.1
resolution: "keycloak-js@npm:24.0.1"
dependencies:
js-sha256: ^0.11.0
jwt-decode: ^4.0.0
checksum: ad38c57a72b2e2e5cc456e77fe1903317f55882e76460105f7207020fb17a2ddcbe6a3e2c1cf09a359dfdc9202c9dc446bffe132155fac3296c1507e67c8a418
languageName: node
linkType: hard

"kruptein@npm:^3.0.0":
version: 3.0.6
resolution: "kruptein@npm:3.0.6"
Expand Down