Skip to content

Commit

Permalink
fix(API): Implement authorisation (#103)
Browse files Browse the repository at this point in the history
Fixes #84.

# High-level changes

- Implement _super admin_ support by using an optional env var (`AUTHORITY_SUPERADMIN`) that specifies the JWT subject id of the super admin (i.e., their email address).
- **Existing API tests are now run as a super admin**.
- Update test suite for each endpoint to run authorisation checks with every type of user (using test util `testOrgRouteAuth()`).
- Anonymous users now get a `401` on any endpoint under `/orgs`.
- Regular members aren't allowed to update their own membership for security reasons: they shouldn't be allowed to change anything -- especially their user name. 
- Regular members aren't allowed to delete their own membership: I can't think of any legitimate use case for it, but I can think of things goes awry if the membership dataset for an organisation goes out of sync with the org's data sources.

# Notes

- Auth takes precedence over existence checks for security reasons. So, for example, no authenticated user could just request `GET /orgs/bbc.com` to check if `bbc.com` is registered:
  - A `403` will be returned regardless of whether `bbc.com` exists or not, if the current user isn't an admin of `bbc.com`. For example, if the user is a regular member of `bbc.com` or not even a member at all.
  - A `200` will be returned if the org exists and the user is an admin of `bbc.com`.
  - Super admins will get a `200` if the org exists or `404` if it doesn't.

# Testing

First off, you need an access token to make authenticated requests to anything under `/orgs`. To obtain one for the super admin (`admin@veraid.example`), run:

```http
### Authenticate with authorisation server (client credentials)
POST http://mock-authz-server.default.10.103.177.106.sslip.io/default/token
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&client_id=admin@veraid.example&client_secret=s3cr3t
```

Replace the email address above to impersonate any user -- even someone who's not a member of any org.

You can now make authenticated requests. For example:

```http
### Create org
POST http://veraid-authority.default.10.103.177.106.sslip.io/orgs
Authorization: Bearer <INSERT-ACCESS-TOKEN-HERE>
Content-Type: application/json

{
  "name": "example.com",
  "memberAccessType": "OPEN"
}

### Get org
GET http://veraid-authority.default.10.103.177.106.sslip.io/orgs/example.com
Authorization: Bearer <INSERT-ACCESS-TOKEN-HERE>
```
  • Loading branch information
gnarea authored Apr 20, 2023
1 parent a310818 commit 671a424
Show file tree
Hide file tree
Showing 29 changed files with 856 additions and 314 deletions.
26 changes: 21 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ All processes require the following variables:
- `KMS_ADAPTER` (required; e.g., `AWS`, `GCP`).
- Any other variable required by the specific adapter in use. Refer to the [`@relaycorp/webcrypto-kms` documentation](https://www.npmjs.com/package/@relaycorp/webcrypto-kms).

The API server additionally requires the following variables:
The API server additionally uses the following variables:

- Authentication-related variables:
- `OAUTH2_JWKS_URL` (required). The URL to the JWKS endpoint of the authorisation server.
- Either `OAUTH2_TOKEN_ISSUER` or `OAUTH2_TOKEN_ISSUER_REGEX` (required). The (URL of the) authorisation server.
- `OAUTH2_TOKEN_AUDIENC[example.sink.spec.ts](src%2FbackgroundQueue%2Fsinks%2Fexample.sink.spec.ts)E` (required). The identifier of the current instance of this server (typically its public URL).
- Authorisation-related variables:
- `AUTHORITY_SUPERADMIN` (optional): The JWT _subject id_ of the super admin, which in this app we require it to be an email address. When unset, routes that require super admin role (e.g., `POST /orgs`) won't work by design. This is desirable in cases where an instance of this server will only ever support a handful of domain names (they could set the `AUTHORITY_SUPERADMIN` to create the orgs, and then unset the super admin var).

## Development

Expand All @@ -35,12 +37,24 @@ To start the app, simply run:
skaffold dev
```

You can find the URL to the HTTP server by running:
You can find the URL to the HTTP servers by running:

```
kn service describe veraid-authority -o url
kn service list
```

To make authenticated requests to the API server, you need to get an access token from the mock authorisation server first. For example, to get an access token for the super admin (`admin@veraid.example`), run:

```http
### Authenticate with authorisation server (client credentials)
POST http://mock-authz-server.default.10.103.177.106.sslip.io/default/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&client_id=admin@veraid.example&client_secret=s3cr3t
```

You can then make authenticated requests to the API server by setting the `Authorization` header to `Bearer <access_token>`.

## Architecture

This multi-tenant server will allow one or more organisations to manage their VeraId setup, and it'll also allow organisation members to claim and renew their VeraId Ids.
Expand All @@ -49,14 +63,16 @@ This multi-tenant server will allow one or more organisations to manage their Ve

### Authentication and authorisation

We use OAuth2 with JWKS to delegate authentication to an external identity provider.
We use OAuth2 with JWKS to delegate authentication to an external identity provider. We require the JWT token's `sub` claim to be the email address of the user.

The API employs the following roles:

- Admin. They can do absolutely anything on any organisation.
- Super admin. They can do absolutely anything on any organisation.
- Org admin. They can do anything within their own organisation.
- Org member. They can manage much of their own membership in their respective organisation.

Authorisation grant logs use the level `DEBUG` to minimise PII transmission and storage for legal/privacy reasons, whilst denial logs use the level `INFO` for auditing purposes.

### HTTP Endpoints

It will support the following API endpoints, which are to be consumed by the VeraId CA Console (a CLI used by organisation admins) and VeraId signature producers (used by organisation members):
Expand Down
3 changes: 3 additions & 0 deletions k8s/api-service.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ spec:
env:
- name: AUTHORITY_VERSION
value: "1.0.0dev1"
- name: AUTHORITY_SUPERADMIN
value: admin@veraid.example

- name: MONGODB_USERNAME
valueFrom:
configMapKeyRef:
Expand Down
112 changes: 112 additions & 0 deletions src/api/orgAuthPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { getModelForClass } from '@typegoose/typegoose';
import envVar from 'env-var';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import fastifyPlugin, { type PluginMetadata } from 'fastify-plugin';
import type { Connection } from 'mongoose';

import { MemberModelSchema, Role } from '../models/Member.model.js';
import { HTTP_STATUS_CODES } from '../utilities/http.js';
import type { Result } from '../utilities/result.js';
import type { PluginDone } from '../utilities/fastify/PluginDone.js';

interface OrgRequestParams {
readonly orgName: string;
readonly memberId: string;
}

interface AuthenticatedFastifyRequest extends FastifyRequest {
user: { sub: string };
}

interface AuthorisedFastifyRequest extends AuthenticatedFastifyRequest {
isUserAdmin: boolean;
}

interface AuthorisationGrant {
readonly isAdmin: boolean;
readonly reason: string;
}

async function decideAuthorisation(
userEmail: string,
request: FastifyRequest,
dbConnection: Connection,
superAdmin?: string,
): Promise<Result<AuthorisationGrant, string>> {
if (superAdmin === userEmail) {
return { didSucceed: true, result: { reason: 'User is super admin', isAdmin: true } };
}

const { orgName, memberId } = request.params as Partial<OrgRequestParams>;

if (orgName === undefined) {
return { didSucceed: false, reason: 'Non-super admin tries to access bulk org endpoint' };
}

const memberModel = getModelForClass(MemberModelSchema, {
existingConnection: dbConnection,
});
const member = await memberModel.findOne({ orgName, email: userEmail }).select(['role']);
if (member === null) {
return { didSucceed: false, reason: 'User is not a member of the org' };
}
if (member.role === Role.ORG_ADMIN) {
return { didSucceed: true, result: { reason: 'User is org admin', isAdmin: true } };
}

if (member.id === memberId) {
return {
didSucceed: true,
result: { reason: 'User is accessing their own membership', isAdmin: false },
};
}

return { didSucceed: false, reason: 'User is not accessing their membership' };
}

async function denyAuthorisation(reason: string, reply: FastifyReply, userEmail: string) {
reply.log.info({ userEmail, reason }, 'Authorisation denied');
await reply.code(HTTP_STATUS_CODES.FORBIDDEN).send();
}

function registerOrgAuth(fastify: FastifyInstance, _opts: PluginMetadata, done: PluginDone): void {
fastify.addHook('onRequest', fastify.authenticate);

fastify.decorateRequest('isUserAdmin', false);

fastify.addHook('onRequest', async (request, reply) => {
const superAdmin = envVar.get('AUTHORITY_SUPERADMIN').asString();
const userEmail = (request as AuthenticatedFastifyRequest).user.sub;
const decision = await decideAuthorisation(userEmail, request, fastify.mongoose, superAdmin);
const reason = decision.didSucceed ? decision.result.reason : decision.reason;
if (decision.didSucceed) {
(request as AuthorisedFastifyRequest).isUserAdmin = decision.result.isAdmin;
request.log.debug({ userEmail, reason }, 'Authorisation granted');
} else {
await denyAuthorisation(reason, reply, userEmail);
}
});

done();
}

/**
* Require the current user to be a super admin or an admin of the current org.
*
* This is defined as type `any` instead of `preParsingHookHandler` because the latter would
* discard the types for all the request parameters (e.g., `request.params`) in the route, since
* `preParsingHookHandler` doesn't offer a generic parameter that honours such parameters.
*/
const requireUserToBeAdmin: any = async (
request: AuthorisedFastifyRequest,
reply: FastifyReply,
) => {
if (!request.isUserAdmin) {
await denyAuthorisation('User is not an admin', reply, request.user.sub);
}
};

const orgAuthPlugin = fastifyPlugin(registerOrgAuth, { name: 'org-auth' });
export default orgAuthPlugin;

export { requireUserToBeAdmin };
63 changes: 31 additions & 32 deletions src/api/routes/awala.routes.spec.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import { jest } from '@jest/globals';
import type { FastifyInstance } from 'fastify';

import type { FastifyTypedInstance } from '../../utilities/fastify/FastifyTypedInstance.js';
import { HTTP_STATUS_CODES } from '../../utilities/http.js';
import { mockSpy } from '../../testUtils/jest.js';
import type { Result } from '../../utilities/result.js';
import { type MockLogSet, partialPinoLog } from '../../testUtils/logging.js';
import { generateKeyPair } from '../../testUtils/webcrypto.js';
import { derSerialisePublicKey } from '../../utilities/webcrypto.js';
import { MemberPublicKeyImportProblemType } from '../../MemberKeyImportTokenProblemType.js';
import type { MemberProblemType } from '../../MemberProblemType.js';
import {
AWALA_PDA,
MEMBER_KEY_IMPORT_TOKEN,
MEMBER_PUBLIC_KEY_MONGO_ID,
MEMBER_KEY_IMPORT_TOKEN,
SIGNATURE,
} from '../../testUtils/stubs.js';
import { makeMockLogging, partialPinoLog } from '../../testUtils/logging.js';
import { generateKeyPair } from '../../testUtils/webcrypto.js';
import { derSerialisePublicKey } from '../../utilities/webcrypto.js';
import { MemberPublicKeyImportProblemType } from '../../MemberKeyImportTokenProblemType.js';
import type { MemberProblemType } from '../../MemberProblemType.js';

const mockProcessMemberKeyImportToken = mockSpy(
jest.fn<() => Promise<Result<undefined, MemberPublicKeyImportProblemType>>>(),
Expand All @@ -38,16 +38,15 @@ const publicKeyBuffer = await derSerialisePublicKey(publicKey);
const publicKeyBase64 = publicKeyBuffer.toString('base64');

describe('awala routes', () => {
const mockLogging = makeMockLogging();
const getTestApiServer = makeTestApiServer(mockLogging.logger);
let serverInstance: FastifyTypedInstance;

const getTestServerFixture = makeTestApiServer();
let server: FastifyInstance;
let logs: MockLogSet;
beforeEach(() => {
serverInstance = getTestApiServer();
({ server, logs } = getTestServerFixture());
});

test('Invalid content type should resolve to unsupported media type error', async () => {
const response = await serverInstance.inject({
const response = await server.inject({
method: 'POST',
url: '/awala',

Expand All @@ -72,7 +71,7 @@ describe('awala routes', () => {
};

test('Valid data should be accepted', async () => {
const response = await serverInstance.inject({
const response = await server.inject({
method: 'POST',
url: '/awala',
headers: validHeaders,
Expand All @@ -84,8 +83,8 @@ describe('awala routes', () => {

expect(response).toHaveProperty('statusCode', HTTP_STATUS_CODES.ACCEPTED);
expect(mockCreateMemberBundleRequest).toHaveBeenCalledOnceWith(validPayload, {
logger: serverInstance.log,
dbConnection: serverInstance.mongoose,
logger: server.log,
dbConnection: server.mongoose,
});
});

Expand All @@ -95,15 +94,15 @@ describe('awala routes', () => {
memberBundleStartDate: 'INVALID_DATE',
};

const response = await serverInstance.inject({
const response = await server.inject({
method: 'POST',
url: '/awala',
headers: validHeaders,
payload: methodPayload,
});

expect(response).toHaveProperty('statusCode', HTTP_STATUS_CODES.BAD_REQUEST);
expect(mockLogging.logs).toContainEqual(
expect(logs).toContainEqual(
partialPinoLog('info', 'Refused invalid member bundle request', {
publicKeyId: MEMBER_PUBLIC_KEY_MONGO_ID,
reason: expect.stringContaining('memberBundleStartDate'),
Expand All @@ -117,15 +116,15 @@ describe('awala routes', () => {
awalaPda: 'INVALID_BASE_64',
};

const response = await serverInstance.inject({
const response = await server.inject({
method: 'POST',
url: '/awala',
headers: validHeaders,
payload: methodPayload,
});

expect(response).toHaveProperty('statusCode', HTTP_STATUS_CODES.BAD_REQUEST);
expect(mockLogging.logs).toContainEqual(
expect(logs).toContainEqual(
partialPinoLog('info', 'Refused invalid member bundle request', {
publicKeyId: MEMBER_PUBLIC_KEY_MONGO_ID,
reason: expect.stringContaining('awalaPda'),
Expand All @@ -139,15 +138,15 @@ describe('awala routes', () => {
signature: 'INVALID_BASE_64',
};

const response = await serverInstance.inject({
const response = await server.inject({
method: 'POST',
url: '/awala',
headers: validHeaders,
payload: methodPayload,
});

expect(response).toHaveProperty('statusCode', HTTP_STATUS_CODES.BAD_REQUEST);
expect(mockLogging.logs).toContainEqual(
expect(logs).toContainEqual(
partialPinoLog('info', 'Refused invalid member bundle request', {
publicKeyId: MEMBER_PUBLIC_KEY_MONGO_ID,
reason: expect.stringContaining('signature'),
Expand All @@ -172,7 +171,7 @@ describe('awala routes', () => {
didSucceed: true,
});

const response = await serverInstance.inject({
const response = await server.inject({
method: 'POST',
url: '/awala',
headers: validHeaders,
Expand All @@ -181,8 +180,8 @@ describe('awala routes', () => {

expect(response).toHaveProperty('statusCode', HTTP_STATUS_CODES.ACCEPTED);
expect(mockProcessMemberKeyImportToken).toHaveBeenCalledOnceWith(validPayload, {
logger: serverInstance.log,
dbConnection: serverInstance.mongoose,
logger: server.log,
dbConnection: server.mongoose,
});
});

Expand All @@ -192,15 +191,15 @@ describe('awala routes', () => {
awalaPda: 'INVALID_BASE_64',
};

const response = await serverInstance.inject({
const response = await server.inject({
method: 'POST',
url: '/awala',
headers: validHeaders,
payload: methodPayload,
});

expect(response).toHaveProperty('statusCode', HTTP_STATUS_CODES.BAD_REQUEST);
expect(mockLogging.logs).toContainEqual(
expect(logs).toContainEqual(
partialPinoLog('info', 'Refused invalid member bundle request', {
reason: expect.stringContaining('awalaPda'),
}),
Expand All @@ -213,15 +212,15 @@ describe('awala routes', () => {
publicKeyImportToken: undefined,
};

const response = await serverInstance.inject({
const response = await server.inject({
method: 'POST',
url: '/awala',
headers: validHeaders,
payload: methodPayload,
});

expect(response).toHaveProperty('statusCode', HTTP_STATUS_CODES.BAD_REQUEST);
expect(mockLogging.logs).toContainEqual(
expect(logs).toContainEqual(
partialPinoLog('info', 'Refused invalid member bundle request', {
reason: expect.stringContaining('publicKeyImportToken'),
}),
Expand All @@ -237,7 +236,7 @@ describe('awala routes', () => {
reason,
});

const response = await serverInstance.inject({
const response = await server.inject({
method: 'POST',
url: '/awala',
headers: validHeaders,
Expand All @@ -246,8 +245,8 @@ describe('awala routes', () => {

expect(response).toHaveProperty('statusCode', HTTP_STATUS_CODES.BAD_REQUEST);
expect(mockProcessMemberKeyImportToken).toHaveBeenCalledOnceWith(validPayload, {
logger: serverInstance.log,
dbConnection: serverInstance.mongoose,
logger: server.log,
dbConnection: server.mongoose,
});
});
});
Expand Down
Loading

0 comments on commit 671a424

Please sign in to comment.