Skip to content
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
3 changes: 3 additions & 0 deletions packages/api-gateway-service/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,6 @@ CONFIG_PATH=

## Keycloak Public Key for JWT Token verification
KEYCLOAK_PUBKEY=

## User blacklist (optional, loaded once at startup)
BLACKLIST_FILE_PATH=./blacklist.txt
3 changes: 3 additions & 0 deletions packages/api-gateway-service/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,8 @@ node_modules
# ignore dist
dist

# Local blacklist copy
blacklist.txt

# Logs
npm-debug.log*
25 changes: 25 additions & 0 deletions packages/api-gateway-service/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,31 @@ API Gateway handles all the tasks involved in accepting and processing up to hun

*Note:* Before starting the gateway, also make sure the microservices in this project are configured properly.

## User blacklist

When `BLACKLIST_FILE_PATH` is set, the gateway loads a text file of blocked user identifiers once at startup. Restart the gateway to pick up file changes.

```env
BLACKLIST_FILE_PATH=./blacklist.txt
```

See [`blacklist.example.txt`](blacklist.example.txt). One **uid** or **email** per line; empty lines and `#` comments are ignored.

Regenerate from Compass:

```bash
node scripts/generate-blacklist-from-compass-output.mjs <catalog-entity.json>
```

Matching uses the **token owner** identity (not `rhatUUID`):

- **JWT:** Keycloak `uid` and `email` (or `mail`) from the access token
- **API key:** owning user's `uid` and `mail` from User Group when `ownerType` is `User`

Downstream forwarding still uses `rhatUUID` in Apollo context / `X-OP-User-ID` for JWTs. Group-owned API keys are not evaluated against the blacklist.

OpenShift: [openshift/README.md](openshift/README.md).

## Running Tests

```bash
Expand Down
3 changes: 3 additions & 0 deletions packages/api-gateway-service/blacklist.example.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# One identifier per line (uid or email)
jdoe
blocked.user@redhat.com
3 changes: 2 additions & 1 deletion packages/api-gateway-service/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ module.exports = {
},
"collectCoverage": true,
"testMatch": [
"**/src/e2e/*.spec.(ts|tsx|js)"
"**/src/e2e/*.spec.(ts|tsx|js)",
"**/src/blacklist/*.spec.(ts|tsx|js)"
],
"testEnvironment": "node"
}
Expand Down
2 changes: 1 addition & 1 deletion packages/api-gateway-service/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"dev": "webpack --watch --config webpack.dev.js",
"build": "webpack --config webpack.prod.js",
"build:dev": "webpack --config webpack.dev.js",
"test": "echo \"Error: no test specified\""
"test": "jest"
},
"author": {
"name": "Rigin Oommen",
Expand Down
154 changes: 98 additions & 56 deletions packages/api-gateway-service/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ if ( process.env.NODE_ENV === 'test' ) {
dotenv.config();
}

import { ApolloServer, AuthenticationError } from 'apollo-server-express';
import { ApolloServer, AuthenticationError, ForbiddenError } from 'apollo-server-express';
import express from 'express';
import http from 'http';
import cors from 'cors';
Expand All @@ -15,6 +15,14 @@ import { stitchedSchemas } from './src/stitch-schema';
import { verifyAPIKey, verifyJwtToken } from './src/verify-token';
import path from 'path';
import helmet from 'helmet';
import {
getBlacklistIndex,
initBlacklist,
isBlacklistEnabled,
} from './src/blacklist/blacklist';
import { extractTokenOwnerClaims } from './src/blacklist/extractUserClaims';
import { extractOwnerClaims } from './src/blacklist/extractOwnerClaims';
import { isUserBlacklisted } from './src/blacklist/isUserBlacklisted';

/* Setting base url and port for the server */
const baseUrl = process.env.BASE_URL ?? '/';
Expand All @@ -38,6 +46,15 @@ app.use( helmet( {
/* include cors middleware */
app.use( cors() );

function assertNotBlacklisted( claims: { uid?: string; email?: string } ): void {
if ( !isBlacklistEnabled() ) {
return;
}
if ( isUserBlacklisted( getBlacklistIndex(), claims ) ) {
throw new ForbiddenError( 'Access denied' );
}
}

const context = ({ req, connection }: any) => {
const authorizationHeader = req?.headers?.authorization || connection?.context?.Authorization;

Expand All @@ -52,71 +69,96 @@ const context = ({ req, connection }: any) => {
const token = authorizationHeader.split( ' ' )[ 1 ];

if ( uuidValidate( token ) ) {
return verifyAPIKey(token)
.then((res) => ({ uid: res._id, roles: res.roles, scopes: res.scopes, token }))
.catch((err) => {
throw new AuthenticationError(err.message);
});
} else {
return verifyJwtToken( token, ( err: any, payload: any ) => {
if ( err ) {
return verifyAPIKey( token )
.then( ( res ) => {
if ( res.ownerType === 'User' && res.owner ) {
assertNotBlacklisted( extractOwnerClaims( res.owner ) );
}
return { uid: res._id, roles: res.roles, scopes: res.scopes, token };
} )
.catch( ( err ) => {
if ( err instanceof ForbiddenError ) {
throw err;
}
throw new AuthenticationError( err.message );
} );
}

return new Promise( ( resolve, reject ) => {
verifyJwtToken( token, ( err: any, payload: any ) => {
if ( err ) {
reject( new AuthenticationError( err.message ) );
return;
}
try {
assertNotBlacklisted( extractTokenOwnerClaims( payload ) );
resolve( {
uid: payload.rhatUUID,
roles: payload.role,
scope: payload.scope?.split( ' ' ),
token,
} );
} catch ( blacklistErr ) {
reject( blacklistErr );
}
return { uid: payload.rhatUUID, roles: payload.role, scope: payload.scope?.split(' '), token };
} );
}
} );
};

stitchedSchemas()
.then( schema => {
/* Defining the Apollo Server */
const apollo = new ApolloServer( {
subscriptions: {
path: subsciptionsBaseUrl,
/* Creating the server based on the environment */
const server = http.createServer( app );

async function startGateway(): Promise<void> {
await initBlacklist();

const schema = await stitchedSchemas();

const apollo = new ApolloServer( {
subscriptions: {
path: subsciptionsBaseUrl,
},
schema,
context,
introspection: true,
tracing: process.env.NODE_ENV !== 'production',
playground: <any>{
title: 'API Gateway',
settings: {
'request.credentials': 'include'
},
schema,
context,
introspection: true,
tracing: process.env.NODE_ENV !== 'production',
playground: <any>{
title: 'API Gateway',
settings: {
'request.credentials': 'include'
},
headers: {
Authorization: `Bearer <ENTER_API_KEY_HERE>`, /* lgtm [js/hardcoded-credentials] */
},
headers: {
Authorization: `Bearer <ENTER_API_KEY_HERE>`, /* lgtm [js/hardcoded-credentials] */
},
plugins: [
{
requestDidStart: ( requestContext ) => {
if ( requestContext.request.http?.headers.has( 'x-apollo-tracing' ) ) {
return;
}
console.log( new Date().toISOString(), `- Incoming ${ requestContext.request.http?.method } request from: ${ requestContext.request.http?.headers.get( 'origin' ) || 'unknown' }`, `- via ${ requestContext.request.http?.headers.get( 'user-agent' ) }` );
},
plugins: [
{
requestDidStart: ( requestContext ) => {
if ( requestContext.request.http?.headers.has( 'x-apollo-tracing' ) ) {
return;
}
console.log( new Date().toISOString(), `- Incoming ${ requestContext.request.http?.method } request from: ${ requestContext.request.http?.headers.get( 'origin' ) || 'unknown' }`, `- via ${ requestContext.request.http?.headers.get( 'user-agent' ) }` );
}
],
formatError: error => ( {
message: error.message,
locations: error.locations,
path: error.path,
...error.extensions,
} ),
} );

/* Applying apollo middleware to express server */
apollo.applyMiddleware( { app, path: baseUrl } );
apollo.installSubscriptionHandlers( server );
} )
.catch( err => {
console.error( err );
throw err;
}
],
formatError: error => ( {
message: error.message,
locations: error.locations,
path: error.path,
...error.extensions,
} ),
} );

/* Creating the server based on the environment */
const server = http.createServer( app );
apollo.applyMiddleware( { app, path: baseUrl } );
apollo.installSubscriptionHandlers( server );

export default server.listen( port, () => {
console.log( `Gateway Running on ${ process.env.NODE_ENV } environment at port ${ port }` );
server.listen( port, () => {
console.log( `Gateway Running on ${ process.env.NODE_ENV } environment at port ${ port }` );
} );
}

startGateway().catch( err => {
console.error( err );
process.exit( 1 );
} );

export default server;
28 changes: 28 additions & 0 deletions packages/api-gateway-service/src/blacklist/blacklist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { loadBlacklistFromFile } from './loadBlacklistFromFile';
import { BlacklistIndex } from './types';

const emptyIndex = (): BlacklistIndex => ({ entries: new Set<string>() });

let blacklistIndex: BlacklistIndex = emptyIndex();

function getBlacklistFilePath(): string | undefined {
const path = process.env.BLACKLIST_FILE_PATH?.trim();
return path || undefined;
}

export function isBlacklistEnabled(): boolean {
return Boolean(getBlacklistFilePath());
}

export function getBlacklistIndex(): BlacklistIndex {
return blacklistIndex;
}

export async function initBlacklist(): Promise<void> {
const filePath = getBlacklistFilePath();
if (!filePath) {
blacklistIndex = emptyIndex();
return;
}
blacklistIndex = await loadBlacklistFromFile(filePath);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { extractOwnerClaims } from './extractOwnerClaims';
import { parseBlacklistFile } from './parseBlacklistFile';
import { isUserBlacklisted } from './isUserBlacklisted';

describe('extractOwnerClaims', () => {
it('maps uid and mail to UserClaims', () => {
expect(
extractOwnerClaims({ uid: 'jdoe', mail: 'jdoe@redhat.com' })
).toEqual({
uid: 'jdoe',
email: 'jdoe@redhat.com',
});
});

it('omits missing fields', () => {
expect(extractOwnerClaims({})).toEqual({
uid: undefined,
email: undefined,
});
});
});

describe('API key owner blacklist', () => {
const index = parseBlacklistFile('jdoe\nblocked@redhat.com');

it('blocks User owner when uid is listed', () => {
const claims = extractOwnerClaims({ uid: 'jdoe', mail: 'jdoe@redhat.com' });
expect(isUserBlacklisted(index, claims)).toBe(true);
});

it('blocks User owner when mail is listed', () => {
const claims = extractOwnerClaims({
uid: 'other',
mail: 'blocked@redhat.com',
});
expect(isUserBlacklisted(index, claims)).toBe(true);
});

it('allows User owner when neither field matches', () => {
const claims = extractOwnerClaims({
uid: 'allowed',
mail: 'allowed@redhat.com',
});
expect(isUserBlacklisted(index, claims)).toBe(false);
});
});
13 changes: 13 additions & 0 deletions packages/api-gateway-service/src/blacklist/extractOwnerClaims.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { UserClaims } from './types';

export type ApiKeyOwnerUser = {
uid?: string;
mail?: string;
};

export function extractOwnerClaims(owner: ApiKeyOwnerUser): UserClaims {
return {
uid: typeof owner.uid === 'string' ? owner.uid : undefined,
email: typeof owner.mail === 'string' ? owner.mail : undefined,
};
}
Loading
Loading