Skip to content

Commit

Permalink
Add Endpoint to Trigger Mission Model Extraction (#93)
Browse files Browse the repository at this point in the history
* Update Package-lock.json

* Add `HASURA_METADATA_API_URL` envvar

* Add auth handler for admin-only endpoints

- uses the jwt and hasura 'x-hasura-role' headers to authorize
- validate that the JWT is good (ie unexpired) when `AUTH_TYPE=NONE`

* Allow specifying expiry when generating a JWT

* Add `/modelExtraction` endpoint
  • Loading branch information
Mythicaeda authored Jun 27, 2024
1 parent 218531b commit 37ce58a
Show file tree
Hide file tree
Showing 7 changed files with 839 additions and 460 deletions.
1 change: 1 addition & 0 deletions docs/ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ This document provides detailed information about environment variables for the
| `DEFAULT_ROLE_NO_AUTH` | Default role when authentication is disabled. | `string` | aerie_admin |
| `GQL_API_URL` | URL of GraphQL API for the GraphQL Playground. | `string` | http://localhost:8080/v1/graphql |
| `GQL_API_WS_URL` | URL of GraphQL WebSocket API for the GraphQL Playground. | `string` | ws://localhost:8080/v1/graphql |
| `HASURA_METADATA_API_URL` | URL of Hasura Metadata API. | `string` | http://hasura:8080/v1/metadata |
| `HASURA_GRAPHQL_JWT_SECRET` | The JWT secret. Also in Hasura. **Required** even if auth off in Hasura. | `string` | |
| `JWT_ALGORITHMS` | List of [JWT signing algorithms][algorithms]. Must include algorithm in `HASURA_GRAPHQL_JWT_SECRET`. | `array` | ["HS256"] |
| `JWT_EXPIRATION` | Amount of time until JWT expires. | `string` | 36h |
Expand Down
1,107 changes: 660 additions & 447 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export type Env = {
JWT_EXPIRATION: string;
LOG_FILE: string;
LOG_LEVEL: string;
HASURA_METADATA_API_URL: string;
PORT: string;
AERIE_DB_HOST: string;
AERIE_DB_PORT: string;
Expand Down Expand Up @@ -45,6 +46,7 @@ export const defaultEnv: Env = {
GQL_API_URL: 'http://localhost:8080/v1/graphql',
GQL_API_WS_URL: 'ws://localhost:8080/v1/graphql',
HASURA_GRAPHQL_JWT_SECRET: '',
HASURA_METADATA_API_URL: 'http://hasura:8080/v1/metadata',
JWT_ALGORITHMS: ['HS256'],
JWT_EXPIRATION: '36h',
LOG_FILE: 'console',
Expand Down Expand Up @@ -116,6 +118,7 @@ export function getEnv(): Env {
const GQL_API_URL = env['GQL_API_URL'] ?? defaultEnv.GQL_API_URL;
const GQL_API_WS_URL = env['GQL_API_WS_URL'] ?? defaultEnv.GQL_API_WS_URL;
const HASURA_GRAPHQL_JWT_SECRET = env['HASURA_GRAPHQL_JWT_SECRET'] ?? defaultEnv.HASURA_GRAPHQL_JWT_SECRET;
const HASURA_METADATA_API_URL = env['HASURA_METADATA_API_URL'] ?? defaultEnv.HASURA_METADATA_API_URL;
const JWT_ALGORITHMS = parseArray(env['JWT_ALGORITHMS'], defaultEnv.JWT_ALGORITHMS);
const JWT_EXPIRATION = env['JWT_EXPIRATION'] ?? defaultEnv.JWT_EXPIRATION;
const LOG_FILE = env['LOG_FILE'] ?? defaultEnv.LOG_FILE;
Expand Down Expand Up @@ -146,6 +149,7 @@ export function getEnv(): Env {
GQL_API_URL,
GQL_API_WS_URL,
HASURA_GRAPHQL_JWT_SECRET,
HASURA_METADATA_API_URL,
JWT_ALGORITHMS,
JWT_EXPIRATION,
LOG_FILE,
Expand Down
2 changes: 2 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import initApiPlaygroundRoutes from './packages/api-playground/api-playground.js
import initAuthRoutes from './packages/auth/routes.js';
import { DbMerlin } from './packages/db/db.js';
import initFileRoutes from './packages/files/files.js';
import initHasuraRoutes from './packages/hasura/hasura-events.js';
import initHealthRoutes from './packages/health/health.js';
import initSwaggerRoutes from './packages/swagger/swagger.js';
import cookieParser from 'cookie-parser';
Expand Down Expand Up @@ -44,6 +45,7 @@ async function main(): Promise<void> {
initAuthRoutes(app, authHandler);
initFileRoutes(app);
initHealthRoutes(app);
initHasuraRoutes(app);
initSwaggerRoutes(app);

app.listen(PORT, () => {
Expand Down
11 changes: 8 additions & 3 deletions src/packages/auth/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,11 +129,16 @@ export function decodeJwt(authorizationHeader: string | undefined): JwtDecode {
}
}

export function generateJwt(username: string, defaultRole: string, allowedRoles: string[]): string | null {
export function generateJwt(
username: string,
defaultRole: string,
allowedRoles: string[],
expiry: string = getEnv().JWT_EXPIRATION,
): string | null {
try {
const { HASURA_GRAPHQL_JWT_SECRET, JWT_EXPIRATION } = getEnv();
const { HASURA_GRAPHQL_JWT_SECRET } = getEnv();
const { key, type }: JwtSecret = JSON.parse(HASURA_GRAPHQL_JWT_SECRET);
const options: jwt.SignOptions = { algorithm: type as Algorithm, expiresIn: JWT_EXPIRATION };
const options: jwt.SignOptions = { algorithm: type as Algorithm, expiresIn: expiry };
const payload: JwtPayload = {
'https://hasura.io/jwt/claims': {
'x-hasura-allowed-roles': allowedRoles,
Expand Down
50 changes: 40 additions & 10 deletions src/packages/auth/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,50 @@
import type { NextFunction, Request, Response } from 'express';
import { getEnv } from '../../env.js';
import { session } from './functions.js';
import { decodeJwt, session } from './functions.js';

export const auth = async (req: Request, res: Response, next: NextFunction) => {
const { AUTH_TYPE } = getEnv();
const authorizationHeader = req.get('authorization');
const response = await session(authorizationHeader);

if (AUTH_TYPE === 'none') {
if (response.success) {
next();
} else {
const authorizationHeader = req.get('authorization');
const response = await session(authorizationHeader);
res.status(401).send({ message: 'Unauthorized', success: false });
}
};

// Only permits `aerie_admin` users
export const adminOnlyAuth = async (req: Request, res: Response, next: NextFunction) => {
const authorizationHeader = req.get('authorization');
const response = await session(authorizationHeader);

if (response.success) {
next();
} else {
res.status(401).send({ message: 'Unauthorized', success: false });
if (response.success) {
const { jwtPayload } = decodeJwt(authorizationHeader);
if (jwtPayload == null) {
res.status(401).send({ message: 'No authorization headers present.' });
return;
}

const defaultRole = jwtPayload['https://hasura.io/jwt/claims']['x-hasura-default-role'] as string;
const allowedRoles = jwtPayload['https://hasura.io/jwt/claims']['x-hasura-allowed-roles'] as string[];

const { headers } = req;
const { 'x-hasura-role': role } = headers;

if (role != undefined) {
if (!allowedRoles.includes(role as string)) {
res.status(401).send({ message: 'Declared role is not in allowed roles.' });
return;
}
if (role != 'aerie_admin') {
res.status(403).send({ message: 'Current active role is unauthorized.' });
return;
}
} else if (defaultRole != 'aerie_admin') {
res.status(403).send({ message: 'Current active role is unauthorized.' });
return;
}
next();
} else {
res.status(401).send({ message: 'Unauthorized', success: false });
}
};
124 changes: 124 additions & 0 deletions src/packages/hasura/hasura-events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import type { Express } from 'express';
import { adminOnlyAuth } from '../auth/middleware.js';
import rateLimit from 'express-rate-limit';
import getLogger from '../../logger.js';
import { getEnv } from '../../env.js';
import { generateJwt, decodeJwt } from '../auth/functions.js';

export default (app: Express) => {
const logger = getLogger('packages/hasura/hasura-events');
const { RATE_LIMITER_LOGIN_MAX } = getEnv();

const refreshLimiter = rateLimit({
legacyHeaders: false,
max: RATE_LIMITER_LOGIN_MAX,
standardHeaders: true,
windowMs: 15 * 60 * 1000, // 15 minutes
});

/**
* @swagger
* /modelExtraction:
* post:
* security:
* - bearerAuth: []
* consumes:
* - application/json
* produces:
* - application/json
* parameters:
* - in: header
* name: x-hasura-role
* schema:
* type: string
* required: false
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* missionModelId:
* type: integer
* responses:
* 200:
* description: ExtractionResponse
* 403:
* description: Unauthorized error
* 401:
* description: Unauthenticated error
* summary: Request extraction of a Mission Model's JAR
* tags:
* - Hasura
*/
app.post('/modelExtraction', refreshLimiter, adminOnlyAuth, async (req, res) => {
const { jwtPayload } = decodeJwt(req.get('authorization'));
const username = jwtPayload?.username as string;

const { body } = req;
const { missionModelId } = body;

// Invoke endpoints using the Hasura Metadata API
const { HASURA_METADATA_API_URL: metadataURL } = getEnv();

// Generate a temporary token that has Hasura Admin access
const tempToken = generateJwt(username, 'admin', ['admin'], '10s');

const headers = {
Authorization: `Bearer ${tempToken}`,
'Content-Type': 'application/json',
'x-hasura-role': 'admin',
'x-hasura-user-id': username,
};

const generateBody = (name: string) =>
JSON.stringify({
args: {
name: `refresh${name}`,
payload: { id: missionModelId },
source: 'Aerie',
},
type: 'pg_invoke_event_trigger',
});

const extract = async (name: string) => {
return await fetch(metadataURL, {
body: generateBody(name),
headers,
method: 'POST',
})
.then(response => {
if (!response.ok) {
logger.error(`Bad status received when extracting ${name}: [${response.status}] ${response.statusText}`);
return {
error: `Bad status received when extracting ${name}: [${response.status}] ${response.statusText}`,
status: response.status,
statusText: response.statusText,
};
}
return response.json();
})
.catch(error => {
logger.error(`Error connecting to Hasura metadata API at ${metadataURL}. Full error below:\n${error}`);
return { error: `Error connecting to metadata API at ${metadataURL}` };
});
};

const [activityTypeResp, modelParameterResp, resourceTypeResp] = await Promise.all([
extract('ActivityTypes'),
extract('ModelParameters'),
extract('ResourceTypes'),
]);

logger.info(`POST /modelExtraction: Extraction triggered for model: ${missionModelId}`);

res.json({
message: `Extraction triggered for model: ${missionModelId}`,
response: {
activity_types: activityTypeResp,
model_parameters: modelParameterResp,
resource_types: resourceTypeResp,
},
});
});
};

0 comments on commit 37ce58a

Please sign in to comment.