diff --git a/config/config.ts b/config/config.ts index 8b13e15b8..8b39c508e 100644 --- a/config/config.ts +++ b/config/config.ts @@ -52,6 +52,7 @@ import { getPierianDxPipelineManagerStackProps, getPierianDxPipelineTableStackProps, } from './stacks/pierianDxPipelineManager'; +import { getAuthorizationManagerStackProps } from './stacks/authorizationManager'; import { getSashPipelineManagerStackProps, getSashPipelineTableStackProps } from './stacks/sash'; import { getOncoanalyserPipelineManagerStackProps, @@ -77,6 +78,7 @@ interface EnvironmentConfig { export const getEnvironmentConfig = (stage: AppStage): EnvironmentConfig | null => { const stackProps = { statefulConfig: { + authorizationManagerStackProps: getAuthorizationManagerStackProps(stage), dataBucketStackProps: getDataBucketStackProps(stage), sharedStackProps: getSharedStackProps(stage), postgresManagerStackProps: getPostgresManagerStackProps(), diff --git a/config/constants.ts b/config/constants.ts index f7abdc484..bc2b204e8 100644 --- a/config/constants.ts +++ b/config/constants.ts @@ -36,6 +36,12 @@ export const vpcProps: VpcLookupOptions = { }, }; +/** + * The SSM Parameter Name for HTTP Lambda Authorizer ARN (admin user pool group) + */ +export const adminHttpLambdaAuthorizerParameterName = + '/orcabus/authorization-stack/admin-http-lambda-authorization-arn'; + // upstream infra: cognito export const cognitoPortalAppClientIdParameterName = '/data_portal/client/data2/cog_app_client_id_stage'; diff --git a/config/stacks/authorizationManager.ts b/config/stacks/authorizationManager.ts new file mode 100644 index 000000000..d6c34c20e --- /dev/null +++ b/config/stacks/authorizationManager.ts @@ -0,0 +1,21 @@ +import { AuthorizationManagerStackProps } from '../../lib/workload/stateful/stacks/authorization-manager/stack'; +import { + cognitoUserPoolIdParameterName, + region, + accountIdAlias, + AppStage, + adminHttpLambdaAuthorizerParameterName, +} from '../constants'; + +export const getAuthorizationManagerStackProps = ( + stage: AppStage +): AuthorizationManagerStackProps => { + return { + cognito: { + userPoolIdParameterName: cognitoUserPoolIdParameterName, + region: region, + accountNumber: accountIdAlias[stage], + }, + adminHttpLambdaAuthorizerParameterName: adminHttpLambdaAuthorizerParameterName, + }; +}; diff --git a/lib/workload/components/api-gateway/README.md b/lib/workload/components/api-gateway/README.md new file mode 100644 index 000000000..e62a5653d --- /dev/null +++ b/lib/workload/components/api-gateway/README.md @@ -0,0 +1,26 @@ +# ApiGatewayConstruct + +Usage example: + +```ts +const apiGateway = new ApiGatewayConstruct(this, 'ApiGateway', props.apiGatewayCognitoProps); +const httpApi = apiGateway.httpApi; + +const apiIntegration = new HttpLambdaIntegration('ApiIntegration', apiFn); + +new HttpRoute(this, 'GetHttpRoute', { + httpApi: httpApi, + integration: apiIntegration, + routeKey: HttpRouteKey.with('/{proxy+}', HttpMethod.GET), +}); + +// To protect the path/method with an admin role within the User Pool Cognito +// More details on ./.../stateful/authorization-stack + +new HttpRoute(this, 'PostHttpRoute', { + httpApi: httpApi, + integration: apiIntegration, + authorizer: apiGateway.cognitoAdminGroupAuthorizer, + routeKey: HttpRouteKey.with('/{proxy+}', HttpMethod.POST), +}); +``` diff --git a/lib/workload/components/api-gateway/index.ts b/lib/workload/components/api-gateway/index.ts index 0744b5220..d671f8889 100644 --- a/lib/workload/components/api-gateway/index.ts +++ b/lib/workload/components/api-gateway/index.ts @@ -1,13 +1,19 @@ import { Construct } from 'constructs'; import { aws_ssm, Duration, RemovalPolicy } from 'aws-cdk-lib'; -import { HttpJwtAuthorizer } from 'aws-cdk-lib/aws-apigatewayv2-authorizers'; +import { + HttpJwtAuthorizer, + HttpLambdaAuthorizer, + HttpLambdaResponseType, +} from 'aws-cdk-lib/aws-apigatewayv2-authorizers'; import { CfnStage, CorsHttpMethod, DomainName, HttpApi } from 'aws-cdk-lib/aws-apigatewayv2'; import { Certificate } from 'aws-cdk-lib/aws-certificatemanager'; import { IStringParameter, StringParameter } from 'aws-cdk-lib/aws-ssm'; import { LogGroup, RetentionDays } from 'aws-cdk-lib/aws-logs'; import { ARecord, HostedZone, RecordTarget } from 'aws-cdk-lib/aws-route53'; import { Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam'; +import { Function } from 'aws-cdk-lib/aws-lambda'; import { ApiGatewayv2DomainProperties } from 'aws-cdk-lib/aws-route53-targets'; +import { adminHttpLambdaAuthorizerParameterName } from '../../../../config/constants'; export interface ApiGwLogsConfig { /** @@ -56,6 +62,7 @@ export interface ApiGatewayConstructProps { export class ApiGatewayConstruct extends Construct { private readonly _httpApi: HttpApi; private readonly _domainName: string; + readonly cognitoAdminGroupAuthorizer: HttpLambdaAuthorizer; constructor(scope: Construct, id: string, props: ApiGatewayConstructProps) { super(scope, id); @@ -96,12 +103,16 @@ export class ApiGatewayConstruct extends Construct { allowOrigins: props.corsAllowOrigins, maxAge: Duration.days(10), }, - defaultAuthorizer: this.getAuthorizer(props), + defaultAuthorizer: this.getJWTAuthorizer(props), defaultDomainMapping: { domainName: apiGWDomainName, }, }); + this.cognitoAdminGroupAuthorizer = this.getCognitoAdminGroupHTTPAuthorizer( + adminHttpLambdaAuthorizerParameterName + ); + new ARecord(this, 'CustomDomainARecord', { zone: HostedZone.fromHostedZoneAttributes(this, 'UmccrHostedZone', { hostedZoneId, @@ -160,7 +171,7 @@ export class ApiGatewayConstruct extends Construct { accessLogs.grantWrite(role); } - private getAuthorizer(props: ApiGatewayConstructProps): HttpJwtAuthorizer { + private getJWTAuthorizer(props: ApiGatewayConstructProps): HttpJwtAuthorizer { /** * FIXME One fine day in future when we have proper Cognito AAI setup. * For the moment, we leverage Portal and established Cognito infrastructure. @@ -190,6 +201,33 @@ export class ApiGatewayConstruct extends Construct { }); } + /** + * Get the Cognito Admin Group HTTP Lambda Authorizer + * @param adminHttpLambdaAuthorizerParameterName The SSM Parameter Name that stores the ARN of the lambda authorizer + * @returns + */ + private getCognitoAdminGroupHTTPAuthorizer(adminHttpLambdaAuthorizerParameterName: string) { + const lambdaArn = StringParameter.valueForStringParameter( + this, + adminHttpLambdaAuthorizerParameterName + ); + + // Get the lambda HTTP authorizer defined in the authorization stack manager + const lambdaAuthorizer = Function.fromFunctionAttributes( + this, + 'AdminGroupHTTPAuthorizerLambda', + { + functionArn: lambdaArn, + sameEnvironment: true, + } + ); + + return new HttpLambdaAuthorizer('AdminGroupLambdaAuthorizer', lambdaAuthorizer, { + authorizerName: 'CognitoAdminGroupLambdaAuthorizer', + responseTypes: [HttpLambdaResponseType.SIMPLE], + }); + } + get httpApi(): HttpApi { return this._httpApi; } diff --git a/lib/workload/stateful/stacks/authorization-manager/README.md b/lib/workload/stateful/stacks/authorization-manager/README.md new file mode 100644 index 000000000..54998d97d --- /dev/null +++ b/lib/workload/stateful/stacks/authorization-manager/README.md @@ -0,0 +1,26 @@ +# Authorization Stack + +This stack contains resources that handle authorization requests. + +## AWS Verified Permissions + +The current stack deploys AWS Verified Permissions, defining an identity source and policies as described below. A HTTP Lambda Authorizer is also included for use with other stacks. + +### Identity Source + +- **UMCCR Cognito User Pool** + + Sourced from the UMCCR Cognito User Pool, defined in the infrastructure Terraform repository. The AWS Cognito User Pool + is expected to have an `admin` group, which will be used in the policy. Note that the JWT must be generated with the + latest token containing the proper Cognito group claims for it to work. This also applies when a user is removed from + the group; the JWT must expire to become invalid. + +### Policy + +- **AdminPolicy** + + A static policy defined in the stack that allows anyone in the `admin` group of the Cognito user pool to perform any + action. This essentially checks if a user is in the `admin` group, integrated with the Cognito setup. + + The HTTP Lambda Authorizer is also defined for use in stacks where routes/methods need to comply with this policy. The + Lambda ARN is stored in SSM Parameter String defined in `config/constants.ts` as the `adminHttpLambdaAuthorizerParameterName` constant. diff --git a/lib/workload/stateful/stacks/authorization-manager/cedarSchema.json b/lib/workload/stateful/stacks/authorization-manager/cedarSchema.json new file mode 100644 index 000000000..5651dd12e --- /dev/null +++ b/lib/workload/stateful/stacks/authorization-manager/cedarSchema.json @@ -0,0 +1,32 @@ +{ + "OrcaBus": { + "actions": { + "writeAccess": { + "appliesTo": { + "principalTypes": ["User"] + } + } + }, + "entityTypes": { + "CognitoUserGroup": { + "memberOfTypes": [], + "shape": { + "type": "Record", + "attributes": {} + } + }, + "User": { + "memberOfTypes": ["CognitoUserGroup"], + "shape": { + "attributes": { + "email": { + "type": "String", + "required": false + } + }, + "type": "Record" + } + } + } + } +} diff --git a/lib/workload/stateful/stacks/authorization-manager/http-lambda-authorizer/admin_access_authorizer.py b/lib/workload/stateful/stacks/authorization-manager/http-lambda-authorizer/admin_access_authorizer.py new file mode 100644 index 000000000..b00184cdd --- /dev/null +++ b/lib/workload/stateful/stacks/authorization-manager/http-lambda-authorizer/admin_access_authorizer.py @@ -0,0 +1,28 @@ +import os +import boto3 + +client = boto3.client('verifiedpermissions') + + +def handler(event, context): + response = { + "isAuthorized": False, + } + + auth_token = event["headers"]["authorization"] + + try: + + if auth_token: + avp_response = client.is_authorized_with_token( + policyStoreId=os.environ.get("POLICY_STORE_ID"), + identityToken=auth_token, + ) + + return { + "isAuthorized": avp_response.get("decision", None) == "ALLOW", + } + else: + return response + except BaseException: + return response diff --git a/lib/workload/stateful/stacks/authorization-manager/stack.ts b/lib/workload/stateful/stacks/authorization-manager/stack.ts new file mode 100644 index 000000000..7ed0bff0a --- /dev/null +++ b/lib/workload/stateful/stacks/authorization-manager/stack.ts @@ -0,0 +1,135 @@ +import { Stack, StackProps } from 'aws-cdk-lib'; +import { Construct } from 'constructs'; +import { StringParameter } from 'aws-cdk-lib/aws-ssm'; +import { CfnPolicyStore, CfnPolicy, CfnIdentitySource } from 'aws-cdk-lib/aws-verifiedpermissions'; +import { PythonFunction } from '@aws-cdk/aws-lambda-python-alpha'; + +import cedarSchemaJson from './cedarSchema.json'; +import { Architecture, Runtime } from 'aws-cdk-lib/aws-lambda'; +import path from 'path'; +import { PolicyStatement } from 'aws-cdk-lib/aws-iam'; + +export interface AuthorizationManagerStackProps { + cognito: CognitoConfig; + adminHttpLambdaAuthorizerParameterName: string; +} + +interface CognitoConfig { + /** + * The SSM parameter name that cognito user pool ID is stored + */ + userPoolIdParameterName: string; + /** + * The AWS region where the cognito user pool is deployed + */ + region: string; + /** + * The AWS account number where the cognito user pool is deployed + */ + accountNumber: string; +} + +export class AuthorizationManagerStack extends Stack { + constructor(scope: Construct, id: string, props: StackProps & AuthorizationManagerStackProps) { + super(scope, id, props); + + // Amazon Verified Permission + const policyStore = new CfnPolicyStore(this, 'VerifiedPermissionPolicyStore', { + validationSettings: { mode: 'STRICT' }, + description: 'OrcaBus authorization policy', + schema: { + cedarJson: JSON.stringify(cedarSchemaJson), + }, + }); + + this.setupCognitoIntegrationAndPolicy({ + cognito: props.cognito, + policyStoreId: policyStore.attrPolicyStoreId, + }); + + this.setupTokenLambdaAuthorization({ + policyStoreARN: policyStore.attrArn, + policyStoreId: policyStore.attrPolicyStoreId, + adminHttpLambdaAuthorizerParameterName: props.adminHttpLambdaAuthorizerParameterName, + }); + } + + /** + * This sets up the Verified Permissions integration with Cognito. + * It sources users from the Cognito user pool and creates a static policy + * that grants all permissions to users in the admin group within the user pool. + * + * @param props Cognito properties + */ + private setupCognitoIntegrationAndPolicy(props: { + policyStoreId: string; + cognito: CognitoConfig; + }) { + // Grab the user pool ID from SSM + const userPoolId = StringParameter.fromStringParameterName( + this, + 'CognitoUserPoolIdStringParameter', + props.cognito.userPoolIdParameterName + ).stringValue; + + // Allow the policy store to source the identity from existing Cognito User Pool Id + new CfnIdentitySource(this, 'VerifiedPermissionIdentitySource', { + configuration: { + cognitoUserPoolConfiguration: { + userPoolArn: `arn:aws:cognito-idp:${props.cognito.region}:${props.cognito.accountNumber}:userpool/${userPoolId}`, + groupConfiguration: { + groupEntityType: 'OrcaBus::CognitoUserGroup', // Refer to './cedarSchema.json' + }, + }, + }, + principalEntityType: 'OrcaBus::User', + policyStoreId: props.policyStoreId, + }); + + // Create a static policy that allow user from the admin group to allow all actions + new CfnPolicy(this, 'CognitoPortalAdminPolicy', { + definition: { + static: { + statement: ` + permit ( + principal in OrcaBus::CognitoUserGroup::"${userPoolId}|admin", + action, + resource + ); + `, + description: + 'Allow all action for all resource for user in the admin cognito user pool group', + }, + }, + policyStoreId: props.policyStoreId, + }); + } + + private setupTokenLambdaAuthorization(props: { + policyStoreId: string; + policyStoreARN: string; + adminHttpLambdaAuthorizerParameterName: string; + }) { + const adminLambdaAuth = new PythonFunction(this, 'AdminHTTPAuthorizerLambda', { + entry: path.join(__dirname, 'http-lambda-authorizer'), + architecture: Architecture.ARM_64, + runtime: Runtime.PYTHON_3_12, + index: 'admin_access_authorizer.py', + retryAttempts: 0, + environment: { POLICY_STORE_ID: props.policyStoreId }, + initialPolicy: [ + new PolicyStatement({ + actions: ['verifiedpermissions:IsAuthorizedWithToken'], + resources: [props.policyStoreARN], + }), + ], + }); + + new StringParameter(this, 'AdminHTTPAuthorizerLambdaARNParameter', { + parameterName: props.adminHttpLambdaAuthorizerParameterName, + description: + 'ARN of the HTTP lambda authorizer that allow access for admin in the cognito user pool group', + stringValue: adminLambdaAuth.functionArn, + }); + } +} diff --git a/lib/workload/stateful/statefulStackCollectionClass.ts b/lib/workload/stateful/statefulStackCollectionClass.ts index 07333e84b..74c9f7383 100644 --- a/lib/workload/stateful/statefulStackCollectionClass.ts +++ b/lib/workload/stateful/statefulStackCollectionClass.ts @@ -49,6 +49,10 @@ import { PierianDxPipelineTable, PierianDxPipelineTableStackProps, } from './stacks/pieriandx-pipeline-dynamo-db/deploy'; +import { + AuthorizationManagerStack, + AuthorizationManagerStackProps, +} from './stacks/authorization-manager/stack'; import { OncoanalyserNfPipelineTable, OncoanalyserNfPipelineTableStackProps, @@ -60,6 +64,7 @@ import { export interface StatefulStackCollectionProps { dataBucketStackProps: DataBucketStackProps; + authorizationManagerStackProps: AuthorizationManagerStackProps; sharedStackProps: SharedStackProps; postgresManagerStackProps: PostgresManagerStackProps; tokenServiceStackProps: TokenServiceStackProps; @@ -81,6 +86,7 @@ export interface StatefulStackCollectionProps { export class StatefulStackCollection { // You could add more stack here and initiate it at the constructor. See example below for reference + readonly authorizationManagerStack: Stack; readonly dataBucketStack: Stack; readonly sharedStack: Stack; readonly postgresManagerStack: Stack; @@ -112,6 +118,15 @@ export class StatefulStackCollection { }); } + this.authorizationManagerStack = new AuthorizationManagerStack( + scope, + 'AuthorizationManagerStack', + { + ...this.createTemplateProps(env, 'AuthorizationManagerStack'), + ...statefulConfiguration.authorizationManagerStackProps, + } + ); + this.sharedStack = new SharedStack(scope, 'SharedStack', { ...this.createTemplateProps(env, 'SharedStack'), ...statefulConfiguration.sharedStackProps, diff --git a/lib/workload/stateless/stacks/filemanager/deploy/stack.ts b/lib/workload/stateless/stacks/filemanager/deploy/stack.ts index efa8d7332..5846e4e94 100644 --- a/lib/workload/stateless/stacks/filemanager/deploy/stack.ts +++ b/lib/workload/stateless/stacks/filemanager/deploy/stack.ts @@ -153,12 +153,14 @@ export class Filemanager extends Stack { new HttpRoute(this, 'PatchHttpRoute', { httpApi: httpApi, integration: apiIntegration, + authorizer: apiGateway.cognitoAdminGroupAuthorizer, routeKey: HttpRouteKey.with('/{proxy+}', HttpMethod.PATCH), }); new HttpRoute(this, 'PostHttpRoute', { httpApi: httpApi, integration: apiIntegration, + authorizer: apiGateway.cognitoAdminGroupAuthorizer, routeKey: HttpRouteKey.with('/{proxy+}', HttpMethod.POST), }); diff --git a/test/stateful/pipeline/deployment.test.ts b/test/stateful/pipeline/deployment.test.ts index 0c144320a..cec5ccf7a 100644 --- a/test/stateful/pipeline/deployment.test.ts +++ b/test/stateful/pipeline/deployment.test.ts @@ -68,6 +68,20 @@ function applyNagSuppression(stackId: string, stack: Stack) { // for each stack specific switch (stackId) { + case 'AuthorizationManagerStack': + // FIXME - https://github.com/umccr/orcabus/issues/174 + NagSuppressions.addResourceSuppressionsByPath( + stack, + // pragma: allowlist nextline secret + ['/AuthorizationManagerStack/AdminHTTPAuthorizerLambda/ServiceRole/Resource'], + [ + { + id: 'AwsSolutions-IAM4', + reason: 'Tracking this at (https://github.com/umccr/orcabus/issues/174)', + }, + ] + ); + break; case 'TokenServiceStack': // suppress by resource NagSuppressions.addResourceSuppressionsByPath(