-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
fix(API): Implement authorisation (#103)
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
Showing
29 changed files
with
856 additions
and
314 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.