From 7d7feef7a683763a8f62ee3c3d0084954a8259bf Mon Sep 17 00:00:00 2001 From: Evan Stohlmann Date: Tue, 11 Mar 2025 15:57:43 -0600 Subject: [PATCH 01/32] pull request notification --- .github/workflows/code.test-and-lint.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/code.test-and-lint.yml b/.github/workflows/code.test-and-lint.yml index 81657046..deb57fea 100644 --- a/.github/workflows/code.test-and-lint.yml +++ b/.github/workflows/code.test-and-lint.yml @@ -25,6 +25,15 @@ jobs: SLACK_FOOTER: '' MSG_MINIMAL: true SLACK_MESSAGE: 'PR Created ${{ github.event.pull_request.html_url }}' + - name: Send Mission Solution PR Created Notification + if: github.event_name == 'pull_request' && github.event.action == 'opened' + uses: rtCamp/action-slack-notify@v2 + env: + SLACK_WEBHOOK: ${{ secrets.MISSION_SOLUTION_PR_WEBHOOK }} + SLACK_TITLE: 'PR Created for repo ${{github.event.repository.name}}: ${{ github.event.pull_request.title }} by ${{ github.event.pull_request.user.login }}' + SLACK_FOOTER: '' + MSG_MINIMAL: true + SLACK_MESSAGE: 'PR Created ${{ github.event.pull_request.html_url }}' backend_build: name: Backend Tests runs-on: ubuntu-latest From 2fb6c474d8ac8018344e4a1257027bfe1ff28fe7 Mon Sep 17 00:00:00 2001 From: Evan Stohlmann Date: Wed, 12 Mar 2025 00:30:36 -0600 Subject: [PATCH 02/32] pull request notification --- .github/workflows/code.test-and-lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/code.test-and-lint.yml b/.github/workflows/code.test-and-lint.yml index deb57fea..1cdc5d37 100644 --- a/.github/workflows/code.test-and-lint.yml +++ b/.github/workflows/code.test-and-lint.yml @@ -30,7 +30,7 @@ jobs: uses: rtCamp/action-slack-notify@v2 env: SLACK_WEBHOOK: ${{ secrets.MISSION_SOLUTION_PR_WEBHOOK }} - SLACK_TITLE: 'PR Created for repo ${{github.event.repository.name}}: ${{ github.event.pull_request.title }} by ${{ github.event.pull_request.user.login }}' + SLACK_TITLE: '${{github.event.repository.name}} PR Created: ${{ github.event.pull_request.title }} by ${{ github.event.pull_request.user.login }}' SLACK_FOOTER: '' MSG_MINIMAL: true SLACK_MESSAGE: 'PR Created ${{ github.event.pull_request.html_url }}' From 7e31a507c6ff86807ca52bc3bec57adafe155710 Mon Sep 17 00:00:00 2001 From: Joseph Harold <121983012+jmharold@users.noreply.github.com> Date: Thu, 22 May 2025 13:04:16 -0600 Subject: [PATCH 03/32] decomposing stacks into constructs --- lib/constructs/api/adminConstruct.ts | 221 +++ lib/constructs/api/apiDeploymentConstruct.ts | 41 + .../api/appConfigurationConstruct.ts | 82 ++ lib/constructs/api/datasetsConstructs.ts | 135 ++ lib/constructs/api/emrConstruct.ts | 105 ++ .../api/groupMembershipHistoryConstruct.ts | 70 + lib/constructs/api/groupsConstruct.ts | 145 ++ lib/constructs/api/inferenceConstruct.ts | 163 +++ lib/constructs/api/jobsConstruct.ts | 169 +++ lib/constructs/api/notebooksConstruct.ts | 146 ++ lib/constructs/api/projectsConstruct.ts | 276 ++++ lib/constructs/api/restApiConstruct.ts | 333 +++++ lib/constructs/api/translateConstruct.ts | 119 ++ lib/constructs/iamConstruct.ts | 1268 +++++++++++++++++ lib/constructs/infra/coreConstruct.ts | 730 ++++++++++ lib/constructs/infra/sagemakerConstruct.ts | 53 + lib/constructs/kmsConstruct.ts | 91 ++ lib/constructs/vpcConstruct.ts | 153 ++ lib/stacks/api/admin.ts | 197 +-- lib/stacks/api/apiDeployment.ts | 20 +- lib/stacks/api/appConfiguration.ts | 58 +- lib/stacks/api/datasets.ts | 111 +- lib/stacks/api/emr.ts | 83 +- lib/stacks/api/groupMembershipHistory.ts | 48 +- lib/stacks/api/groups.ts | 123 +- lib/stacks/api/inference.ts | 139 +- lib/stacks/api/jobs.ts | 145 +- lib/stacks/api/notebooks.ts | 124 +- lib/stacks/api/projects.ts | 252 +--- lib/stacks/api/restApi.ts | 264 +--- lib/stacks/api/translate.ts | 95 +- lib/stacks/iam.ts | 1227 +--------------- lib/stacks/infra/core.ts | 686 +-------- lib/stacks/infra/sagemaker.ts | 28 +- lib/stacks/kms.ts | 64 +- lib/stacks/vpc.ts | 126 +- lib/utils/configTypes.ts | 24 +- lib/utils/layers.ts | 13 +- 38 files changed, 4414 insertions(+), 3713 deletions(-) create mode 100644 lib/constructs/api/adminConstruct.ts create mode 100644 lib/constructs/api/apiDeploymentConstruct.ts create mode 100644 lib/constructs/api/appConfigurationConstruct.ts create mode 100644 lib/constructs/api/datasetsConstructs.ts create mode 100644 lib/constructs/api/emrConstruct.ts create mode 100644 lib/constructs/api/groupMembershipHistoryConstruct.ts create mode 100644 lib/constructs/api/groupsConstruct.ts create mode 100644 lib/constructs/api/inferenceConstruct.ts create mode 100644 lib/constructs/api/jobsConstruct.ts create mode 100644 lib/constructs/api/notebooksConstruct.ts create mode 100644 lib/constructs/api/projectsConstruct.ts create mode 100644 lib/constructs/api/restApiConstruct.ts create mode 100644 lib/constructs/api/translateConstruct.ts create mode 100644 lib/constructs/iamConstruct.ts create mode 100644 lib/constructs/infra/coreConstruct.ts create mode 100644 lib/constructs/infra/sagemakerConstruct.ts create mode 100644 lib/constructs/kmsConstruct.ts create mode 100644 lib/constructs/vpcConstruct.ts diff --git a/lib/constructs/api/adminConstruct.ts b/lib/constructs/api/adminConstruct.ts new file mode 100644 index 00000000..805afd66 --- /dev/null +++ b/lib/constructs/api/adminConstruct.ts @@ -0,0 +1,221 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { Stack } from 'aws-cdk-lib'; +import { LayerVersion } from 'aws-cdk-lib/aws-lambda'; +import { StringParameter } from 'aws-cdk-lib/aws-ssm'; +import { MLSpacePythonLambdaFunction, registerAPIEndpoint } from '../../utils/apiFunction'; +import { ApiStackProperties } from './restApiConstruct'; +import { RestApi } from 'aws-cdk-lib/aws-apigateway'; +import { Construct } from 'constructs'; + +export class AdminApiConstruct extends Construct { + constructor (scope: Stack, id: string, props: ApiStackProperties) { + super(scope, id); + + // Get common layer based on arn from SSM due to issues with cross stack references + const commonLambdaLayer = LayerVersion.fromLayerVersionArn( + this, + 'mls-common-lambda-layer', + StringParameter.valueForStringParameter(this, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) + ); + + const restApi = RestApi.fromRestApiAttributes(this, 'RestApi', { + restApiId: props.restApiId, + rootResourceId: props.rootResourceId, + }); + + const apis: MLSpacePythonLambdaFunction[] = [ + { + name: 'list_all', + resource: 'user', + description: 'Returns a list of all MLSpace users', + path: 'user', + method: 'GET', + }, + { + name: 'delete', + resource: 'user', + description: 'Removes an MLSpace user', + path: 'user/{username}', + method: 'DELETE', + }, + { + name: 'create', + resource: 'user', + description: 'Creates a user for the system', + path: 'user', + method: 'POST', + environment: { + NEW_USER_SUSPENSION_DEFAULT: props.mlspaceConfig.NEW_USERS_SUSPENDED ? 'True' : 'False', + }, + }, + { + name: 'get', + resource: 'user', + description: 'Get an MLSpace user', + path: 'user/{username}', + method: 'GET', + }, + { + name: 'get_projects', + resource: 'user', + description: 'Get an MLSpace user\'s projects', + path: 'user/{username}/projects', + method: 'GET', + }, + { + name: 'get_groups', + resource: 'user', + description: 'Get an MLSpace user\'s groups', + path: 'user/{username}/groups', + method: 'GET', + }, + { + id: 'dataset-admin', + name: 'list_resources', + resource: 'dataset', + description: 'List all global, group, and private datasets', + path: 'admin/datasets', + method: 'GET', + }, + { + name: 'update', + resource: 'user', + description: 'Update an MLSpace user', + path: 'user/{username}', + method: 'PUT', + }, + { + name: 'login', + resource: 'user', + description: 'Update an MLSpace users lastLogin attribute', + path: 'login', + method: 'PUT', + }, + { + name: 'current', + resource: 'user', + description: 'Retrieve the user record for the current user', + path: 'current-user', + method: 'GET', + }, + { + name: 'describe', + resource: 'config', + description: + 'Get the current env config including env variables, param file, and notebook lifecycle config', + path: 'config', + method: 'GET', + environment: { + BUCKET: props.configBucketName, + S3_KEY: props.mlspaceConfig.NOTEBOOK_PARAMETERS_FILE_NAME, + }, + }, + { + name: 'create', + resource: 'report', + description: 'Generates a report of project resources and users for admins', + path: 'report', + method: 'POST', + environment: { + DATA_BUCKET: props.dataBucketName, + }, + }, + { + name: 'list', + resource: 'report', + description: 'Lists reports of project resources and users for admins', + path: 'report', + method: 'GET', + environment: { + DATA_BUCKET: props.dataBucketName, + }, + }, + { + name: 'download', + resource: 'report', + description: 'Get S3 URL of MLSpace report', + path: 'report/{reportName}', + method: 'GET', + environment: { + DATA_BUCKET: props.dataBucketName, + }, + }, + { + name: 'delete', + resource: 'report', + description: 'Deletes an MLSpace report', + path: 'report/{reportName}', + method: 'DELETE', + environment: { + DATA_BUCKET: props.dataBucketName, + }, + }, + { + name: 'sync_metadata', + resource: 'migration', + description: 'Sync resource metadata to MLSpace Resource Metadata table', + path: 'admin/sync-metadata', + method: 'POST', + }, + { + name: 'list_subnets', + resource: 'metadata', + description: 'List available subnets in which MLSpace resources can be launched', + path: 'metadata/subnets', + method: 'GET', + environment: { + BUCKET: props.configBucketName, + S3_KEY: props.mlspaceConfig.NOTEBOOK_PARAMETERS_FILE_NAME, + }, + }, + { + name: 'compute_types', + resource: 'metadata', + description: 'Describe available instance types for a sagemaker notebook', + path: 'metadata/compute-types', + method: 'GET', + }, + { + name: 'notebook_options', + resource: 'metadata', + description: + 'Gets notebook instance types and lifecycle configs for create notebooks', + path: 'metadata/notebook-options', + method: 'GET', + }, + ]; + + apis.forEach((f) => { + registerAPIEndpoint( + scope, + restApi, + props.authorizer, + props.applicationRole, + props.applicationRole.roleName, + props.notebookInstanceRole.roleName, + props.lambdaSourcePath, + [commonLambdaLayer], + f, + props.mlSpaceVPC, + props.securityGroups, + props.mlspaceConfig, + props.permissionsBoundaryArn + ); + }); + } +} diff --git a/lib/constructs/api/apiDeploymentConstruct.ts b/lib/constructs/api/apiDeploymentConstruct.ts new file mode 100644 index 00000000..60d76b9c --- /dev/null +++ b/lib/constructs/api/apiDeploymentConstruct.ts @@ -0,0 +1,41 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { Stack, StackProps } from 'aws-cdk-lib'; +import { Deployment, RestApi } from 'aws-cdk-lib/aws-apigateway'; +import { Construct } from 'constructs'; + +export type ApiDeploymentStackProps = { + readonly restApiId: string; +} & StackProps; + +export class ApiDeploymentConstruct extends Construct { + constructor (scope: Stack, name: string, props: ApiDeploymentStackProps) { + super(scope, name); + + // Use timestamp in logical id to force an API deployment + // Related CDK issues: + // https://github.com/aws/aws-cdk/issues/12417 + // https://github.com/aws/aws-cdk/issues/13383 + const deployment = new Deployment(this, `ApiDeployment-${new Date().getTime()}`, { + api: RestApi.fromRestApiId(this, 'MLSpaceRestApiRef', props.restApiId), + }); + // This hack will allow us to redeploy to an existing stage but once CDK + // adds first class support for this we will migrate + // https://github.com/aws/aws-cdk/issues/25582 + (deployment as any).resource.stageName = 'Prod'; + } +} \ No newline at end of file diff --git a/lib/constructs/api/appConfigurationConstruct.ts b/lib/constructs/api/appConfigurationConstruct.ts new file mode 100644 index 00000000..fca31aca --- /dev/null +++ b/lib/constructs/api/appConfigurationConstruct.ts @@ -0,0 +1,82 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { Stack } from 'aws-cdk-lib'; +import { LayerVersion } from 'aws-cdk-lib/aws-lambda'; +import { StringParameter } from 'aws-cdk-lib/aws-ssm'; +import { MLSpacePythonLambdaFunction, registerAPIEndpoint } from '../../utils/apiFunction'; +import { ApiStackProperties } from './restApiConstruct'; +import { RestApi } from 'aws-cdk-lib/aws-apigateway'; +import { Construct } from 'constructs'; + +export class AppConfigurationApiConstruct extends Construct { + constructor (scope: Stack, id: string, props: ApiStackProperties) { + super(scope, id); + + // Get common layer based on arn from SSM due to issues with cross stack references + const commonLambdaLayer = LayerVersion.fromLayerVersionArn( + this, + 'mls-common-lambda-layer', + StringParameter.valueForStringParameter(this, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) + ); + + const restApi = RestApi.fromRestApiAttributes(this, 'RestApi', { + restApiId: props.restApiId, + rootResourceId: props.rootResourceId, + }); + + const apis: MLSpacePythonLambdaFunction[] = [ + { + name: 'get_configuration', + resource: 'app_configuration', + description: 'Get the requested number of MLSpace application configurations, starting from the most recent', + path: 'app-config', + method: 'GET', + noAuthorizer: true + }, + { + name: 'update_configuration', + resource: 'app_configuration', + description: 'Update the MLSpace application configuration', + path: 'app-config', + method: 'POST', + environment: { + ENDPOINT_CONFIG_INSTANCE_CONSTRAINT_POLICY_ARN: props.endpointConfigInstanceConstraintPolicy?.managedPolicyArn || '', + JOB_INSTANCE_CONSTRAINT_POLICY_ARN: props.jobInstanceConstraintPolicy?.managedPolicyArn || '', + } + }, + ]; + + const system_permissions = ['update_configuration']; + apis.forEach((f) => { + registerAPIEndpoint( + scope, + restApi, + props.authorizer, + system_permissions.includes(f.name) ? props.systemRole : props.applicationRole, + props.applicationRole.roleName, + props.notebookInstanceRole.roleName, + props.lambdaSourcePath, + [commonLambdaLayer], + f, + props.mlSpaceVPC, + props.securityGroups, + props.mlspaceConfig, + props.permissionsBoundaryArn + ); + }); + } +} diff --git a/lib/constructs/api/datasetsConstructs.ts b/lib/constructs/api/datasetsConstructs.ts new file mode 100644 index 00000000..b73c8729 --- /dev/null +++ b/lib/constructs/api/datasetsConstructs.ts @@ -0,0 +1,135 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { Stack } from 'aws-cdk-lib'; +import { LayerVersion } from 'aws-cdk-lib/aws-lambda'; +import { StringParameter } from 'aws-cdk-lib/aws-ssm'; +import { MLSpacePythonLambdaFunction, registerAPIEndpoint } from '../../utils/apiFunction'; +import { ApiStackProperties } from './restApiConstruct'; +import { RestApi } from 'aws-cdk-lib/aws-apigateway'; +import { Construct } from 'constructs'; + +export class DatasetsApiConstruct extends Construct { + constructor (scope: Stack, id: string, props: ApiStackProperties) { + super(scope, id); + + // Get common layer based on arn from SSM due to issues with cross stack references + const commonLambdaLayer = LayerVersion.fromLayerVersionArn( + this, + 'mls-common-lambda-layer', + StringParameter.valueForStringParameter(this, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) + ); + + const restApi = RestApi.fromRestApiAttributes(this, 'RestApi', { + restApiId: props.restApiId, + rootResourceId: props.rootResourceId, + }); + + const apis: MLSpacePythonLambdaFunction[] = [ + { + name: 'presigned_url', + resource: 'dataset', + description: 'Generates presigned url for MLSpace Dataset', + path: 'dataset/presigned-url', + method: 'POST', + environment: { + DATA_BUCKET: props.dataBucketName, + }, + }, + { + name: 'create_dataset', + resource: 'dataset', + description: 'Creates a new dataset', + path: 'dataset/create', + method: 'POST', + environment: { + DATA_BUCKET: props.dataBucketName, + }, + }, + { + id: 'dataset-personal', + name: 'list_resources', + resource: 'dataset', + description: 'List all global, group, and private datasets for user', + path: 'dataset', + method: 'GET', + }, + { + name: 'edit', + resource: 'dataset', + description: 'Edits dataset', + path: 'v2/dataset/{type}/{scope}/{datasetName}', + method: 'PUT', + }, + { + name: 'get', + resource: 'dataset', + description: 'Gets dataset details', + path: 'v2/dataset/{type}/{scope}/{datasetName}', + method: 'GET', + }, + { + name: 'delete', + resource: 'dataset', + description: 'Removes a dataset from an MLSpace project', + path: 'v2/dataset/{type}/{scope}/{datasetName}', + method: 'DELETE', + environment: { + DATA_BUCKET: props.dataBucketName, + }, + }, + { + name: 'delete_file', + resource: 'dataset', + description: 'Removes a file from a dataset', + // use a greedy path here so object keys containing '/' are fully matched + path: 'v2/dataset/{type}/{scope}/{datasetName}/{file+}', + method: 'DELETE', + environment: { + DATA_BUCKET: props.dataBucketName, + }, + }, + { + name: 'list_files', + resource: 'dataset', + description: 'List all file in a dataset', + path: 'v2/dataset/{type}/{scope}/{datasetName}/files', + method: 'GET', + environment: { + DATA_BUCKET: props.dataBucketName, + }, + }, + ]; + + apis.forEach((f) => { + registerAPIEndpoint( + scope, + restApi, + props.authorizer, + props.applicationRole, + props.applicationRole.roleName, + props.notebookInstanceRole.roleName, + props.lambdaSourcePath, + [commonLambdaLayer], + f, + props.mlSpaceVPC, + props.securityGroups, + props.mlspaceConfig, + props.permissionsBoundaryArn + ); + }); + } +} diff --git a/lib/constructs/api/emrConstruct.ts b/lib/constructs/api/emrConstruct.ts new file mode 100644 index 00000000..f24adb44 --- /dev/null +++ b/lib/constructs/api/emrConstruct.ts @@ -0,0 +1,105 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { Stack } from 'aws-cdk-lib'; +import { LayerVersion } from 'aws-cdk-lib/aws-lambda'; +import { StringParameter } from 'aws-cdk-lib/aws-ssm'; +import { MLSpacePythonLambdaFunction, registerAPIEndpoint } from '../../utils/apiFunction'; +import { ApiStackProperties } from './restApiConstruct'; +import { RestApi } from 'aws-cdk-lib/aws-apigateway'; +import { Construct } from 'constructs'; + +export class EmrApiConstruct extends Construct { + constructor (scope: Stack, id: string, props: ApiStackProperties) { + super(scope, id); + + // Get common layer based on arn from SSM due to issues with cross stack references + const commonLambdaLayer = LayerVersion.fromLayerVersionArn( + this, + 'mls-common-lambda-layer', + StringParameter.valueForStringParameter(this, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) + ); + + const restApi = RestApi.fromRestApiAttributes(this, 'RestApi', { + restApiId: props.restApiId, + rootResourceId: props.rootResourceId, + }); + + const apis: MLSpacePythonLambdaFunction[] = [ + { + name: 'get', + resource: 'emr', + description: 'Describe an EMR Cluster', + path: 'emr/{clusterId}', + method: 'GET', + }, + { + name: 'delete', + resource: 'emr', + description: 'Delete an EMR Cluster', + path: 'emr/{clusterId}', + method: 'DELETE', + }, + { + name: 'remove', + resource: 'emr', + description: 'Remove an EMR Cluster from the resource metadata table', + path: 'emr/{clusterId}/remove', + method: 'DELETE', + }, + { + name: 'set_resource_termination', + resource: 'resource_scheduler', + description: 'Update the termination time of an EMR Cluster', + path: 'emr/{clusterId}/schedule', + method: 'PUT', + id: 'resource_scheduler-set-emr-termination', + }, + { + name: 'list_applications', + resource: 'emr', + description: 'List all applications available to install and configure when launching a cluster', + path: 'emr/applications', + method: 'GET', + }, + { + name: 'list_release_labels', + resource: 'emr', + description: 'List of available EMR release labels', + path: 'emr/release', + method: 'GET', + }, + ]; + + apis.forEach((f) => { + registerAPIEndpoint( + scope, + restApi, + props.authorizer, + props.applicationRole, + props.applicationRole.roleName, + props.notebookInstanceRole.roleName, + props.lambdaSourcePath, + [commonLambdaLayer], + f, + props.mlSpaceVPC, + props.securityGroups, + props.mlspaceConfig, + props.permissionsBoundaryArn + ); + }); + } +} diff --git a/lib/constructs/api/groupMembershipHistoryConstruct.ts b/lib/constructs/api/groupMembershipHistoryConstruct.ts new file mode 100644 index 00000000..1de45346 --- /dev/null +++ b/lib/constructs/api/groupMembershipHistoryConstruct.ts @@ -0,0 +1,70 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import { Stack } from 'aws-cdk-lib'; +import { LayerVersion } from 'aws-cdk-lib/aws-lambda'; +import { StringParameter } from 'aws-cdk-lib/aws-ssm'; +import { MLSpacePythonLambdaFunction, registerAPIEndpoint } from '../../utils/apiFunction'; +import { ApiStackProperties } from './restApiConstruct'; +import { RestApi } from 'aws-cdk-lib/aws-apigateway'; +import { Construct } from 'constructs'; + +export class GroupMembershipHistoryApiConstruct extends Construct { + constructor (scope: Stack, id: string, props: ApiStackProperties) { + super(scope, id); + + // Get common layer based on arn from SSM due to issues with cross stack references + const commonLambdaLayer = LayerVersion.fromLayerVersionArn( + this, + 'mls-common-lambda-layer', + StringParameter.valueForStringParameter(this, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) + ); + + const restApi = RestApi.fromRestApiAttributes(this, 'RestApi', { + restApiId: props.restApiId, + rootResourceId: props.rootResourceId, + }); + + const apis: MLSpacePythonLambdaFunction[] = [ + { + name: 'list_all_for_group', + resource: 'group_membership_history', + description: 'List all group membership history for a specific group', + path: 'group-membership-history/{groupName}', + method: 'GET', + }, + ]; + + apis.forEach((f) => { + const system_permissions = ['remove_user', 'update', 'delete']; + registerAPIEndpoint( + scope, + restApi, + props.authorizer, + system_permissions.includes(f.name) ? props.systemRole : props.applicationRole, + props.applicationRole.roleName, + props.notebookInstanceRole.roleName, + props.lambdaSourcePath, + [commonLambdaLayer], + f, + props.mlSpaceVPC, + props.securityGroups, + props.mlspaceConfig, + props.permissionsBoundaryArn + ); + }); + } +} diff --git a/lib/constructs/api/groupsConstruct.ts b/lib/constructs/api/groupsConstruct.ts new file mode 100644 index 00000000..f57e66ff --- /dev/null +++ b/lib/constructs/api/groupsConstruct.ts @@ -0,0 +1,145 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { Stack } from 'aws-cdk-lib'; +import { LayerVersion } from 'aws-cdk-lib/aws-lambda'; +import { StringParameter } from 'aws-cdk-lib/aws-ssm'; +import { MLSpacePythonLambdaFunction, registerAPIEndpoint } from '../../utils/apiFunction'; +import { ApiStackProperties } from './restApiConstruct'; +import { RestApi } from 'aws-cdk-lib/aws-apigateway'; +import { Construct } from 'constructs'; + +export class GroupsApiConstruct extends Construct { + constructor (scope: Stack, id: string, props: ApiStackProperties) { + super(scope, id); + + // Get common layer based on arn from SSM due to issues with cross stack references + const commonLambdaLayer = LayerVersion.fromLayerVersionArn( + this, + 'mls-common-lambda-layer', + StringParameter.valueForStringParameter(this, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) + ); + + const restApi = RestApi.fromRestApiAttributes(this, 'RestApi', { + restApiId: props.restApiId, + rootResourceId: props.rootResourceId, + }); + + const apis: MLSpacePythonLambdaFunction[] = [ + { + name: 'list_all', + resource: 'group', + description: 'List all MLSpace groups for a user', + path: 'group', + method: 'GET', + }, + { + name: 'create', + resource: 'group', + description: 'Create a new MLSpace group', + path: 'group', + method: 'POST', + environment: { + DATA_BUCKET: props.dataBucketName, + }, + }, + { + name: 'group_datasets', + resource: 'group', + description: 'Lists datasets that belong to a group', + path: 'group/{groupName}/datasets', + method: 'GET', + }, + { + name: 'group_users', + resource: 'group', + description: 'Lists users that belong to a group', + path: 'group/{groupName}/users', + method: 'GET', + }, + { + name: 'add_users', + resource: 'group', + description: 'Adds users to a group', + path: 'group/{groupName}/users', + method: 'POST', + environment: { + DATA_BUCKET: props.dataBucketName, + }, + }, + { + name: 'delete', + resource: 'group', + description: 'Delete an MLSpace group', + path: 'group/{groupName}', + method: 'DELETE', + environment: { + DATA_BUCKET: props.dataBucketName, + }, + }, + { + name: 'get', + resource: 'group', + description: 'Gets the corresponding group object', + path: 'group/{groupName}', + method: 'GET', + }, + { + name: 'group_projects', + resource: 'group', + description: 'Lists projects that belong to a group', + path: 'group/{groupName}/projects', + method: 'GET', + }, + { + name: 'remove_user', + resource: 'group', + description: 'Removes a user from a group', + path: 'group/{groupName}/users/{username}', + method: 'DELETE', + environment: { + DATA_BUCKET: props.dataBucketName, + }, + }, + { + name: 'update', + resource: 'group', + description: 'Updates group state (suspended/active)', + path: 'group/{groupName}', + method: 'PUT', + }, + ]; + + apis.forEach((f) => { + const system_permissions = ['remove_user', 'update', 'delete']; + registerAPIEndpoint( + scope, + restApi, + props.authorizer, + system_permissions.includes(f.name) ? props.systemRole : props.applicationRole, + props.applicationRole.roleName, + props.notebookInstanceRole.roleName, + props.lambdaSourcePath, + [commonLambdaLayer], + f, + props.mlSpaceVPC, + props.securityGroups, + props.mlspaceConfig, + props.permissionsBoundaryArn + ); + }); + } +} diff --git a/lib/constructs/api/inferenceConstruct.ts b/lib/constructs/api/inferenceConstruct.ts new file mode 100644 index 00000000..bf591aaa --- /dev/null +++ b/lib/constructs/api/inferenceConstruct.ts @@ -0,0 +1,163 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { Stack } from 'aws-cdk-lib'; +import { LayerVersion } from 'aws-cdk-lib/aws-lambda'; +import { StringParameter } from 'aws-cdk-lib/aws-ssm'; +import { MLSpacePythonLambdaFunction, registerAPIEndpoint } from '../../utils/apiFunction'; +import { ApiStackProperties } from './restApiConstruct'; +import { RestApi } from 'aws-cdk-lib/aws-apigateway'; +import { Construct } from 'constructs'; + +export class InferenceApiConstruct extends Construct { + constructor (scope: Stack, id: string, props: ApiStackProperties) { + super(scope, id); + + // Get common layer based on arn from SSM due to issues with cross stack references + const commonLambdaLayer = LayerVersion.fromLayerVersionArn( + this, + 'mls-common-lambda-layer', + StringParameter.valueForStringParameter(this, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) + ); + + const restApi = RestApi.fromRestApiAttributes(this, 'RestApi', { + restApiId: props.restApiId, + rootResourceId: props.rootResourceId, + }); + + const apis: MLSpacePythonLambdaFunction[] = [ + { + name: 'create', + resource: 'model', + description: 'Creates a new model', + path: 'model', + method: 'POST', + environment: { + BUCKET: props.configBucketName, + S3_KEY: props.notebookParamFileKey, + }, + }, + { + name: 'describe', + resource: 'model', + description: 'Returns the description of a given model', + path: 'model/{modelName}', + method: 'GET', + }, + { + name: 'list_images', + resource: 'model', + description: 'Gets ECR paths for models', + path: 'model/images', + method: 'GET', + }, + { + name: 'delete', + resource: 'model', + description: 'Delete a model', + path: 'model/{modelName}', + method: 'DELETE', + }, + { + name: 'create', + resource: 'endpoint', + description: 'Creates a new endpoint', + path: 'endpoint', + method: 'POST', + }, + { + name: 'describe', + resource: 'endpoint', + description: 'Returns the description of an endpoint', + path: 'endpoint/{endpointName}', + method: 'GET', + }, + { + name: 'update', + resource: 'endpoint', + description: 'Updates an existing endpoint with a new endpoint config', + path: 'endpoint/{endpointName}', + method: 'PUT', + }, + { + name: 'delete', + resource: 'endpoint', + description: 'Deletes an Endpoint', + path: 'endpoint/{endpointName}', + method: 'DELETE', + }, + { + id: 'endpoint-get-logs', + name: 'get', + resource: 'logs', + description: 'Returns the log events for the specified endpoint', + path: 'endpoint/{endpointName}/logs', + method: 'GET', + }, + { + name: 'create', + resource: 'endpoint_config', + description: 'Creates a new endpoint config', + path: 'endpoint-config', + method: 'POST', + environment: { + BUCKET: props.configBucketName, + S3_KEY: props.notebookParamFileKey, + }, + }, + { + name: 'describe', + resource: 'endpoint_config', + description: 'Returns the description of an endpoint config', + path: 'endpoint-config/{endpointConfigName}', + method: 'GET', + }, + { + name: 'delete', + resource: 'endpoint_config', + description: 'Deletes an endpoint config', + path: 'endpoint-config/{endpointConfigName}', + method: 'DELETE', + }, + { + name: 'set_resource_termination', + resource: 'resource_scheduler', + description: 'Update the termination time of a SageMaker Endpoint', + path: 'endpoint/{endpointName}/schedule', + method: 'PUT', + id: 'resource_scheduler-set-endpoint-termination', + }, + ]; + + apis.forEach((f) => { + registerAPIEndpoint( + scope, + restApi, + props.authorizer, + props.applicationRole, + props.applicationRole.roleName, + props.notebookInstanceRole.roleName, + props.lambdaSourcePath, + [commonLambdaLayer], + f, + props.mlSpaceVPC, + props.securityGroups, + props.mlspaceConfig, + props.permissionsBoundaryArn + ); + }); + } +} diff --git a/lib/constructs/api/jobsConstruct.ts b/lib/constructs/api/jobsConstruct.ts new file mode 100644 index 00000000..193aad9c --- /dev/null +++ b/lib/constructs/api/jobsConstruct.ts @@ -0,0 +1,169 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { Stack } from 'aws-cdk-lib'; +import { LayerVersion } from 'aws-cdk-lib/aws-lambda'; +import { StringParameter } from 'aws-cdk-lib/aws-ssm'; +import { MLSpacePythonLambdaFunction, registerAPIEndpoint } from '../../utils/apiFunction'; +import { ApiStackProperties } from './restApiConstruct'; +import { RestApi } from 'aws-cdk-lib/aws-apigateway'; +import { Construct } from 'constructs'; + +export class JobsApiConstruct extends Construct { + constructor (scope: Stack, id: string, props: ApiStackProperties) { + super(scope, id); + + // Get common layer based on arn from SSM due to issues with cross stack references + const commonLambdaLayer = LayerVersion.fromLayerVersionArn( + this, + 'mls-common-lambda-layer', + StringParameter.valueForStringParameter(this, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) + ); + + const restApi = RestApi.fromRestApiAttributes(this, 'RestApi', { + restApiId: props.restApiId, + rootResourceId: props.rootResourceId, + }); + + const apis: MLSpacePythonLambdaFunction[] = [ + { + name: 'list_training_jobs', + resource: 'hpo_job', + description: 'List HPO training jobs', + path: 'job/hpo/{jobName}/training-jobs', + method: 'GET', + }, + { + name: 'describe', + resource: 'training_job', + description: 'Returns the description of a given training job', + path: 'job/training/{jobName}', + method: 'GET', + }, + { + name: 'create', + resource: 'training_job', + description: 'Creates a new training job', + path: 'job/training', + method: 'POST', + environment: { + ROLE_ARN: props.notebookInstanceRole.roleArn, + BUCKET: props.configBucketName, + S3_KEY: props.notebookParamFileKey, + ENVIRONMENT: props.deploymentEnvironmentName, + }, + }, + { + name: 'create', + resource: 'transform_job', + description: 'Creates a transform job', + path: 'job/transform', + method: 'POST', + environment: { + BUCKET: props.configBucketName, + S3_KEY: props.notebookParamFileKey, + ENVIRONMENT: props.deploymentEnvironmentName, + DATA_BUCKET: props.dataBucketName, + }, + }, + { + name: 'stop', + resource: 'transform_job', + description: 'Stop transform job', + path: 'job/transform/{jobName}/stop', + method: 'POST', + }, + { + name: 'describe', + resource: 'transform_job', + description: 'Describes a transform job', + path: 'job/transform/{jobName}', + method: 'GET', + }, + { + name: 'create', + resource: 'hpo_job', + description: 'Creates a HPO job', + path: 'job/hpo', + method: 'POST', + environment: { + BUCKET: props.configBucketName, + S3_KEY: props.notebookParamFileKey, + ENVIRONMENT: props.deploymentEnvironmentName, + }, + }, + { + name: 'stop', + resource: 'hpo_job', + description: 'Stop HPO job', + path: 'job/hpo/{jobName}/stop', + method: 'POST', + }, + { + name: 'describe', + resource: 'hpo_job', + description: 'Describes a HPO job', + path: 'job/hpo/{jobName}', + method: 'GET', + }, + { + id: 'job-get-logs', + name: 'get', + resource: 'logs', + description: 'Returns the log events for the specified job', + path: 'job/{jobType}/{jobName}/logs', + method: 'GET', + }, + { + name: 'describe', + resource: 'labeling_job', + description: 'Describes a Ground Truth labeling job', + path: 'job/labeling/{jobName}', + method: 'GET', + }, + { + name: 'create', + resource: 'labeling_job', + description: 'Creates a Labeling job', + path: 'job/labeling', + method: 'POST', + environment: { + BUCKET: props.configBucketName, + S3_KEY: props.notebookParamFileKey, + ENVIRONMENT: props.deploymentEnvironmentName, + }, + }, + ]; + + apis.forEach((f) => { + registerAPIEndpoint( + scope, + restApi, + props.authorizer, + props.applicationRole, + props.applicationRole.roleName, + props.notebookInstanceRole.roleName, + props.lambdaSourcePath, + [commonLambdaLayer], + f, + props.mlSpaceVPC, + props.securityGroups, + props.mlspaceConfig, + props.permissionsBoundaryArn + ); + }); + } +} diff --git a/lib/constructs/api/notebooksConstruct.ts b/lib/constructs/api/notebooksConstruct.ts new file mode 100644 index 00000000..055917d5 --- /dev/null +++ b/lib/constructs/api/notebooksConstruct.ts @@ -0,0 +1,146 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { Stack } from 'aws-cdk-lib'; +import { LayerVersion } from 'aws-cdk-lib/aws-lambda'; +import { StringParameter } from 'aws-cdk-lib/aws-ssm'; +import { MLSpacePythonLambdaFunction, registerAPIEndpoint } from '../../utils/apiFunction'; +import { ApiStackProperties } from './restApiConstruct'; +import { RestApi } from 'aws-cdk-lib/aws-apigateway'; +import { Construct } from 'constructs'; + +export class NotebooksApiConstruct extends Construct { + constructor (scope: Stack, id: string, props: ApiStackProperties) { + super(scope, id); + + // Get common layer based on arn from SSM due to issues with cross stack references + const commonLambdaLayer = LayerVersion.fromLayerVersionArn( + scope, + 'mls-common-lambda-layer', + StringParameter.valueForStringParameter(this, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) + ); + + const restApi = RestApi.fromRestApiAttributes(this, 'RestApi', { + restApiId: props.restApiId, + rootResourceId: props.rootResourceId, + }); + + const apis: MLSpacePythonLambdaFunction[] = [ + { + id: 'notebook-personal', + name: 'list_resources', + resource: 'notebook', + description: 'Gets all notebooks a user has access to', + path: 'notebook', + method: 'GET', + }, + { + name: 'describe', + resource: 'notebook', + description: 'Describe a sagemaker notebook instance', + path: 'notebook/{notebookName}', + method: 'GET', + }, + { + name: 'edit', + resource: 'notebook', + description: 'Update a sagemaker notebook instance', + path: 'notebook/{notebookName}', + method: 'PUT', + environment: { + BUCKET: props.configBucketName, + S3_KEY: props.notebookParamFileKey, + }, + }, + { + name: 'create', + resource: 'notebook', + description: 'Create a sagemaker notebook instance in an MLSpace project', + path: 'notebook', + method: 'POST', + environment: { + BUCKET: props.configBucketName, + DATA_BUCKET: props.dataBucketName, + ENVIRONMENT: props.deploymentEnvironmentName, + ROLE_ARN: props.notebookInstanceRole.roleArn, + S3_KEY: props.notebookParamFileKey, + }, + }, + { + name: 'delete', + resource: 'notebook', + description: 'Delete a sagemaker notebook instance in an MLSpace project', + path: 'notebook/{notebookName}', + method: 'DELETE', + }, + { + name: 'start', + resource: 'notebook', + description: 'Starts a sagemaker notebook instance in an MLSpace project', + path: 'notebook/{notebookName}/start', + method: 'POST', + }, + { + name: 'stop', + resource: 'notebook', + description: 'Stop a sagemaker notebook instance in an MLSpace project', + path: 'notebook/{notebookName}/stop', + method: 'POST', + }, + { + name: 'presigned_url', + resource: 'notebook', + description: 'Gets a presigned URL to open a sagemaker notebook instance', + path: 'notebook/{notebookName}/url', + method: 'GET', + }, + { + id: 'notebooks-get-logs', + name: 'get', + resource: 'logs', + description: 'Returns the log events for the specified notebook', + path: 'notebook/{notebookName}/logs', + method: 'GET', + }, + { + name: 'set_resource_termination', + resource: 'resource_scheduler', + description: 'Update the termination time of a SageMaker Notebook', + path: 'notebook/{notebookName}/schedule', + method: 'PUT', + id: 'resource_scheduler-set-notebook-termination', + }, + ]; + + apis.forEach((f) => { + registerAPIEndpoint( + scope, + restApi, + props.authorizer, + props.applicationRole, + props.applicationRole.roleName, + props.notebookInstanceRole.roleName, + props.lambdaSourcePath, + [commonLambdaLayer], + f, + props.mlSpaceVPC, + props.securityGroups, + props.mlspaceConfig, + props.permissionsBoundaryArn + ); + }); + } +} diff --git a/lib/constructs/api/projectsConstruct.ts b/lib/constructs/api/projectsConstruct.ts new file mode 100644 index 00000000..20159d5f --- /dev/null +++ b/lib/constructs/api/projectsConstruct.ts @@ -0,0 +1,276 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { Stack } from 'aws-cdk-lib'; +import { LayerVersion } from 'aws-cdk-lib/aws-lambda'; +import { StringParameter } from 'aws-cdk-lib/aws-ssm'; +import { MLSpacePythonLambdaFunction, registerAPIEndpoint } from '../../utils/apiFunction'; +import { ApiStackProperties } from './restApiConstruct'; +import { RestApi } from 'aws-cdk-lib/aws-apigateway'; +import { Construct } from 'constructs'; + +export class ProjectsApiConstruct extends Construct { + constructor (scope: Stack, id: string, props: ApiStackProperties) { + super(scope, id); + + // Get common layer based on arn from SSM due to issues with cross stack references + const commonLambdaLayer = LayerVersion.fromLayerVersionArn( + this, + 'mls-common-lambda-layer', + StringParameter.valueForStringParameter(this, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) + ); + + const restApi = RestApi.fromRestApiAttributes(this, 'RestApi', { + restApiId: props.restApiId, + rootResourceId: props.rootResourceId, + }); + + const apis: MLSpacePythonLambdaFunction[] = [ + { + name: 'list_all', + resource: 'project', + description: 'List all MLSpace projects', + path: 'project', + method: 'GET', + }, + { + name: 'create', + resource: 'project', + description: 'Create a new MLSpace project', + path: 'project', + method: 'POST', + environment: { + DATA_BUCKET: props.dataBucketName, + }, + }, + { + name: 'project_users', + resource: 'project', + description: 'Lists users that belong to a project', + path: 'project/{projectName}/users', + method: 'GET', + }, + { + name: 'add_users', + resource: 'project', + description: 'Adds users to a project', + path: 'project/{projectName}/users', + method: 'POST', + environment: { + DATA_BUCKET: props.dataBucketName, + }, + }, + { + name: 'update_project_user', + resource: 'project', + description: 'Change the role of an MLSpace user within a project', + path: 'project/{projectName}/users/{username}', + method: 'PUT', + }, + { + name: 'add_groups', + resource: 'project', + description: 'Adds groups to a project', + path: 'project/{projectName}/groups', + method: 'POST', + environment: { + DATA_BUCKET: props.dataBucketName, + }, + }, + { + name: 'update_project_group', + resource: 'project', + description: 'Change the role of an MLSpace group within a project', + path: 'project/{projectName}/groups/{groupName}', + method: 'PUT', + environment: { + DATA_BUCKET: props.dataBucketName, + }, + }, + { + name: 'delete', + resource: 'project', + description: 'Delete an MLSpace project', + path: 'project/{projectName}', + method: 'DELETE', + environment: { + DATA_BUCKET: props.dataBucketName, + }, + }, + { + name: 'get', + resource: 'project', + description: 'Gets the corresponding project object', + path: 'project/{projectName}', + method: 'GET', + }, + { + name: 'remove_user', + resource: 'project', + description: 'Removes a user from a project', + path: 'project/{projectName}/users/{username}', + method: 'DELETE', + environment: { + DATA_BUCKET: props.dataBucketName, + }, + }, + { + name: 'remove_group', + resource: 'project', + description: 'Removes a group from a project', + path: 'project/{projectName}/groups/{groupName}', + method: 'DELETE', + environment: { + DATA_BUCKET: props.dataBucketName, + }, + }, + { + name: 'update', + resource: 'project', + description: 'Updates project state (suspended/active)', + path: 'project/{projectName}', + method: 'PUT', + }, + { + name: 'list_resources', + resource: 'training_job', + description: 'List training jobs', + path: 'project/{projectName}/jobs/training', + method: 'GET', + }, + { + name: 'list_resources', + resource: 'transform_job', + description: 'Lists transform jobs', + path: 'project/{projectName}/jobs/transform', + method: 'GET', + }, + { + name: 'list_resources', + resource: 'hpo_job', + description: 'Lists HPO jobs', + path: 'project/{projectName}/jobs/hpo', + method: 'GET', + }, + { + name: 'list_resources', + resource: 'labeling_job', + description: 'List Ground Truth labeling jobs', + path: 'project/{projectName}/jobs/labeling', + method: 'GET', + }, + { + name: 'list_workteams', + resource: 'labeling_job', + description: 'Describes a Ground Truth workteams', + path: 'project/{projectName}/jobs/labeling/teams', + method: 'GET', + }, + { + name: 'list_resources', + resource: 'notebook', + description: 'List all notebook instances in MLSpace', + path: 'project/{projectName}/notebooks', + method: 'GET', + }, + { + name: 'list_resources', + resource: 'model', + description: 'List models', + path: 'project/{projectName}/models', + method: 'GET', + }, + { + name: 'list_resources', + resource: 'endpoint', + description: 'Lists endpoints', + path: 'project/{projectName}/endpoints', + method: 'GET', + }, + { + name: 'list_resources', + resource: 'endpoint_config', + description: 'Lists endpoint configs', + path: 'project/{projectName}/endpoint-configs', + method: 'GET', + }, + { + name: 'list_resources', + resource: 'dataset', + description: 'List all datasets associated with the specified project', + path: 'project/{projectName}/datasets', + method: 'GET', + }, + { + name: 'list_all', + resource: 'emr', + description: 'List all EMR Clusters in MLSpace', + path: 'project/{projectName}/emr', + method: 'GET', + }, + { + name: 'create', + resource: 'emr', + description: 'Create an EMR Cluster in an MLSpace project', + path: 'project/{projectName}/emr', + method: 'POST', + environment: { + BUCKET: props.configBucketName, + S3_KEY: props.notebookParamFileKey, + EMR_SECURITY_CONFIGURATION: props.mlspaceConfig.EMR_SECURITY_CONFIG_NAME, + EMR_EC2_ROLE_NAME: props.emrEC2RoleName || '', + EMR_SERVICE_ROLE_NAME: props.emrServiceRoleName || '', + EMR_EC2_SSH_KEY: props.mlspaceConfig.EMR_EC2_SSH_KEY, + DATA_BUCKET: props.dataBucketName, + LOG_BUCKET: props.cwlBucketName, + }, + }, + { + name: 'list', + resource: 'batch_translate', + description: 'List pages of Batch Translate jobs for a project in MLSpace', + path: 'project/{projectName}/batch-translate-jobs', + method: 'GET', + }, + { + name: 'project_groups', + resource: 'project', + description: 'Lists groups that belong to a project', + path: 'project/{projectName}/groups', + method: 'GET', + }, + ]; + + apis.forEach((f) => { + const system_permissions = ['remove_user', 'update', 'delete']; + registerAPIEndpoint( + scope, + restApi, + props.authorizer, + system_permissions.includes(f.name) ? props.systemRole : props.applicationRole, + props.applicationRole.roleName, + props.notebookInstanceRole.roleName, + props.lambdaSourcePath, + [commonLambdaLayer], + f, + props.mlSpaceVPC, + props.securityGroups, + props.mlspaceConfig, + props.permissionsBoundaryArn + ); + }); + } +} diff --git a/lib/constructs/api/restApiConstruct.ts b/lib/constructs/api/restApiConstruct.ts new file mode 100644 index 00000000..44864bff --- /dev/null +++ b/lib/constructs/api/restApiConstruct.ts @@ -0,0 +1,333 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { Aspects, Duration, RemovalPolicy, Stack, StackProps } from 'aws-cdk-lib'; +import { + AccessLogField, + AccessLogFormat, + AwsIntegration, + Cors, + EndpointType, + IAuthorizer, + IdentitySource, + LogGroupLogDestination, + RequestAuthorizer, + RestApi, + StageOptions, +} from 'aws-cdk-lib/aws-apigateway'; +import { ISecurityGroup, IVpc } from 'aws-cdk-lib/aws-ec2'; +import { IManagedPolicy, IRole, Role } from 'aws-cdk-lib/aws-iam'; +import { Code, Function, LayerVersion } from 'aws-cdk-lib/aws-lambda'; +import { LogGroup } from 'aws-cdk-lib/aws-logs'; +import { Bucket } from 'aws-cdk-lib/aws-s3'; +import { BucketDeployment, Source } from 'aws-cdk-lib/aws-s3-deployment'; +import { StringParameter } from 'aws-cdk-lib/aws-ssm'; +import { ADCLambdaCABundleAspect } from '../../utils/adcCertBundleAspect'; +import { createLambdaLayer } from '../../utils/layers'; +import { MLSpaceConfig } from '../../utils/configTypes'; +import { Construct } from 'constructs'; + + +export type ApiStackProperties = { + readonly restApiId: string; + readonly rootResourceId: string; + readonly dataBucketName: string; + readonly cwlBucketName: string; + readonly applicationRole: IRole; + readonly systemRole: IRole; + readonly notebookInstanceRole: IRole; + readonly endpointConfigInstanceConstraintPolicy?: IManagedPolicy, + readonly jobInstanceConstraintPolicy?: IManagedPolicy, + readonly mlspaceKmsInstanceConditionsPolicy?: IManagedPolicy, + readonly configBucketName: string; + readonly notebookParamFileKey: string; + readonly deploymentEnvironmentName: string; + readonly authorizer: IAuthorizer; + readonly lambdaSourcePath: string; + readonly mlSpaceVPC: IVpc; + readonly securityGroups: ISecurityGroup[]; + readonly permissionsBoundaryArn?: string; + readonly emrEC2RoleName?: string; + readonly emrServiceRoleName?: string; + readonly mlspaceConfig: MLSpaceConfig; +} & StackProps; + +export type RestApiStackProperties = { + readonly frontEndAssetsPath: string; + readonly lambdaSourcePath: string; + readonly dataBucketName: string; + readonly websiteBucketName: string; + readonly websiteS3ReaderRole: IRole; + readonly mlSpaceAppRole: IRole; + readonly verifyOIDCTokenSignature: boolean; + readonly mlSpaceVPC: IVpc; + readonly lambdaSecurityGroups: ISecurityGroup[]; + readonly isIso?: boolean; + readonly enableTranslate: boolean; + readonly mlspaceConfig: MLSpaceConfig; +} & StackProps; + +export class RestApiConstruct extends Construct { + public mlspaceRequestAuthorizer: RequestAuthorizer; + public mlSpaceRestApiId: string; + public mlSpaceRestApiRootResourceId: string; + + constructor (scope: Stack, id: string, props: RestApiStackProperties) { + super(scope, id); + // Depending on your needs the throttling configuration can be changed here. These limits + // only impact calls to the MLSpace APIs and will have no impact on the backing AWS APIs. If + // an MLSpace API is calling a SageMaker API with a TPS limit of 10 then setting this value + // to anything greater than 10 may result in throttling from SageMaker directly. + let deployOptions: StageOptions = { + stageName: 'Prod', + throttlingRateLimit: 100, + throttlingBurstLimit: 100, + }; + if (props.mlspaceConfig.ENABLE_ACCESS_LOGGING) { + const apiAccessLogGroup = new LogGroup(this, 'mlspace-APIGWLogGroup', { + logGroupName: '/aws/apigateway/MLSpace', + removalPolicy: RemovalPolicy.DESTROY, + }); + deployOptions = { + ...deployOptions, + accessLogDestination: new LogGroupLogDestination(apiAccessLogGroup), + accessLogFormat: AccessLogFormat.custom( + JSON.stringify({ + requestId: AccessLogField.contextRequestId(), + requestTime: AccessLogField.contextRequestTime(), + authorizerPrincipalId: AccessLogField.contextAuthorizerPrincipalId(), + identity: { + accountId: AccessLogField.contextIdentityAccountId(), + apiKeyId: AccessLogField.contextIdentityApiKeyId(), + caller: AccessLogField.contextIdentityCaller(), + sourceIp: AccessLogField.contextIdentitySourceIp(), + user: AccessLogField.contextIdentityUser(), + userAgent: AccessLogField.contextIdentityUserAgent(), + userArn: AccessLogField.contextIdentityUserArn(), + }, + requestContext: { + stage: AccessLogField.contextStage(), + protocol: AccessLogField.contextProtocol(), + httpMethod: AccessLogField.contextHttpMethod(), + path: AccessLogField.contextPath(), + resourcePath: AccessLogField.contextResourcePath(), + resourceId: AccessLogField.contextResourceId(), + }, + response: { + statusCode: AccessLogField.contextStatus(), + latency: AccessLogField.contextResponseLatency(), + length: AccessLogField.contextResponseLength(), + }, + error: { + message: AccessLogField.contextErrorMessage(), + responseType: AccessLogField.contextErrorResponseType(), + }, + }) + ), + }; + } + const mlSpaceRestApi = new RestApi(this, 'mlspace-api', { + restApiName: 'MLSpace API', + description: 'The MLSpace API Layer.', + endpointConfiguration: { types: [EndpointType.REGIONAL] }, + deployOptions, + deploy: true, + defaultCorsPreflightOptions: { + allowOrigins: Cors.ALL_ORIGINS, + allowHeaders: [ + ...Cors.DEFAULT_HEADERS, + 'x-mlspace-dataset-scope', + 'x-mlspace-dataset-type', + 'x-mlspace-project', + ], + }, + // Support binary media types used for documentation images and fonts + binaryMediaTypes: ['font/*', 'image/*'], + }); + // Configure static site resources + const proxyMethodResponse = [ + { + statusCode: '200', + responseParameters: { + 'method.response.header.Content-Length': true, + 'method.response.header.Content-Type': true, + 'method.response.header.Content-Disposition': true, + }, + }, + ]; + const proxyRequestParameters = { + 'method.request.header.Accept': true, + 'method.request.header.Content-Type': true, + 'method.request.header.Content-Disposition': true, + }; + const proxyIntegrationResponse = [ + { + statusCode: '200', + responseParameters: { + 'method.response.header.Content-Length': + 'integration.response.header.Content-Length', + 'method.response.header.Content-Type': + 'integration.response.header.Content-Type', + 'method.response.header.Content-Disposition': + 'integration.response.header.Content-Disposition', + }, + }, + ]; + const proxyIntegrationRequestParameters = { + 'integration.request.header.Accept': 'method.request.header.Accept', + 'integration.request.header.Content-Disposition': + 'method.request.header.Content-Disposition', + 'integration.request.header.Content-Type': 'method.request.header.Content-Type', + }; + mlSpaceRestApi.root.addMethod( + 'GET', + new AwsIntegration({ + region: props.env?.region, + service: 's3', + path: `${props.websiteBucketName}/index.html`, + integrationHttpMethod: 'GET', + options: { + credentialsRole: props.websiteS3ReaderRole, + integrationResponses: proxyIntegrationResponse, + requestParameters: proxyIntegrationRequestParameters, + }, + }), + { + methodResponses: proxyMethodResponse, + requestParameters: proxyRequestParameters, + } + ); + + mlSpaceRestApi.root.addResource('{proxy+}').addMethod( + 'GET', + new AwsIntegration({ + region: props.env?.region, + service: 's3', + path: `${props.websiteBucketName}/{proxy}`, + integrationHttpMethod: 'ANY', + options: { + credentialsRole: props.websiteS3ReaderRole, + integrationResponses: proxyIntegrationResponse, + requestParameters: { + ...proxyIntegrationRequestParameters, + 'integration.request.path.proxy': 'method.request.path.proxy', + }, + }, + }), + { + requestParameters: { + ...proxyRequestParameters, + 'method.request.path.proxy': true, + }, + methodResponses: proxyMethodResponse, + } + ); + + const jwtDependencyLayer = createLambdaLayer(this, 'jwt', undefined, props.mlspaceConfig.JWT_LAYER_PATH); + // Get common layer based on arn from SSM due to issues with cross stack references + const commonLambdaLayer = LayerVersion.fromLayerVersionArn( + this, + 'mls-common-lambda-layer', + StringParameter.valueForStringParameter(this, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) + ); + + let ssmIdPEndpoint; + if (props.mlspaceConfig.IDP_ENDPOINT_SSM_PARAM) { + ssmIdPEndpoint = StringParameter.valueForStringParameter(this, props.mlspaceConfig.IDP_ENDPOINT_SSM_PARAM); + } + + const authorizerLambda = new Function(this, 'MLSpaceAuthorizerLambda', { + runtime: props.mlspaceConfig.LAMBDA_RUNTIME, + architecture: props.mlspaceConfig.LAMBDA_ARCHITECTURE, + handler: 'ml_space_lambda.authorizer.lambda_function.lambda_handler', + functionName: 'mls-lambda-authorizer', + code: Code.fromAsset(props.lambdaSourcePath), + description: 'MLSpace Authentication and Authorization Lambda', + timeout: Duration.seconds(30), + memorySize: 512, + role: props.mlSpaceAppRole, + layers: [jwtDependencyLayer.layerVersion, commonLambdaLayer], + environment: { + OIDC_URL: ssmIdPEndpoint || props.mlspaceConfig.INTERNAL_OIDC_URL || props.mlspaceConfig.OIDC_URL, + OIDC_CLIENT_NAME: props.mlspaceConfig.OIDC_CLIENT_NAME, + OIDC_VERIFY_SSL: props.mlspaceConfig.OIDC_VERIFY_SSL ? 'True' : 'False', + OIDC_VERIFY_SIGNATURE: props.verifyOIDCTokenSignature ? 'True' : 'False', + ...props.mlspaceConfig.ADDITIONAL_LAMBDA_ENVIRONMENT_VARS, + }, + vpc: props.mlSpaceVPC, + securityGroups: props.lambdaSecurityGroups, + }); + + this.mlspaceRequestAuthorizer = new RequestAuthorizer(this, 'MLSpaceAPIGWAuthorizer', { + handler: authorizerLambda, + resultsCacheTtl: Duration.seconds(0), + identitySources: [IdentitySource.header('Authorization')], + }); + + // TODO: I probably messed something up here. This did not originally exist before Stack -> Construct + this.mlspaceRequestAuthorizer._attachToApi(mlSpaceRestApi); + + // Dynamic config relies on api URL and we don't want to do this in a separate stack + const appEnvironmentConfig = { + OIDC_URL: ssmIdPEndpoint || props.mlspaceConfig.OIDC_URL, + OIDC_REDIRECT_URI: props.mlspaceConfig.OIDC_REDIRECT_URI || mlSpaceRestApi.url, + OIDC_CLIENT_NAME: props.mlspaceConfig.OIDC_CLIENT_NAME, + LAMBDA_ENDPOINT: mlSpaceRestApi.url, + MANAGE_IAM_ROLES: props.mlspaceConfig.MANAGE_IAM_ROLES, + SHOW_MIGRATION_OPTIONS: props.mlspaceConfig.SHOW_MIGRATION_OPTIONS, + ENABLE_TRANSLATE: props.enableTranslate, + APPLICATION_NAME: props.mlspaceConfig.APPLICATION_NAME, + DATASET_BUCKET: props.dataBucketName, + AWS_REGION: props.mlspaceConfig.AWS_REGION, + BACKGROUND_REFRESH_INTERVAL: props.mlspaceConfig.BACKGROUND_REFRESH_INTERVAL + }; + + // MLSpace static react app + const websiteBucket = Bucket.fromBucketName( + this, + 'mlspace-static-website-bucket', + props.websiteBucketName + ); + + const frontEndDeployment = new BucketDeployment(this, 'MLSpaceFrontEndDeployment', { + sources: [ + Source.asset(props.frontEndAssetsPath), + Source.data('env.js', `window.env = ${JSON.stringify(appEnvironmentConfig)}`), + ], + destinationBucket: websiteBucket, + memoryLimit: 1024, + prune: true, + role: props.mlspaceConfig.BUCKET_DEPLOYMENT_ROLE_ARN + ? Role.fromRoleArn( + this, + 'mlspace-website-deploy-role', + props.mlspaceConfig.BUCKET_DEPLOYMENT_ROLE_ARN, + { + mutable: false, + } + ) + : undefined, + }); + + if (props.isIso) { + Aspects.of(frontEndDeployment).add(new ADCLambdaCABundleAspect()); + Aspects.of(authorizerLambda).add(new ADCLambdaCABundleAspect()); + } + + this.mlSpaceRestApiId = mlSpaceRestApi.restApiId; + this.mlSpaceRestApiRootResourceId = mlSpaceRestApi.restApiRootResourceId; + } +} diff --git a/lib/constructs/api/translateConstruct.ts b/lib/constructs/api/translateConstruct.ts new file mode 100644 index 00000000..fe0664e6 --- /dev/null +++ b/lib/constructs/api/translateConstruct.ts @@ -0,0 +1,119 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { Stack } from 'aws-cdk-lib'; +import { LayerVersion } from 'aws-cdk-lib/aws-lambda'; +import { StringParameter } from 'aws-cdk-lib/aws-ssm'; +import { MLSpacePythonLambdaFunction, registerAPIEndpoint } from '../../utils/apiFunction'; +import { ApiStackProperties } from './restApiConstruct'; +import { RestApi } from 'aws-cdk-lib/aws-apigateway'; +import { Construct } from 'constructs'; + +export class TranslateApiConstruct extends Construct { + constructor (scope: Stack, id: string, props: ApiStackProperties) { + super(scope, id); + + // Get common layer based on arn from SSM due to issues with cross stack references + const commonLambdaLayer = LayerVersion.fromLayerVersionArn( + this, + 'mls-common-lambda-layer', + StringParameter.valueForStringParameter(this, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) + ); + + const restApi = RestApi.fromRestApiAttributes(this, 'RestApi', { + restApiId: props.restApiId, + rootResourceId: props.rootResourceId, + }); + + const apis: MLSpacePythonLambdaFunction[] = [ + { + name: 'translate_text', + resource: 'translate_realtime', + description: + 'Perform a real-time translation of a source text, with a source and target language', + path: 'translate/realtime/text', + method: 'POST', + }, + { + name: 'translate_document', + resource: 'translate_realtime', + description: + 'Perform a real-time translation of a source document, with a source and target language', + path: 'translate/realtime/document', + method: 'POST', + }, + { + name: 'describe', + resource: 'batch_translate', + description: 'Describe a Batch Translate job', + path: 'batch-translate/{jobId}', + method: 'GET', + }, + { + name: 'create', + resource: 'batch_translate', + description: 'Create a Batch Translate job in an MLSpace project', + path: 'batch-translate', + method: 'POST', + environment: { + BUCKET: props.configBucketName, + S3_KEY: props.notebookParamFileKey, + DATA_BUCKET: props.dataBucketName, + TRANSLATE_DATE_ROLE_ARN: props.applicationRole.roleArn, + }, + }, + { + name: 'stop', + resource: 'batch_translate', + description: 'Stop a Batch Translate job', + path: 'batch-translate/{jobId}/stop', + method: 'POST', + }, + { + name: 'list_languages', + resource: 'metadata', + description: 'List the supported languages for AWS Translate', + path: 'translate/list-languages', + method: 'GET', + }, + { + name: 'list', + resource: 'custom_terminology', + description: 'List pages of Custom Terminologies for AWS Translate', + path: 'translate/custom-terminologies', + method: 'GET', + }, + ]; + + apis.forEach((f) => { + registerAPIEndpoint( + scope, + restApi, + props.authorizer, + props.applicationRole, + props.applicationRole.roleName, + props.notebookInstanceRole.roleName, + props.lambdaSourcePath, + [commonLambdaLayer], + f, + props.mlSpaceVPC, + props.securityGroups, + props.mlspaceConfig, + props.permissionsBoundaryArn + ); + }); + } +} diff --git a/lib/constructs/iamConstruct.ts b/lib/constructs/iamConstruct.ts new file mode 100644 index 00000000..641b713d --- /dev/null +++ b/lib/constructs/iamConstruct.ts @@ -0,0 +1,1268 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { Aws, Stack, StackProps } from 'aws-cdk-lib'; +import { CfnAccount } from 'aws-cdk-lib/aws-apigateway'; +import { IVpc } from 'aws-cdk-lib/aws-ec2'; +import { + CfnInstanceProfile, + CompositePrincipal, + Effect, + IManagedPolicy, + IRole, + ManagedPolicy, + PolicyStatement, + Role, + ServicePrincipal +} from 'aws-cdk-lib/aws-iam'; +import { IKey } from 'aws-cdk-lib/aws-kms'; +import { MLSpaceConfig } from '../utils/configTypes'; +import { Construct } from 'constructs'; + +export type IAMStackProp = { + readonly dataBucketName: string; + readonly configBucketName: string; + readonly websiteBucketName: string; + readonly encryptionKey: IKey; + readonly mlSpaceVPC: IVpc; + readonly mlSpaceDefaultSecurityGroupId: string; + readonly enableTranslate: boolean; + readonly isIso?: boolean; + readonly mlspaceConfig: MLSpaceConfig; +} & StackProps; + +export class IAMConstruct extends Construct { + public mlSpaceAppRole: IRole; + public mlSpaceNotebookRole: IRole; + public s3ReaderRole: IRole; + public mlSpacePermissionsBoundary?: IManagedPolicy; + public emrServiceRoleName: string; + public emrEC2RoleName: string; + public mlspaceEndpointConfigInstanceConstraintPolicy?: IManagedPolicy; + public mlspaceJobInstanceConstraintPolicy?: IManagedPolicy; + public mlSpaceSystemRole: IRole; + public mlspaceKmsInstanceConditionsPolicy: IManagedPolicy; + + constructor (scope: Stack, id: string, props: IAMStackProp) { + super(scope, id); + + /** + * Comprehend Permissions + * Translate Permissions + */ + const mlActions = ['comprehend:Detect*', 'comprehend:BatchDetect*']; + if (props.enableTranslate) { + // Translate Permissions + mlActions.push('translate:TranslateText'); + } + // Required tags that force the request to be specific to an MLSpace managed resource + const requestTagsConditions = { + 'aws:RequestTag/project': 'false', + // this is excluded because it is covered by requestSystemTagEqualsConditions below + // 'aws:RequestTag/system': 'false', + 'aws:RequestTag/user': 'false', + }; + + const enum SystemTagCondition { + Equals, + NotEquals + } + + const requestSystemTagEqualsConditions = { + [SystemTagCondition.Equals]: { + 'StringEqualsIgnoreCase': { + 'aws:RequestTag/system': props.mlspaceConfig.SYSTEM_TAG, + } + }, + [SystemTagCondition.NotEquals]: { + 'StringNotEqualsIgnoreCase': { + 'aws:RequestTag/system': props.mlspaceConfig.SYSTEM_TAG, + } + } + }; + // Required tags that ensure a created or accessed resource are properly managed by MLSpace + const resourceTagsConditions = { + 'aws:ResourceTag/project': 'false', + 'aws:ResourceTag/system': 'false', + 'aws:ResourceTag/user': 'false', + }; + + const resourceSystemTagEqualsConditions = { + [SystemTagCondition.Equals]: { + 'StringEqualsIgnoreCase': { + 'aws:ResourceTag/system': props.mlspaceConfig.SYSTEM_TAG, + } + }, + [SystemTagCondition.NotEquals]: { + 'StringNotEqualsIgnoreCase': { + 'aws:ResourceTag/system': props.mlspaceConfig.SYSTEM_TAG, + } + } + }; + + const ec2ArnBase = `arn:${scope.partition}:ec2:${Aws.REGION}:${scope.account}`; + const privateSubnetArnList = props.mlSpaceVPC.privateSubnets.map( + (s) => `${ec2ArnBase}:subnet/${s.subnetId}` + ); + + // Role names + const mlspaceSystemRoleName = 'mlspace-system-role'; + const mlSpaceNotebookRoleName = 'mlspace-notebook-role'; + + + if (props.mlspaceConfig.KMS_INSTANCE_CONDITIONS_POLICY_ARN) { + this.mlspaceKmsInstanceConditionsPolicy = ManagedPolicy.fromManagedPolicyArn(this, 'mlspace-kms-instance-constraint-policy', props.mlspaceConfig.KMS_INSTANCE_CONDITIONS_POLICY_ARN); + } else { + this.mlspaceKmsInstanceConditionsPolicy = new ManagedPolicy(this, 'mlspace-kms-instance-constraint-policy', { + managedPolicyName: `${props.mlspaceConfig.IAM_RESOURCE_PREFIX}-kms-instance-constraint-policy`, + statements: [ + new PolicyStatement({ + effect: Effect.DENY, + actions: [ + 'sagemaker:CreateEndpointConfig', + 'sagemaker:CreateHyperParameterTuningJob', + 'sagemaker:CreateNotebookInstance', + 'sagemaker:CreateTrainingJob', + 'sagemaker:CreateTransformJob' + ], + resources: ['*'], + conditions: { + 'Null': { + 'sagemaker:VolumeKmsKey': 'true' + }, + }, + }), + ] + }); + } + + const invertedBooleanConditions = (conditions: {[key: string]: string}) => Object.fromEntries(Object.entries(conditions).map(([key, value]) => { + return [key, value === 'true' ? 'false' : 'true']; + })); + + /** + * NOTEBOOK POLICY & ROLE SECTION + * Notebook policy - base permissions used when in a notebook and also applied to general use of the application + * Notebook role - the role and permissions used when users are accessing a notebook + */ + const notebookPolicyStatements = (partition: string, region: string, allow_all_instances: boolean = false) => { + const statements = [ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['kms:CreateGrant'], + resources: [props.encryptionKey.keyArn], + conditions: { + Bool: { + 'kms:GrantIsForAWSResource': 'true', + }, + }, + }), + /** + * HPO Permissions + * Training Permissions + * Transform Permissions + */ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + // EC2 permissions required to create hpo/training/transform jobs in a private VPC + 'ec2:CreateNetworkInterface', + 'ec2:CreateNetworkInterfacePermission', + 'ec2:DeleteNetworkInterface', + 'ec2:DeleteNetworkInterfacePermission', + // KMS permissions are required to encrypt job output and decrypt job input + 'kms:Decrypt', + 'kms:DescribeKey', + 'kms:Encrypt', + 'kms:GenerateDataKey', + ], + resources: [ + // EC2 actions resource identifiers + ...privateSubnetArnList, + `${ec2ArnBase}:security-group/${props.mlSpaceDefaultSecurityGroupId}`, + `${ec2ArnBase}:network-interface/*`, + // KMS action resource identifier + props.encryptionKey.keyArn, + ], + }), + // General Permissions - Allows tagging of SageMaker resources created within a notebook + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['sagemaker:AddTags'], + resources: [`arn:${partition}:sagemaker:${region}:${scope.account}:*`], + }), + // General Permissions - Read Only + Metric Write permissions + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + // EC2 describe actions that are not bound by resource identifier. + 'ec2:DescribeNetworkInterfaces', + 'ec2:DescribeDhcpOptions', + 'ec2:DescribeSubnets', + 'ec2:DescribeSecurityGroups', + 'ec2:DescribeVpcs', + // SageMaker list actions that are not bound by resource identifier. + 'sagemaker:DescribeWorkteam', + 'sagemaker:ListEndpointConfigs', + 'sagemaker:ListEndpoints', + 'sagemaker:ListLabelingJobs', + 'sagemaker:ListModels', + 'sagemaker:ListTags', + 'sagemaker:ListTrainingJobs', + 'sagemaker:ListTransformJobs', + 'sagemaker:ListHyperParameterTuningJobs', + 'sagemaker:ListTrainingJobsForHyperParameterTuningJob', + 'sagemaker:ListWorkteams', + /* + * Permissions not bound to specific resources. Log groups and metrics are created as + * part of various SageMaker resources that can be launched by users (training jobs, + * endpoints, etc). The iam:GetRole permission is used to allow users to get the current + * role the notebook is executing under so that they can use that role to create + * SageMaker resources. + */ + 'iam:GetRole', + 'cloudwatch:PutMetricData', + 'logs:CreateLogGroup', + 'logs:CreateLogStream', + 'logs:DescribeLogStreams', + 'logs:PutLogEvents', + ], + resources: ['*'], + }), + // Endpoint and LabelingJob Permissions + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['sagemaker:CreateEndpoint', 'sagemaker:CreateLabelingJob'], + resources: [`arn:${partition}:sagemaker:${region}:${scope.account}:*`], + conditions: { + Null: requestTagsConditions, + ...requestSystemTagEqualsConditions[SystemTagCondition.Equals] + }, + }), + /** + * Endpoint Permissions + * This statement/action must be separate from the above statement. + * If request tag conditions are applied to this action + resource combination then it will fail. + */ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['sagemaker:CreateEndpoint'], + resources: [ + `arn:${partition}:sagemaker:${region}:${scope.account}:endpoint-config/*`, + ], + }), + // Model Permissions + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['sagemaker:CreateModel'], + resources: [`arn:${partition}:sagemaker:${region}:${scope.account}:model/*`], + conditions: { + Null: { + 'sagemaker:VpcSecurityGroupIds': 'false', + 'sagemaker:VpcSubnets': 'false', + ...requestTagsConditions, + }, + ...requestSystemTagEqualsConditions[SystemTagCondition.Equals] + }, + }), + /** + * Various Permissions + * + * SageMaker permissions to allow users to monitor the status of resources they've + * created. These statements will be supplemented with user/project specific policies + * to ensure users can only describe/interact with resources that have been tagged + * with their username and/or project name. + */ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + // Training Permissions + 'sagemaker:DescribeTrainingJob', + 'sagemaker:StopTrainingJob', + // Transform Permissions + 'sagemaker:DescribeTransformJob', + 'sagemaker:StopTransformJob', + // Model Permissions + 'sagemaker:DescribeModel', + 'sagemaker:DeleteModel', + // HPO Permissions + 'sagemaker:DescribeHyperParameterTuningJob', + 'sagemaker:StopHyperParameterTuningJob', + // Endpoint Permissions + 'sagemaker:DescribeEndpoint', + 'sagemaker:DeleteEndpoint', + 'sagemaker:InvokeEndpoint', + 'sagemaker:UpdateEndpoint', + 'sagemaker:UpdateEndpointWeightsAndCapacities', + // Endpoint Config Permissions + 'sagemaker:DescribeEndpointConfig', + 'sagemaker:DeleteEndpointConfig', + // Labeling Permissions + 'sagemaker:DescribeLabelingJob', + 'sagemaker:StopLabelingJob', + ], + resources: [`arn:${partition}:sagemaker:${region}:${scope.account}:*`], + conditions: { + Null: resourceTagsConditions, + ...resourceSystemTagEqualsConditions[SystemTagCondition.Equals] + }, + }), + /** + * Comprehend Permissions + * Translate Permissions + */ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: mlActions, + // Translate doesn't assign arns/doesn't support restricting resources for the actions we require + resources: ['*'], + }), + /** + * General permissions + * Allow read access to MLSpace config and examples bucket as well as SageMaker public + * examples bucket + */ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['s3:GetObject', 's3:ListBucket'], + resources: [ + `arn:${partition}:s3:::${props.configBucketName}`, + `arn:${partition}:s3:::${props.configBucketName}/*`, + `arn:${partition}:s3:::sagemaker-sample-files`, + `arn:${partition}:s3:::sagemaker-sample-files/*`, + `arn:${partition}:s3:::${props.dataBucketName}/global-read-only/*`, + ], + }), + /** + * Allow listing the contents of the MLSpace example data bucket. + * List bucket may not be needed if onCreate script is changed to use 's3 cp' instead of 's3 sync' + */ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['s3:ListBucket'], + resources: [`arn:${partition}:s3:::${props.dataBucketName}`], + conditions: { + StringLike: { + 's3:prefix': 'global-read-only/*', + }, + }, + }), + /** + * Bedrock Permissions + */ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + // mutating + 'bedrock:Associate*', + 'bedrock:Create*', + 'bedrock:BatchDelete*', + 'bedrock:Delete*', + 'bedrock:Put*', + 'bedrock:Retrieve*', + 'bedrock:Start*', + 'bedrock:Update*', + + // non-mutating + 'bedrock:Apply*', + 'bedrock:Detect*', + 'bedrock:List*', + 'bedrock:Get*', + 'bedrock:Invoke*', + 'bedrock:Retrieve*', + ], + resources: [`arn:${partition}:sagemaker:${region}:${scope.account}:*`], + conditions: { + Null: { + ...requestTagsConditions, + ...resourceTagsConditions, + }, + ...requestSystemTagEqualsConditions[SystemTagCondition.Equals] + }, + }), + ]; + + if (props.enableTranslate) { + // Translate Permissions + statements.push(new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 'translate:StopTextTranslationJob', + 'translate:List*', + 'translate:StartTextTranslationJob', + 'translate:DescribeTextTranslationJob', + 'translate:TranslateDocument', + 'translate:TranslateText', + ], + resources: ['*'], + })); + // Translate Permissions - Allows for passing the role to translate + statements.push(new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['iam:PassRole'], + resources: [ + `arn:${partition}:iam::${scope.account}:role/${props.mlspaceConfig.IAM_RESOURCE_PREFIX}*`, + ], + conditions: { + StringEquals: { + 'iam:PassedToService': 'translate.amazonaws.com', + }, + }, + })); + } + /** + * General permissions + * If the default notebook policy is the only policy that will be attached + * to a notebook then we need to give blanket dataset access. If we're managing + * IAM roles then the user/project policies that get attached to the dynamically created + * notebook role will lock things down to global, project, and user levels. + */ + if (!props.mlspaceConfig.MANAGE_IAM_ROLES) { + statements.push( + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['s3:*'], + resources: [`arn:${partition}:s3:::${props.dataBucketName}/*`], + }) + ); + } + + /** + * Using the new instance restrain policies requires different permissions based on DynamicRoles permissions + * + * Additionally the permissions boundary needs statements that have ALLOW permissions + */ + if (!props.mlspaceConfig.MANAGE_IAM_ROLES || allow_all_instances) { + statements.push( + // Endpoint Configuration and TransformJob Permissions + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['sagemaker:CreateEndpointConfig', 'sagemaker:CreateTransformJob'], + resources: [ + `arn:${partition}:sagemaker:${region}:${scope.account}:*`, + ], + conditions: { + Null: { + ...requestTagsConditions, + }, + ...requestSystemTagEqualsConditions[SystemTagCondition.Equals] + }, + })); + statements.push( + // HPO Permissions + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 'sagemaker:CreateHyperParameterTuningJob', + 'sagemaker:CreateTrainingJob', + ], + resources: [ + `arn:${partition}:sagemaker:${region}:${scope.account}:training-job/*`, + `arn:${partition}:sagemaker:${region}:${scope.account}:hyper-parameter-tuning-job/*` + ], + conditions: { + Null: { + 'sagemaker:VpcSecurityGroupIds': 'false', + 'sagemaker:VpcSubnets': 'false', + ...requestTagsConditions, + }, + ...requestSystemTagEqualsConditions[SystemTagCondition.Equals] + }, + })); + } else { + statements.push( + // Endpoint Configuration and TransformJob Permissions + new PolicyStatement({ + effect: Effect.DENY, + actions: ['sagemaker:CreateEndpointConfig', 'sagemaker:CreateTransformJob'], + resources: [ + `arn:${partition}:sagemaker:${region}:${scope.account}:*`, + ], + conditions: { + Null: { + ...invertedBooleanConditions(requestTagsConditions), + }, + ...requestSystemTagEqualsConditions[SystemTagCondition.NotEquals] + }, + })); + statements.push( + // HPO Permissions + new PolicyStatement({ + effect: Effect.DENY, + actions: [ + 'sagemaker:CreateHyperParameterTuningJob', + 'sagemaker:CreateTrainingJob', + ], + resources: [ + `arn:${partition}:sagemaker:${region}:${scope.account}:training-job/*`, + `arn:${partition}:sagemaker:${region}:${scope.account}:hyper-parameter-tuning-job/*` + ], + conditions: { + Null: { + 'sagemaker:VpcSecurityGroupIds': 'true', + 'sagemaker:VpcSubnets': 'true', + ...invertedBooleanConditions(requestTagsConditions), + }, + ...requestSystemTagEqualsConditions[SystemTagCondition.NotEquals] + }, + })); + } + + return statements; + }; + + /* + * WARNING: Changing this method will cause any policy statement created by this to be regenerated. This will cause + * any changes to this policy (like dynamic policy updates for app configuration changes) to be lost until the app + * configuration is updated and it updates this policy with the expected values. + */ + const instanceConstraintPolicyStatement = (partition: string, region: string, actionResourcePair: {[key: string]: string}) => { + const [actions, resources] = Object.entries(actionResourcePair).reduce(([actionAccumulator, resourcesAccumulator], [action, resource]) => { + return [[...actionAccumulator, `sagemaker:${action}`], [...resourcesAccumulator, `arn:${partition}:sagemaker:${region}:${scope.account}:${resource}/*`]]; + }, [[] as string[], [] as string[]]); + + return [ + new PolicyStatement({ + effect: Effect.ALLOW, + actions, + resources, + conditions: { + 'ForAnyValue:StringEquals': { + 'sagemaker:InstanceTypes': [], + }, + }, + }), + ]; + }; + + const notebookPolicy = new ManagedPolicy(this, 'mlspace-notebook-policy', { + statements: notebookPolicyStatements(scope.partition, Aws.REGION), + description: 'Enables general MLSpace actions in notebooks and across the entire application.' + }); + const notebookManagedPolicies: IManagedPolicy[] = [notebookPolicy]; + + if (this.mlspaceKmsInstanceConditionsPolicy) { + notebookManagedPolicies.push(this.mlspaceKmsInstanceConditionsPolicy); + } + + if (props.mlspaceConfig.MANAGE_IAM_ROLES) { + if (props.mlspaceConfig.ENDPOINT_CONFIG_INSTANCE_CONSTRAINT_POLICY_ARN) { + this.mlspaceEndpointConfigInstanceConstraintPolicy = ManagedPolicy.fromManagedPolicyArn(this, 'mlspace-endpoint-config-instance-constraint', props.mlspaceConfig.ENDPOINT_CONFIG_INSTANCE_CONSTRAINT_POLICY_ARN); + } else { + /* + * WARNING: @see instanceConstraintPolicyStatement + */ + this.mlspaceEndpointConfigInstanceConstraintPolicy = new ManagedPolicy(this, 'mlspace-endpoint-config-instance-constraint', { + managedPolicyName: `${props.mlspaceConfig.IAM_RESOURCE_PREFIX}-endpoint-instance-constraint`, + statements: instanceConstraintPolicyStatement(scope.partition, Aws.REGION, {CreateEndpointConfig: 'endpoint-config'}) + }); + } + + if (props.mlspaceConfig.JOB_INSTANCE_CONSTRAINT_POLICY_ARN) { + this.mlspaceJobInstanceConstraintPolicy = ManagedPolicy.fromManagedPolicyArn(this, 'mlspace-job-instance-constraint', props.mlspaceConfig.JOB_INSTANCE_CONSTRAINT_POLICY_ARN); + } else { + /* + * WARNING: @see instanceConstraintPolicyStatement + */ + this.mlspaceJobInstanceConstraintPolicy = new ManagedPolicy(this, 'mlspace-job-instance-constraint', { + managedPolicyName: `${props.mlspaceConfig.IAM_RESOURCE_PREFIX}-job-instance-constraint`, + statements: [ + instanceConstraintPolicyStatement(scope.partition, Aws.REGION, { + CreateHyperParameterTuningJob: 'hyper-parameter-tuning-job', + CreateTrainingJob: 'training-job' + })[0], + instanceConstraintPolicyStatement(scope.partition, Aws.REGION, {CreateTransformJob: 'transform-job'})[0] + ] + }); + } + + notebookManagedPolicies.push(this.mlspaceEndpointConfigInstanceConstraintPolicy, this.mlspaceJobInstanceConstraintPolicy); + } + + // If roles are manually created use the existing role + if (props.mlspaceConfig.NOTEBOOK_ROLE_ARN) { + this.mlSpaceNotebookRole = Role.fromRoleArn( + this, + mlSpaceNotebookRoleName, + props.mlspaceConfig.NOTEBOOK_ROLE_ARN + ); + } else { + // If roles are managed by CDK, create the notebook role + + // Translate Permissions Principles + const notebookPolicyAllowPrinciples = props.enableTranslate + ? new CompositePrincipal( + new ServicePrincipal('sagemaker.amazonaws.com'), + new ServicePrincipal('translate.amazonaws.com') + ) + : new ServicePrincipal('sagemaker.amazonaws.com'); + + this.mlSpaceNotebookRole = new Role(this, mlSpaceNotebookRoleName, { + roleName: mlSpaceNotebookRoleName, + assumedBy: notebookPolicyAllowPrinciples, + managedPolicies: notebookManagedPolicies, + description: + 'Allows SageMaker Notebooks within ML Space to access necessary AWS services (S3, SQS, DynamoDB, ...)', + }); + } + + /** + * PERMISSIONS BOUNDARY SECTION + * If roles are dynamically managed, applies the permissions boundary that limits maximum permissions + */ + if (props.mlspaceConfig.MANAGE_IAM_ROLES) { + // If role was manually created + if (props.mlspaceConfig.PERMISSIONS_BOUNDARY_POLICY_NAME) { + this.mlSpacePermissionsBoundary = ManagedPolicy.fromManagedPolicyName( + this, + 'mlspace-existing-boundary', + props.mlspaceConfig.PERMISSIONS_BOUNDARY_POLICY_NAME + ); + } else { + // If roles are dynamically managed + // Translate Permissions Principles + const passRolePrincipals = props.enableTranslate + ? ['sagemaker.amazonaws.com', 'translate.amazonaws.com'] + : 'sagemaker.amazonaws.com'; + + // Permission boundary policy that ensures IAM policies never exceed these permissions + this.mlSpacePermissionsBoundary = new ManagedPolicy( + this, + 'mlspace-project-user-role-boundary', + { + managedPolicyName: 'mlspace-project-user-permission-boundary', + statements: [ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 's3:DeleteObject', + 's3:GetObject', + 's3:PutObject', + 's3:PutObjectTagging', + ], + resources: [ + `arn:*:s3:::${props.dataBucketName}/project/*`, + `arn:*:s3:::${props.dataBucketName}/group/*`, + `arn:*:s3:::${props.dataBucketName}/global/*`, + `arn:*:s3:::${props.dataBucketName}/private/*`, + ], + }), + + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['s3:GetObject', 's3:PutObject', 's3:PutObjectTagging'], + resources: [`arn:*:s3:::${props.dataBucketName}/index/*`], + }), + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['s3:ListBucket'], + resources: [`arn:*:s3:::${props.dataBucketName}`], + conditions: { + StringLike: { + 's3:prefix': ['global/*', 'index/*', 'private/*', 'project/*', 'group/*'], + }, + }, + }), + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['s3:GetBucketLocation'], + resources: [`arn:*:s3:::${props.dataBucketName}`], + }), + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['iam:PassRole'], + /* + * When SageMaker resources are created through a notebook (Training jobs, + * Transform jobs, HPO jobs, Models, etc) the API calls will use the role + * associated with the user making the request. As this is a permissions + * boundary being applied to dynamically created roles we can't scope + * this to an individual role rather we scope it to roles with the MLSpace + * prefix. + * + * Additional details are avaiable in the documentation: + * https://docs.aws.amazon.com/sagemaker/latest/dg/sagemaker-roles.html#sagemaker-roles-pass-role + * + * This needs to match the IAM_RESOURCE_PREFIX prefix in iam_manager.py + */ + resources: [`arn:*:iam::${scope.account}:role/${props.mlspaceConfig.IAM_RESOURCE_PREFIX}*`], + conditions: { + StringEquals: { + 'iam:PassedToService': passRolePrincipals, + }, + }, + }), + ...notebookPolicyStatements('*', '*', true), + ], + } + ); + } + } + + /** + * APP POLICY & ROLE SECTION + * + * This role is the summation of the following policies: + * - Notebook policy - base permissions shared between the notebook role and app role + * - App policy - additional permissions for the app that extend the notebook policy permissions + * - App Deny Services policy - Denies access to disabled services + * - service-role/AWSLambdaVPCAccessExecutionRole - AWS managed role + */ + const mlSpaceAppRoleName = 'mlspace-app-role'; + const appPolicyAndStatements = (partition: string, region: string, roleName: string) => { + const statements = [ + // General Permissions - Additional KMS permission unique to the app role to retire grants + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['kms:RetireGrant'], + resources: [props.encryptionKey.keyArn], + }), + /** + * General Permissions + * Additional permissions necessary to display logs for the various SageMaker + * resources, EMR clusters, and other entities via the logs lambda. + */ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['logs:FilterLogEvents'], + resources: ['*'], + }), + // General Permissions + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['iam:PassRole', 'iam:ListRoleTags'], + resources: [ + /** + * When this stack is folded back into the IAM stack we need to switch + * these to be dynamic. At the moment though we have a weird dependency + * order with the two stacks being split. + */ + `arn:${scope.partition}:iam::${scope.account}:role/EMR_DefaultRole`, + `arn:${scope.partition}:iam::${scope.account}:role/EMR_EC2_DefaultRole`, + `arn:${scope.partition}:iam::${scope.account}:role/${roleName}`, + ], + }), + // General Permissions - DynamoDB permissions + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 'dynamodb:GetItem', + 'dynamodb:PutItem', + 'dynamodb:Scan', + 'dynamodb:DeleteItem', + 'dynamodb:Query', + 'dynamodb:UpdateItem', + ], + resources: [ + `arn:${scope.partition}:dynamodb:${Aws.REGION}:${scope.account}:table/mlspace-*`, + ], + }), + /** + * EMR Permissions + * EMR specific permission to allow communication between notebook instances and + * EMR clusters + */ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['ec2:AuthorizeSecurityGroupIngress'], + resources: [`${ec2ArnBase}:security-group/*`], + }), + /** + * Various Permissions + * + * Additional EC2 permissions required for the application role. Most of the + * permissions are covered in the attached mlspace-notebook-policy policy. This + * block includes some additional permissions are required for EMR functionality as + * well as generic metadata operations needed by notebooks. + */ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + // EMR Permissions + 'ec2:DescribeInstances', + 'ec2:DescribeRouteTables', + /** + * General Permissions + * EC2 permission necessary to list available instance types for endpoints, + * notebooks, training jobs, and others + */ + 'ec2:DescribeInstanceTypeOfferings', + 'ec2:DescribeInstanceTypes', + /** + * Notebook Permissions + * Additional EC2 permission needed to start/stop/delete SageMaker Notebook + * Instances (see StartNotebookInstance section for additional details + * https://docs.aws.amazon.com/sagemaker/latest/dg/api-permissions-reference.html) + */ + 'ec2:DescribeVpcEndpoints', + ], + resources: ['*'], + }), + /** + * General Permissions + * S3 permissions related to CRUD operations for datasets, as well as SageMaker job + * input/output, reading of static web app content, notebook and emr cluster + * configuration and sample notebooks/data. + */ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 's3:List*', + 's3:Get*', + 's3:PutObject', + 's3:PutObjectTagging', + 's3:DeleteObject', + 's3:PutBucketNotification', + ], + resources: [`arn:${scope.partition}:s3:::*`], + }), + /** + * Notebook Permissions + * Additional SageMaker permissions that the application role uses that the default + * notebook policy does not support - primarily the ability to create Notebook + * Instances and actions related to those notebooks. + */ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['sagemaker:CreateNotebookInstance'], + resources: [ + `arn:${scope.partition}:sagemaker:${Aws.REGION}:${scope.account}:notebook-instance/*`, + ], + conditions: { + StringEquals: { + 'sagemaker:DirectInternetAccess': 'Disabled', + 'sagemaker:RootAccess': 'Disabled', + }, + Null: { + 'sagemaker:VpcSecurityGroupIds': 'false', + 'sagemaker:VpcSubnets': 'false', + ...requestTagsConditions, + }, + }, + }), + // Notebook Permissions + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 'sagemaker:CreateNotebookInstanceLifecycleConfig', + 'sagemaker:UpdateNotebookInstanceLifecycleConfig', + 'sagemaker:DeleteNotebookInstanceLifecycleConfig', + 'sagemaker:DescribeNotebookInstanceLifecycleConfig', + ], + resources: [ + `arn:${scope.partition}:sagemaker:${Aws.REGION}:${scope.account}:notebook-instance-lifecycle-config/*`, + ], + }), + // Notebook Permissions + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 'sagemaker:DeleteNotebookInstance', + 'sagemaker:DescribeNotebookInstance', + 'sagemaker:StartNotebookInstance', + 'sagemaker:StopNotebookInstance', + 'sagemaker:UpdateNotebookInstance', + ], + resources: [ + `arn:${scope.partition}:sagemaker:${Aws.REGION}:${scope.account}:notebook-instance/*`, + ], + conditions: { + Null: { + ...resourceTagsConditions, + }, + }, + }), + /** + * Notebook Permissions + * Must be separate from above due to resource tag conditions not applying + */ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['sagemaker:CreatePresignedNotebookInstanceUrl'], + resources: [ + `arn:${scope.partition}:sagemaker:${Aws.REGION}:${scope.account}:notebook-instance/*`, + ], + }), + // Notebook Permissions - Not bound by identifier + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 'sagemaker:ListNotebookInstanceLifecycleConfigs', + 'sagemaker:ListNotebookInstances', + ], + // SageMaker list actions that are not bound by resource identifier + resources: ['*'], + }), + // General Permissions - Allows the invocation of MLSpace lambda functions + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['lambda:InvokeFunction'], + resources: [ + `arn:${scope.partition}:lambda:${Aws.REGION}:${scope.account}:function:mls-lambda-*`, + ], + }), + /** + * EMR Permissions + * Policy actions required for launching, terminating, and managing EMR clusters + * within MLSpace + */ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 'elasticmapreduce:RunJobFlow', + 'elasticmapreduce:ListClusters', + 'elasticmapreduce:ListReleaseLabels' + ], + resources: ['*'], + }), + // EMR Permissions + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 'elasticmapreduce:DescribeCluster', + 'elasticmapreduce:ListInstances', + 'elasticmapreduce:AddTags', + 'elasticmapreduce:TerminateJobFlows', + 'elasticmapreduce:SetTerminationProtection', + ], + resources: [ + `arn:${scope.partition}:elasticmapreduce:${Aws.REGION}:${scope.account}:cluster/*`, + ], + }), + ]; + + if (props.mlspaceConfig.MANAGE_IAM_ROLES && this.mlSpacePermissionsBoundary) { + /** + * General Permissions - Dynamic Roles IAM Permissions + * All of the following statements are required when using managed IAM roles + */ + statements.push( + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['iam:CreateRole'], + resources: [ + `arn:${scope.partition}:iam::${scope.account}:role/${props.mlspaceConfig.IAM_RESOURCE_PREFIX}*`, + ], + conditions: { + StringEqualsIgnoreCase: { + 'iam:ResourceTag/system': props.mlspaceConfig.SYSTEM_TAG, + }, + StringEquals: { + 'iam:PermissionsBoundary': + this.mlSpacePermissionsBoundary.managedPolicyArn, + }, + }, + }), + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 'iam:AttachRolePolicy', + 'iam:DetachRolePolicy', + 'iam:DeleteRole', + 'iam:DeleteRolePolicy', + 'iam:PutRolePolicy', + ], + // This needs to match the IAM_RESOURCE_PREFIX prefix in iam_manager.py + resources: [ + `arn:${scope.partition}:iam::${scope.account}:role/${props.mlspaceConfig.IAM_RESOURCE_PREFIX}*`, + ], + conditions: { + StringEqualsIgnoreCase: { + 'iam:ResourceTag/system': props.mlspaceConfig.SYSTEM_TAG, + }, + }, + }), + // Only certain policies should be allowed to attach to the notebook and app roles + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 'iam:AttachRolePolicy', + 'iam:DetachRolePolicy', + ], + // This needs to match the IAM_RESOURCE_PREFIX prefix in iam_manager.py + resources: [ + // This is needed for the deny services policy to be attached to the notebook and app roles + `arn:${scope.partition}:iam::${scope.account}:role/${mlSpaceAppRoleName}`, + `arn:${scope.partition}:iam::${scope.account}:role/${mlSpaceNotebookRoleName}`, + `arn:${scope.partition}:iam::${scope.account}:role/${props.mlspaceConfig.IAM_RESOURCE_PREFIX}*`, + ], + conditions: { + StringEqualsIgnoreCase: { + // Only allow dynamic attachment for the deny services policy + 'iam:PolicyARN': `arn:${scope.partition}:iam::${scope.account}:policy/${props.mlspaceConfig.IAM_RESOURCE_PREFIX}-app-denied-services`, + 'iam:ResourceTag/system': props.mlspaceConfig.SYSTEM_TAG, + }, + }, + }), + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 'iam:ListRoles', + 'iam:ListEntitiesForPolicy', + 'iam:ListPolicyVersions', + 'iam:ListAttachedRolePolicies', + 'iam:GetRole', + 'iam:GetPolicy', + 'iam:ListRoleTags', + ], + resources: ['*'], + }), + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 'iam:CreatePolicy', + 'iam:CreatePolicyVersion', + 'iam:DeletePolicy', + 'iam:DeletePolicyVersion', + 'iam:TagPolicy', + ], + // This needs to match the IAM_RESOURCE_PREFIX prefix in iam_manager.py + resources: [ + `arn:${scope.partition}:iam::${scope.account}:policy/${props.mlspaceConfig.IAM_RESOURCE_PREFIX}*`, + ], + }), + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['iam:SimulatePrincipalPolicy', 'iam:TagRole', 'iam:AttachRolePolicy'], + resources: [ + `arn:${scope.partition}:iam::${scope.account}:role/${props.mlspaceConfig.IAM_RESOURCE_PREFIX}*`, + ], + }), + /* + * When SageMaker resources are created through the MLSpace Webapp (Training jobs, + * Transform jobs, HPO jobs, Models, etc) the API calls will use the dynamic role + * Transform with the user making the request. The "iam:passRole" action is + * required in order to run these resources as the role associated with the user. + * Additional details are available in the documentation: + * https://docs.aws.amazon.com/sagemaker/latest/dg/sagemaker-roles.html#sagemaker-roles-pass-role + * + * This needs to match the IAM_RESOURCE_PREFIX prefix in iam_manager.py + */ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['iam:PassRole'], + resources: [ + `arn:${scope.partition}:iam::${scope.account}:role/${props.mlspaceConfig.IAM_RESOURCE_PREFIX}*`, + ], + conditions: { + StringEquals: { + 'iam:PassedToService': 'sagemaker.amazonaws.com', + }, + }, + }) + ); + } + + if (props.enableTranslate) { + statements.push( + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['iam:PassRole'], + // We don't *currently* run these jobs using the user IAM roles so we can + // specify a specific role here + resources: [ + `arn:${scope.partition}:iam::${scope.account}:role/${roleName}`, + ], + conditions: { + StringEquals: { + 'iam:PassedToService': 'translate.amazonaws.com', + }, + }, + }) + ); + } + return statements; + }; + + if (props.mlspaceConfig.APP_ROLE_ARN) { + this.mlSpaceAppRole = Role.fromRoleArn(this, 'mlspace-app-role', props.mlspaceConfig.APP_ROLE_ARN); + } else { + // ML Space Application role + + + const appPolicy = new ManagedPolicy(this, 'mlspace-app-policy', { + statements: appPolicyAndStatements(scope.partition, Aws.REGION, mlSpaceAppRoleName) + }); + + const appPolicyAllowPrinciples = props.enableTranslate + ? new CompositePrincipal( + new ServicePrincipal('lambda.amazonaws.com'), + new ServicePrincipal('translate.amazonaws.com') + ) + : new ServicePrincipal('lambda.amazonaws.com'); + this.mlSpaceAppRole = new Role(this, 'mlspace-app-role', { + roleName: mlSpaceAppRoleName, + assumedBy: appPolicyAllowPrinciples, + managedPolicies: [ + appPolicy, + ...notebookManagedPolicies, + ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaVPCAccessExecutionRole') + ], + description: + 'Allows ML Space Application to access necessary AWS services (S3, SQS, DynamoDB, ...)', + }); + } + + /** + * System Permissions Role + * This role will provision permissions to the MLSpace system to perform actions independently of what + * users are capable of doing. Ex: a service like EMR may be disabled, but this role will allow the system + * to terminate EMR clusters even though users can't perform any EMR actions. + * These actions include cleaning up resources for deleted projects and suspended users. + */ + if (props.mlspaceConfig.SYSTEM_ROLE_ARN) { + this.mlSpaceSystemRole = Role.fromRoleArn(this, mlspaceSystemRoleName, props.mlspaceConfig.SYSTEM_ROLE_ARN); + } else { + const systemPolicy = new ManagedPolicy(this, 'mlspace-system-policy', { + statements: appPolicyAndStatements(scope.partition, Aws.REGION, mlspaceSystemRoleName), + }); + const systemPolicyAllowPrinciples = props.enableTranslate + ? new CompositePrincipal( + new ServicePrincipal('lambda.amazonaws.com'), + new ServicePrincipal('translate.amazonaws.com') + ) + : new ServicePrincipal('lambda.amazonaws.com'); + this.mlSpaceSystemRole = new Role(this, mlspaceSystemRoleName, { + roleName: mlspaceSystemRoleName, + assumedBy: systemPolicyAllowPrinciples, + managedPolicies: [ + systemPolicy, + notebookPolicy, + ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaVPCAccessExecutionRole') + ], + description: + 'Allows ML Space System to access necessary AWS services (S3, DynamoDB, Sagemaker services, ...)', + }); + } + + /** + * Provides the API Gateway S3 proxy access to the statically hosted website files + * See: + * - /README.md for "S3_READER_ROLE_ARN" + * - /frontend/docs/admin-guide/install.html#s3-reader-role + */ + if (props.mlspaceConfig.S3_READER_ROLE_ARN) { + this.s3ReaderRole = Role.fromRoleArn( + this, + 'mlspace-s3-reader-role', + props.mlspaceConfig.S3_READER_ROLE_ARN + ); + } else { + const s3WebsiteReadOnlyPolicy = new ManagedPolicy(this, 'mlspace-website-read-policy', { + statements: [ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['s3:GetObject'], + resources: [`arn:${scope.partition}:s3:::${props.websiteBucketName}/*`], + }), + ], + }); + this.s3ReaderRole = new Role(this, 'mlspace-s3-reader-role', { + assumedBy: new ServicePrincipal('apigateway.amazonaws.com'), + roleName: 'mlspace-s3-reader-Role', + managedPolicies: [s3WebsiteReadOnlyPolicy], + description: 'Allows API gateway to proxy static website assets', + }); + } + + /** + * Enables logging for S3 and API Gateway + * See + * - /README.md for "ENABLE_ACCESS_LOGGING" + */ + if (props.mlspaceConfig.ENABLE_ACCESS_LOGGING) { + if (props.mlspaceConfig.APIGATEWAY_CLOUDWATCH_ROLE_ARN) { + new CfnAccount(this, 'mlspace-cwl-api-gateway-account', { + cloudWatchRoleArn: props.mlspaceConfig.APIGATEWAY_CLOUDWATCH_ROLE_ARN, + }); + } else { + // Create CW Role + const apiGatewayCloudWatchRole = new Role(this, 'mlspace-cwl-role', { + assumedBy: new ServicePrincipal('apigateway.amazonaws.com'), + managedPolicies: [ + ManagedPolicy.fromAwsManagedPolicyName( + 'service-role/AmazonAPIGatewayPushToCloudWatchLogs' + ), + ], + }); + + new CfnAccount(this, 'mlspace-cwl-api-gateway-account', { + cloudWatchRoleArn: apiGatewayCloudWatchRole.roleArn, + }); + } + } + + /** + * EMR Permissions Role + * See: + * - /README.md for "EMR_DEFAULT_ROLE_ARN" + * - /frontend/docs/admin-guide/install.html#emr-roles + */ + if (props.mlspaceConfig.EMR_DEFAULT_ROLE_ARN) { + const existingEmrServiceRole = Role.fromRoleArn( + this, + 'mlspace-emr_defaultrole', + props.mlspaceConfig.EMR_DEFAULT_ROLE_ARN + ); + this.emrServiceRoleName = existingEmrServiceRole.roleName; + } else { + const serviceRoleName = 'EMR_DefaultRole'; + new Role(this, 'mlspace-emr_defaultrole', { + assumedBy: new ServicePrincipal('elasticmapreduce.amazonaws.com'), + roleName: serviceRoleName, + managedPolicies: [ + ManagedPolicy.fromAwsManagedPolicyName( + 'service-role/AmazonElasticMapReduceRole' + ), + ], + description: 'Provides needed permissions for running an EMR Cluster.', + }); + this.emrServiceRoleName = serviceRoleName; + } + + /** + * EMR Permissions Role + * See + * - /README.md for "EMR_EC2_INSTANCE_ROLE_ARN" + * - /frontend/docs/admin-guide/install.html#emr-roles + */ + if (props.mlspaceConfig.EMR_EC2_INSTANCE_ROLE_ARN) { + const existingEmrEC2Role = Role.fromRoleArn( + this, + 'mlspace-emr_ec2_defaultrole', + props.mlspaceConfig.EMR_EC2_INSTANCE_ROLE_ARN + ); + this.emrEC2RoleName = existingEmrEC2Role.roleName; + } else { + const emrEC2RoleName = 'EMR_EC2_DefaultRole'; + const ec2EMRRole = new Role(this, 'mlspace-emr_ec2_defaultrole', { + assumedBy: new ServicePrincipal('ec2.amazonaws.com'), + roleName: emrEC2RoleName, + managedPolicies: [ + ManagedPolicy.fromAwsManagedPolicyName( + 'service-role/AmazonElasticMapReduceforEC2Role' + ), + ], + description: 'Provides needed permissions for running an EMR Cluster.', + }); + + new CfnInstanceProfile(this, 'mlspace-emr-instance-profile', { + roles: [ec2EMRRole.roleName], + instanceProfileName: ec2EMRRole.roleName, + }); + this.emrEC2RoleName = ec2EMRRole.roleName; + } + } +} diff --git a/lib/constructs/infra/coreConstruct.ts b/lib/constructs/infra/coreConstruct.ts new file mode 100644 index 00000000..5119c252 --- /dev/null +++ b/lib/constructs/infra/coreConstruct.ts @@ -0,0 +1,730 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { Aspects, Duration, RemovalPolicy, Stack, StackProps } from 'aws-cdk-lib'; +import { Trail } from 'aws-cdk-lib/aws-cloudtrail'; +import { AttributeType, BillingMode, ProjectionType, Table, TableEncryption } from 'aws-cdk-lib/aws-dynamodb'; +import { ISecurityGroup, IVpc } from 'aws-cdk-lib/aws-ec2'; +import { CfnSecurityConfiguration } from 'aws-cdk-lib/aws-emr'; +import { Rule, Schedule } from 'aws-cdk-lib/aws-events'; +import { LambdaFunction } from 'aws-cdk-lib/aws-events-targets'; +import { Effect, IManagedPolicy, IRole, PolicyStatement, Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam'; +import { IKey } from 'aws-cdk-lib/aws-kms'; +import { Code, Function } from 'aws-cdk-lib/aws-lambda'; +import { + Bucket, + BucketAccessControl, + BucketEncryption, + EventType, + HttpMethods, + ObjectOwnership, +} from 'aws-cdk-lib/aws-s3'; +import { BucketDeployment, Source } from 'aws-cdk-lib/aws-s3-deployment'; +import { LambdaDestination } from 'aws-cdk-lib/aws-s3-notifications'; +import { Subscription, SubscriptionProtocol, Topic } from 'aws-cdk-lib/aws-sns'; +import { StringParameter } from 'aws-cdk-lib/aws-ssm'; +import { ADCLambdaCABundleAspect } from '../../utils/adcCertBundleAspect'; +import { createLambdaLayer } from '../../utils/layers'; +import { MLSpaceConfig } from '../../utils/configTypes'; +import { AwsCustomResource, PhysicalResourceId } from 'aws-cdk-lib/custom-resources'; +import { generateAppConfig } from '../../utils/initialAppConfig'; +import { Construct } from 'constructs'; + +export type CoreStackProps = { + readonly lambdaSourcePath: string; + readonly notificationDistro: string; + readonly configBucketName: string; + readonly dataBucketName: string; + readonly cwlBucketName: string; + readonly websiteBucketName: string; + readonly accessLogsBucketName: string; + readonly encryptionKey: IKey; + readonly mlSpaceAppRole: IRole; + readonly mlspaceKmsInstanceConditionsPolicy: IManagedPolicy; + readonly mlSpaceNotebookRole: IRole; + readonly mlspaceEndpointConfigInstanceConstraintPolicy?: IManagedPolicy, + readonly mlspaceJobInstanceConstraintPolicy?: IManagedPolicy, + readonly mlSpaceVPC: IVpc; + readonly mlSpaceDefaultSecurityGroupId: string; + readonly isIso?: boolean; + readonly lambdaSecurityGroups: ISecurityGroup[]; + readonly mlspaceConfig: MLSpaceConfig; +} & StackProps; + +export class CoreConstruct extends Construct { + constructor (scope: Stack, id: string, props: CoreStackProps) { + super(scope, id); + + const logsServicePrincipal = new ServicePrincipal('logs.amazonaws.com'); + + if (props.mlspaceConfig.NOTIFICATION_DISTRO) { + new Subscription(this, 'Subscription', { + topic: new Topic(this, 'mlspace-topic'), + endpoint: props.notificationDistro, + protocol: SubscriptionProtocol.EMAIL, + }); + } + + let accessLogBucket = undefined; + if (props.mlspaceConfig.ENABLE_ACCESS_LOGGING) { + accessLogBucket = new Bucket(this, 'mlspace-access-logs-bucket', { + bucketName: props.accessLogsBucketName, + encryption: BucketEncryption.S3_MANAGED, + publicReadAccess: false, + versioned: true, + enforceSSL: true, + objectOwnership: ObjectOwnership.BUCKET_OWNER_PREFERRED, + }); + } + + // Config Bucket (holds emr config/notebook parameters) + const configBucket = new Bucket(this, 'mlspace-config-bucket', { + bucketName: props.configBucketName, + ...props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN ? {encryptionKey: props.encryptionKey} : {encryption: BucketEncryption.S3_MANAGED}, + removalPolicy: RemovalPolicy.DESTROY, + versioned: true, + enforceSSL: true, + serverAccessLogsBucket: accessLogBucket, + serverAccessLogsPrefix: accessLogBucket ? 'mlspace-config-bucket' : undefined, + }); + + // Publish notebook config + // This is a pretty ugly hack but at the moment you can't use json data with + // cross stack parameters - https://github.com/aws/aws-cdk/issues/21503 + const mlspaceNotebookRole = new StringParameter(this, 'dynamic-config-notebook-role', { + parameterName: 'notebook-param-notebook-role-arn', + stringValue: props.mlSpaceNotebookRole.roleArn, + }); + const secGroupId = new StringParameter(this, 'dynamic-config-security-group', { + parameterName: 'notebook-param-vpc-security-group', + stringValue: props.mlSpaceDefaultSecurityGroupId, + }); + const subnetIds = new StringParameter(this, 'dynamic-config-subnets', { + parameterName: 'notebook-param-subnet-ids', + stringValue: props.mlSpaceVPC.isolatedSubnets + .concat(props.mlSpaceVPC.privateSubnets) + .map((s) => s.subnetId) + .join(','), + }); + + const kmsKeyId = new StringParameter(this, 'dynamic-config-kms-id', { + parameterName: 'notebook-param-kms-id', + stringValue: props.encryptionKey.keyId, + }); + const notebookParams = { + pSMSKMSKeyId: kmsKeyId.stringValue, + pSMSRoleARN: mlspaceNotebookRole.stringValue, + pSMSSecurityGroupId: [secGroupId.stringValue], + pSMSSubnetIds: subnetIds.stringValue, + pSMSLifecycleConfigName: props.mlspaceConfig.MLSPACE_LIFECYCLE_CONFIG_NAME, + pSMSDataBucketName: props.dataBucketName, + }; + + const configDeployment = new BucketDeployment(this, 'MLSpaceConfigDeployment', { + sources: [ + Source.jsonData(props.mlspaceConfig.NOTEBOOK_PARAMETERS_FILE_NAME, notebookParams), + Source.asset('./lib/resources/config'), + ], + destinationBucket: configBucket, + prune: true, + role: props.mlspaceConfig.BUCKET_DEPLOYMENT_ROLE_ARN + ? Role.fromRoleArn(this, 'mlspace-config-deploy-role', props.mlspaceConfig.BUCKET_DEPLOYMENT_ROLE_ARN, { + mutable: false, + }) + : undefined, + }); + + // Static Site + const websiteBucket = new Bucket(this, 'mlspace-website-bucket', { + bucketName: props.websiteBucketName, + accessControl: BucketAccessControl.BUCKET_OWNER_FULL_CONTROL, + encryption: BucketEncryption.S3_MANAGED, + removalPolicy: RemovalPolicy.DESTROY, + enforceSSL: true, + websiteErrorDocument: 'index.html', + websiteIndexDocument: 'index.html', + cors: [ + { + allowedMethods: [ + HttpMethods.GET, + HttpMethods.POST, + HttpMethods.PUT, + HttpMethods.DELETE, + ], + allowedOrigins: ['*'], + exposedHeaders: [ + 'x-amz-server-side-encryption', + 'x-amz-request-id', + 'x-amz-id-2', + ], + allowedHeaders: ['*'], + }, + ], + serverAccessLogsBucket: accessLogBucket, + serverAccessLogsPrefix: accessLogBucket ? 'mlspace-website-bucket' : undefined, + }); + websiteBucket.grantRead(new ServicePrincipal('apigateway.amazonaws.com')); + + // Data Bucket + const dataBucket = new Bucket(this, 'mlspace-data-bucket', { + bucketName: props.dataBucketName, + ...props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN ? {encryptionKey: props.encryptionKey} : {encryption: BucketEncryption.S3_MANAGED}, + removalPolicy: RemovalPolicy.DESTROY, + versioned: true, + enforceSSL: true, + cors: [ + { + allowedMethods: [HttpMethods.GET, HttpMethods.POST], + allowedHeaders: ['*'], + allowedOrigins: ['*'], + exposedHeaders: ['Access-Control-Allow-Origin'], + }, + ], + serverAccessLogsBucket: accessLogBucket, + serverAccessLogsPrefix: accessLogBucket ? 'mlspace-data-bucket' : undefined, + }); + + const exampleDataDeployment = new BucketDeployment(this, 'MLSpaceExampleDataDeployment', { + sources: [ + Source.jsonData(props.mlspaceConfig.NOTEBOOK_PARAMETERS_FILE_NAME, notebookParams), + Source.asset('lib/resources/sagemaker/global/'), + ], + destinationKeyPrefix: 'global-read-only/resources/', + destinationBucket: dataBucket, + prune: false, + role: props.mlspaceConfig.BUCKET_DEPLOYMENT_ROLE_ARN + ? Role.fromRoleArn( + this, + 'mlspace-example-data-deploy-role', + props.mlspaceConfig.BUCKET_DEPLOYMENT_ROLE_ARN, + { + mutable: false, + } + ) + : undefined, + }); + + const commonLambdaLayer = createLambdaLayer(this, 'common', undefined, props.mlspaceConfig.COMMON_LAYER_PATH); + + // Save common layer arn to SSM to avoid issue related to cross stack references + new StringParameter(this, 'VersionArn', { + parameterName: props.mlspaceConfig.COMMON_LAYER_ARN_PARAM, + stringValue: commonLambdaLayer.layerVersion.layerVersionArn, + }); + + // Lambda for populating the initial allowed instances in the app config + const appConfigLambda = new Function(this, 'appConfigDeployment', { + functionName: 'mls-lambda-app-config-deployment', + description: + 'Populates the initial app config', + runtime: props.mlspaceConfig.LAMBDA_RUNTIME, + architecture: props.mlspaceConfig.LAMBDA_ARCHITECTURE, + handler: 'ml_space_lambda.initial_app_config.lambda_function.lambda_handler', + code: Code.fromAsset(props.lambdaSourcePath), + timeout: Duration.seconds(30), + role: props.mlSpaceAppRole, + environment: { + APP_CONFIG_TABLE: props.mlspaceConfig.APP_CONFIGURATION_TABLE_NAME, + SYSTEM_TAG: props.mlspaceConfig.SYSTEM_TAG, + MANAGE_IAM_ROLES: props.mlspaceConfig.MANAGE_IAM_ROLES ? 'True' : '', + ENDPOINT_CONFIG_INSTANCE_CONSTRAINT_POLICY_ARN: props.mlspaceEndpointConfigInstanceConstraintPolicy?.managedPolicyArn || '', + JOB_INSTANCE_CONSTRAINT_POLICY_ARN: props.mlspaceJobInstanceConstraintPolicy?.managedPolicyArn || '', + }, + layers: [commonLambdaLayer.layerVersion], + vpc: props.mlSpaceVPC, + securityGroups: props.lambdaSecurityGroups, + }); + + // Lambda for cleaning up permissions that have been deprecated from the application + const permissionCleanupLambda = new Function(this, 'permissionCleanupLambda', { + functionName: 'mls-lambda-permission-cleanup', + description: + 'Clears out deprecated permissions from tables', + runtime: props.mlspaceConfig.LAMBDA_RUNTIME, + architecture: props.mlspaceConfig.LAMBDA_ARCHITECTURE, + handler: 'ml_space_lambda.cleanup_deprecated_permissions.lambda_function.lambda_handler', + code: Code.fromAsset(props.lambdaSourcePath), + timeout: Duration.seconds(30), + role: props.mlSpaceAppRole, + layers: [commonLambdaLayer.layerVersion], + vpc: props.mlSpaceVPC, + securityGroups: props.lambdaSecurityGroups, + }); + + const dynamicRolesAttachPoliciesOnDeployLambda = new Function(this, 'drAttachPoliciesOnDeployLambda', { + functionName: 'mls-lambda-dr-attach-policies-on-deploy', + description: 'Attaches policies from notebook role to all dynamic user roles.', + runtime: props.mlspaceConfig.LAMBDA_RUNTIME, + architecture: props.mlspaceConfig.LAMBDA_ARCHITECTURE, + handler: 'ml_space_lambda.initial_app_config.lambda_function.update_dynamic_roles_with_notebook_policies', + code: Code.fromAsset(props.lambdaSourcePath), + timeout: Duration.seconds(30), + role: props.mlSpaceAppRole, + environment: { + ENDPOINT_CONFIG_INSTANCE_CONSTRAINT_POLICY_ARN: props.mlspaceEndpointConfigInstanceConstraintPolicy?.managedPolicyArn || '', + JOB_INSTANCE_CONSTRAINT_POLICY_ARN: props.mlspaceJobInstanceConstraintPolicy?.managedPolicyArn || '', + KMS_INSTANCE_CONDITIONS_POLICY_ARN: props.mlspaceKmsInstanceConditionsPolicy.managedPolicyArn, + NOTEBOOK_ROLE_NAME: props.mlSpaceNotebookRole.roleName, + SYSTEM_TAG: props.mlspaceConfig.SYSTEM_TAG, + MANAGE_IAM_ROLES: props.mlspaceConfig.MANAGE_IAM_ROLES ? 'True' : '', + }, + layers: [commonLambdaLayer.layerVersion], + vpc: props.mlSpaceVPC, + securityGroups: props.lambdaSecurityGroups, + }); + + // run dynamicRolesAttachPoliciesOnDeployLambda every deploy + new AwsCustomResource(this, 'drAttachPoliciesOnDeploy', { + onCreate: { + service: 'Lambda', + action: 'invoke', + physicalResourceId: PhysicalResourceId.of(`drAttachPoliciesOnDeployLambda-${Date.now()}`), + parameters: { + FunctionName: dynamicRolesAttachPoliciesOnDeployLambda.functionName, + Payload: '{}' + }, + }, + role: props.mlSpaceAppRole + }); + + const updateInstanceKmsConditionsLambda = new Function(this, 'updateInstanceKmsConditionsLambda', { + functionName: 'mls-lambda-instance-kms-conditions', + description: '', + runtime: props.mlspaceConfig.LAMBDA_RUNTIME, + architecture: props.mlspaceConfig.LAMBDA_ARCHITECTURE, + handler: 'ml_space_lambda.utils.lambda_functions.update_instance_kms_key_conditions', + code: Code.fromAsset(props.lambdaSourcePath), + timeout: Duration.seconds(30), + role: props.mlSpaceAppRole, + environment: { + MANAGE_IAM_ROLES: props.mlspaceConfig.MANAGE_IAM_ROLES ? 'True' : '', + KMS_INSTANCE_CONDITIONS_POLICY_ARN: props.mlspaceKmsInstanceConditionsPolicy.managedPolicyArn + }, + layers: [commonLambdaLayer.layerVersion], + vpc: props.mlSpaceVPC, + securityGroups: props.lambdaSecurityGroups, + }); + + // run updateInstanceKmsConditionsLambda every deploy + new AwsCustomResource(this, 'kms-key-constraints', { + onCreate: { + service: 'Lambda', + action: 'invoke', + physicalResourceId: PhysicalResourceId.of(`kmsKeyConstraints-${Date.now()}`), + parameters: { + FunctionName: updateInstanceKmsConditionsLambda.functionName, + Payload: '{}' + }, + }, + role: props.mlSpaceAppRole + }); + + // schedule updateInstanceKmsConditionsLambda to run every day + const updateInstanceKmsConditionsLambdaScheduleRule = new Rule(this, 'updateInstanceKmsConditionsLambdaScheduleRule', { + schedule: Schedule.cron({hour: '2', minute: '45'}) + }); + updateInstanceKmsConditionsLambdaScheduleRule.addTarget(new LambdaFunction(updateInstanceKmsConditionsLambda)); + + const notifierLambdaLayer = createLambdaLayer(this, 'common', 'notifier', props.mlspaceConfig.COMMON_LAYER_PATH); + + const s3NotificationLambda = new Function(this, 's3Notifier', { + functionName: 'mls-lambda-s3-notifier', + description: + 'S3 event notification function to handle ddb actions in response to dataset file actions', + runtime: props.mlspaceConfig.LAMBDA_RUNTIME, + architecture: props.mlspaceConfig.LAMBDA_ARCHITECTURE, + handler: 'ml_space_lambda.s3_event_put_notification.lambda_function.lambda_handler', + code: Code.fromAsset(props.lambdaSourcePath), + timeout: Duration.seconds(5), + role: props.mlSpaceAppRole, + environment: { + DATA_BUCKET: props.dataBucketName, + DATASETS_TABLE: props.mlspaceConfig.DATASETS_TABLE_NAME, + PROJECTS_TABLE: props.mlspaceConfig.PROJECTS_TABLE_NAME, + PROJECT_USERS_TABLE: props.mlspaceConfig.PROJECT_USERS_TABLE_NAME, + USERS_TABLE: props.mlspaceConfig.USERS_TABLE_NAME, + ...props.mlspaceConfig.ADDITIONAL_LAMBDA_ENVIRONMENT_VARS, + }, + layers: [notifierLambdaLayer.layerVersion], + vpc: props.mlSpaceVPC, + securityGroups: props.lambdaSecurityGroups, + }); + + s3NotificationLambda.addPermission('s3Notifier-invoke', { + action: 'lambda:InvokeFunction', + principal: new ServicePrincipal('s3.amazonaws.com'), + sourceAccount: scope.account, + sourceArn: dataBucket.bucketArn, + }); + + dataBucket.addEventNotification( + EventType.OBJECT_CREATED, + new LambdaDestination(s3NotificationLambda) + ); + + const terminateResourcesLambda = new Function(this, 'resourceTerminator', { + functionName: 'mls-lambda-resource-terminator', + description: + 'Sweeper function that stops/terminates resources based on scheduled configuration', + runtime: props.mlspaceConfig.LAMBDA_RUNTIME, + architecture: props.mlspaceConfig.LAMBDA_ARCHITECTURE, + handler: 'ml_space_lambda.resource_scheduler.lambda_functions.terminate_resources', + code: Code.fromAsset(props.lambdaSourcePath), + timeout: Duration.minutes(15), + role: props.mlSpaceAppRole, + environment: { + RESOURCE_SCHEDULE_TABLE: props.mlspaceConfig.RESOURCE_SCHEDULE_TABLE_NAME, + ...props.mlspaceConfig.ADDITIONAL_LAMBDA_ENVIRONMENT_VARS, + }, + layers: [commonLambdaLayer.layerVersion], + vpc: props.mlSpaceVPC, + securityGroups: props.lambdaSecurityGroups, + }); + + const ruleName = 'mlspace-rule-terminate-resources'; + new Rule(this, ruleName, { + schedule: Schedule.rate(Duration.minutes(props.mlspaceConfig.RESOURCE_TERMINATION_INTERVAL)), + targets: [new LambdaFunction(terminateResourcesLambda)], + ruleName: ruleName, + }); + + // Logs Bucket + const cwlBucket = new Bucket(this, 'mlspace-logs-bucket', { + bucketName: props.cwlBucketName, + removalPolicy: RemovalPolicy.DESTROY, + ...props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN ? {encryptionKey: props.encryptionKey} : {encryption: BucketEncryption.S3_MANAGED}, + enforceSSL: true, + cors: [ + { + allowedMethods: [HttpMethods.POST], + allowedHeaders: ['*'], + allowedOrigins: ['*'], + }, + ], + serverAccessLogsBucket: accessLogBucket, + serverAccessLogsPrefix: accessLogBucket ? 'mlspace-logs-bucket' : undefined, + }); + cwlBucket.addToResourcePolicy( + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['s3:GetBucketAcl'], + resources: [cwlBucket.bucketArn], + principals: [logsServicePrincipal], + }) + ); + cwlBucket.addToResourcePolicy( + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['s3:PutObject'], + resources: [`${cwlBucket.bucketArn}/cloudwatch/*`], + principals: [logsServicePrincipal], + conditions: { + StringEquals: { + 's3:x-amz-acl': 'bucket-owner-full-control', + }, + }, + }) + ); + + // Cloudtrail setup + if (props.mlspaceConfig.CREATE_MLSPACE_CLOUDTRAIL_TRAIL) { + new Trail(this, 'mlspace-cloudtrail', { + trailName: 'mlspace-cloudtrail', + isMultiRegionTrail: true, + includeGlobalServiceEvents: true, + bucket: cwlBucket, + }); + } + + // Datasets Table + const datasetScopeAttribute = { name: 'scope', type: AttributeType.STRING }; + const datasetNameAttribute = { name: 'name', type: AttributeType.STRING }; + new Table(this, 'mlspace-ddb-datasets', { + tableName: props.mlspaceConfig.DATASETS_TABLE_NAME, + partitionKey: datasetScopeAttribute, + sortKey: datasetNameAttribute, + billingMode: BillingMode.PAY_PER_REQUEST, + encryption: TableEncryption.AWS_MANAGED, + }); + + // Projects Table + new Table(this, 'mlspace-ddb-projects', { + tableName: props.mlspaceConfig.PROJECTS_TABLE_NAME, + partitionKey: { name: 'name', type: AttributeType.STRING }, + billingMode: BillingMode.PAY_PER_REQUEST, + encryption: TableEncryption.AWS_MANAGED, + }); + + // Project Users Table + const projectAttribute = { name: 'project', type: AttributeType.STRING }; + const userAttribute = { name: 'user', type: AttributeType.STRING }; + const projectUsersTable = new Table(this, 'mlspace-ddb-project-users', { + tableName: props.mlspaceConfig.PROJECT_USERS_TABLE_NAME, + partitionKey: projectAttribute, + sortKey: userAttribute, + billingMode: BillingMode.PAY_PER_REQUEST, + encryption: TableEncryption.AWS_MANAGED, + }); + + projectUsersTable.addGlobalSecondaryIndex({ + indexName: 'ReverseLookup', + partitionKey: userAttribute, + sortKey: projectAttribute, + projectionType: ProjectionType.KEYS_ONLY, + }); + + // Groups Table + new Table(this, 'mlspace-ddb-groups', { + tableName: props.mlspaceConfig.GROUPS_TABLE_NAME, + partitionKey: { name: 'name', type: AttributeType.STRING }, + billingMode: BillingMode.PAY_PER_REQUEST, + encryption: TableEncryption.AWS_MANAGED, + }); + + // Group Datasets Table + const groupAttribute = { name: 'group', type: AttributeType.STRING }; + const groupDatasetAttribute = { name: 'dataset', type: AttributeType.STRING }; + const groupDatasetTable = new Table(this, 'mlspace-ddb-group-datasets', { + tableName: props.mlspaceConfig.GROUP_DATASETS_TABLE_NAME, + partitionKey: groupAttribute, + sortKey: groupDatasetAttribute, + billingMode: BillingMode.PAY_PER_REQUEST, + encryption: TableEncryption.AWS_MANAGED, + }); + + groupDatasetTable.addGlobalSecondaryIndex({ + indexName: 'ReverseLookup', + partitionKey: groupDatasetAttribute, + sortKey: groupAttribute, + projectionType: ProjectionType.KEYS_ONLY + }); + + // Group Users Table + const groupUserAttribute = { name: 'user', type: AttributeType.STRING }; + const groupUsersTable = new Table(this, 'mlspace-ddb-group-users', { + tableName: props.mlspaceConfig.GROUP_USERS_TABLE_NAME, + partitionKey: groupAttribute, + sortKey: groupUserAttribute, + billingMode: BillingMode.PAY_PER_REQUEST, + encryption: TableEncryption.AWS_MANAGED, + }); + + groupUsersTable.addGlobalSecondaryIndex({ + indexName: 'ReverseLookup', + partitionKey: groupUserAttribute, + sortKey: groupAttribute, + projectionType: ProjectionType.KEYS_ONLY, + }); + + // Group Membership History Table + const groupMembershipHistoryAttribute = { name: 'group', type: AttributeType.STRING }; + const groupMembershipHistorySortAttribute = { name: 'actionedAt', type: AttributeType.NUMBER }; + new Table(this, 'mlspace-ddb-group-membership-history', { + tableName: props.mlspaceConfig.GROUPS_MEMBERSHIP_HISTORY_TABLE_NAME, + partitionKey: groupMembershipHistoryAttribute, + sortKey: groupMembershipHistorySortAttribute, + billingMode: BillingMode.PAY_PER_REQUEST, + encryption: TableEncryption.AWS_MANAGED, + }); + + // Users Table + new Table(this, 'mlspace-ddb-users', { + tableName: props.mlspaceConfig.USERS_TABLE_NAME, + partitionKey: { name: 'username', type: AttributeType.STRING }, + billingMode: BillingMode.PAY_PER_REQUEST, + encryption: TableEncryption.AWS_MANAGED, + }); + + // Project Groups Table + const projectGroupsTable = new Table(this, 'mlspace-ddb-project-groups', { + tableName: props.mlspaceConfig.PROJECT_GROUPS_TABLE_NAME, + partitionKey: projectAttribute, + sortKey: groupAttribute, + billingMode: BillingMode.PAY_PER_REQUEST, + encryption: TableEncryption.AWS_MANAGED, + }); + + projectGroupsTable.addGlobalSecondaryIndex({ + indexName: 'ReverseLookup', + partitionKey: groupAttribute, + sortKey: projectAttribute, + projectionType: ProjectionType.KEYS_ONLY, + }); + + // Resource Termination Schedule Table + const resourceIdAttribute = { name: 'resourceId', type: AttributeType.STRING }; + const resourceTypeAttribute = { name: 'resourceType', type: AttributeType.STRING }; + new Table(this, 'mlspace-ddb-resource-schedule', { + tableName: props.mlspaceConfig.RESOURCE_SCHEDULE_TABLE_NAME, + partitionKey: resourceIdAttribute, + sortKey: resourceTypeAttribute, + billingMode: BillingMode.PAY_PER_REQUEST, + encryption: TableEncryption.AWS_MANAGED, + }); + + // Resources Metadata Table + const resourcesMetadataTable = new Table(this, 'mlspace-resource-metadata', { + tableName: props.mlspaceConfig.RESOURCE_METADATA_TABLE_NAME, + partitionKey: resourceTypeAttribute, + sortKey: resourceIdAttribute, + billingMode: BillingMode.PAY_PER_REQUEST, + encryption: TableEncryption.AWS_MANAGED, + }); + + resourcesMetadataTable.addLocalSecondaryIndex({ + indexName: 'ProjectResources', + sortKey: projectAttribute, + projectionType: ProjectionType.ALL, + }); + + resourcesMetadataTable.addLocalSecondaryIndex({ + indexName: 'UserResources', + sortKey: userAttribute, + projectionType: ProjectionType.ALL, + }); + + // App Configuration Table + new Table(this, 'mlspace-ddb-app-configuration', { + tableName: props.mlspaceConfig.APP_CONFIGURATION_TABLE_NAME, + partitionKey: { name: 'configScope', type: AttributeType.STRING }, + sortKey: { name: 'versionId', type: AttributeType.NUMBER }, + billingMode: BillingMode.PAY_PER_REQUEST, + encryption: TableEncryption.AWS_MANAGED, + }); + + // Populate the App Config table with default config + new AwsCustomResource(this, 'mlspace-init-ddb-app-config', { + onCreate: { + service: 'DynamoDB', + action: 'putItem', + parameters: { + TableName: props.mlspaceConfig.APP_CONFIGURATION_TABLE_NAME, + Item: generateAppConfig(), + }, + physicalResourceId: PhysicalResourceId.of('initAppConfigData'), + }, + role: props.mlSpaceAppRole + }); + + new AwsCustomResource(this, 'initial-app-config-deployment-001', { + onCreate: { + service: 'Lambda', + action: 'invoke', + physicalResourceId: PhysicalResourceId.of('initAllowedInstanceTypes'), + parameters: { + FunctionName: appConfigLambda.functionName, + Payload: '{}' + }, + }, + role: props.mlSpaceAppRole + }); + + new AwsCustomResource(this, 'cleanup-deprecated-permissions', { + onCreate: { + service: 'Lambda', + action: 'invoke', + physicalResourceId: PhysicalResourceId.of(`cleanupDeprecatedResources-${Date.now()}`), + parameters: { + FunctionName: permissionCleanupLambda.functionName, + Payload: '{}' + }, + }, + role: props.mlSpaceAppRole + }); + + // EMR Security Configuration + new CfnSecurityConfiguration(this, 'mlspace-emr-security-config', { + name: props.mlspaceConfig.EMR_SECURITY_CONFIG_NAME, + securityConfiguration: { + InstanceMetadataServiceConfiguration: { + MinimumInstanceMetadataServiceVersion: 2, + HttpPutResponseHopLimit: 1, + }, + }, + }); + + const resourceMetadataLambda = new Function(this, 'mlspace-resource-metadata-lambda', { + functionName: 'mls-lambda-resource-metadata', + description: + 'Lambda to process event bridge notifications and update corresponding entries in the mlspace resource metadata ddb table.', + runtime: props.mlspaceConfig.LAMBDA_RUNTIME, + architecture: props.mlspaceConfig.LAMBDA_ARCHITECTURE, + handler: 'ml_space_lambda.resource_metadata.lambda_functions.process_event', + code: Code.fromAsset(props.lambdaSourcePath), + timeout: Duration.seconds(90), + role: props.mlSpaceAppRole, + environment: { + RESOURCE_METADATA_TABLE: props.mlspaceConfig.RESOURCE_METADATA_TABLE_NAME, + SYSTEM_TAG: props.mlspaceConfig.SYSTEM_TAG, + ...props.mlspaceConfig.ADDITIONAL_LAMBDA_ENVIRONMENT_VARS, + }, + layers: [commonLambdaLayer.layerVersion], + vpc: props.mlSpaceVPC, + securityGroups: props.lambdaSecurityGroups, + }); + + // Event bridge rule for resource metadata capture + new Rule(this, 'mlspace-resource-metadata-rule', { + ruleName: 'mlspace-resource-metadata-sync', + eventPattern: { + account: [scope.account], + source: ['aws.sagemaker', 'aws.translate', 'aws.emr'], + detailType: [ + 'SageMaker Endpoint State Change', + 'SageMaker Endpoint Config State Change', + 'SageMaker Ground Truth Labeling Job State Change', + 'SageMaker HyperParameter Tuning Job State Change', + 'SageMaker Notebook Instance State Change', + 'SageMaker Model State Change', + 'SageMaker Training Job State Change', + 'SageMaker Transform Job State Change', + 'Translate TextTranslationJob State Change', + 'EMR Cluster State Change', + + ], + }, + targets: [new LambdaFunction(resourceMetadataLambda)], + }); + + new Rule(this, 'mlspace-cloudtrail-metadata-rule', { + ruleName: 'mlspace-cloudtrail-metadata-sync', + eventPattern: { + account: [scope.account], + source: ['aws.sagemaker', 'aws.translate', 'aws.emr'], + detailType: ['AWS API Call via CloudTrail'], + detail: { + eventSource: ['sagemaker.amazonaws.com', 'translate.amazonaws.com'], + eventName: [ + 'CreateLabelingJob', + 'StartTextTranslationJob', + 'StopTextTranslationJob', + 'RunJobFlow', + ], + }, + }, + targets: [new LambdaFunction(resourceMetadataLambda)], + }); + + if (props.isIso) { + const adcCABundleAspect = new ADCLambdaCABundleAspect(); + Aspects.of(configDeployment).add(adcCABundleAspect); + Aspects.of(exampleDataDeployment).add(adcCABundleAspect); + Aspects.of(resourceMetadataLambda).add(adcCABundleAspect); + Aspects.of(s3NotificationLambda).add(adcCABundleAspect); + Aspects.of(terminateResourcesLambda).add(adcCABundleAspect); + } + } +} diff --git a/lib/constructs/infra/sagemakerConstruct.ts b/lib/constructs/infra/sagemakerConstruct.ts new file mode 100644 index 00000000..95eb18f2 --- /dev/null +++ b/lib/constructs/infra/sagemakerConstruct.ts @@ -0,0 +1,53 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { Fn, Stack, StackProps } from 'aws-cdk-lib'; +import { CfnNotebookInstanceLifecycleConfig } from 'aws-cdk-lib/aws-sagemaker'; +import { readFileSync } from 'fs'; +import { MLSpaceConfig } from '../../utils/configTypes'; +import { Construct } from 'constructs'; + +export type SagemakerStackProp = { + readonly dataBucketName: string; + readonly mlspaceConfig: MLSpaceConfig; +} & StackProps; + +export class SagemakerConstruct extends Construct { + constructor (scope: Stack, id: string, props: SagemakerStackProp) { + super(scope, id); + + new CfnNotebookInstanceLifecycleConfig(this, 'mlspace-notebook-lifecycle-config', { + notebookInstanceLifecycleConfigName: props.mlspaceConfig.MLSPACE_LIFECYCLE_CONFIG_NAME, + onCreate: [ + { + content: Fn.base64( + readFileSync('lib/resources/sagemaker/lifecycle-create.sh', 'utf8').replace( + //g, + props.dataBucketName + ) + ), + }, + ], + onStart: [ + { + content: Fn.base64( + readFileSync('lib/resources/sagemaker/lifecycle-start.sh', 'utf8') + ), + }, + ], + }); + } +} diff --git a/lib/constructs/kmsConstruct.ts b/lib/constructs/kmsConstruct.ts new file mode 100644 index 00000000..e8e46b58 --- /dev/null +++ b/lib/constructs/kmsConstruct.ts @@ -0,0 +1,91 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { Stack, StackProps } from 'aws-cdk-lib'; +import { MLSpaceConfig } from '../utils/configTypes'; +import { Construct } from 'constructs'; +import { + AccountRootPrincipal, + Effect, + PolicyDocument, + PolicyStatement, + Role, +} from 'aws-cdk-lib/aws-iam'; +import { IKey, Key } from 'aws-cdk-lib/aws-kms'; + +export type KMSStackProp = { + readonly keyManagerRoleName: string; + readonly mlspaceConfig: MLSpaceConfig; +} & StackProps; + +export class KMSConstruct extends Construct { + public readonly masterKey: IKey; + + constructor (scope: Stack, id: string, props: KMSStackProp) { + super(scope, id); + + if (props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN) { + this.masterKey = Key.fromKeyArn( + scope, + 'imported-kms-key', + props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN + ); + } else { + this.masterKey = new Key(scope, 'mlspace-kms-key', { + policy: new PolicyDocument({ + statements: [ + new PolicyStatement({ + effect: Effect.ALLOW, + principals: [new AccountRootPrincipal()], + actions: ['kms:*'], + resources: ['*'], + }), + new PolicyStatement({ + effect: Effect.ALLOW, + principals: [ + Role.fromRoleName( + scope, + 'mlspace-keymanager-role', + props.keyManagerRoleName + ), + ], + actions: [ + 'kms:Create*', + 'kms:Describe*', + 'kms:Enable*', + 'kms:List*', + 'kms:Put*', + 'kms:Update*', + 'kms:Revoke*', + 'kms:Disable*', + 'kms:Get*', + 'kms:Delete*', + 'kms:TagResource', + 'kms:UntagResource', + 'kms:ScheduleKeyDeletion', + 'kms:CancelKeyDeletion', + ], + resources: ['*'], + }), + ], + }), + alias: 'alias/mlspace-key', + description: 'KMS key for encrypting the objects in an S3 bucket', + enableKeyRotation: false, + }); + } + } +} diff --git a/lib/constructs/vpcConstruct.ts b/lib/constructs/vpcConstruct.ts new file mode 100644 index 00000000..5dcec0bb --- /dev/null +++ b/lib/constructs/vpcConstruct.ts @@ -0,0 +1,153 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { Stack } from 'aws-cdk-lib'; +import { + GatewayVpcEndpointAwsService, + ISecurityGroup, + IVpc, + InterfaceVpcEndpointAwsService, + InterfaceVpcEndpointService, + SecurityGroup, + SubnetType, + Vpc, +} from 'aws-cdk-lib/aws-ec2'; +import { Construct } from 'constructs'; +import { VPCStackProps } from '../stacks/vpc'; + +export class VPCConstruct extends Construct { + public readonly vpc: IVpc; + public readonly vpcSecurityGroupId: string; + public readonly vpcSecurityGroup: ISecurityGroup; + + constructor (scope: Stack, id: string, props: VPCStackProps) { + super(scope, id); + + const isIsoB = scope.region === 'us-isob-east-1'; + const isIsoEast = scope.region === 'us-iso-east-1'; + const isIsoWest = scope.region === 'us-iso-west-1'; + + if (props.mlspaceConfig.EXISTING_VPC_NAME && + props.mlspaceConfig.EXISTING_VPC_ID && + props.mlspaceConfig.EXISTING_VPC_DEFAULT_SECURITY_GROUP) { + this.vpc = Vpc.fromLookup(scope, 'imported-vpc', { + vpcId: props.mlspaceConfig.EXISTING_VPC_ID, + vpcName: props.mlspaceConfig.EXISTING_VPC_NAME, + }); + this.vpcSecurityGroupId = props.mlspaceConfig.EXISTING_VPC_DEFAULT_SECURITY_GROUP; + } else { + const mlSpaceVPC = new Vpc(scope, 'MLSpace-VPC', { + enableDnsHostnames: true, + enableDnsSupport: true, + availabilityZones: isIsoB ? ['us-isob-east-1b', 'us-isob-east-1c'] : undefined, + restrictDefaultSecurityGroup: false, + subnetConfiguration: [ + { + cidrMask: 23, + name: 'MLSpace-Public', + subnetType: SubnetType.PUBLIC, + }, + { + cidrMask: 23, + name: 'MLSpace-Private', + subnetType: SubnetType.PRIVATE_WITH_EGRESS, + }, + ], + }); + + this.vpc = mlSpaceVPC; + this.vpcSecurityGroupId = mlSpaceVPC.vpcDefaultSecurityGroup; + + if (props.deployS3Endpoint) { + this.vpc.addGatewayEndpoint('mlspace-S3-gateway-endpoint', { + service: GatewayVpcEndpointAwsService.S3, + }); + } + + if (props.deployDDBEndpoint && !isIsoEast) { + this.vpc.addGatewayEndpoint('mlspace-ddb-gateway-endpoint', { + service: GatewayVpcEndpointAwsService.DYNAMODB, + }); + } + + if (props.deployCWEndpoint && !props.isIso) { + this.vpc.addInterfaceEndpoint('mlspace-cw-interface-endpoint', { + service: InterfaceVpcEndpointAwsService.CLOUDWATCH_MONITORING, + privateDnsEnabled: true, + }); + } + + if (props.deployCWLEndpoint && !props.isIso) { + this.vpc.addInterfaceEndpoint('mlspace-cwl-interface-endpoint', { + service: InterfaceVpcEndpointAwsService.CLOUDWATCH_LOGS, + privateDnsEnabled: true, + }); + } + + let partitionPrefix; + if (isIsoEast || isIsoWest) { + // eslint-disable-next-line spellcheck/spell-checker + partitionPrefix = 'gov.ic.c2s'; + } else if (isIsoB) { + // eslint-disable-next-line spellcheck/spell-checker + partitionPrefix = 'gov.sgov.sc2s'; + } + + this.vpc.addInterfaceEndpoint('mlspace-sm-api-interface-endpoint', { + service: partitionPrefix + ? new InterfaceVpcEndpointService( + `${partitionPrefix}.${scope.region}.sagemaker.api` + ) + : InterfaceVpcEndpointAwsService.SAGEMAKER_API, + privateDnsEnabled: true, + }); + + this.vpc.addInterfaceEndpoint('mlspace-sm-runtime-interface-endpoint', { + service: partitionPrefix + ? new InterfaceVpcEndpointService( + `${partitionPrefix}.${scope.region}.sagemaker.runtime` + ) + : InterfaceVpcEndpointAwsService.SAGEMAKER_RUNTIME, + privateDnsEnabled: true, + }); + + this.vpc.addInterfaceEndpoint('mlspace-sm-notebook-interface-endpoint', { + service: InterfaceVpcEndpointAwsService.SAGEMAKER_NOTEBOOK, + privateDnsEnabled: true, + }); + + if (props.deploySTSEndpoint && !props.isIso) { + this.vpc.addInterfaceEndpoint('mlspace-sts-interface-endpoint', { + service: InterfaceVpcEndpointAwsService.STS, + privateDnsEnabled: true, + }); + } + + if (props.deployCFNEndpoint && !props.isIso) { + this.vpc.addInterfaceEndpoint('mlspace-cfn-interface-endpoint', { + service: InterfaceVpcEndpointAwsService.CLOUDFORMATION, + privateDnsEnabled: true, + }); + } + } + + this.vpcSecurityGroup = SecurityGroup.fromSecurityGroupId( + scope, + 'mls-vpc-default-sg', + this.vpcSecurityGroupId + ); + } +} diff --git a/lib/stacks/api/admin.ts b/lib/stacks/api/admin.ts index e28d7e06..78b2a36a 100644 --- a/lib/stacks/api/admin.ts +++ b/lib/stacks/api/admin.ts @@ -15,11 +15,8 @@ */ import { App, Stack } from 'aws-cdk-lib'; -import { LayerVersion } from 'aws-cdk-lib/aws-lambda'; -import { StringParameter } from 'aws-cdk-lib/aws-ssm'; -import { MLSpacePythonLambdaFunction, registerAPIEndpoint } from '../../utils/apiFunction'; import { ApiStackProperties } from './restApi'; -import { RestApi } from 'aws-cdk-lib/aws-apigateway'; +import { AdminApiConstruct } from '../../constructs/api/adminConstruct'; export class AdminApiStack extends Stack { constructor (parent: App, id: string, props: ApiStackProperties) { @@ -28,196 +25,8 @@ export class AdminApiStack extends Stack { ...props, }); - // Get common layer based on arn from SSM due to issues with cross stack references - const commonLambdaLayer = LayerVersion.fromLayerVersionArn( - this, - 'mls-common-lambda-layer', - StringParameter.valueForStringParameter(this, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) - ); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const adminApiConstruct = new AdminApiConstruct(this, id + 'Resources', props); - const restApi = RestApi.fromRestApiAttributes(this, 'RestApi', { - restApiId: props.restApiId, - rootResourceId: props.rootResourceId, - }); - - const apis: MLSpacePythonLambdaFunction[] = [ - { - name: 'list_all', - resource: 'user', - description: 'Returns a list of all MLSpace users', - path: 'user', - method: 'GET', - }, - { - name: 'delete', - resource: 'user', - description: 'Removes an MLSpace user', - path: 'user/{username}', - method: 'DELETE', - }, - { - name: 'create', - resource: 'user', - description: 'Creates a user for the system', - path: 'user', - method: 'POST', - environment: { - NEW_USER_SUSPENSION_DEFAULT: props.mlspaceConfig.NEW_USERS_SUSPENDED ? 'True' : 'False', - }, - }, - { - name: 'get', - resource: 'user', - description: 'Get an MLSpace user', - path: 'user/{username}', - method: 'GET', - }, - { - name: 'get_projects', - resource: 'user', - description: 'Get an MLSpace user\'s projects', - path: 'user/{username}/projects', - method: 'GET', - }, - { - name: 'get_groups', - resource: 'user', - description: 'Get an MLSpace user\'s groups', - path: 'user/{username}/groups', - method: 'GET', - }, - { - id: 'dataset-admin', - name: 'list_resources', - resource: 'dataset', - description: 'List all global, group, and private datasets', - path: 'admin/datasets', - method: 'GET', - }, - { - name: 'update', - resource: 'user', - description: 'Update an MLSpace user', - path: 'user/{username}', - method: 'PUT', - }, - { - name: 'login', - resource: 'user', - description: 'Update an MLSpace users lastLogin attribute', - path: 'login', - method: 'PUT', - }, - { - name: 'current', - resource: 'user', - description: 'Retrieve the user record for the current user', - path: 'current-user', - method: 'GET', - }, - { - name: 'describe', - resource: 'config', - description: - 'Get the current env config including env variables, param file, and notebook lifecycle config', - path: 'config', - method: 'GET', - environment: { - BUCKET: props.configBucketName, - S3_KEY: props.mlspaceConfig.NOTEBOOK_PARAMETERS_FILE_NAME, - }, - }, - { - name: 'create', - resource: 'report', - description: 'Generates a report of project resources and users for admins', - path: 'report', - method: 'POST', - environment: { - DATA_BUCKET: props.dataBucketName, - }, - }, - { - name: 'list', - resource: 'report', - description: 'Lists reports of project resources and users for admins', - path: 'report', - method: 'GET', - environment: { - DATA_BUCKET: props.dataBucketName, - }, - }, - { - name: 'download', - resource: 'report', - description: 'Get S3 URL of MLSpace report', - path: 'report/{reportName}', - method: 'GET', - environment: { - DATA_BUCKET: props.dataBucketName, - }, - }, - { - name: 'delete', - resource: 'report', - description: 'Deletes an MLSpace report', - path: 'report/{reportName}', - method: 'DELETE', - environment: { - DATA_BUCKET: props.dataBucketName, - }, - }, - { - name: 'sync_metadata', - resource: 'migration', - description: 'Sync resource metadata to MLSpace Resource Metadata table', - path: 'admin/sync-metadata', - method: 'POST', - }, - { - name: 'list_subnets', - resource: 'metadata', - description: 'List available subnets in which MLSpace resources can be launched', - path: 'metadata/subnets', - method: 'GET', - environment: { - BUCKET: props.configBucketName, - S3_KEY: props.mlspaceConfig.NOTEBOOK_PARAMETERS_FILE_NAME, - }, - }, - { - name: 'compute_types', - resource: 'metadata', - description: 'Describe available instance types for a sagemaker notebook', - path: 'metadata/compute-types', - method: 'GET', - }, - { - name: 'notebook_options', - resource: 'metadata', - description: - 'Gets notebook instance types and lifecycle configs for create notebooks', - path: 'metadata/notebook-options', - method: 'GET', - }, - ]; - - apis.forEach((f) => { - registerAPIEndpoint( - this, - restApi, - props.authorizer, - props.applicationRole, - props.applicationRole.roleName, - props.notebookInstanceRole.roleName, - props.lambdaSourcePath, - [commonLambdaLayer], - f, - props.mlSpaceVPC, - props.securityGroups, - props.mlspaceConfig, - props.permissionsBoundaryArn - ); - }); } } diff --git a/lib/stacks/api/apiDeployment.ts b/lib/stacks/api/apiDeployment.ts index 2e51ae65..f0993433 100644 --- a/lib/stacks/api/apiDeployment.ts +++ b/lib/stacks/api/apiDeployment.ts @@ -15,29 +15,21 @@ */ import { App, Stack, StackProps } from 'aws-cdk-lib'; -import { Deployment, RestApi } from 'aws-cdk-lib/aws-apigateway'; +import { ApiDeploymentConstruct } from '../../constructs/api/apiDeploymentConstruct'; export type ApiDeploymentStackProps = { readonly restApiId: string; } & StackProps; export class ApiDeploymentStack extends Stack { - constructor (parent: App, name: string, props: ApiDeploymentStackProps) { - super(parent, name, { + constructor (parent: App, id: string, props: ApiDeploymentStackProps) { + super(parent, id, { terminationProtection: false, ...props, }); - // Use timestamp in logical id to force an API deployment - // Related CDK issues: - // https://github.com/aws/aws-cdk/issues/12417 - // https://github.com/aws/aws-cdk/issues/13383 - const deployment = new Deployment(this, `ApiDeployment-${new Date().getTime()}`, { - api: RestApi.fromRestApiId(this, 'MLSpaceRestApiRef', props.restApiId), - }); - // This hack will allow us to redeploy to an existing stage but once CDK - // adds first class support for this we will migrate - // https://github.com/aws/aws-cdk/issues/25582 - (deployment as any).resource.stageName = 'Prod'; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const apiDeploymentConstruct = new ApiDeploymentConstruct(this, id + 'Resources', props); + } } \ No newline at end of file diff --git a/lib/stacks/api/appConfiguration.ts b/lib/stacks/api/appConfiguration.ts index caf8e98a..1e707270 100644 --- a/lib/stacks/api/appConfiguration.ts +++ b/lib/stacks/api/appConfiguration.ts @@ -15,11 +15,8 @@ */ import { App, Stack } from 'aws-cdk-lib'; -import { LayerVersion } from 'aws-cdk-lib/aws-lambda'; -import { StringParameter } from 'aws-cdk-lib/aws-ssm'; -import { MLSpacePythonLambdaFunction, registerAPIEndpoint } from '../../utils/apiFunction'; import { ApiStackProperties } from './restApi'; -import { RestApi } from 'aws-cdk-lib/aws-apigateway'; +import { AppConfigurationApiConstruct } from '../../constructs/api/appConfigurationConstruct'; export class AppConfigurationApiStack extends Stack { constructor (parent: App, id: string, props: ApiStackProperties) { @@ -28,57 +25,8 @@ export class AppConfigurationApiStack extends Stack { ...props, }); - // Get common layer based on arn from SSM due to issues with cross stack references - const commonLambdaLayer = LayerVersion.fromLayerVersionArn( - this, - 'mls-common-lambda-layer', - StringParameter.valueForStringParameter(this, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) - ); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const appConfigurationApiConstruct = new AppConfigurationApiConstruct(this, id + 'Resources', props); - const restApi = RestApi.fromRestApiAttributes(this, 'RestApi', { - restApiId: props.restApiId, - rootResourceId: props.rootResourceId, - }); - - const apis: MLSpacePythonLambdaFunction[] = [ - { - name: 'get_configuration', - resource: 'app_configuration', - description: 'Get the requested number of MLSpace application configurations, starting from the most recent', - path: 'app-config', - method: 'GET', - noAuthorizer: true - }, - { - name: 'update_configuration', - resource: 'app_configuration', - description: 'Update the MLSpace application configuration', - path: 'app-config', - method: 'POST', - environment: { - ENDPOINT_CONFIG_INSTANCE_CONSTRAINT_POLICY_ARN: props.endpointConfigInstanceConstraintPolicy?.managedPolicyArn || '', - JOB_INSTANCE_CONSTRAINT_POLICY_ARN: props.jobInstanceConstraintPolicy?.managedPolicyArn || '', - } - }, - ]; - - const system_permissions = ['update_configuration']; - apis.forEach((f) => { - registerAPIEndpoint( - this, - restApi, - props.authorizer, - system_permissions.includes(f.name) ? props.systemRole : props.applicationRole, - props.applicationRole.roleName, - props.notebookInstanceRole.roleName, - props.lambdaSourcePath, - [commonLambdaLayer], - f, - props.mlSpaceVPC, - props.securityGroups, - props.mlspaceConfig, - props.permissionsBoundaryArn - ); - }); } } diff --git a/lib/stacks/api/datasets.ts b/lib/stacks/api/datasets.ts index f48806dd..c07ffe99 100644 --- a/lib/stacks/api/datasets.ts +++ b/lib/stacks/api/datasets.ts @@ -15,11 +15,8 @@ */ import { App, Stack } from 'aws-cdk-lib'; -import { LayerVersion } from 'aws-cdk-lib/aws-lambda'; -import { StringParameter } from 'aws-cdk-lib/aws-ssm'; -import { MLSpacePythonLambdaFunction, registerAPIEndpoint } from '../../utils/apiFunction'; import { ApiStackProperties } from './restApi'; -import { RestApi } from 'aws-cdk-lib/aws-apigateway'; +import { DatasetsApiConstruct } from '../../constructs/api/datasetsConstructs'; export class DatasetsApiStack extends Stack { constructor (parent: App, id: string, props: ApiStackProperties) { @@ -28,110 +25,8 @@ export class DatasetsApiStack extends Stack { ...props, }); - // Get common layer based on arn from SSM due to issues with cross stack references - const commonLambdaLayer = LayerVersion.fromLayerVersionArn( - this, - 'mls-common-lambda-layer', - StringParameter.valueForStringParameter(this, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) - ); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const datasetsApiConstruct = new DatasetsApiConstruct(this, id + 'Resources', props); - const restApi = RestApi.fromRestApiAttributes(this, 'RestApi', { - restApiId: props.restApiId, - rootResourceId: props.rootResourceId, - }); - - const apis: MLSpacePythonLambdaFunction[] = [ - { - name: 'presigned_url', - resource: 'dataset', - description: 'Generates presigned url for MLSpace Dataset', - path: 'dataset/presigned-url', - method: 'POST', - environment: { - DATA_BUCKET: props.dataBucketName, - }, - }, - { - name: 'create_dataset', - resource: 'dataset', - description: 'Creates a new dataset', - path: 'dataset/create', - method: 'POST', - environment: { - DATA_BUCKET: props.dataBucketName, - }, - }, - { - id: 'dataset-personal', - name: 'list_resources', - resource: 'dataset', - description: 'List all global, group, and private datasets for user', - path: 'dataset', - method: 'GET', - }, - { - name: 'edit', - resource: 'dataset', - description: 'Edits dataset', - path: 'v2/dataset/{type}/{scope}/{datasetName}', - method: 'PUT', - }, - { - name: 'get', - resource: 'dataset', - description: 'Gets dataset details', - path: 'v2/dataset/{type}/{scope}/{datasetName}', - method: 'GET', - }, - { - name: 'delete', - resource: 'dataset', - description: 'Removes a dataset from an MLSpace project', - path: 'v2/dataset/{type}/{scope}/{datasetName}', - method: 'DELETE', - environment: { - DATA_BUCKET: props.dataBucketName, - }, - }, - { - name: 'delete_file', - resource: 'dataset', - description: 'Removes a file from a dataset', - // use a greedy path here so object keys containing '/' are fully matched - path: 'v2/dataset/{type}/{scope}/{datasetName}/{file+}', - method: 'DELETE', - environment: { - DATA_BUCKET: props.dataBucketName, - }, - }, - { - name: 'list_files', - resource: 'dataset', - description: 'List all file in a dataset', - path: 'v2/dataset/{type}/{scope}/{datasetName}/files', - method: 'GET', - environment: { - DATA_BUCKET: props.dataBucketName, - }, - }, - ]; - - apis.forEach((f) => { - registerAPIEndpoint( - this, - restApi, - props.authorizer, - props.applicationRole, - props.applicationRole.roleName, - props.notebookInstanceRole.roleName, - props.lambdaSourcePath, - [commonLambdaLayer], - f, - props.mlSpaceVPC, - props.securityGroups, - props.mlspaceConfig, - props.permissionsBoundaryArn - ); - }); } } diff --git a/lib/stacks/api/emr.ts b/lib/stacks/api/emr.ts index 42b632f2..b01fd464 100644 --- a/lib/stacks/api/emr.ts +++ b/lib/stacks/api/emr.ts @@ -15,11 +15,8 @@ */ import { App, Stack } from 'aws-cdk-lib'; -import { LayerVersion } from 'aws-cdk-lib/aws-lambda'; -import { StringParameter } from 'aws-cdk-lib/aws-ssm'; -import { MLSpacePythonLambdaFunction, registerAPIEndpoint } from '../../utils/apiFunction'; import { ApiStackProperties } from './restApi'; -import { RestApi } from 'aws-cdk-lib/aws-apigateway'; +import { EmrApiConstruct } from '../../constructs/api/emrConstruct'; export class EmrApiStack extends Stack { constructor (parent: App, id: string, props: ApiStackProperties) { @@ -28,80 +25,8 @@ export class EmrApiStack extends Stack { ...props, }); - // Get common layer based on arn from SSM due to issues with cross stack references - const commonLambdaLayer = LayerVersion.fromLayerVersionArn( - this, - 'mls-common-lambda-layer', - StringParameter.valueForStringParameter(this, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) - ); - - const restApi = RestApi.fromRestApiAttributes(this, 'RestApi', { - restApiId: props.restApiId, - rootResourceId: props.rootResourceId, - }); - - const apis: MLSpacePythonLambdaFunction[] = [ - { - name: 'get', - resource: 'emr', - description: 'Describe an EMR Cluster', - path: 'emr/{clusterId}', - method: 'GET', - }, - { - name: 'delete', - resource: 'emr', - description: 'Delete an EMR Cluster', - path: 'emr/{clusterId}', - method: 'DELETE', - }, - { - name: 'remove', - resource: 'emr', - description: 'Remove an EMR Cluster from the resource metadata table', - path: 'emr/{clusterId}/remove', - method: 'DELETE', - }, - { - name: 'set_resource_termination', - resource: 'resource_scheduler', - description: 'Update the termination time of an EMR Cluster', - path: 'emr/{clusterId}/schedule', - method: 'PUT', - id: 'resource_scheduler-set-emr-termination', - }, - { - name: 'list_applications', - resource: 'emr', - description: 'List all applications available to install and configure when launching a cluster', - path: 'emr/applications', - method: 'GET', - }, - { - name: 'list_release_labels', - resource: 'emr', - description: 'List of available EMR release labels', - path: 'emr/release', - method: 'GET', - }, - ]; - - apis.forEach((f) => { - registerAPIEndpoint( - this, - restApi, - props.authorizer, - props.applicationRole, - props.applicationRole.roleName, - props.notebookInstanceRole.roleName, - props.lambdaSourcePath, - [commonLambdaLayer], - f, - props.mlSpaceVPC, - props.securityGroups, - props.mlspaceConfig, - props.permissionsBoundaryArn - ); - }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const emrApiConstruct = new EmrApiConstruct(this, id + 'Resources', props); + } } diff --git a/lib/stacks/api/groupMembershipHistory.ts b/lib/stacks/api/groupMembershipHistory.ts index c3dc4dbf..90af4c91 100644 --- a/lib/stacks/api/groupMembershipHistory.ts +++ b/lib/stacks/api/groupMembershipHistory.ts @@ -15,11 +15,8 @@ */ import { App, Stack } from 'aws-cdk-lib'; -import { LayerVersion } from 'aws-cdk-lib/aws-lambda'; -import { StringParameter } from 'aws-cdk-lib/aws-ssm'; -import { MLSpacePythonLambdaFunction, registerAPIEndpoint } from '../../utils/apiFunction'; import { ApiStackProperties } from './restApi'; -import { RestApi } from 'aws-cdk-lib/aws-apigateway'; +import { GroupMembershipHistoryApiConstruct } from '../../constructs/api/groupMembershipHistoryConstruct'; export class GroupMembershipHistoryApiStack extends Stack { constructor (parent: App, id: string, props: ApiStackProperties) { @@ -28,45 +25,8 @@ export class GroupMembershipHistoryApiStack extends Stack { ...props, }); - // Get common layer based on arn from SSM due to issues with cross stack references - const commonLambdaLayer = LayerVersion.fromLayerVersionArn( - this, - 'mls-common-lambda-layer', - StringParameter.valueForStringParameter(this, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) - ); - - const restApi = RestApi.fromRestApiAttributes(this, 'RestApi', { - restApiId: props.restApiId, - rootResourceId: props.rootResourceId, - }); - - const apis: MLSpacePythonLambdaFunction[] = [ - { - name: 'list_all_for_group', - resource: 'group_membership_history', - description: 'List all group membership history for a specific group', - path: 'group-membership-history/{groupName}', - method: 'GET', - }, - ]; - - apis.forEach((f) => { - const system_permissions = ['remove_user', 'update', 'delete']; - registerAPIEndpoint( - this, - restApi, - props.authorizer, - system_permissions.includes(f.name) ? props.systemRole : props.applicationRole, - props.applicationRole.roleName, - props.notebookInstanceRole.roleName, - props.lambdaSourcePath, - [commonLambdaLayer], - f, - props.mlSpaceVPC, - props.securityGroups, - props.mlspaceConfig, - props.permissionsBoundaryArn - ); - }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const groupMembershipHistoryApiConstruct = new GroupMembershipHistoryApiConstruct(this, id + 'Resources', props); + } } diff --git a/lib/stacks/api/groups.ts b/lib/stacks/api/groups.ts index e708a557..7cbd8ce6 100644 --- a/lib/stacks/api/groups.ts +++ b/lib/stacks/api/groups.ts @@ -15,11 +15,8 @@ */ import { App, Stack } from 'aws-cdk-lib'; -import { LayerVersion } from 'aws-cdk-lib/aws-lambda'; -import { StringParameter } from 'aws-cdk-lib/aws-ssm'; -import { MLSpacePythonLambdaFunction, registerAPIEndpoint } from '../../utils/apiFunction'; import { ApiStackProperties } from './restApi'; -import { RestApi } from 'aws-cdk-lib/aws-apigateway'; +import { GroupsApiConstruct } from '../../constructs/api/groupsConstruct'; export class GroupsApiStack extends Stack { constructor (parent: App, id: string, props: ApiStackProperties) { @@ -28,120 +25,8 @@ export class GroupsApiStack extends Stack { ...props, }); - // Get common layer based on arn from SSM due to issues with cross stack references - const commonLambdaLayer = LayerVersion.fromLayerVersionArn( - this, - 'mls-common-lambda-layer', - StringParameter.valueForStringParameter(this, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) - ); - - const restApi = RestApi.fromRestApiAttributes(this, 'RestApi', { - restApiId: props.restApiId, - rootResourceId: props.rootResourceId, - }); - - const apis: MLSpacePythonLambdaFunction[] = [ - { - name: 'list_all', - resource: 'group', - description: 'List all MLSpace groups for a user', - path: 'group', - method: 'GET', - }, - { - name: 'create', - resource: 'group', - description: 'Create a new MLSpace group', - path: 'group', - method: 'POST', - environment: { - DATA_BUCKET: props.dataBucketName, - }, - }, - { - name: 'group_datasets', - resource: 'group', - description: 'Lists datasets that belong to a group', - path: 'group/{groupName}/datasets', - method: 'GET', - }, - { - name: 'group_users', - resource: 'group', - description: 'Lists users that belong to a group', - path: 'group/{groupName}/users', - method: 'GET', - }, - { - name: 'add_users', - resource: 'group', - description: 'Adds users to a group', - path: 'group/{groupName}/users', - method: 'POST', - environment: { - DATA_BUCKET: props.dataBucketName, - }, - }, - { - name: 'delete', - resource: 'group', - description: 'Delete an MLSpace group', - path: 'group/{groupName}', - method: 'DELETE', - environment: { - DATA_BUCKET: props.dataBucketName, - }, - }, - { - name: 'get', - resource: 'group', - description: 'Gets the corresponding group object', - path: 'group/{groupName}', - method: 'GET', - }, - { - name: 'group_projects', - resource: 'group', - description: 'Lists projects that belong to a group', - path: 'group/{groupName}/projects', - method: 'GET', - }, - { - name: 'remove_user', - resource: 'group', - description: 'Removes a user from a group', - path: 'group/{groupName}/users/{username}', - method: 'DELETE', - environment: { - DATA_BUCKET: props.dataBucketName, - }, - }, - { - name: 'update', - resource: 'group', - description: 'Updates group state (suspended/active)', - path: 'group/{groupName}', - method: 'PUT', - }, - ]; - - apis.forEach((f) => { - const system_permissions = ['remove_user', 'update', 'delete']; - registerAPIEndpoint( - this, - restApi, - props.authorizer, - system_permissions.includes(f.name) ? props.systemRole : props.applicationRole, - props.applicationRole.roleName, - props.notebookInstanceRole.roleName, - props.lambdaSourcePath, - [commonLambdaLayer], - f, - props.mlSpaceVPC, - props.securityGroups, - props.mlspaceConfig, - props.permissionsBoundaryArn - ); - }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const groupsApiConstruct = new GroupsApiConstruct(this, id + 'Resources', props); + } } diff --git a/lib/stacks/api/inference.ts b/lib/stacks/api/inference.ts index c2a7e4f1..99237432 100644 --- a/lib/stacks/api/inference.ts +++ b/lib/stacks/api/inference.ts @@ -15,11 +15,8 @@ */ import { App, Stack } from 'aws-cdk-lib'; -import { LayerVersion } from 'aws-cdk-lib/aws-lambda'; -import { StringParameter } from 'aws-cdk-lib/aws-ssm'; -import { MLSpacePythonLambdaFunction, registerAPIEndpoint } from '../../utils/apiFunction'; import { ApiStackProperties } from './restApi'; -import { RestApi } from 'aws-cdk-lib/aws-apigateway'; +import { InferenceApiConstruct } from '../../constructs/api/inferenceConstruct'; export class InferenceApiStack extends Stack { constructor (parent: App, id: string, props: ApiStackProperties) { @@ -28,138 +25,8 @@ export class InferenceApiStack extends Stack { ...props, }); - // Get common layer based on arn from SSM due to issues with cross stack references - const commonLambdaLayer = LayerVersion.fromLayerVersionArn( - this, - 'mls-common-lambda-layer', - StringParameter.valueForStringParameter(this, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) - ); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const inferenceApiConstruct = new InferenceApiConstruct(this, id + 'Resources', props); - const restApi = RestApi.fromRestApiAttributes(this, 'RestApi', { - restApiId: props.restApiId, - rootResourceId: props.rootResourceId, - }); - - const apis: MLSpacePythonLambdaFunction[] = [ - { - name: 'create', - resource: 'model', - description: 'Creates a new model', - path: 'model', - method: 'POST', - environment: { - BUCKET: props.configBucketName, - S3_KEY: props.notebookParamFileKey, - }, - }, - { - name: 'describe', - resource: 'model', - description: 'Returns the description of a given model', - path: 'model/{modelName}', - method: 'GET', - }, - { - name: 'list_images', - resource: 'model', - description: 'Gets ECR paths for models', - path: 'model/images', - method: 'GET', - }, - { - name: 'delete', - resource: 'model', - description: 'Delete a model', - path: 'model/{modelName}', - method: 'DELETE', - }, - { - name: 'create', - resource: 'endpoint', - description: 'Creates a new endpoint', - path: 'endpoint', - method: 'POST', - }, - { - name: 'describe', - resource: 'endpoint', - description: 'Returns the description of an endpoint', - path: 'endpoint/{endpointName}', - method: 'GET', - }, - { - name: 'update', - resource: 'endpoint', - description: 'Updates an existing endpoint with a new endpoint config', - path: 'endpoint/{endpointName}', - method: 'PUT', - }, - { - name: 'delete', - resource: 'endpoint', - description: 'Deletes an Endpoint', - path: 'endpoint/{endpointName}', - method: 'DELETE', - }, - { - id: 'endpoint-get-logs', - name: 'get', - resource: 'logs', - description: 'Returns the log events for the specified endpoint', - path: 'endpoint/{endpointName}/logs', - method: 'GET', - }, - { - name: 'create', - resource: 'endpoint_config', - description: 'Creates a new endpoint config', - path: 'endpoint-config', - method: 'POST', - environment: { - BUCKET: props.configBucketName, - S3_KEY: props.notebookParamFileKey, - }, - }, - { - name: 'describe', - resource: 'endpoint_config', - description: 'Returns the description of an endpoint config', - path: 'endpoint-config/{endpointConfigName}', - method: 'GET', - }, - { - name: 'delete', - resource: 'endpoint_config', - description: 'Deletes an endpoint config', - path: 'endpoint-config/{endpointConfigName}', - method: 'DELETE', - }, - { - name: 'set_resource_termination', - resource: 'resource_scheduler', - description: 'Update the termination time of a SageMaker Endpoint', - path: 'endpoint/{endpointName}/schedule', - method: 'PUT', - id: 'resource_scheduler-set-endpoint-termination', - }, - ]; - - apis.forEach((f) => { - registerAPIEndpoint( - this, - restApi, - props.authorizer, - props.applicationRole, - props.applicationRole.roleName, - props.notebookInstanceRole.roleName, - props.lambdaSourcePath, - [commonLambdaLayer], - f, - props.mlSpaceVPC, - props.securityGroups, - props.mlspaceConfig, - props.permissionsBoundaryArn - ); - }); } } diff --git a/lib/stacks/api/jobs.ts b/lib/stacks/api/jobs.ts index 4eff169e..221e2294 100644 --- a/lib/stacks/api/jobs.ts +++ b/lib/stacks/api/jobs.ts @@ -15,11 +15,8 @@ */ import { App, Stack } from 'aws-cdk-lib'; -import { LayerVersion } from 'aws-cdk-lib/aws-lambda'; -import { StringParameter } from 'aws-cdk-lib/aws-ssm'; -import { MLSpacePythonLambdaFunction, registerAPIEndpoint } from '../../utils/apiFunction'; import { ApiStackProperties } from './restApi'; -import { RestApi } from 'aws-cdk-lib/aws-apigateway'; +import { JobsApiConstruct } from '../../constructs/api/jobsConstruct'; export class JobsApiStack extends Stack { constructor (parent: App, id: string, props: ApiStackProperties) { @@ -28,144 +25,8 @@ export class JobsApiStack extends Stack { ...props, }); - // Get common layer based on arn from SSM due to issues with cross stack references - const commonLambdaLayer = LayerVersion.fromLayerVersionArn( - this, - 'mls-common-lambda-layer', - StringParameter.valueForStringParameter(this, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) - ); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const jobsApiConstruct = new JobsApiConstruct(this, id + 'Resources', props); - const restApi = RestApi.fromRestApiAttributes(this, 'RestApi', { - restApiId: props.restApiId, - rootResourceId: props.rootResourceId, - }); - - const apis: MLSpacePythonLambdaFunction[] = [ - { - name: 'list_training_jobs', - resource: 'hpo_job', - description: 'List HPO training jobs', - path: 'job/hpo/{jobName}/training-jobs', - method: 'GET', - }, - { - name: 'describe', - resource: 'training_job', - description: 'Returns the description of a given training job', - path: 'job/training/{jobName}', - method: 'GET', - }, - { - name: 'create', - resource: 'training_job', - description: 'Creates a new training job', - path: 'job/training', - method: 'POST', - environment: { - ROLE_ARN: props.notebookInstanceRole.roleArn, - BUCKET: props.configBucketName, - S3_KEY: props.notebookParamFileKey, - ENVIRONMENT: props.deploymentEnvironmentName, - }, - }, - { - name: 'create', - resource: 'transform_job', - description: 'Creates a transform job', - path: 'job/transform', - method: 'POST', - environment: { - BUCKET: props.configBucketName, - S3_KEY: props.notebookParamFileKey, - ENVIRONMENT: props.deploymentEnvironmentName, - DATA_BUCKET: props.dataBucketName, - }, - }, - { - name: 'stop', - resource: 'transform_job', - description: 'Stop transform job', - path: 'job/transform/{jobName}/stop', - method: 'POST', - }, - { - name: 'describe', - resource: 'transform_job', - description: 'Describes a transform job', - path: 'job/transform/{jobName}', - method: 'GET', - }, - { - name: 'create', - resource: 'hpo_job', - description: 'Creates a HPO job', - path: 'job/hpo', - method: 'POST', - environment: { - BUCKET: props.configBucketName, - S3_KEY: props.notebookParamFileKey, - ENVIRONMENT: props.deploymentEnvironmentName, - }, - }, - { - name: 'stop', - resource: 'hpo_job', - description: 'Stop HPO job', - path: 'job/hpo/{jobName}/stop', - method: 'POST', - }, - { - name: 'describe', - resource: 'hpo_job', - description: 'Describes a HPO job', - path: 'job/hpo/{jobName}', - method: 'GET', - }, - { - id: 'job-get-logs', - name: 'get', - resource: 'logs', - description: 'Returns the log events for the specified job', - path: 'job/{jobType}/{jobName}/logs', - method: 'GET', - }, - { - name: 'describe', - resource: 'labeling_job', - description: 'Describes a Ground Truth labeling job', - path: 'job/labeling/{jobName}', - method: 'GET', - }, - { - name: 'create', - resource: 'labeling_job', - description: 'Creates a Labeling job', - path: 'job/labeling', - method: 'POST', - environment: { - BUCKET: props.configBucketName, - S3_KEY: props.notebookParamFileKey, - ENVIRONMENT: props.deploymentEnvironmentName, - }, - }, - ]; - - apis.forEach((f) => { - registerAPIEndpoint( - this, - restApi, - props.authorizer, - props.applicationRole, - props.applicationRole.roleName, - props.notebookInstanceRole.roleName, - props.lambdaSourcePath, - [commonLambdaLayer], - f, - props.mlSpaceVPC, - props.securityGroups, - props.mlspaceConfig, - props.permissionsBoundaryArn - ); - }); } } diff --git a/lib/stacks/api/notebooks.ts b/lib/stacks/api/notebooks.ts index 43a2f77f..4b1d7012 100644 --- a/lib/stacks/api/notebooks.ts +++ b/lib/stacks/api/notebooks.ts @@ -15,11 +15,8 @@ */ import { App, Stack } from 'aws-cdk-lib'; -import { LayerVersion } from 'aws-cdk-lib/aws-lambda'; -import { StringParameter } from 'aws-cdk-lib/aws-ssm'; -import { MLSpacePythonLambdaFunction, registerAPIEndpoint } from '../../utils/apiFunction'; import { ApiStackProperties } from './restApi'; -import { RestApi } from 'aws-cdk-lib/aws-apigateway'; +import { NotebooksApiConstruct } from '../../constructs/api/notebooksConstruct'; export class NotebooksApiStack extends Stack { constructor (parent: App, id: string, props: ApiStackProperties) { @@ -28,121 +25,8 @@ export class NotebooksApiStack extends Stack { ...props, }); - // Get common layer based on arn from SSM due to issues with cross stack references - const commonLambdaLayer = LayerVersion.fromLayerVersionArn( - this, - 'mls-common-lambda-layer', - StringParameter.valueForStringParameter(this, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) - ); - - const restApi = RestApi.fromRestApiAttributes(this, 'RestApi', { - restApiId: props.restApiId, - rootResourceId: props.rootResourceId, - }); - - const apis: MLSpacePythonLambdaFunction[] = [ - { - id: 'notebook-personal', - name: 'list_resources', - resource: 'notebook', - description: 'Gets all notebooks a user has access to', - path: 'notebook', - method: 'GET', - }, - { - name: 'describe', - resource: 'notebook', - description: 'Describe a sagemaker notebook instance', - path: 'notebook/{notebookName}', - method: 'GET', - }, - { - name: 'edit', - resource: 'notebook', - description: 'Update a sagemaker notebook instance', - path: 'notebook/{notebookName}', - method: 'PUT', - environment: { - BUCKET: props.configBucketName, - S3_KEY: props.notebookParamFileKey, - }, - }, - { - name: 'create', - resource: 'notebook', - description: 'Create a sagemaker notebook instance in an MLSpace project', - path: 'notebook', - method: 'POST', - environment: { - BUCKET: props.configBucketName, - DATA_BUCKET: props.dataBucketName, - ENVIRONMENT: props.deploymentEnvironmentName, - ROLE_ARN: props.notebookInstanceRole.roleArn, - S3_KEY: props.notebookParamFileKey, - }, - }, - { - name: 'delete', - resource: 'notebook', - description: 'Delete a sagemaker notebook instance in an MLSpace project', - path: 'notebook/{notebookName}', - method: 'DELETE', - }, - { - name: 'start', - resource: 'notebook', - description: 'Starts a sagemaker notebook instance in an MLSpace project', - path: 'notebook/{notebookName}/start', - method: 'POST', - }, - { - name: 'stop', - resource: 'notebook', - description: 'Stop a sagemaker notebook instance in an MLSpace project', - path: 'notebook/{notebookName}/stop', - method: 'POST', - }, - { - name: 'presigned_url', - resource: 'notebook', - description: 'Gets a presigned URL to open a sagemaker notebook instance', - path: 'notebook/{notebookName}/url', - method: 'GET', - }, - { - id: 'notebooks-get-logs', - name: 'get', - resource: 'logs', - description: 'Returns the log events for the specified notebook', - path: 'notebook/{notebookName}/logs', - method: 'GET', - }, - { - name: 'set_resource_termination', - resource: 'resource_scheduler', - description: 'Update the termination time of a SageMaker Notebook', - path: 'notebook/{notebookName}/schedule', - method: 'PUT', - id: 'resource_scheduler-set-notebook-termination', - }, - ]; - - apis.forEach((f) => { - registerAPIEndpoint( - this, - restApi, - props.authorizer, - props.applicationRole, - props.applicationRole.roleName, - props.notebookInstanceRole.roleName, - props.lambdaSourcePath, - [commonLambdaLayer], - f, - props.mlSpaceVPC, - props.securityGroups, - props.mlspaceConfig, - props.permissionsBoundaryArn - ); - }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const notebooksApiConstruct = new NotebooksApiConstruct(this, id + 'Resources', props); + } } diff --git a/lib/stacks/api/projects.ts b/lib/stacks/api/projects.ts index 6df2a124..0cea21c6 100644 --- a/lib/stacks/api/projects.ts +++ b/lib/stacks/api/projects.ts @@ -15,11 +15,8 @@ */ import { App, Stack } from 'aws-cdk-lib'; -import { LayerVersion } from 'aws-cdk-lib/aws-lambda'; -import { StringParameter } from 'aws-cdk-lib/aws-ssm'; -import { MLSpacePythonLambdaFunction, registerAPIEndpoint } from '../../utils/apiFunction'; import { ApiStackProperties } from './restApi'; -import { RestApi } from 'aws-cdk-lib/aws-apigateway'; +import { ProjectsApiConstruct } from '../../constructs/api/projectsConstruct'; export class ProjectsApiStack extends Stack { constructor (parent: App, id: string, props: ApiStackProperties) { @@ -28,251 +25,8 @@ export class ProjectsApiStack extends Stack { ...props, }); - // Get common layer based on arn from SSM due to issues with cross stack references - const commonLambdaLayer = LayerVersion.fromLayerVersionArn( - this, - 'mls-common-lambda-layer', - StringParameter.valueForStringParameter(this, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) - ); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const projectsApiConstruct = new ProjectsApiConstruct(this, id + 'Resources', props); - const restApi = RestApi.fromRestApiAttributes(this, 'RestApi', { - restApiId: props.restApiId, - rootResourceId: props.rootResourceId, - }); - - const apis: MLSpacePythonLambdaFunction[] = [ - { - name: 'list_all', - resource: 'project', - description: 'List all MLSpace projects', - path: 'project', - method: 'GET', - }, - { - name: 'create', - resource: 'project', - description: 'Create a new MLSpace project', - path: 'project', - method: 'POST', - environment: { - DATA_BUCKET: props.dataBucketName, - }, - }, - { - name: 'project_users', - resource: 'project', - description: 'Lists users that belong to a project', - path: 'project/{projectName}/users', - method: 'GET', - }, - { - name: 'add_users', - resource: 'project', - description: 'Adds users to a project', - path: 'project/{projectName}/users', - method: 'POST', - environment: { - DATA_BUCKET: props.dataBucketName, - }, - }, - { - name: 'update_project_user', - resource: 'project', - description: 'Change the role of an MLSpace user within a project', - path: 'project/{projectName}/users/{username}', - method: 'PUT', - }, - { - name: 'add_groups', - resource: 'project', - description: 'Adds groups to a project', - path: 'project/{projectName}/groups', - method: 'POST', - environment: { - DATA_BUCKET: props.dataBucketName, - }, - }, - { - name: 'update_project_group', - resource: 'project', - description: 'Change the role of an MLSpace group within a project', - path: 'project/{projectName}/groups/{groupName}', - method: 'PUT', - environment: { - DATA_BUCKET: props.dataBucketName, - }, - }, - { - name: 'delete', - resource: 'project', - description: 'Delete an MLSpace project', - path: 'project/{projectName}', - method: 'DELETE', - environment: { - DATA_BUCKET: props.dataBucketName, - }, - }, - { - name: 'get', - resource: 'project', - description: 'Gets the corresponding project object', - path: 'project/{projectName}', - method: 'GET', - }, - { - name: 'remove_user', - resource: 'project', - description: 'Removes a user from a project', - path: 'project/{projectName}/users/{username}', - method: 'DELETE', - environment: { - DATA_BUCKET: props.dataBucketName, - }, - }, - { - name: 'remove_group', - resource: 'project', - description: 'Removes a group from a project', - path: 'project/{projectName}/groups/{groupName}', - method: 'DELETE', - environment: { - DATA_BUCKET: props.dataBucketName, - }, - }, - { - name: 'update', - resource: 'project', - description: 'Updates project state (suspended/active)', - path: 'project/{projectName}', - method: 'PUT', - }, - { - name: 'list_resources', - resource: 'training_job', - description: 'List training jobs', - path: 'project/{projectName}/jobs/training', - method: 'GET', - }, - { - name: 'list_resources', - resource: 'transform_job', - description: 'Lists transform jobs', - path: 'project/{projectName}/jobs/transform', - method: 'GET', - }, - { - name: 'list_resources', - resource: 'hpo_job', - description: 'Lists HPO jobs', - path: 'project/{projectName}/jobs/hpo', - method: 'GET', - }, - { - name: 'list_resources', - resource: 'labeling_job', - description: 'List Ground Truth labeling jobs', - path: 'project/{projectName}/jobs/labeling', - method: 'GET', - }, - { - name: 'list_workteams', - resource: 'labeling_job', - description: 'Describes a Ground Truth workteams', - path: 'project/{projectName}/jobs/labeling/teams', - method: 'GET', - }, - { - name: 'list_resources', - resource: 'notebook', - description: 'List all notebook instances in MLSpace', - path: 'project/{projectName}/notebooks', - method: 'GET', - }, - { - name: 'list_resources', - resource: 'model', - description: 'List models', - path: 'project/{projectName}/models', - method: 'GET', - }, - { - name: 'list_resources', - resource: 'endpoint', - description: 'Lists endpoints', - path: 'project/{projectName}/endpoints', - method: 'GET', - }, - { - name: 'list_resources', - resource: 'endpoint_config', - description: 'Lists endpoint configs', - path: 'project/{projectName}/endpoint-configs', - method: 'GET', - }, - { - name: 'list_resources', - resource: 'dataset', - description: 'List all datasets associated with the specified project', - path: 'project/{projectName}/datasets', - method: 'GET', - }, - { - name: 'list_all', - resource: 'emr', - description: 'List all EMR Clusters in MLSpace', - path: 'project/{projectName}/emr', - method: 'GET', - }, - { - name: 'create', - resource: 'emr', - description: 'Create an EMR Cluster in an MLSpace project', - path: 'project/{projectName}/emr', - method: 'POST', - environment: { - BUCKET: props.configBucketName, - S3_KEY: props.notebookParamFileKey, - EMR_SECURITY_CONFIGURATION: props.mlspaceConfig.EMR_SECURITY_CONFIG_NAME, - EMR_EC2_ROLE_NAME: props.emrEC2RoleName || '', - EMR_SERVICE_ROLE_NAME: props.emrServiceRoleName || '', - EMR_EC2_SSH_KEY: props.mlspaceConfig.EMR_EC2_SSH_KEY, - DATA_BUCKET: props.dataBucketName, - LOG_BUCKET: props.cwlBucketName, - }, - }, - { - name: 'list', - resource: 'batch_translate', - description: 'List pages of Batch Translate jobs for a project in MLSpace', - path: 'project/{projectName}/batch-translate-jobs', - method: 'GET', - }, - { - name: 'project_groups', - resource: 'project', - description: 'Lists groups that belong to a project', - path: 'project/{projectName}/groups', - method: 'GET', - }, - ]; - - apis.forEach((f) => { - const system_permissions = ['remove_user', 'update', 'delete']; - registerAPIEndpoint( - this, - restApi, - props.authorizer, - system_permissions.includes(f.name) ? props.systemRole : props.applicationRole, - props.applicationRole.roleName, - props.notebookInstanceRole.roleName, - props.lambdaSourcePath, - [commonLambdaLayer], - f, - props.mlSpaceVPC, - props.securityGroups, - props.mlspaceConfig, - props.permissionsBoundaryArn - ); - }); } } diff --git a/lib/stacks/api/restApi.ts b/lib/stacks/api/restApi.ts index 4c6b0a09..cce2a4d9 100644 --- a/lib/stacks/api/restApi.ts +++ b/lib/stacks/api/restApi.ts @@ -14,30 +14,15 @@ limitations under the License. */ -import { App, Aspects, Duration, RemovalPolicy, Stack, StackProps } from 'aws-cdk-lib'; +import { App, Stack, StackProps } from 'aws-cdk-lib'; import { - AccessLogField, - AccessLogFormat, - AwsIntegration, - Cors, - EndpointType, IAuthorizer, - IdentitySource, - LogGroupLogDestination, RequestAuthorizer, - RestApi, - StageOptions, } from 'aws-cdk-lib/aws-apigateway'; import { ISecurityGroup, IVpc } from 'aws-cdk-lib/aws-ec2'; -import { IManagedPolicy, IRole, Role } from 'aws-cdk-lib/aws-iam'; -import { Code, Function, LayerVersion } from 'aws-cdk-lib/aws-lambda'; -import { LogGroup } from 'aws-cdk-lib/aws-logs'; -import { Bucket } from 'aws-cdk-lib/aws-s3'; -import { BucketDeployment, Source } from 'aws-cdk-lib/aws-s3-deployment'; -import { StringParameter } from 'aws-cdk-lib/aws-ssm'; -import { ADCLambdaCABundleAspect } from '../../utils/adcCertBundleAspect'; -import { createLambdaLayer } from '../../utils/layers'; +import { IManagedPolicy, IRole } from 'aws-cdk-lib/aws-iam'; import { MLSpaceConfig } from '../../utils/configTypes'; +import { RestApiConstruct } from '../../constructs/api/restApiConstruct'; export type ApiStackProperties = { @@ -89,243 +74,12 @@ export class RestApiStack extends Stack { terminationProtection: false, ...props, }); - // Depending on your needs the throttling configuration can be changed here. These limits - // only impact calls to the MLSpace APIs and will have no impact on the backing AWS APIs. If - // an MLSpace API is calling a SageMaker API with a TPS limit of 10 then setting this value - // to anything greater than 10 may result in throttling from SageMaker directly. - let deployOptions: StageOptions = { - stageName: 'Prod', - throttlingRateLimit: 100, - throttlingBurstLimit: 100, - }; - if (props.mlspaceConfig.ENABLE_ACCESS_LOGGING) { - const apiAccessLogGroup = new LogGroup(this, 'mlspace-APIGWLogGroup', { - logGroupName: '/aws/apigateway/MLSpace', - removalPolicy: RemovalPolicy.DESTROY, - }); - deployOptions = { - ...deployOptions, - accessLogDestination: new LogGroupLogDestination(apiAccessLogGroup), - accessLogFormat: AccessLogFormat.custom( - JSON.stringify({ - requestId: AccessLogField.contextRequestId(), - requestTime: AccessLogField.contextRequestTime(), - authorizerPrincipalId: AccessLogField.contextAuthorizerPrincipalId(), - identity: { - accountId: AccessLogField.contextIdentityAccountId(), - apiKeyId: AccessLogField.contextIdentityApiKeyId(), - caller: AccessLogField.contextIdentityCaller(), - sourceIp: AccessLogField.contextIdentitySourceIp(), - user: AccessLogField.contextIdentityUser(), - userAgent: AccessLogField.contextIdentityUserAgent(), - userArn: AccessLogField.contextIdentityUserArn(), - }, - requestContext: { - stage: AccessLogField.contextStage(), - protocol: AccessLogField.contextProtocol(), - httpMethod: AccessLogField.contextHttpMethod(), - path: AccessLogField.contextPath(), - resourcePath: AccessLogField.contextResourcePath(), - resourceId: AccessLogField.contextResourceId(), - }, - response: { - statusCode: AccessLogField.contextStatus(), - latency: AccessLogField.contextResponseLatency(), - length: AccessLogField.contextResponseLength(), - }, - error: { - message: AccessLogField.contextErrorMessage(), - responseType: AccessLogField.contextErrorResponseType(), - }, - }) - ), - }; - } - const mlSpaceRestApi = new RestApi(this, 'mlspace-api', { - restApiName: 'MLSpace API', - description: 'The MLSpace API Layer.', - endpointConfiguration: { types: [EndpointType.REGIONAL] }, - deployOptions, - deploy: true, - defaultCorsPreflightOptions: { - allowOrigins: Cors.ALL_ORIGINS, - allowHeaders: [ - ...Cors.DEFAULT_HEADERS, - 'x-mlspace-dataset-scope', - 'x-mlspace-dataset-type', - 'x-mlspace-project', - ], - }, - // Support binary media types used for documentation images and fonts - binaryMediaTypes: ['font/*', 'image/*'], - }); - // Configure static site resources - const proxyMethodResponse = [ - { - statusCode: '200', - responseParameters: { - 'method.response.header.Content-Length': true, - 'method.response.header.Content-Type': true, - 'method.response.header.Content-Disposition': true, - }, - }, - ]; - const proxyRequestParameters = { - 'method.request.header.Accept': true, - 'method.request.header.Content-Type': true, - 'method.request.header.Content-Disposition': true, - }; - const proxyIntegrationResponse = [ - { - statusCode: '200', - responseParameters: { - 'method.response.header.Content-Length': - 'integration.response.header.Content-Length', - 'method.response.header.Content-Type': - 'integration.response.header.Content-Type', - 'method.response.header.Content-Disposition': - 'integration.response.header.Content-Disposition', - }, - }, - ]; - const proxyIntegrationRequestParameters = { - 'integration.request.header.Accept': 'method.request.header.Accept', - 'integration.request.header.Content-Disposition': - 'method.request.header.Content-Disposition', - 'integration.request.header.Content-Type': 'method.request.header.Content-Type', - }; - mlSpaceRestApi.root.addMethod( - 'GET', - new AwsIntegration({ - region: props.env?.region, - service: 's3', - path: `${props.websiteBucketName}/index.html`, - integrationHttpMethod: 'GET', - options: { - credentialsRole: props.websiteS3ReaderRole, - integrationResponses: proxyIntegrationResponse, - requestParameters: proxyIntegrationRequestParameters, - }, - }), - { - methodResponses: proxyMethodResponse, - requestParameters: proxyRequestParameters, - } - ); - - mlSpaceRestApi.root.addResource('{proxy+}').addMethod( - 'GET', - new AwsIntegration({ - region: props.env?.region, - service: 's3', - path: `${props.websiteBucketName}/{proxy}`, - integrationHttpMethod: 'ANY', - options: { - credentialsRole: props.websiteS3ReaderRole, - integrationResponses: proxyIntegrationResponse, - requestParameters: { - ...proxyIntegrationRequestParameters, - 'integration.request.path.proxy': 'method.request.path.proxy', - }, - }, - }), - { - requestParameters: { - ...proxyRequestParameters, - 'method.request.path.proxy': true, - }, - methodResponses: proxyMethodResponse, - } - ); - - const jwtDependencyLayer = createLambdaLayer(this, 'jwt'); - // Get common layer based on arn from SSM due to issues with cross stack references - const commonLambdaLayer = LayerVersion.fromLayerVersionArn( - this, - 'mls-common-lambda-layer', - StringParameter.valueForStringParameter(this, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) - ); - - let ssmIdPEndpoint; - if (props.mlspaceConfig.IDP_ENDPOINT_SSM_PARAM) { - ssmIdPEndpoint = StringParameter.valueForStringParameter(this, props.mlspaceConfig.IDP_ENDPOINT_SSM_PARAM); - } - - const authorizerLambda = new Function(this, 'MLSpaceAuthorizerLambda', { - runtime: props.mlspaceConfig.LAMBDA_RUNTIME, - architecture: props.mlspaceConfig.LAMBDA_ARCHITECTURE, - handler: 'ml_space_lambda.authorizer.lambda_function.lambda_handler', - functionName: 'mls-lambda-authorizer', - code: Code.fromAsset(props.lambdaSourcePath), - description: 'MLSpace Authentication and Authorization Lambda', - timeout: Duration.seconds(30), - memorySize: 512, - role: props.mlSpaceAppRole, - layers: [jwtDependencyLayer.layerVersion, commonLambdaLayer], - environment: { - OIDC_URL: ssmIdPEndpoint || props.mlspaceConfig.INTERNAL_OIDC_URL || props.mlspaceConfig.OIDC_URL, - OIDC_CLIENT_NAME: props.mlspaceConfig.OIDC_CLIENT_NAME, - OIDC_VERIFY_SSL: props.mlspaceConfig.OIDC_VERIFY_SSL ? 'True' : 'False', - OIDC_VERIFY_SIGNATURE: props.verifyOIDCTokenSignature ? 'True' : 'False', - ...props.mlspaceConfig.ADDITIONAL_LAMBDA_ENVIRONMENT_VARS, - }, - vpc: props.mlSpaceVPC, - securityGroups: props.lambdaSecurityGroups, - }); - - this.mlspaceRequestAuthorizer = new RequestAuthorizer(this, 'MLSpaceAPIGWAuthorizer', { - handler: authorizerLambda, - resultsCacheTtl: Duration.seconds(0), - identitySources: [IdentitySource.header('Authorization')], - }); - - // Dynamic config relies on api URL and we don't want to do this in a separate stack - const appEnvironmentConfig = { - OIDC_URL: ssmIdPEndpoint || props.mlspaceConfig.OIDC_URL, - OIDC_REDIRECT_URI: props.mlspaceConfig.OIDC_REDIRECT_URI || mlSpaceRestApi.url, - OIDC_CLIENT_NAME: props.mlspaceConfig.OIDC_CLIENT_NAME, - LAMBDA_ENDPOINT: mlSpaceRestApi.url, - MANAGE_IAM_ROLES: props.mlspaceConfig.MANAGE_IAM_ROLES, - SHOW_MIGRATION_OPTIONS: props.mlspaceConfig.SHOW_MIGRATION_OPTIONS, - ENABLE_TRANSLATE: props.enableTranslate, - APPLICATION_NAME: props.mlspaceConfig.APPLICATION_NAME, - DATASET_BUCKET: props.dataBucketName, - AWS_REGION: props.mlspaceConfig.AWS_REGION, - BACKGROUND_REFRESH_INTERVAL: props.mlspaceConfig.BACKGROUND_REFRESH_INTERVAL - }; - - // MLSpace static react app - const websiteBucket = Bucket.fromBucketName( - this, - 'mlspace-static-website-bucket', - props.websiteBucketName - ); - - const frontEndDeployment = new BucketDeployment(this, 'MLSpaceFrontEndDeployment', { - sources: [ - Source.asset(props.frontEndAssetsPath), - Source.data('env.js', `window.env = ${JSON.stringify(appEnvironmentConfig)}`), - ], - destinationBucket: websiteBucket, - prune: true, - role: props.mlspaceConfig.BUCKET_DEPLOYMENT_ROLE_ARN - ? Role.fromRoleArn( - this, - 'mlspace-website-deploy-role', - props.mlspaceConfig.BUCKET_DEPLOYMENT_ROLE_ARN, - { - mutable: false, - } - ) - : undefined, - }); - - if (props.isIso) { - Aspects.of(frontEndDeployment).add(new ADCLambdaCABundleAspect()); - Aspects.of(authorizerLambda).add(new ADCLambdaCABundleAspect()); - } + + const restApiConstruct = new RestApiConstruct(this, id + 'Resources', props); + + this.mlspaceRequestAuthorizer = restApiConstruct.mlspaceRequestAuthorizer; + this.mlSpaceRestApiId = restApiConstruct.mlSpaceRestApiId; + this.mlSpaceRestApiRootResourceId = restApiConstruct.mlSpaceRestApiRootResourceId; - this.mlSpaceRestApiId = mlSpaceRestApi.restApiId; - this.mlSpaceRestApiRootResourceId = mlSpaceRestApi.restApiRootResourceId; } } diff --git a/lib/stacks/api/translate.ts b/lib/stacks/api/translate.ts index b6644949..19a0bf6a 100644 --- a/lib/stacks/api/translate.ts +++ b/lib/stacks/api/translate.ts @@ -15,11 +15,8 @@ */ import { App, Stack } from 'aws-cdk-lib'; -import { LayerVersion } from 'aws-cdk-lib/aws-lambda'; -import { StringParameter } from 'aws-cdk-lib/aws-ssm'; -import { MLSpacePythonLambdaFunction, registerAPIEndpoint } from '../../utils/apiFunction'; import { ApiStackProperties } from './restApi'; -import { RestApi } from 'aws-cdk-lib/aws-apigateway'; +import { TranslateApiConstruct } from '../../constructs/api/translateConstruct'; export class TranslateApiStack extends Stack { constructor (parent: App, id: string, props: ApiStackProperties) { @@ -28,94 +25,8 @@ export class TranslateApiStack extends Stack { ...props, }); - // Get common layer based on arn from SSM due to issues with cross stack references - const commonLambdaLayer = LayerVersion.fromLayerVersionArn( - this, - 'mls-common-lambda-layer', - StringParameter.valueForStringParameter(this, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) - ); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const translateApiConstruct = new TranslateApiConstruct(this, id + 'Resources', props); - const restApi = RestApi.fromRestApiAttributes(this, 'RestApi', { - restApiId: props.restApiId, - rootResourceId: props.rootResourceId, - }); - - const apis: MLSpacePythonLambdaFunction[] = [ - { - name: 'translate_text', - resource: 'translate_realtime', - description: - 'Perform a real-time translation of a source text, with a source and target language', - path: 'translate/realtime/text', - method: 'POST', - }, - { - name: 'translate_document', - resource: 'translate_realtime', - description: - 'Perform a real-time translation of a source document, with a source and target language', - path: 'translate/realtime/document', - method: 'POST', - }, - { - name: 'describe', - resource: 'batch_translate', - description: 'Describe a Batch Translate job', - path: 'batch-translate/{jobId}', - method: 'GET', - }, - { - name: 'create', - resource: 'batch_translate', - description: 'Create a Batch Translate job in an MLSpace project', - path: 'batch-translate', - method: 'POST', - environment: { - BUCKET: props.configBucketName, - S3_KEY: props.notebookParamFileKey, - DATA_BUCKET: props.dataBucketName, - TRANSLATE_DATE_ROLE_ARN: props.applicationRole.roleArn, - }, - }, - { - name: 'stop', - resource: 'batch_translate', - description: 'Stop a Batch Translate job', - path: 'batch-translate/{jobId}/stop', - method: 'POST', - }, - { - name: 'list_languages', - resource: 'metadata', - description: 'List the supported languages for AWS Translate', - path: 'translate/list-languages', - method: 'GET', - }, - { - name: 'list', - resource: 'custom_terminology', - description: 'List pages of Custom Terminologies for AWS Translate', - path: 'translate/custom-terminologies', - method: 'GET', - }, - ]; - - apis.forEach((f) => { - registerAPIEndpoint( - this, - restApi, - props.authorizer, - props.applicationRole, - props.applicationRole.roleName, - props.notebookInstanceRole.roleName, - props.lambdaSourcePath, - [commonLambdaLayer], - f, - props.mlSpaceVPC, - props.securityGroups, - props.mlspaceConfig, - props.permissionsBoundaryArn - ); - }); } } diff --git a/lib/stacks/iam.ts b/lib/stacks/iam.ts index 293f9b8f..f8e28dee 100644 --- a/lib/stacks/iam.ts +++ b/lib/stacks/iam.ts @@ -14,22 +14,15 @@ limitations under the License. */ -import { App, Aws, Stack, StackProps } from 'aws-cdk-lib'; -import { CfnAccount } from 'aws-cdk-lib/aws-apigateway'; +import { App, Stack, StackProps } from 'aws-cdk-lib'; import { IVpc } from 'aws-cdk-lib/aws-ec2'; import { - CfnInstanceProfile, - CompositePrincipal, - Effect, IManagedPolicy, IRole, - ManagedPolicy, - PolicyStatement, - Role, - ServicePrincipal } from 'aws-cdk-lib/aws-iam'; import { IKey } from 'aws-cdk-lib/aws-kms'; import { MLSpaceConfig } from '../utils/configTypes'; +import { IAMConstruct } from '../constructs/iamConstruct'; export type IAMStackProp = { readonly dataBucketName: string; @@ -61,1210 +54,18 @@ export class IAMStack extends Stack { ...props, }); - /** - * Comprehend Permissions - * Translate Permissions - */ - const mlActions = ['comprehend:Detect*', 'comprehend:BatchDetect*']; - if (props.enableTranslate) { - // Translate Permissions - mlActions.push('translate:TranslateText'); - } - // Required tags that force the request to be specific to an MLSpace managed resource - const requestTagsConditions = { - 'aws:RequestTag/project': 'false', - // this is excluded because it is covered by requestSystemTagEqualsConditions below - // 'aws:RequestTag/system': 'false', - 'aws:RequestTag/user': 'false', - }; + const iamConstruct = new IAMConstruct(this, name + 'Resources', props); + + this.mlSpaceAppRole = iamConstruct.mlSpaceAppRole; + this.mlSpaceNotebookRole = iamConstruct.mlSpaceNotebookRole; + this.s3ReaderRole = iamConstruct.s3ReaderRole; + this.mlSpacePermissionsBoundary = iamConstruct.mlSpacePermissionsBoundary; + this.emrServiceRoleName = iamConstruct.emrServiceRoleName; + this.emrEC2RoleName = iamConstruct.emrEC2RoleName; + this.mlspaceEndpointConfigInstanceConstraintPolicy = iamConstruct.mlspaceEndpointConfigInstanceConstraintPolicy; + this.mlspaceJobInstanceConstraintPolicy = iamConstruct.mlspaceJobInstanceConstraintPolicy; + this.mlSpaceSystemRole = iamConstruct.mlSpaceSystemRole; + this.mlspaceKmsInstanceConditionsPolicy = iamConstruct.mlspaceKmsInstanceConditionsPolicy; - const enum SystemTagCondition { - Equals, - NotEquals - } - - const requestSystemTagEqualsConditions = { - [SystemTagCondition.Equals]: { - 'StringEqualsIgnoreCase': { - 'aws:RequestTag/system': props.mlspaceConfig.SYSTEM_TAG, - } - }, - [SystemTagCondition.NotEquals]: { - 'StringNotEqualsIgnoreCase': { - 'aws:RequestTag/system': props.mlspaceConfig.SYSTEM_TAG, - } - } - }; - // Required tags that ensure a created or accessed resource are properly managed by MLSpace - const resourceTagsConditions = { - 'aws:ResourceTag/project': 'false', - 'aws:ResourceTag/system': 'false', - 'aws:ResourceTag/user': 'false', - }; - - const resourceSystemTagEqualsConditions = { - [SystemTagCondition.Equals]: { - 'StringEqualsIgnoreCase': { - 'aws:ResourceTag/system': props.mlspaceConfig.SYSTEM_TAG, - } - }, - [SystemTagCondition.NotEquals]: { - 'StringNotEqualsIgnoreCase': { - 'aws:ResourceTag/system': props.mlspaceConfig.SYSTEM_TAG, - } - } - }; - - const ec2ArnBase = `arn:${this.partition}:ec2:${Aws.REGION}:${this.account}`; - const privateSubnetArnList = props.mlSpaceVPC.privateSubnets.map( - (s) => `${ec2ArnBase}:subnet/${s.subnetId}` - ); - - // Role names - const mlspaceSystemRoleName = 'mlspace-system-role'; - const mlSpaceNotebookRoleName = 'mlspace-notebook-role'; - - - if (props.mlspaceConfig.KMS_INSTANCE_CONDITIONS_POLICY_ARN) { - this.mlspaceKmsInstanceConditionsPolicy = ManagedPolicy.fromManagedPolicyArn(this, 'mlspace-kms-instance-constraint-policy', props.mlspaceConfig.KMS_INSTANCE_CONDITIONS_POLICY_ARN); - } else { - this.mlspaceKmsInstanceConditionsPolicy = new ManagedPolicy(this, 'mlspace-kms-instance-constraint-policy', { - managedPolicyName: `${props.mlspaceConfig.IAM_RESOURCE_PREFIX}-kms-instance-constraint-policy`, - statements: [ - new PolicyStatement({ - effect: Effect.DENY, - actions: [ - 'sagemaker:CreateEndpointConfig', - 'sagemaker:CreateHyperParameterTuningJob', - 'sagemaker:CreateNotebookInstance', - 'sagemaker:CreateTrainingJob', - 'sagemaker:CreateTransformJob' - ], - resources: ['*'], - conditions: { - 'Null': { - 'sagemaker:VolumeKmsKey': 'true' - }, - }, - }), - ] - }); - } - - const invertedBooleanConditions = (conditions: {[key: string]: string}) => Object.fromEntries(Object.entries(conditions).map(([key, value]) => { - return [key, value === 'true' ? 'false' : 'true']; - })); - - /** - * NOTEBOOK POLICY & ROLE SECTION - * Notebook policy - base permissions used when in a notebook and also applied to general use of the application - * Notebook role - the role and permissions used when users are accessing a notebook - */ - const notebookPolicyStatements = (partition: string, region: string, allow_all_instances: boolean = false) => { - const statements = [ - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['kms:CreateGrant'], - resources: [props.encryptionKey.keyArn], - conditions: { - Bool: { - 'kms:GrantIsForAWSResource': 'true', - }, - }, - }), - /** - * HPO Permissions - * Training Permissions - * Transform Permissions - */ - new PolicyStatement({ - effect: Effect.ALLOW, - actions: [ - // EC2 permissions required to create hpo/training/transform jobs in a private VPC - 'ec2:CreateNetworkInterface', - 'ec2:CreateNetworkInterfacePermission', - 'ec2:DeleteNetworkInterface', - 'ec2:DeleteNetworkInterfacePermission', - // KMS permissions are required to encrypt job output and decrypt job input - 'kms:Decrypt', - 'kms:DescribeKey', - 'kms:Encrypt', - 'kms:GenerateDataKey', - ], - resources: [ - // EC2 actions resource identifiers - ...privateSubnetArnList, - `${ec2ArnBase}:security-group/${props.mlSpaceDefaultSecurityGroupId}`, - `${ec2ArnBase}:network-interface/*`, - // KMS action resource identifier - props.encryptionKey.keyArn, - ], - }), - // General Permissions - Allows tagging of SageMaker resources created within a notebook - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['sagemaker:AddTags'], - resources: [`arn:${partition}:sagemaker:${region}:${this.account}:*`], - }), - // General Permissions - Read Only + Metric Write permissions - new PolicyStatement({ - effect: Effect.ALLOW, - actions: [ - // EC2 describe actions that are not bound by resource identifier. - 'ec2:DescribeNetworkInterfaces', - 'ec2:DescribeDhcpOptions', - 'ec2:DescribeSubnets', - 'ec2:DescribeSecurityGroups', - 'ec2:DescribeVpcs', - // SageMaker list actions that are not bound by resource identifier. - 'sagemaker:DescribeWorkteam', - 'sagemaker:ListEndpointConfigs', - 'sagemaker:ListEndpoints', - 'sagemaker:ListLabelingJobs', - 'sagemaker:ListModels', - 'sagemaker:ListTags', - 'sagemaker:ListTrainingJobs', - 'sagemaker:ListTransformJobs', - 'sagemaker:ListHyperParameterTuningJobs', - 'sagemaker:ListTrainingJobsForHyperParameterTuningJob', - 'sagemaker:ListWorkteams', - /* - * Permissions not bound to specific resources. Log groups and metrics are created as - * part of various SageMaker resources that can be launched by users (training jobs, - * endpoints, etc). The iam:GetRole permission is used to allow users to get the current - * role the notebook is executing under so that they can use that role to create - * SageMaker resources. - */ - 'iam:GetRole', - 'cloudwatch:PutMetricData', - 'logs:CreateLogGroup', - 'logs:CreateLogStream', - 'logs:DescribeLogStreams', - 'logs:PutLogEvents', - ], - resources: ['*'], - }), - // Endpoint and LabelingJob Permissions - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['sagemaker:CreateEndpoint', 'sagemaker:CreateLabelingJob'], - resources: [`arn:${partition}:sagemaker:${region}:${this.account}:*`], - conditions: { - Null: requestTagsConditions, - ...requestSystemTagEqualsConditions[SystemTagCondition.Equals] - }, - }), - /** - * Endpoint Permissions - * This statement/action must be separate from the above statement. - * If request tag conditions are applied to this action + resource combination then it will fail. - */ - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['sagemaker:CreateEndpoint'], - resources: [ - `arn:${partition}:sagemaker:${region}:${this.account}:endpoint-config/*`, - ], - }), - // Model Permissions - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['sagemaker:CreateModel'], - resources: [`arn:${partition}:sagemaker:${region}:${this.account}:model/*`], - conditions: { - Null: { - 'sagemaker:VpcSecurityGroupIds': 'false', - 'sagemaker:VpcSubnets': 'false', - ...requestTagsConditions, - }, - ...requestSystemTagEqualsConditions[SystemTagCondition.Equals] - }, - }), - /** - * Various Permissions - * - * SageMaker permissions to allow users to monitor the status of resources they've - * created. These statements will be supplemented with user/project specific policies - * to ensure users can only describe/interact with resources that have been tagged - * with their username and/or project name. - */ - new PolicyStatement({ - effect: Effect.ALLOW, - actions: [ - // Training Permissions - 'sagemaker:DescribeTrainingJob', - 'sagemaker:StopTrainingJob', - // Transform Permissions - 'sagemaker:DescribeTransformJob', - 'sagemaker:StopTransformJob', - // Model Permissions - 'sagemaker:DescribeModel', - 'sagemaker:DeleteModel', - // HPO Permissions - 'sagemaker:DescribeHyperParameterTuningJob', - 'sagemaker:StopHyperParameterTuningJob', - // Endpoint Permissions - 'sagemaker:DescribeEndpoint', - 'sagemaker:DeleteEndpoint', - 'sagemaker:InvokeEndpoint', - 'sagemaker:UpdateEndpoint', - 'sagemaker:UpdateEndpointWeightsAndCapacities', - // Endpoint Config Permissions - 'sagemaker:DescribeEndpointConfig', - 'sagemaker:DeleteEndpointConfig', - // Labeling Permissions - 'sagemaker:DescribeLabelingJob', - 'sagemaker:StopLabelingJob', - ], - resources: [`arn:${partition}:sagemaker:${region}:${this.account}:*`], - conditions: { - Null: resourceTagsConditions, - ...resourceSystemTagEqualsConditions[SystemTagCondition.Equals] - }, - }), - /** - * Comprehend Permissions - * Translate Permissions - */ - new PolicyStatement({ - effect: Effect.ALLOW, - actions: mlActions, - // Translate doesn't assign arns/doesn't support restricting resources for the actions we require - resources: ['*'], - }), - /** - * General permissions - * Allow read access to MLSpace config and examples bucket as well as SageMaker public - * examples bucket - */ - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['s3:GetObject', 's3:ListBucket'], - resources: [ - `arn:${partition}:s3:::${props.configBucketName}`, - `arn:${partition}:s3:::${props.configBucketName}/*`, - `arn:${partition}:s3:::sagemaker-sample-files`, - `arn:${partition}:s3:::sagemaker-sample-files/*`, - `arn:${partition}:s3:::${props.dataBucketName}/global-read-only/*`, - ], - }), - /** - * Allow listing the contents of the MLSpace example data bucket. - * List bucket may not be needed if onCreate script is changed to use 's3 cp' instead of 's3 sync' - */ - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['s3:ListBucket'], - resources: [`arn:${partition}:s3:::${props.dataBucketName}`], - conditions: { - StringLike: { - 's3:prefix': 'global-read-only/*', - }, - }, - }), - /** - * Bedrock Permissions - */ - new PolicyStatement({ - effect: Effect.ALLOW, - actions: [ - // mutating - 'bedrock:Associate*', - 'bedrock:Create*', - 'bedrock:BatchDelete*', - 'bedrock:Delete*', - 'bedrock:Put*', - 'bedrock:Retrieve*', - 'bedrock:Start*', - 'bedrock:Update*', - - // non-mutating - 'bedrock:Apply*', - 'bedrock:Detect*', - 'bedrock:List*', - 'bedrock:Get*', - 'bedrock:Invoke*', - 'bedrock:Retrieve*', - ], - resources: [`arn:${partition}:sagemaker:${region}:${this.account}:*`], - conditions: { - Null: { - ...requestTagsConditions, - ...resourceTagsConditions, - }, - ...requestSystemTagEqualsConditions[SystemTagCondition.Equals] - }, - }), - ]; - - if (props.enableTranslate) { - // Translate Permissions - statements.push(new PolicyStatement({ - effect: Effect.ALLOW, - actions: [ - 'translate:StopTextTranslationJob', - 'translate:List*', - 'translate:StartTextTranslationJob', - 'translate:DescribeTextTranslationJob', - 'translate:TranslateDocument', - 'translate:TranslateText', - ], - resources: ['*'], - })); - // Translate Permissions - Allows for passing the role to translate - statements.push(new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['iam:PassRole'], - resources: [ - `arn:${partition}:iam::${this.account}:role/${props.mlspaceConfig.IAM_RESOURCE_PREFIX}*`, - ], - conditions: { - StringEquals: { - 'iam:PassedToService': 'translate.amazonaws.com', - }, - }, - })); - } - /** - * General permissions - * If the default notebook policy is the only policy that will be attached - * to a notebook then we need to give blanket dataset access. If we're managing - * IAM roles then the user/project policies that get attached to the dynamically created - * notebook role will lock things down to global, project, and user levels. - */ - if (!props.mlspaceConfig.MANAGE_IAM_ROLES) { - statements.push( - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['s3:*'], - resources: [`arn:${partition}:s3:::${props.dataBucketName}/*`], - }) - ); - } - - /** - * Using the new instance restrain policies requires different permissions based on DynamicRoles permissions - * - * Additionally the permissions boundary needs statements that have ALLOW permissions - */ - if (!props.mlspaceConfig.MANAGE_IAM_ROLES || allow_all_instances) { - statements.push( - // Endpoint Configuration and TransformJob Permissions - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['sagemaker:CreateEndpointConfig', 'sagemaker:CreateTransformJob'], - resources: [ - `arn:${partition}:sagemaker:${region}:${this.account}:*`, - ], - conditions: { - Null: { - ...requestTagsConditions, - }, - ...requestSystemTagEqualsConditions[SystemTagCondition.Equals] - }, - })); - statements.push( - // HPO Permissions - new PolicyStatement({ - effect: Effect.ALLOW, - actions: [ - 'sagemaker:CreateHyperParameterTuningJob', - 'sagemaker:CreateTrainingJob', - ], - resources: [ - `arn:${partition}:sagemaker:${region}:${this.account}:training-job/*`, - `arn:${partition}:sagemaker:${region}:${this.account}:hyper-parameter-tuning-job/*` - ], - conditions: { - Null: { - 'sagemaker:VpcSecurityGroupIds': 'false', - 'sagemaker:VpcSubnets': 'false', - ...requestTagsConditions, - }, - ...requestSystemTagEqualsConditions[SystemTagCondition.Equals] - }, - })); - } else { - statements.push( - // Endpoint Configuration and TransformJob Permissions - new PolicyStatement({ - effect: Effect.DENY, - actions: ['sagemaker:CreateEndpointConfig', 'sagemaker:CreateTransformJob'], - resources: [ - `arn:${partition}:sagemaker:${region}:${this.account}:*`, - ], - conditions: { - Null: { - ...invertedBooleanConditions(requestTagsConditions), - }, - ...requestSystemTagEqualsConditions[SystemTagCondition.NotEquals] - }, - })); - statements.push( - // HPO Permissions - new PolicyStatement({ - effect: Effect.DENY, - actions: [ - 'sagemaker:CreateHyperParameterTuningJob', - 'sagemaker:CreateTrainingJob', - ], - resources: [ - `arn:${partition}:sagemaker:${region}:${this.account}:training-job/*`, - `arn:${partition}:sagemaker:${region}:${this.account}:hyper-parameter-tuning-job/*` - ], - conditions: { - Null: { - 'sagemaker:VpcSecurityGroupIds': 'true', - 'sagemaker:VpcSubnets': 'true', - ...invertedBooleanConditions(requestTagsConditions), - }, - ...requestSystemTagEqualsConditions[SystemTagCondition.NotEquals] - }, - })); - } - - return statements; - }; - - /* - * WARNING: Changing this method will cause any policy statement created by this to be regenerated. This will cause - * any changes to this policy (like dynamic policy updates for app configuration changes) to be lost until the app - * configuration is updated and it updates this policy with the expected values. - */ - const instanceConstraintPolicyStatement = (partition: string, region: string, actionResourcePair: {[key: string]: string}) => { - const [actions, resources] = Object.entries(actionResourcePair).reduce(([actionAccumulator, resourcesAccumulator], [action, resource]) => { - return [[...actionAccumulator, `sagemaker:${action}`], [...resourcesAccumulator, `arn:${partition}:sagemaker:${region}:${this.account}:${resource}/*`]]; - }, [[] as string[], [] as string[]]); - - return [ - new PolicyStatement({ - effect: Effect.ALLOW, - actions, - resources, - conditions: { - 'ForAnyValue:StringEquals': { - 'sagemaker:InstanceTypes': [], - }, - }, - }), - ]; - }; - - const notebookPolicy = new ManagedPolicy(this, 'mlspace-notebook-policy', { - statements: notebookPolicyStatements(this.partition, Aws.REGION), - description: 'Enables general MLSpace actions in notebooks and across the entire application.' - }); - const notebookManagedPolicies: IManagedPolicy[] = [notebookPolicy]; - - if (this.mlspaceKmsInstanceConditionsPolicy) { - notebookManagedPolicies.push(this.mlspaceKmsInstanceConditionsPolicy); - } - - if (props.mlspaceConfig.MANAGE_IAM_ROLES) { - if (props.mlspaceConfig.ENDPOINT_CONFIG_INSTANCE_CONSTRAINT_POLICY_ARN) { - this.mlspaceEndpointConfigInstanceConstraintPolicy = ManagedPolicy.fromManagedPolicyArn(this, 'mlspace-endpoint-config-instance-constraint', props.mlspaceConfig.ENDPOINT_CONFIG_INSTANCE_CONSTRAINT_POLICY_ARN); - } else { - /* - * WARNING: @see instanceConstraintPolicyStatement - */ - this.mlspaceEndpointConfigInstanceConstraintPolicy = new ManagedPolicy(this, 'mlspace-endpoint-config-instance-constraint', { - managedPolicyName: `${props.mlspaceConfig.IAM_RESOURCE_PREFIX}-endpoint-instance-constraint`, - statements: instanceConstraintPolicyStatement(this.partition, Aws.REGION, {CreateEndpointConfig: 'endpoint-config'}) - }); - } - - if (props.mlspaceConfig.JOB_INSTANCE_CONSTRAINT_POLICY_ARN) { - this.mlspaceJobInstanceConstraintPolicy = ManagedPolicy.fromManagedPolicyArn(this, 'mlspace-job-instance-constraint', props.mlspaceConfig.JOB_INSTANCE_CONSTRAINT_POLICY_ARN); - } else { - /* - * WARNING: @see instanceConstraintPolicyStatement - */ - this.mlspaceJobInstanceConstraintPolicy = new ManagedPolicy(this, 'mlspace-job-instance-constraint', { - managedPolicyName: `${props.mlspaceConfig.IAM_RESOURCE_PREFIX}-job-instance-constraint`, - statements: [ - instanceConstraintPolicyStatement(this.partition, Aws.REGION, { - CreateHyperParameterTuningJob: 'hyper-parameter-tuning-job', - CreateTrainingJob: 'training-job' - })[0], - instanceConstraintPolicyStatement(this.partition, Aws.REGION, {CreateTransformJob: 'transform-job'})[0] - ] - }); - } - - notebookManagedPolicies.push(this.mlspaceEndpointConfigInstanceConstraintPolicy, this.mlspaceJobInstanceConstraintPolicy); - } - - // If roles are manually created use the existing role - if (props.mlspaceConfig.NOTEBOOK_ROLE_ARN) { - this.mlSpaceNotebookRole = Role.fromRoleArn( - this, - mlSpaceNotebookRoleName, - props.mlspaceConfig.NOTEBOOK_ROLE_ARN - ); - } else { - // If roles are managed by CDK, create the notebook role - - // Translate Permissions Principles - const notebookPolicyAllowPrinciples = props.enableTranslate - ? new CompositePrincipal( - new ServicePrincipal('sagemaker.amazonaws.com'), - new ServicePrincipal('translate.amazonaws.com') - ) - : new ServicePrincipal('sagemaker.amazonaws.com'); - - this.mlSpaceNotebookRole = new Role(this, mlSpaceNotebookRoleName, { - roleName: mlSpaceNotebookRoleName, - assumedBy: notebookPolicyAllowPrinciples, - managedPolicies: notebookManagedPolicies, - description: - 'Allows SageMaker Notebooks within ML Space to access necessary AWS services (S3, SQS, DynamoDB, ...)', - }); - } - - /** - * PERMISSIONS BOUNDARY SECTION - * If roles are dynamically managed, applies the permissions boundary that limits maximum permissions - */ - if (props.mlspaceConfig.MANAGE_IAM_ROLES) { - // If role was manually created - if (props.mlspaceConfig.PERMISSIONS_BOUNDARY_POLICY_NAME) { - this.mlSpacePermissionsBoundary = ManagedPolicy.fromManagedPolicyName( - this, - 'mlspace-existing-boundary', - props.mlspaceConfig.PERMISSIONS_BOUNDARY_POLICY_NAME - ); - } else { - // If roles are dynamically managed - // Translate Permissions Principles - const passRolePrincipals = props.enableTranslate - ? ['sagemaker.amazonaws.com', 'translate.amazonaws.com'] - : 'sagemaker.amazonaws.com'; - - // Permission boundary policy that ensures IAM policies never exceed these permissions - this.mlSpacePermissionsBoundary = new ManagedPolicy( - this, - 'mlspace-project-user-role-boundary', - { - managedPolicyName: 'mlspace-project-user-permission-boundary', - statements: [ - new PolicyStatement({ - effect: Effect.ALLOW, - actions: [ - 's3:DeleteObject', - 's3:GetObject', - 's3:PutObject', - 's3:PutObjectTagging', - ], - resources: [ - `arn:*:s3:::${props.dataBucketName}/project/*`, - `arn:*:s3:::${props.dataBucketName}/group/*`, - `arn:*:s3:::${props.dataBucketName}/global/*`, - `arn:*:s3:::${props.dataBucketName}/private/*`, - ], - }), - - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['s3:GetObject', 's3:PutObject', 's3:PutObjectTagging'], - resources: [`arn:*:s3:::${props.dataBucketName}/index/*`], - }), - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['s3:ListBucket'], - resources: [`arn:*:s3:::${props.dataBucketName}`], - conditions: { - StringLike: { - 's3:prefix': ['global/*', 'index/*', 'private/*', 'project/*', 'group/*'], - }, - }, - }), - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['s3:GetBucketLocation'], - resources: [`arn:*:s3:::${props.dataBucketName}`], - }), - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['iam:PassRole'], - /* - * When SageMaker resources are created through a notebook (Training jobs, - * Transform jobs, HPO jobs, Models, etc) the API calls will use the role - * associated with the user making the request. As this is a permissions - * boundary being applied to dynamically created roles we can't scope - * this to an individual role rather we scope it to roles with the MLSpace - * prefix. - * - * Additional details are avaiable in the documentation: - * https://docs.aws.amazon.com/sagemaker/latest/dg/sagemaker-roles.html#sagemaker-roles-pass-role - * - * This needs to match the IAM_RESOURCE_PREFIX prefix in iam_manager.py - */ - resources: [`arn:*:iam::${this.account}:role/${props.mlspaceConfig.IAM_RESOURCE_PREFIX}*`], - conditions: { - StringEquals: { - 'iam:PassedToService': passRolePrincipals, - }, - }, - }), - ...notebookPolicyStatements('*', '*', true), - ], - } - ); - } - } - - /** - * APP POLICY & ROLE SECTION - * - * This role is the summation of the following policies: - * - Notebook policy - base permissions shared between the notebook role and app role - * - App policy - additional permissions for the app that extend the notebook policy permissions - * - App Deny Services policy - Denies access to disabled services - * - service-role/AWSLambdaVPCAccessExecutionRole - AWS managed role - */ - const mlSpaceAppRoleName = 'mlspace-app-role'; - const appPolicyAndStatements = (partition: string, region: string, roleName: string) => { - const statements = [ - // General Permissions - Additional KMS permission unique to the app role to retire grants - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['kms:RetireGrant'], - resources: [props.encryptionKey.keyArn], - }), - /** - * General Permissions - * Additional permissions necessary to display logs for the various SageMaker - * resources, EMR clusters, and other entities via the logs lambda. - */ - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['logs:FilterLogEvents'], - resources: ['*'], - }), - // General Permissions - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['iam:PassRole', 'iam:ListRoleTags'], - resources: [ - /** - * When this stack is folded back into the IAM stack we need to switch - * these to be dynamic. At the moment though we have a weird dependency - * order with the two stacks being split. - */ - `arn:${this.partition}:iam::${this.account}:role/EMR_DefaultRole`, - `arn:${this.partition}:iam::${this.account}:role/EMR_EC2_DefaultRole`, - `arn:${this.partition}:iam::${this.account}:role/${roleName}`, - ], - }), - // General Permissions - DynamoDB permissions - new PolicyStatement({ - effect: Effect.ALLOW, - actions: [ - 'dynamodb:GetItem', - 'dynamodb:PutItem', - 'dynamodb:Scan', - 'dynamodb:DeleteItem', - 'dynamodb:Query', - 'dynamodb:UpdateItem', - ], - resources: [ - `arn:${this.partition}:dynamodb:${Aws.REGION}:${this.account}:table/mlspace-*`, - ], - }), - /** - * EMR Permissions - * EMR specific permission to allow communication between notebook instances and - * EMR clusters - */ - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['ec2:AuthorizeSecurityGroupIngress'], - resources: [`${ec2ArnBase}:security-group/*`], - }), - /** - * Various Permissions - * - * Additional EC2 permissions required for the application role. Most of the - * permissions are covered in the attached mlspace-notebook-policy policy. This - * block includes some additional permissions are required for EMR functionality as - * well as generic metadata operations needed by notebooks. - */ - new PolicyStatement({ - effect: Effect.ALLOW, - actions: [ - // EMR Permissions - 'ec2:DescribeInstances', - 'ec2:DescribeRouteTables', - /** - * General Permissions - * EC2 permission necessary to list available instance types for endpoints, - * notebooks, training jobs, and others - */ - 'ec2:DescribeInstanceTypeOfferings', - 'ec2:DescribeInstanceTypes', - /** - * Notebook Permissions - * Additional EC2 permission needed to start/stop/delete SageMaker Notebook - * Instances (see StartNotebookInstance section for additional details - * https://docs.aws.amazon.com/sagemaker/latest/dg/api-permissions-reference.html) - */ - 'ec2:DescribeVpcEndpoints', - ], - resources: ['*'], - }), - /** - * General Permissions - * S3 permissions related to CRUD operations for datasets, as well as SageMaker job - * input/output, reading of static web app content, notebook and emr cluster - * configuration and sample notebooks/data. - */ - new PolicyStatement({ - effect: Effect.ALLOW, - actions: [ - 's3:List*', - 's3:Get*', - 's3:PutObject', - 's3:PutObjectTagging', - 's3:DeleteObject', - 's3:PutBucketNotification', - ], - resources: [`arn:${this.partition}:s3:::*`], - }), - /** - * Notebook Permissions - * Additional SageMaker permissions that the application role uses that the default - * notebook policy does not support - primarily the ability to create Notebook - * Instances and actions related to those notebooks. - */ - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['sagemaker:CreateNotebookInstance'], - resources: [ - `arn:${this.partition}:sagemaker:${Aws.REGION}:${this.account}:notebook-instance/*`, - ], - conditions: { - StringEquals: { - 'sagemaker:DirectInternetAccess': 'Disabled', - 'sagemaker:RootAccess': 'Disabled', - }, - Null: { - 'sagemaker:VpcSecurityGroupIds': 'false', - 'sagemaker:VpcSubnets': 'false', - ...requestTagsConditions, - }, - }, - }), - // Notebook Permissions - new PolicyStatement({ - effect: Effect.ALLOW, - actions: [ - 'sagemaker:CreateNotebookInstanceLifecycleConfig', - 'sagemaker:UpdateNotebookInstanceLifecycleConfig', - 'sagemaker:DeleteNotebookInstanceLifecycleConfig', - 'sagemaker:DescribeNotebookInstanceLifecycleConfig', - ], - resources: [ - `arn:${this.partition}:sagemaker:${Aws.REGION}:${this.account}:notebook-instance-lifecycle-config/*`, - ], - }), - // Notebook Permissions - new PolicyStatement({ - effect: Effect.ALLOW, - actions: [ - 'sagemaker:DeleteNotebookInstance', - 'sagemaker:DescribeNotebookInstance', - 'sagemaker:StartNotebookInstance', - 'sagemaker:StopNotebookInstance', - 'sagemaker:UpdateNotebookInstance', - ], - resources: [ - `arn:${this.partition}:sagemaker:${Aws.REGION}:${this.account}:notebook-instance/*`, - ], - conditions: { - Null: { - ...resourceTagsConditions, - }, - }, - }), - /** - * Notebook Permissions - * Must be separate from above due to resource tag conditions not applying - */ - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['sagemaker:CreatePresignedNotebookInstanceUrl'], - resources: [ - `arn:${this.partition}:sagemaker:${Aws.REGION}:${this.account}:notebook-instance/*`, - ], - }), - // Notebook Permissions - Not bound by identifier - new PolicyStatement({ - effect: Effect.ALLOW, - actions: [ - 'sagemaker:ListNotebookInstanceLifecycleConfigs', - 'sagemaker:ListNotebookInstances', - ], - // SageMaker list actions that are not bound by resource identifier - resources: ['*'], - }), - // General Permissions - Allows the invocation of MLSpace lambda functions - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['lambda:InvokeFunction'], - resources: [ - `arn:${this.partition}:lambda:${Aws.REGION}:${this.account}:function:mls-lambda-*`, - ], - }), - /** - * EMR Permissions - * Policy actions required for launching, terminating, and managing EMR clusters - * within MLSpace - */ - new PolicyStatement({ - effect: Effect.ALLOW, - actions: [ - 'elasticmapreduce:RunJobFlow', - 'elasticmapreduce:ListClusters', - 'elasticmapreduce:ListReleaseLabels' - ], - resources: ['*'], - }), - // EMR Permissions - new PolicyStatement({ - effect: Effect.ALLOW, - actions: [ - 'elasticmapreduce:DescribeCluster', - 'elasticmapreduce:ListInstances', - 'elasticmapreduce:AddTags', - 'elasticmapreduce:TerminateJobFlows', - 'elasticmapreduce:SetTerminationProtection', - ], - resources: [ - `arn:${this.partition}:elasticmapreduce:${Aws.REGION}:${this.account}:cluster/*`, - ], - }), - ]; - - if (props.mlspaceConfig.MANAGE_IAM_ROLES && this.mlSpacePermissionsBoundary) { - /** - * General Permissions - Dynamic Roles IAM Permissions - * All of the following statements are required when using managed IAM roles - */ - statements.push( - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['iam:CreateRole'], - resources: [ - `arn:${this.partition}:iam::${this.account}:role/${props.mlspaceConfig.IAM_RESOURCE_PREFIX}*`, - ], - conditions: { - StringEqualsIgnoreCase: { - 'iam:ResourceTag/system': props.mlspaceConfig.SYSTEM_TAG, - }, - StringEquals: { - 'iam:PermissionsBoundary': - this.mlSpacePermissionsBoundary.managedPolicyArn, - }, - }, - }), - new PolicyStatement({ - effect: Effect.ALLOW, - actions: [ - 'iam:AttachRolePolicy', - 'iam:DetachRolePolicy', - 'iam:DeleteRole', - 'iam:DeleteRolePolicy', - 'iam:PutRolePolicy', - ], - // This needs to match the IAM_RESOURCE_PREFIX prefix in iam_manager.py - resources: [ - `arn:${this.partition}:iam::${this.account}:role/${props.mlspaceConfig.IAM_RESOURCE_PREFIX}*`, - ], - conditions: { - StringEqualsIgnoreCase: { - 'iam:ResourceTag/system': props.mlspaceConfig.SYSTEM_TAG, - }, - }, - }), - // Only certain policies should be allowed to attach to the notebook and app roles - new PolicyStatement({ - effect: Effect.ALLOW, - actions: [ - 'iam:AttachRolePolicy', - 'iam:DetachRolePolicy', - ], - // This needs to match the IAM_RESOURCE_PREFIX prefix in iam_manager.py - resources: [ - // This is needed for the deny services policy to be attached to the notebook and app roles - `arn:${this.partition}:iam::${this.account}:role/${mlSpaceAppRoleName}`, - `arn:${this.partition}:iam::${this.account}:role/${mlSpaceNotebookRoleName}`, - `arn:${this.partition}:iam::${this.account}:role/${props.mlspaceConfig.IAM_RESOURCE_PREFIX}*`, - ], - conditions: { - StringEqualsIgnoreCase: { - // Only allow dynamic attachment for the deny services policy - 'iam:PolicyARN': `arn:${this.partition}:iam::${this.account}:policy/${props.mlspaceConfig.IAM_RESOURCE_PREFIX}-app-denied-services`, - 'iam:ResourceTag/system': props.mlspaceConfig.SYSTEM_TAG, - }, - }, - }), - new PolicyStatement({ - effect: Effect.ALLOW, - actions: [ - 'iam:ListRoles', - 'iam:ListEntitiesForPolicy', - 'iam:ListPolicyVersions', - 'iam:ListAttachedRolePolicies', - 'iam:GetRole', - 'iam:GetPolicy', - 'iam:ListRoleTags', - ], - resources: ['*'], - }), - new PolicyStatement({ - effect: Effect.ALLOW, - actions: [ - 'iam:CreatePolicy', - 'iam:CreatePolicyVersion', - 'iam:DeletePolicy', - 'iam:DeletePolicyVersion', - 'iam:TagPolicy', - ], - // This needs to match the IAM_RESOURCE_PREFIX prefix in iam_manager.py - resources: [ - `arn:${this.partition}:iam::${this.account}:policy/${props.mlspaceConfig.IAM_RESOURCE_PREFIX}*`, - ], - }), - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['iam:SimulatePrincipalPolicy', 'iam:TagRole', 'iam:AttachRolePolicy'], - resources: [ - `arn:${this.partition}:iam::${this.account}:role/${props.mlspaceConfig.IAM_RESOURCE_PREFIX}*`, - ], - }), - /* - * When SageMaker resources are created through the MLSpace Webapp (Training jobs, - * Transform jobs, HPO jobs, Models, etc) the API calls will use the dynamic role - * Transform with the user making the request. The "iam:passRole" action is - * required in order to run these resources as the role associated with the user. - * Additional details are available in the documentation: - * https://docs.aws.amazon.com/sagemaker/latest/dg/sagemaker-roles.html#sagemaker-roles-pass-role - * - * This needs to match the IAM_RESOURCE_PREFIX prefix in iam_manager.py - */ - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['iam:PassRole'], - resources: [ - `arn:${this.partition}:iam::${this.account}:role/${props.mlspaceConfig.IAM_RESOURCE_PREFIX}*`, - ], - conditions: { - StringEquals: { - 'iam:PassedToService': 'sagemaker.amazonaws.com', - }, - }, - }) - ); - } - - if (props.enableTranslate) { - statements.push( - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['iam:PassRole'], - // We don't *currently* run these jobs using the user IAM roles so we can - // specify a specific role here - resources: [ - `arn:${this.partition}:iam::${this.account}:role/${roleName}`, - ], - conditions: { - StringEquals: { - 'iam:PassedToService': 'translate.amazonaws.com', - }, - }, - }) - ); - } - return statements; - }; - - if (props.mlspaceConfig.APP_ROLE_ARN) { - this.mlSpaceAppRole = Role.fromRoleArn(this, 'mlspace-app-role', props.mlspaceConfig.APP_ROLE_ARN); - } else { - // ML Space Application role - - - const appPolicy = new ManagedPolicy(this, 'mlspace-app-policy', { - statements: appPolicyAndStatements(this.partition, Aws.REGION, mlSpaceAppRoleName) - }); - - const appPolicyAllowPrinciples = props.enableTranslate - ? new CompositePrincipal( - new ServicePrincipal('lambda.amazonaws.com'), - new ServicePrincipal('translate.amazonaws.com') - ) - : new ServicePrincipal('lambda.amazonaws.com'); - this.mlSpaceAppRole = new Role(this, 'mlspace-app-role', { - roleName: mlSpaceAppRoleName, - assumedBy: appPolicyAllowPrinciples, - managedPolicies: [ - appPolicy, - ...notebookManagedPolicies, - ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaVPCAccessExecutionRole') - ], - description: - 'Allows ML Space Application to access necessary AWS services (S3, SQS, DynamoDB, ...)', - }); - } - - /** - * System Permissions Role - * This role will provision permissions to the MLSpace system to perform actions independently of what - * users are capable of doing. Ex: a service like EMR may be disabled, but this role will allow the system - * to terminate EMR clusters even though users can't perform any EMR actions. - * These actions include cleaning up resources for deleted projects and suspended users. - */ - if (props.mlspaceConfig.SYSTEM_ROLE_ARN) { - this.mlSpaceSystemRole = Role.fromRoleArn(this, mlspaceSystemRoleName, props.mlspaceConfig.SYSTEM_ROLE_ARN); - } else { - const systemPolicy = new ManagedPolicy(this, 'mlspace-system-policy', { - statements: appPolicyAndStatements(this.partition, Aws.REGION, mlspaceSystemRoleName), - }); - const systemPolicyAllowPrinciples = props.enableTranslate - ? new CompositePrincipal( - new ServicePrincipal('lambda.amazonaws.com'), - new ServicePrincipal('translate.amazonaws.com') - ) - : new ServicePrincipal('lambda.amazonaws.com'); - this.mlSpaceSystemRole = new Role(this, mlspaceSystemRoleName, { - roleName: mlspaceSystemRoleName, - assumedBy: systemPolicyAllowPrinciples, - managedPolicies: [ - systemPolicy, - notebookPolicy, - ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaVPCAccessExecutionRole') - ], - description: - 'Allows ML Space System to access necessary AWS services (S3, DynamoDB, Sagemaker services, ...)', - }); - } - - /** - * Provides the API Gateway S3 proxy access to the statically hosted website files - * See: - * - /README.md for "S3_READER_ROLE_ARN" - * - /frontend/docs/admin-guide/install.html#s3-reader-role - */ - if (props.mlspaceConfig.S3_READER_ROLE_ARN) { - this.s3ReaderRole = Role.fromRoleArn( - this, - 'mlspace-s3-reader-role', - props.mlspaceConfig.S3_READER_ROLE_ARN - ); - } else { - const s3WebsiteReadOnlyPolicy = new ManagedPolicy(this, 'mlspace-website-read-policy', { - statements: [ - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['s3:GetObject'], - resources: [`arn:${this.partition}:s3:::${props.websiteBucketName}/*`], - }), - ], - }); - this.s3ReaderRole = new Role(this, 'mlspace-s3-reader-role', { - assumedBy: new ServicePrincipal('apigateway.amazonaws.com'), - roleName: 'mlspace-s3-reader-Role', - managedPolicies: [s3WebsiteReadOnlyPolicy], - description: 'Allows API gateway to proxy static website assets', - }); - } - - /** - * Enables logging for S3 and API Gateway - * See - * - /README.md for "ENABLE_ACCESS_LOGGING" - */ - if (props.mlspaceConfig.ENABLE_ACCESS_LOGGING) { - if (props.mlspaceConfig.APIGATEWAY_CLOUDWATCH_ROLE_ARN) { - new CfnAccount(this, 'mlspace-cwl-api-gateway-account', { - cloudWatchRoleArn: props.mlspaceConfig.APIGATEWAY_CLOUDWATCH_ROLE_ARN, - }); - } else { - // Create CW Role - const apiGatewayCloudWatchRole = new Role(this, 'mlspace-cwl-role', { - assumedBy: new ServicePrincipal('apigateway.amazonaws.com'), - managedPolicies: [ - ManagedPolicy.fromAwsManagedPolicyName( - 'service-role/AmazonAPIGatewayPushToCloudWatchLogs' - ), - ], - }); - - new CfnAccount(this, 'mlspace-cwl-api-gateway-account', { - cloudWatchRoleArn: apiGatewayCloudWatchRole.roleArn, - }); - } - } - - /** - * EMR Permissions Role - * See: - * - /README.md for "EMR_DEFAULT_ROLE_ARN" - * - /frontend/docs/admin-guide/install.html#emr-roles - */ - if (props.mlspaceConfig.EMR_DEFAULT_ROLE_ARN) { - const existingEmrServiceRole = Role.fromRoleArn( - this, - 'mlspace-emr_defaultrole', - props.mlspaceConfig.EMR_DEFAULT_ROLE_ARN - ); - this.emrServiceRoleName = existingEmrServiceRole.roleName; - } else { - const serviceRoleName = 'EMR_DefaultRole'; - new Role(this, 'mlspace-emr_defaultrole', { - assumedBy: new ServicePrincipal('elasticmapreduce.amazonaws.com'), - roleName: serviceRoleName, - managedPolicies: [ - ManagedPolicy.fromAwsManagedPolicyName( - 'service-role/AmazonElasticMapReduceRole' - ), - ], - description: 'Provides needed permissions for running an EMR Cluster.', - }); - this.emrServiceRoleName = serviceRoleName; - } - - /** - * EMR Permissions Role - * See - * - /README.md for "EMR_EC2_INSTANCE_ROLE_ARN" - * - /frontend/docs/admin-guide/install.html#emr-roles - */ - if (props.mlspaceConfig.EMR_EC2_INSTANCE_ROLE_ARN) { - const existingEmrEC2Role = Role.fromRoleArn( - this, - 'mlspace-emr_ec2_defaultrole', - props.mlspaceConfig.EMR_EC2_INSTANCE_ROLE_ARN - ); - this.emrEC2RoleName = existingEmrEC2Role.roleName; - } else { - const emrEC2RoleName = 'EMR_EC2_DefaultRole'; - const ec2EMRRole = new Role(this, 'mlspace-emr_ec2_defaultrole', { - assumedBy: new ServicePrincipal('ec2.amazonaws.com'), - roleName: emrEC2RoleName, - managedPolicies: [ - ManagedPolicy.fromAwsManagedPolicyName( - 'service-role/AmazonElasticMapReduceforEC2Role' - ), - ], - description: 'Provides needed permissions for running an EMR Cluster.', - }); - - new CfnInstanceProfile(this, 'mlspace-emr-instance-profile', { - roles: [ec2EMRRole.roleName], - instanceProfileName: ec2EMRRole.roleName, - }); - this.emrEC2RoleName = ec2EMRRole.roleName; - } } } diff --git a/lib/stacks/infra/core.ts b/lib/stacks/infra/core.ts index b1988650..fe17ab58 100644 --- a/lib/stacks/infra/core.ts +++ b/lib/stacks/infra/core.ts @@ -14,33 +14,12 @@ limitations under the License. */ -import { App, Aspects, Duration, RemovalPolicy, Stack, StackProps } from 'aws-cdk-lib'; -import { Trail } from 'aws-cdk-lib/aws-cloudtrail'; -import { AttributeType, BillingMode, ProjectionType, Table, TableEncryption } from 'aws-cdk-lib/aws-dynamodb'; +import { App, Stack, StackProps } from 'aws-cdk-lib'; import { ISecurityGroup, IVpc } from 'aws-cdk-lib/aws-ec2'; -import { CfnSecurityConfiguration } from 'aws-cdk-lib/aws-emr'; -import { Rule, Schedule } from 'aws-cdk-lib/aws-events'; -import { LambdaFunction } from 'aws-cdk-lib/aws-events-targets'; -import { Effect, IManagedPolicy, IRole, PolicyStatement, Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam'; +import { IManagedPolicy, IRole } from 'aws-cdk-lib/aws-iam'; import { IKey } from 'aws-cdk-lib/aws-kms'; -import { Code, Function } from 'aws-cdk-lib/aws-lambda'; -import { - Bucket, - BucketAccessControl, - BucketEncryption, - EventType, - HttpMethods, - ObjectOwnership, -} from 'aws-cdk-lib/aws-s3'; -import { BucketDeployment, Source } from 'aws-cdk-lib/aws-s3-deployment'; -import { LambdaDestination } from 'aws-cdk-lib/aws-s3-notifications'; -import { Subscription, SubscriptionProtocol, Topic } from 'aws-cdk-lib/aws-sns'; -import { StringParameter } from 'aws-cdk-lib/aws-ssm'; -import { ADCLambdaCABundleAspect } from '../../utils/adcCertBundleAspect'; -import { createLambdaLayer } from '../../utils/layers'; import { MLSpaceConfig } from '../../utils/configTypes'; -import { AwsCustomResource, PhysicalResourceId } from 'aws-cdk-lib/custom-resources'; -import { generateAppConfig } from '../../utils/initialAppConfig'; +import { CoreConstruct } from '../../constructs/infra/coreConstruct'; export type CoreStackProps = { readonly lambdaSourcePath: string; @@ -70,663 +49,8 @@ export class CoreStack extends Stack { ...props, }); - const logsServicePrincipal = new ServicePrincipal('logs.amazonaws.com'); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const coreConstruct = new CoreConstruct(this, name + 'Resources', props); - if (props.mlspaceConfig.NOTIFICATION_DISTRO) { - new Subscription(this, 'Subscription', { - topic: new Topic(this, 'mlspace-topic'), - endpoint: props.notificationDistro, - protocol: SubscriptionProtocol.EMAIL, - }); - } - - let accessLogBucket = undefined; - if (props.mlspaceConfig.ENABLE_ACCESS_LOGGING) { - accessLogBucket = new Bucket(this, 'mlspace-access-logs-bucket', { - bucketName: props.accessLogsBucketName, - encryption: BucketEncryption.S3_MANAGED, - publicReadAccess: false, - versioned: true, - enforceSSL: true, - objectOwnership: ObjectOwnership.BUCKET_OWNER_PREFERRED, - }); - } - - // Config Bucket (holds emr config/notebook parameters) - const configBucket = new Bucket(this, 'mlspace-config-bucket', { - bucketName: props.configBucketName, - ...props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN ? {encryptionKey: props.encryptionKey} : {encryption: BucketEncryption.S3_MANAGED}, - removalPolicy: RemovalPolicy.DESTROY, - versioned: true, - enforceSSL: true, - serverAccessLogsBucket: accessLogBucket, - serverAccessLogsPrefix: accessLogBucket ? 'mlspace-config-bucket' : undefined, - }); - - // Publish notebook config - // This is a pretty ugly hack but at the moment you can't use json data with - // cross stack parameters - https://github.com/aws/aws-cdk/issues/21503 - const mlspaceNotebookRole = new StringParameter(this, 'dynamic-config-notebook-role', { - parameterName: 'notebook-param-notebook-role-arn', - stringValue: props.mlSpaceNotebookRole.roleArn, - }); - const secGroupId = new StringParameter(this, 'dynamic-config-security-group', { - parameterName: 'notebook-param-vpc-security-group', - stringValue: props.mlSpaceDefaultSecurityGroupId, - }); - const subnetIds = new StringParameter(this, 'dynamic-config-subnets', { - parameterName: 'notebook-param-subnet-ids', - stringValue: props.mlSpaceVPC.isolatedSubnets - .concat(props.mlSpaceVPC.privateSubnets) - .map((s) => s.subnetId) - .join(','), - }); - - const kmsKeyId = new StringParameter(this, 'dynamic-config-kms-id', { - parameterName: 'notebook-param-kms-id', - stringValue: props.encryptionKey.keyId, - }); - const notebookParams = { - pSMSKMSKeyId: kmsKeyId.stringValue, - pSMSRoleARN: mlspaceNotebookRole.stringValue, - pSMSSecurityGroupId: [secGroupId.stringValue], - pSMSSubnetIds: subnetIds.stringValue, - pSMSLifecycleConfigName: props.mlspaceConfig.MLSPACE_LIFECYCLE_CONFIG_NAME, - pSMSDataBucketName: props.dataBucketName, - }; - - const configDeployment = new BucketDeployment(this, 'MLSpaceConfigDeployment', { - sources: [ - Source.jsonData(props.mlspaceConfig.NOTEBOOK_PARAMETERS_FILE_NAME, notebookParams), - Source.asset('./lib/resources/config'), - ], - destinationBucket: configBucket, - prune: true, - role: props.mlspaceConfig.BUCKET_DEPLOYMENT_ROLE_ARN - ? Role.fromRoleArn(this, 'mlspace-config-deploy-role', props.mlspaceConfig.BUCKET_DEPLOYMENT_ROLE_ARN, { - mutable: false, - }) - : undefined, - }); - - // Static Site - const websiteBucket = new Bucket(this, 'mlspace-website-bucket', { - bucketName: props.websiteBucketName, - accessControl: BucketAccessControl.BUCKET_OWNER_FULL_CONTROL, - encryption: BucketEncryption.S3_MANAGED, - removalPolicy: RemovalPolicy.DESTROY, - enforceSSL: true, - websiteErrorDocument: 'index.html', - websiteIndexDocument: 'index.html', - cors: [ - { - allowedMethods: [ - HttpMethods.GET, - HttpMethods.POST, - HttpMethods.PUT, - HttpMethods.DELETE, - ], - allowedOrigins: ['*'], - exposedHeaders: [ - 'x-amz-server-side-encryption', - 'x-amz-request-id', - 'x-amz-id-2', - ], - allowedHeaders: ['*'], - }, - ], - serverAccessLogsBucket: accessLogBucket, - serverAccessLogsPrefix: accessLogBucket ? 'mlspace-website-bucket' : undefined, - }); - websiteBucket.grantRead(new ServicePrincipal('apigateway.amazonaws.com')); - - // Data Bucket - const dataBucket = new Bucket(this, 'mlspace-data-bucket', { - bucketName: props.dataBucketName, - ...props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN ? {encryptionKey: props.encryptionKey} : {encryption: BucketEncryption.S3_MANAGED}, - removalPolicy: RemovalPolicy.DESTROY, - versioned: true, - enforceSSL: true, - cors: [ - { - allowedMethods: [HttpMethods.GET, HttpMethods.POST], - allowedHeaders: ['*'], - allowedOrigins: ['*'], - exposedHeaders: ['Access-Control-Allow-Origin'], - }, - ], - serverAccessLogsBucket: accessLogBucket, - serverAccessLogsPrefix: accessLogBucket ? 'mlspace-data-bucket' : undefined, - }); - - const exampleDataDeployment = new BucketDeployment(this, 'MLSpaceExampleDataDeployment', { - sources: [ - Source.jsonData(props.mlspaceConfig.NOTEBOOK_PARAMETERS_FILE_NAME, notebookParams), - Source.asset('lib/resources/sagemaker/global/'), - ], - destinationKeyPrefix: 'global-read-only/resources/', - destinationBucket: dataBucket, - prune: false, - role: props.mlspaceConfig.BUCKET_DEPLOYMENT_ROLE_ARN - ? Role.fromRoleArn( - this, - 'mlspace-example-data-deploy-role', - props.mlspaceConfig.BUCKET_DEPLOYMENT_ROLE_ARN, - { - mutable: false, - } - ) - : undefined, - }); - - const commonLambdaLayer = createLambdaLayer(this, 'common'); - - // Save common layer arn to SSM to avoid issue related to cross stack references - new StringParameter(this, 'VersionArn', { - parameterName: props.mlspaceConfig.COMMON_LAYER_ARN_PARAM, - stringValue: commonLambdaLayer.layerVersion.layerVersionArn, - }); - - // Lambda for populating the initial allowed instances in the app config - const appConfigLambda = new Function(this, 'appConfigDeployment', { - functionName: 'mls-lambda-app-config-deployment', - description: - 'Populates the initial app config', - runtime: props.mlspaceConfig.LAMBDA_RUNTIME, - architecture: props.mlspaceConfig.LAMBDA_ARCHITECTURE, - handler: 'ml_space_lambda.initial_app_config.lambda_function.lambda_handler', - code: Code.fromAsset(props.lambdaSourcePath), - timeout: Duration.seconds(30), - role: props.mlSpaceAppRole, - environment: { - APP_CONFIG_TABLE: props.mlspaceConfig.APP_CONFIGURATION_TABLE_NAME, - SYSTEM_TAG: props.mlspaceConfig.SYSTEM_TAG, - MANAGE_IAM_ROLES: props.mlspaceConfig.MANAGE_IAM_ROLES ? 'True' : '', - ENDPOINT_CONFIG_INSTANCE_CONSTRAINT_POLICY_ARN: props.mlspaceEndpointConfigInstanceConstraintPolicy?.managedPolicyArn || '', - JOB_INSTANCE_CONSTRAINT_POLICY_ARN: props.mlspaceJobInstanceConstraintPolicy?.managedPolicyArn || '', - }, - layers: [commonLambdaLayer.layerVersion], - vpc: props.mlSpaceVPC, - securityGroups: props.lambdaSecurityGroups, - }); - - // Lambda for cleaning up permissions that have been deprecated from the application - const permissionCleanupLambda = new Function(this, 'permissionCleanupLambda', { - functionName: 'mls-lambda-permission-cleanup', - description: - 'Clears out deprecated permissions from tables', - runtime: props.mlspaceConfig.LAMBDA_RUNTIME, - architecture: props.mlspaceConfig.LAMBDA_ARCHITECTURE, - handler: 'ml_space_lambda.cleanup_deprecated_permissions.lambda_function.lambda_handler', - code: Code.fromAsset(props.lambdaSourcePath), - timeout: Duration.seconds(30), - role: props.mlSpaceAppRole, - layers: [commonLambdaLayer.layerVersion], - vpc: props.mlSpaceVPC, - securityGroups: props.lambdaSecurityGroups, - }); - - const dynamicRolesAttachPoliciesOnDeployLambda = new Function(this, 'drAttachPoliciesOnDeployLambda', { - functionName: 'mls-lambda-dr-attach-policies-on-deploy', - description: 'Attaches policies from notebook role to all dynamic user roles.', - runtime: props.mlspaceConfig.LAMBDA_RUNTIME, - architecture: props.mlspaceConfig.LAMBDA_ARCHITECTURE, - handler: 'ml_space_lambda.initial_app_config.lambda_function.update_dynamic_roles_with_notebook_policies', - code: Code.fromAsset(props.lambdaSourcePath), - timeout: Duration.seconds(30), - role: props.mlSpaceAppRole, - environment: { - ENDPOINT_CONFIG_INSTANCE_CONSTRAINT_POLICY_ARN: props.mlspaceEndpointConfigInstanceConstraintPolicy?.managedPolicyArn || '', - JOB_INSTANCE_CONSTRAINT_POLICY_ARN: props.mlspaceJobInstanceConstraintPolicy?.managedPolicyArn || '', - KMS_INSTANCE_CONDITIONS_POLICY_ARN: props.mlspaceKmsInstanceConditionsPolicy.managedPolicyArn, - NOTEBOOK_ROLE_NAME: props.mlSpaceNotebookRole.roleName, - SYSTEM_TAG: props.mlspaceConfig.SYSTEM_TAG, - MANAGE_IAM_ROLES: props.mlspaceConfig.MANAGE_IAM_ROLES ? 'True' : '', - }, - layers: [commonLambdaLayer.layerVersion], - vpc: props.mlSpaceVPC, - securityGroups: props.lambdaSecurityGroups, - }); - - // run dynamicRolesAttachPoliciesOnDeployLambda every deploy - new AwsCustomResource(this, 'drAttachPoliciesOnDeploy', { - onCreate: { - service: 'Lambda', - action: 'invoke', - physicalResourceId: PhysicalResourceId.of(`drAttachPoliciesOnDeployLambda-${Date.now()}`), - parameters: { - FunctionName: dynamicRolesAttachPoliciesOnDeployLambda.functionName, - Payload: '{}' - }, - }, - role: props.mlSpaceAppRole - }); - - const updateInstanceKmsConditionsLambda = new Function(this, 'updateInstanceKmsConditionsLambda', { - functionName: 'mls-lambda-instance-kms-conditions', - description: '', - runtime: props.mlspaceConfig.LAMBDA_RUNTIME, - architecture: props.mlspaceConfig.LAMBDA_ARCHITECTURE, - handler: 'ml_space_lambda.utils.lambda_functions.update_instance_kms_key_conditions', - code: Code.fromAsset(props.lambdaSourcePath), - timeout: Duration.seconds(30), - role: props.mlSpaceAppRole, - environment: { - MANAGE_IAM_ROLES: props.mlspaceConfig.MANAGE_IAM_ROLES ? 'True' : '', - KMS_INSTANCE_CONDITIONS_POLICY_ARN: props.mlspaceKmsInstanceConditionsPolicy.managedPolicyArn - }, - layers: [commonLambdaLayer.layerVersion], - vpc: props.mlSpaceVPC, - securityGroups: props.lambdaSecurityGroups, - }); - - // run updateInstanceKmsConditionsLambda every deploy - new AwsCustomResource(this, 'kms-key-constraints', { - onCreate: { - service: 'Lambda', - action: 'invoke', - physicalResourceId: PhysicalResourceId.of(`kmsKeyConstraints-${Date.now()}`), - parameters: { - FunctionName: updateInstanceKmsConditionsLambda.functionName, - Payload: '{}' - }, - }, - role: props.mlSpaceAppRole - }); - - // schedule updateInstanceKmsConditionsLambda to run every day - const updateInstanceKmsConditionsLambdaScheduleRule = new Rule(this, 'updateInstanceKmsConditionsLambdaScheduleRule', { - schedule: Schedule.cron({hour: '2', minute: '45'}) - }); - updateInstanceKmsConditionsLambdaScheduleRule.addTarget(new LambdaFunction(updateInstanceKmsConditionsLambda)); - - const notifierLambdaLayer = createLambdaLayer(this, 'common', 'notifier'); - - const s3NotificationLambda = new Function(this, 's3Notifier', { - functionName: 'mls-lambda-s3-notifier', - description: - 'S3 event notification function to handle ddb actions in response to dataset file actions', - runtime: props.mlspaceConfig.LAMBDA_RUNTIME, - architecture: props.mlspaceConfig.LAMBDA_ARCHITECTURE, - handler: 'ml_space_lambda.s3_event_put_notification.lambda_function.lambda_handler', - code: Code.fromAsset(props.lambdaSourcePath), - timeout: Duration.seconds(5), - role: props.mlSpaceAppRole, - environment: { - DATA_BUCKET: props.dataBucketName, - DATASETS_TABLE: props.mlspaceConfig.DATASETS_TABLE_NAME, - PROJECTS_TABLE: props.mlspaceConfig.PROJECTS_TABLE_NAME, - PROJECT_USERS_TABLE: props.mlspaceConfig.PROJECT_USERS_TABLE_NAME, - USERS_TABLE: props.mlspaceConfig.USERS_TABLE_NAME, - ...props.mlspaceConfig.ADDITIONAL_LAMBDA_ENVIRONMENT_VARS, - }, - layers: [notifierLambdaLayer.layerVersion], - vpc: props.mlSpaceVPC, - securityGroups: props.lambdaSecurityGroups, - }); - - s3NotificationLambda.addPermission('s3Notifier-invoke', { - action: 'lambda:InvokeFunction', - principal: new ServicePrincipal('s3.amazonaws.com'), - sourceAccount: this.account, - sourceArn: dataBucket.bucketArn, - }); - - dataBucket.addEventNotification( - EventType.OBJECT_CREATED, - new LambdaDestination(s3NotificationLambda) - ); - - const terminateResourcesLambda = new Function(this, 'resourceTerminator', { - functionName: 'mls-lambda-resource-terminator', - description: - 'Sweeper function that stops/terminates resources based on scheduled configuration', - runtime: props.mlspaceConfig.LAMBDA_RUNTIME, - architecture: props.mlspaceConfig.LAMBDA_ARCHITECTURE, - handler: 'ml_space_lambda.resource_scheduler.lambda_functions.terminate_resources', - code: Code.fromAsset(props.lambdaSourcePath), - timeout: Duration.minutes(15), - role: props.mlSpaceAppRole, - environment: { - RESOURCE_SCHEDULE_TABLE: props.mlspaceConfig.RESOURCE_SCHEDULE_TABLE_NAME, - ...props.mlspaceConfig.ADDITIONAL_LAMBDA_ENVIRONMENT_VARS, - }, - layers: [commonLambdaLayer.layerVersion], - vpc: props.mlSpaceVPC, - securityGroups: props.lambdaSecurityGroups, - }); - - const ruleName = 'mlspace-rule-terminate-resources'; - new Rule(this, ruleName, { - schedule: Schedule.rate(Duration.minutes(props.mlspaceConfig.RESOURCE_TERMINATION_INTERVAL)), - targets: [new LambdaFunction(terminateResourcesLambda)], - ruleName: ruleName, - }); - - // Logs Bucket - const cwlBucket = new Bucket(this, 'mlspace-logs-bucket', { - bucketName: props.cwlBucketName, - removalPolicy: RemovalPolicy.DESTROY, - ...props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN ? {encryptionKey: props.encryptionKey} : {encryption: BucketEncryption.S3_MANAGED}, - enforceSSL: true, - cors: [ - { - allowedMethods: [HttpMethods.POST], - allowedHeaders: ['*'], - allowedOrigins: ['*'], - }, - ], - serverAccessLogsBucket: accessLogBucket, - serverAccessLogsPrefix: accessLogBucket ? 'mlspace-logs-bucket' : undefined, - }); - cwlBucket.addToResourcePolicy( - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['s3:GetBucketAcl'], - resources: [cwlBucket.bucketArn], - principals: [logsServicePrincipal], - }) - ); - cwlBucket.addToResourcePolicy( - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['s3:PutObject'], - resources: [`${cwlBucket.bucketArn}/cloudwatch/*`], - principals: [logsServicePrincipal], - conditions: { - StringEquals: { - 's3:x-amz-acl': 'bucket-owner-full-control', - }, - }, - }) - ); - - // Cloudtrail setup - if (props.mlspaceConfig.CREATE_MLSPACE_CLOUDTRAIL_TRAIL) { - new Trail(this, 'mlspace-cloudtrail', { - trailName: 'mlspace-cloudtrail', - isMultiRegionTrail: true, - includeGlobalServiceEvents: true, - bucket: cwlBucket, - }); - } - - // Datasets Table - const datasetScopeAttribute = { name: 'scope', type: AttributeType.STRING }; - const datasetNameAttribute = { name: 'name', type: AttributeType.STRING }; - new Table(this, 'mlspace-ddb-datasets', { - tableName: props.mlspaceConfig.DATASETS_TABLE_NAME, - partitionKey: datasetScopeAttribute, - sortKey: datasetNameAttribute, - billingMode: BillingMode.PAY_PER_REQUEST, - encryption: TableEncryption.AWS_MANAGED, - }); - - // Projects Table - new Table(this, 'mlspace-ddb-projects', { - tableName: props.mlspaceConfig.PROJECTS_TABLE_NAME, - partitionKey: { name: 'name', type: AttributeType.STRING }, - billingMode: BillingMode.PAY_PER_REQUEST, - encryption: TableEncryption.AWS_MANAGED, - }); - - // Project Users Table - const projectAttribute = { name: 'project', type: AttributeType.STRING }; - const userAttribute = { name: 'user', type: AttributeType.STRING }; - const projectUsersTable = new Table(this, 'mlspace-ddb-project-users', { - tableName: props.mlspaceConfig.PROJECT_USERS_TABLE_NAME, - partitionKey: projectAttribute, - sortKey: userAttribute, - billingMode: BillingMode.PAY_PER_REQUEST, - encryption: TableEncryption.AWS_MANAGED, - }); - - projectUsersTable.addGlobalSecondaryIndex({ - indexName: 'ReverseLookup', - partitionKey: userAttribute, - sortKey: projectAttribute, - projectionType: ProjectionType.KEYS_ONLY, - }); - - // Groups Table - new Table(this, 'mlspace-ddb-groups', { - tableName: props.mlspaceConfig.GROUPS_TABLE_NAME, - partitionKey: { name: 'name', type: AttributeType.STRING }, - billingMode: BillingMode.PAY_PER_REQUEST, - encryption: TableEncryption.AWS_MANAGED, - }); - - // Group Datasets Table - const groupAttribute = { name: 'group', type: AttributeType.STRING }; - const groupDatasetAttribute = { name: 'dataset', type: AttributeType.STRING }; - const groupDatasetTable = new Table(this, 'mlspace-ddb-group-datasets', { - tableName: props.mlspaceConfig.GROUP_DATASETS_TABLE_NAME, - partitionKey: groupAttribute, - sortKey: groupDatasetAttribute, - billingMode: BillingMode.PAY_PER_REQUEST, - encryption: TableEncryption.AWS_MANAGED, - }); - - groupDatasetTable.addGlobalSecondaryIndex({ - indexName: 'ReverseLookup', - partitionKey: groupDatasetAttribute, - sortKey: groupAttribute, - projectionType: ProjectionType.KEYS_ONLY - }); - - // Group Users Table - const groupUserAttribute = { name: 'user', type: AttributeType.STRING }; - const groupUsersTable = new Table(this, 'mlspace-ddb-group-users', { - tableName: props.mlspaceConfig.GROUP_USERS_TABLE_NAME, - partitionKey: groupAttribute, - sortKey: groupUserAttribute, - billingMode: BillingMode.PAY_PER_REQUEST, - encryption: TableEncryption.AWS_MANAGED, - }); - - groupUsersTable.addGlobalSecondaryIndex({ - indexName: 'ReverseLookup', - partitionKey: groupUserAttribute, - sortKey: groupAttribute, - projectionType: ProjectionType.KEYS_ONLY, - }); - - // Group Membership History Table - const groupMembershipHistoryAttribute = { name: 'group', type: AttributeType.STRING }; - const groupMembershipHistorySortAttribute = { name: 'actionedAt', type: AttributeType.NUMBER }; - new Table(this, 'mlspace-ddb-group-membership-history', { - tableName: props.mlspaceConfig.GROUPS_MEMBERSHIP_HISTORY_TABLE_NAME, - partitionKey: groupMembershipHistoryAttribute, - sortKey: groupMembershipHistorySortAttribute, - billingMode: BillingMode.PAY_PER_REQUEST, - encryption: TableEncryption.AWS_MANAGED, - }); - - // Users Table - new Table(this, 'mlspace-ddb-users', { - tableName: props.mlspaceConfig.USERS_TABLE_NAME, - partitionKey: { name: 'username', type: AttributeType.STRING }, - billingMode: BillingMode.PAY_PER_REQUEST, - encryption: TableEncryption.AWS_MANAGED, - }); - - // Project Groups Table - const projectGroupsTable = new Table(this, 'mlspace-ddb-project-groups', { - tableName: props.mlspaceConfig.PROJECT_GROUPS_TABLE_NAME, - partitionKey: projectAttribute, - sortKey: groupAttribute, - billingMode: BillingMode.PAY_PER_REQUEST, - encryption: TableEncryption.AWS_MANAGED, - }); - - projectGroupsTable.addGlobalSecondaryIndex({ - indexName: 'ReverseLookup', - partitionKey: groupAttribute, - sortKey: projectAttribute, - projectionType: ProjectionType.KEYS_ONLY, - }); - - // Resource Termination Schedule Table - const resourceIdAttribute = { name: 'resourceId', type: AttributeType.STRING }; - const resourceTypeAttribute = { name: 'resourceType', type: AttributeType.STRING }; - new Table(this, 'mlspace-ddb-resource-schedule', { - tableName: props.mlspaceConfig.RESOURCE_SCHEDULE_TABLE_NAME, - partitionKey: resourceIdAttribute, - sortKey: resourceTypeAttribute, - billingMode: BillingMode.PAY_PER_REQUEST, - encryption: TableEncryption.AWS_MANAGED, - }); - - // Resources Metadata Table - const resourcesMetadataTable = new Table(this, 'mlspace-resource-metadata', { - tableName: props.mlspaceConfig.RESOURCE_METADATA_TABLE_NAME, - partitionKey: resourceTypeAttribute, - sortKey: resourceIdAttribute, - billingMode: BillingMode.PAY_PER_REQUEST, - encryption: TableEncryption.AWS_MANAGED, - }); - - resourcesMetadataTable.addLocalSecondaryIndex({ - indexName: 'ProjectResources', - sortKey: projectAttribute, - projectionType: ProjectionType.ALL, - }); - - resourcesMetadataTable.addLocalSecondaryIndex({ - indexName: 'UserResources', - sortKey: userAttribute, - projectionType: ProjectionType.ALL, - }); - - // App Configuration Table - new Table(this, 'mlspace-ddb-app-configuration', { - tableName: props.mlspaceConfig.APP_CONFIGURATION_TABLE_NAME, - partitionKey: { name: 'configScope', type: AttributeType.STRING }, - sortKey: { name: 'versionId', type: AttributeType.NUMBER }, - billingMode: BillingMode.PAY_PER_REQUEST, - encryption: TableEncryption.AWS_MANAGED, - }); - - // Populate the App Config table with default config - new AwsCustomResource(this, 'mlspace-init-ddb-app-config', { - onCreate: { - service: 'DynamoDB', - action: 'putItem', - parameters: { - TableName: props.mlspaceConfig.APP_CONFIGURATION_TABLE_NAME, - Item: generateAppConfig(), - }, - physicalResourceId: PhysicalResourceId.of('initAppConfigData'), - }, - role: props.mlSpaceAppRole - }); - - new AwsCustomResource(this, 'initial-app-config-deployment-001', { - onCreate: { - service: 'Lambda', - action: 'invoke', - physicalResourceId: PhysicalResourceId.of('initAllowedInstanceTypes'), - parameters: { - FunctionName: appConfigLambda.functionName, - Payload: '{}' - }, - }, - role: props.mlSpaceAppRole - }); - - new AwsCustomResource(this, 'cleanup-deprecated-permissions', { - onCreate: { - service: 'Lambda', - action: 'invoke', - physicalResourceId: PhysicalResourceId.of(`cleanupDeprecatedResources-${Date.now()}`), - parameters: { - FunctionName: permissionCleanupLambda.functionName, - Payload: '{}' - }, - }, - role: props.mlSpaceAppRole - }); - - // EMR Security Configuration - new CfnSecurityConfiguration(this, 'mlspace-emr-security-config', { - name: props.mlspaceConfig.EMR_SECURITY_CONFIG_NAME, - securityConfiguration: { - InstanceMetadataServiceConfiguration: { - MinimumInstanceMetadataServiceVersion: 2, - HttpPutResponseHopLimit: 1, - }, - }, - }); - - const resourceMetadataLambda = new Function(this, 'mlspace-resource-metadata-lambda', { - functionName: 'mls-lambda-resource-metadata', - description: - 'Lambda to process event bridge notifications and update corresponding entries in the mlspace resource metadata ddb table.', - runtime: props.mlspaceConfig.LAMBDA_RUNTIME, - architecture: props.mlspaceConfig.LAMBDA_ARCHITECTURE, - handler: 'ml_space_lambda.resource_metadata.lambda_functions.process_event', - code: Code.fromAsset(props.lambdaSourcePath), - timeout: Duration.seconds(90), - role: props.mlSpaceAppRole, - environment: { - RESOURCE_METADATA_TABLE: props.mlspaceConfig.RESOURCE_METADATA_TABLE_NAME, - SYSTEM_TAG: props.mlspaceConfig.SYSTEM_TAG, - ...props.mlspaceConfig.ADDITIONAL_LAMBDA_ENVIRONMENT_VARS, - }, - layers: [commonLambdaLayer.layerVersion], - vpc: props.mlSpaceVPC, - securityGroups: props.lambdaSecurityGroups, - }); - - // Event bridge rule for resource metadata capture - new Rule(this, 'mlspace-resource-metadata-rule', { - ruleName: 'mlspace-resource-metadata-sync', - eventPattern: { - account: [this.account], - source: ['aws.sagemaker', 'aws.translate', 'aws.emr'], - detailType: [ - 'SageMaker Endpoint State Change', - 'SageMaker Endpoint Config State Change', - 'SageMaker Ground Truth Labeling Job State Change', - 'SageMaker HyperParameter Tuning Job State Change', - 'SageMaker Notebook Instance State Change', - 'SageMaker Model State Change', - 'SageMaker Training Job State Change', - 'SageMaker Transform Job State Change', - 'Translate TextTranslationJob State Change', - 'EMR Cluster State Change', - - ], - }, - targets: [new LambdaFunction(resourceMetadataLambda)], - }); - - new Rule(this, 'mlspace-cloudtrail-metadata-rule', { - ruleName: 'mlspace-cloudtrail-metadata-sync', - eventPattern: { - account: [this.account], - source: ['aws.sagemaker', 'aws.translate', 'aws.emr'], - detailType: ['AWS API Call via CloudTrail'], - detail: { - eventSource: ['sagemaker.amazonaws.com', 'translate.amazonaws.com'], - eventName: [ - 'CreateLabelingJob', - 'StartTextTranslationJob', - 'StopTextTranslationJob', - 'RunJobFlow', - ], - }, - }, - targets: [new LambdaFunction(resourceMetadataLambda)], - }); - - if (props.isIso) { - const adcCABundleAspect = new ADCLambdaCABundleAspect(); - Aspects.of(configDeployment).add(adcCABundleAspect); - Aspects.of(exampleDataDeployment).add(adcCABundleAspect); - Aspects.of(resourceMetadataLambda).add(adcCABundleAspect); - Aspects.of(s3NotificationLambda).add(adcCABundleAspect); - Aspects.of(terminateResourcesLambda).add(adcCABundleAspect); - } } } diff --git a/lib/stacks/infra/sagemaker.ts b/lib/stacks/infra/sagemaker.ts index a5d63b4a..04013c51 100644 --- a/lib/stacks/infra/sagemaker.ts +++ b/lib/stacks/infra/sagemaker.ts @@ -14,10 +14,9 @@ limitations under the License. */ -import { App, Fn, Stack, StackProps } from 'aws-cdk-lib'; -import { CfnNotebookInstanceLifecycleConfig } from 'aws-cdk-lib/aws-sagemaker'; -import { readFileSync } from 'fs'; +import { App, Stack, StackProps } from 'aws-cdk-lib'; import { MLSpaceConfig } from '../../utils/configTypes'; +import { SagemakerConstruct } from '../../constructs/infra/sagemakerConstruct'; export type SagemakerStackProp = { readonly dataBucketName: string; @@ -31,25 +30,8 @@ export class SagemakerStack extends Stack { ...props, }); - new CfnNotebookInstanceLifecycleConfig(this, 'mlspace-notebook-lifecycle-config', { - notebookInstanceLifecycleConfigName: props.mlspaceConfig.MLSPACE_LIFECYCLE_CONFIG_NAME, - onCreate: [ - { - content: Fn.base64( - readFileSync('lib/resources/sagemaker/lifecycle-create.sh', 'utf8').replace( - //g, - props.dataBucketName - ) - ), - }, - ], - onStart: [ - { - content: Fn.base64( - readFileSync('lib/resources/sagemaker/lifecycle-start.sh', 'utf8') - ), - }, - ], - }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const sagemakerConstruct = new SagemakerConstruct(this, name + 'Resources', props); + } } diff --git a/lib/stacks/kms.ts b/lib/stacks/kms.ts index 5c761ab6..b568d26d 100644 --- a/lib/stacks/kms.ts +++ b/lib/stacks/kms.ts @@ -15,15 +15,9 @@ */ import { App, Stack, StackProps } from 'aws-cdk-lib'; -import { - AccountRootPrincipal, - Effect, - PolicyDocument, - PolicyStatement, - Role, -} from 'aws-cdk-lib/aws-iam'; -import { IKey, Key } from 'aws-cdk-lib/aws-kms'; +import { IKey } from 'aws-cdk-lib/aws-kms'; import { MLSpaceConfig } from '../utils/configTypes'; +import { KMSConstruct } from '../constructs/kmsConstruct'; export type KMSStackProp = { readonly keyManagerRoleName: string; @@ -31,7 +25,7 @@ export type KMSStackProp = { } & StackProps; export class KMSStack extends Stack { - public masterKey: IKey; + public readonly masterKey: IKey; constructor (parent: App, name: string, props: KMSStackProp) { super(parent, name, { @@ -39,51 +33,9 @@ export class KMSStack extends Stack { ...props, }); - if (props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN) { - this.masterKey = Key.fromKeyArn(this, 'imported-kms-key', props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN); - } else { - this.masterKey = new Key(this, 'mlspace-kms-key', { - policy: new PolicyDocument({ - statements: [ - new PolicyStatement({ - effect: Effect.ALLOW, - principals: [new AccountRootPrincipal()], - actions: ['kms:*'], - resources: ['*'], - }), - new PolicyStatement({ - effect: Effect.ALLOW, - principals: [ - Role.fromRoleName( - this, - 'mlspace-keymanager-role', - props.keyManagerRoleName - ), - ], - actions: [ - 'kms:Create*', - 'kms:Describe*', - 'kms:Enable*', - 'kms:List*', - 'kms:Put*', - 'kms:Update*', - 'kms:Revoke*', - 'kms:Disable*', - 'kms:Get*', - 'kms:Delete*', - 'kms:TagResource', - 'kms:UntagResource', - 'kms:ScheduleKeyDeletion', - 'kms:CancelKeyDeletion', - ], - resources: ['*'], - }), - ], - }), - alias: 'alias/mlspace-key', - description: 'KMS key for encrypting the objects in an S3 bucket', - enableKeyRotation: false, - }); - } + const kmsConstruct = new KMSConstruct(this, name + 'Resources', props); + + this.masterKey = kmsConstruct.masterKey; + } -} +} \ No newline at end of file diff --git a/lib/stacks/vpc.ts b/lib/stacks/vpc.ts index 617d1531..cdb6fbed 100644 --- a/lib/stacks/vpc.ts +++ b/lib/stacks/vpc.ts @@ -15,16 +15,8 @@ */ import { App, Stack, StackProps } from 'aws-cdk-lib'; -import { - GatewayVpcEndpointAwsService, - ISecurityGroup, - IVpc, - InterfaceVpcEndpointAwsService, - InterfaceVpcEndpointService, - SecurityGroup, - SubnetType, - Vpc, -} from 'aws-cdk-lib/aws-ec2'; +import { ISecurityGroup, IVpc } from 'aws-cdk-lib/aws-ec2'; +import { VPCConstruct } from '../constructs/vpcConstruct'; import { MLSpaceConfig } from '../utils/configTypes'; export type VPCStackProps = { @@ -37,6 +29,7 @@ export type VPCStackProps = { readonly isIso?: boolean; readonly mlspaceConfig: MLSpaceConfig; } & StackProps; + export class VPCStack extends Stack { public readonly vpc: IVpc; public readonly vpcSecurityGroupId: string; @@ -48,114 +41,11 @@ export class VPCStack extends Stack { ...props, }); - const isIsoB = this.region === 'us-isob-east-1'; - const isIsoEast = this.region === 'us-iso-east-1'; - const isIsoWest = this.region === 'us-iso-west-1'; - - if (props.mlspaceConfig.EXISTING_VPC_NAME && props.mlspaceConfig.EXISTING_VPC_ID && props.mlspaceConfig.EXISTING_VPC_DEFAULT_SECURITY_GROUP) { - this.vpc = Vpc.fromLookup(this, 'imported-vpc', { - vpcId: props.mlspaceConfig.EXISTING_VPC_ID, - vpcName: props.mlspaceConfig.EXISTING_VPC_NAME, - }); - this.vpcSecurityGroupId = props.mlspaceConfig.EXISTING_VPC_DEFAULT_SECURITY_GROUP; - } else { - const mlSpaceVPC = new Vpc(this, 'MLSpace-VPC', { - enableDnsHostnames: true, - enableDnsSupport: true, - maxAzs: isIsoB ? 2 : 3, - subnetConfiguration: [ - { - cidrMask: 23, - name: 'MLSpace-Public', - subnetType: SubnetType.PUBLIC, - }, - { - cidrMask: 23, - name: 'MLSpace-Private', - subnetType: SubnetType.PRIVATE_WITH_EGRESS, - }, - ], - }); - - this.vpc = mlSpaceVPC; - this.vpcSecurityGroupId = mlSpaceVPC.vpcDefaultSecurityGroup; - if (props.deployS3Endpoint) { - this.vpc.addGatewayEndpoint('mlspace-S3-gateway-endpoint', { - service: GatewayVpcEndpointAwsService.S3, - }); - } - - // DBB VPC endpoints exist iso-east but do in isob and iso-west - if (props.deployDDBEndpoint && !isIsoEast) { - this.vpc.addGatewayEndpoint('mlspace-ddb-gateway-endpoint', { - service: GatewayVpcEndpointAwsService.DYNAMODB, - }); - } - - if (props.deployCWEndpoint && !props.isIso) { - this.vpc.addInterfaceEndpoint('mlspace-cw-interface-endpoint', { - service: InterfaceVpcEndpointAwsService.CLOUDWATCH_MONITORING, - privateDnsEnabled: true, - }); - } - - if (props.deployCWLEndpoint && !props.isIso) { - this.vpc.addInterfaceEndpoint('mlspace-cwl-interface-endpoint', { - service: InterfaceVpcEndpointAwsService.CLOUDWATCH_LOGS, - privateDnsEnabled: true, - }); - } - - let partitionPrefix; - if (isIsoEast || isIsoWest) { - // eslint-disable-next-line spellcheck/spell-checker - partitionPrefix = 'gov.ic.c2s'; - } else if (isIsoB) { - // eslint-disable-next-line spellcheck/spell-checker - partitionPrefix = 'gov.sgov.sc2s'; - } - - this.vpc.addInterfaceEndpoint('mlspace-sm-api-interface-endpoint', { - service: partitionPrefix - ? new InterfaceVpcEndpointService( - `${partitionPrefix}.${this.region}.sagemaker.api` - ) - : InterfaceVpcEndpointAwsService.SAGEMAKER_API, - privateDnsEnabled: true, - }); - - this.vpc.addInterfaceEndpoint('mlspace-sm-runtime-interface-endpoint', { - service: partitionPrefix - ? new InterfaceVpcEndpointService( - `${partitionPrefix}.${this.region}.sagemaker.runtime` - ) - : InterfaceVpcEndpointAwsService.SAGEMAKER_RUNTIME, - privateDnsEnabled: true, - }); - - this.vpc.addInterfaceEndpoint('mlspace-sm-notebook-interface-endpoint', { - service: InterfaceVpcEndpointAwsService.SAGEMAKER_NOTEBOOK, - privateDnsEnabled: true, - }); - - if (props.deploySTSEndpoint && !props.isIso) { - this.vpc.addInterfaceEndpoint('mlspace-sts-interface-endpoint', { - service: InterfaceVpcEndpointAwsService.STS, - privateDnsEnabled: true, - }); - } + const vpcConstruct = new VPCConstruct(this, name + 'Resources', props); + + this.vpc = vpcConstruct.vpc; + this.vpcSecurityGroupId = vpcConstruct.vpcSecurityGroupId; + this.vpcSecurityGroup = vpcConstruct.vpcSecurityGroup; - if (props.deployCFNEndpoint && !props.isIso) { - this.vpc.addInterfaceEndpoint('mlspace-cfn-interface-endpoint', { - service: InterfaceVpcEndpointAwsService.CLOUDFORMATION, - privateDnsEnabled: true, - }); - } - } - this.vpcSecurityGroup = SecurityGroup.fromSecurityGroupId( - this, - 'mls-vpc-default-sg}', - this.vpcSecurityGroupId - ); } } diff --git a/lib/utils/configTypes.ts b/lib/utils/configTypes.ts index d66948e7..34cc5bfb 100644 --- a/lib/utils/configTypes.ts +++ b/lib/utils/configTypes.ts @@ -132,6 +132,8 @@ export type MLSpaceConfig = { LAMBDA_ARCHITECTURE: Architecture, LAMBDA_RUNTIME: Runtime, SYSTEM_ROLE_ARN: string, + COMMON_LAYER_PATH?: string, + JWT_LAYER_PATH?: string //Properties that can optionally be set in config.json AWS_ACCOUNT: string, AWS_REGION: string, @@ -152,7 +154,8 @@ export type MLSpaceConfig = { EMR_EC2_INSTANCE_ROLE_ARN: string, BACKGROUND_REFRESH_INTERVAL: number, - SHOW_MIGRATION_OPTIONS?: boolean + SHOW_MIGRATION_OPTIONS?: boolean, + }; const validateRequiredProperty = (val: string, name: string) => { @@ -167,7 +170,7 @@ const validateRequiredProperty = (val: string, name: string) => { * or defaulting to settings in constants.ts if that property hasn't been set * in config.json */ -export function generateConfig () { +export function generateConfig (accountId?: string) { const config: MLSpaceConfig = { // Table names DATASETS_TABLE_NAME: DATASETS_TABLE_NAME, @@ -241,14 +244,15 @@ export function generateConfig () { SHOW_MIGRATION_OPTIONS: SHOW_MIGRATION_OPTIONS }; - - //Check for properties set in config.json and default to that value if it exists - if (fs.existsSync('lib/config.json')) { - const fileConfig: MLSpaceConfig = JSON.parse( - fs.readFileSync('lib/config.json').toString('utf8') - ); - _.merge(config, fileConfig); - } + //Try to load account-specific config or config generated by config-helper + const configPaths = [`lib/config.${accountId}.json`, 'lib/config.json']; + configPaths.forEach((configPath) => { + if (fs.existsSync(configPath)) { + const fileConfig = JSON.parse(fs.readFileSync(configPath).toString('utf8')); + _.merge(config, fileConfig); + return; + } + }); //Check if the cluster-config file exists, and if it does use the ec2-key value if (fs.existsSync('lib/resources/config/cluster-config.json')) { const clusterConfig = JSON.parse( diff --git a/lib/utils/layers.ts b/lib/utils/layers.ts index 6ccde376..f39af5d9 100644 --- a/lib/utils/layers.ts +++ b/lib/utils/layers.ts @@ -38,6 +38,7 @@ export type LambdaLayerProps = { environment?: {[key: string]: string} layerIdentifier?: string, architecture: Architecture, + layerPath?: string }; export class LambdaLayer extends Construct { @@ -49,9 +50,9 @@ export class LambdaLayer extends Construct { constructor (scope: Construct, id: string, props: LambdaLayerProps) { super(scope, id); - const { layerName, description, environment, layerIdentifier, architecture } = props; + const { layerName, description, environment, layerIdentifier, architecture, layerPath } = props; const layerFileName = layerIdentifier || layerName; - const layerZip = path.join(WORKING_DIR, `lambda_dependencies/${layerFileName}_layer.zip`); + const layerZip = layerPath || path.join(WORKING_DIR, `lambda_dependencies/${layerFileName}_layer.zip`); let layerCode: Code; // prioritize manually built layers @@ -136,12 +137,14 @@ export class LambdaLayer extends Construct { export function createLambdaLayer ( scope: Construct, layerName: string, - layerIdentifier?: string + layerIdentifier?: string, + layerPath?: string ): LambdaLayer { return new LambdaLayer(scope, layerIdentifier || layerName, { layerName, description: `Lambda layer for ${layerIdentifier} dependencies needed by MLSpace`, layerIdentifier, - architecture: LAMBDA_ARCHITECTURE + architecture: LAMBDA_ARCHITECTURE, + layerPath }); -} \ No newline at end of file +} From 39e6078025d737bc9965ee721907cd629781aa94 Mon Sep 17 00:00:00 2001 From: Evan Stohlmann Date: Fri, 23 May 2025 13:44:44 -0600 Subject: [PATCH 04/32] Logical ids --- lib/constructs/api/adminConstruct.ts | 6 +- lib/constructs/api/apiDeploymentConstruct.ts | 4 +- .../api/appConfigurationConstruct.ts | 6 +- lib/constructs/api/datasetsConstructs.ts | 6 +- lib/constructs/api/emrConstruct.ts | 6 +- .../api/groupMembershipHistoryConstruct.ts | 6 +- lib/constructs/api/groupsConstruct.ts | 6 +- lib/constructs/api/inferenceConstruct.ts | 6 +- lib/constructs/api/jobsConstruct.ts | 6 +- lib/constructs/api/notebooksConstruct.ts | 4 +- lib/constructs/api/projectsConstruct.ts | 6 +- lib/constructs/api/restApiConstruct.ts | 23 +++-- lib/constructs/api/translateConstruct.ts | 6 +- lib/constructs/iamConstruct.ts | 56 +++++------ lib/constructs/infra/coreConstruct.ts | 96 +++++++++---------- lib/constructs/infra/sagemakerConstruct.ts | 2 +- lib/stacks/api/admin.ts | 2 +- lib/stacks/api/apiDeployment.ts | 2 +- lib/stacks/api/appConfiguration.ts | 2 +- lib/stacks/api/datasets.ts | 2 +- lib/stacks/api/emr.ts | 2 +- lib/stacks/api/groupMembershipHistory.ts | 2 +- lib/stacks/api/groups.ts | 2 +- lib/stacks/api/inference.ts | 2 +- lib/stacks/api/jobs.ts | 2 +- lib/stacks/api/notebooks.ts | 2 +- lib/stacks/api/projects.ts | 2 +- lib/stacks/api/restApi.ts | 2 +- lib/stacks/api/translate.ts | 2 +- lib/stacks/iam.ts | 2 +- lib/stacks/infra/core.ts | 2 +- lib/stacks/infra/sagemaker.ts | 2 +- lib/stacks/kms.ts | 2 +- lib/stacks/vpc.ts | 2 +- 34 files changed, 140 insertions(+), 141 deletions(-) diff --git a/lib/constructs/api/adminConstruct.ts b/lib/constructs/api/adminConstruct.ts index 805afd66..60af8eb8 100644 --- a/lib/constructs/api/adminConstruct.ts +++ b/lib/constructs/api/adminConstruct.ts @@ -28,12 +28,12 @@ export class AdminApiConstruct extends Construct { // Get common layer based on arn from SSM due to issues with cross stack references const commonLambdaLayer = LayerVersion.fromLayerVersionArn( - this, + scope, 'mls-common-lambda-layer', - StringParameter.valueForStringParameter(this, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) + StringParameter.valueForStringParameter(scope, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) ); - const restApi = RestApi.fromRestApiAttributes(this, 'RestApi', { + const restApi = RestApi.fromRestApiAttributes(scope, 'RestApi', { restApiId: props.restApiId, rootResourceId: props.rootResourceId, }); diff --git a/lib/constructs/api/apiDeploymentConstruct.ts b/lib/constructs/api/apiDeploymentConstruct.ts index 60d76b9c..449e4393 100644 --- a/lib/constructs/api/apiDeploymentConstruct.ts +++ b/lib/constructs/api/apiDeploymentConstruct.ts @@ -30,8 +30,8 @@ export class ApiDeploymentConstruct extends Construct { // Related CDK issues: // https://github.com/aws/aws-cdk/issues/12417 // https://github.com/aws/aws-cdk/issues/13383 - const deployment = new Deployment(this, `ApiDeployment-${new Date().getTime()}`, { - api: RestApi.fromRestApiId(this, 'MLSpaceRestApiRef', props.restApiId), + const deployment = new Deployment(scope, `ApiDeployment-${new Date().getTime()}`, { + api: RestApi.fromRestApiId(scope, 'MLSpaceRestApiRef', props.restApiId), }); // This hack will allow us to redeploy to an existing stage but once CDK // adds first class support for this we will migrate diff --git a/lib/constructs/api/appConfigurationConstruct.ts b/lib/constructs/api/appConfigurationConstruct.ts index fca31aca..1d78bde7 100644 --- a/lib/constructs/api/appConfigurationConstruct.ts +++ b/lib/constructs/api/appConfigurationConstruct.ts @@ -28,12 +28,12 @@ export class AppConfigurationApiConstruct extends Construct { // Get common layer based on arn from SSM due to issues with cross stack references const commonLambdaLayer = LayerVersion.fromLayerVersionArn( - this, + scope, 'mls-common-lambda-layer', - StringParameter.valueForStringParameter(this, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) + StringParameter.valueForStringParameter(scope, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) ); - const restApi = RestApi.fromRestApiAttributes(this, 'RestApi', { + const restApi = RestApi.fromRestApiAttributes(scope, 'RestApi', { restApiId: props.restApiId, rootResourceId: props.rootResourceId, }); diff --git a/lib/constructs/api/datasetsConstructs.ts b/lib/constructs/api/datasetsConstructs.ts index b73c8729..92f18b0f 100644 --- a/lib/constructs/api/datasetsConstructs.ts +++ b/lib/constructs/api/datasetsConstructs.ts @@ -28,12 +28,12 @@ export class DatasetsApiConstruct extends Construct { // Get common layer based on arn from SSM due to issues with cross stack references const commonLambdaLayer = LayerVersion.fromLayerVersionArn( - this, + scope, 'mls-common-lambda-layer', - StringParameter.valueForStringParameter(this, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) + StringParameter.valueForStringParameter(scope, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) ); - const restApi = RestApi.fromRestApiAttributes(this, 'RestApi', { + const restApi = RestApi.fromRestApiAttributes(scope, 'RestApi', { restApiId: props.restApiId, rootResourceId: props.rootResourceId, }); diff --git a/lib/constructs/api/emrConstruct.ts b/lib/constructs/api/emrConstruct.ts index f24adb44..7f896907 100644 --- a/lib/constructs/api/emrConstruct.ts +++ b/lib/constructs/api/emrConstruct.ts @@ -28,12 +28,12 @@ export class EmrApiConstruct extends Construct { // Get common layer based on arn from SSM due to issues with cross stack references const commonLambdaLayer = LayerVersion.fromLayerVersionArn( - this, + scope, 'mls-common-lambda-layer', - StringParameter.valueForStringParameter(this, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) + StringParameter.valueForStringParameter(scope, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) ); - const restApi = RestApi.fromRestApiAttributes(this, 'RestApi', { + const restApi = RestApi.fromRestApiAttributes(scope, 'RestApi', { restApiId: props.restApiId, rootResourceId: props.rootResourceId, }); diff --git a/lib/constructs/api/groupMembershipHistoryConstruct.ts b/lib/constructs/api/groupMembershipHistoryConstruct.ts index 1de45346..b2fcf1d3 100644 --- a/lib/constructs/api/groupMembershipHistoryConstruct.ts +++ b/lib/constructs/api/groupMembershipHistoryConstruct.ts @@ -28,12 +28,12 @@ export class GroupMembershipHistoryApiConstruct extends Construct { // Get common layer based on arn from SSM due to issues with cross stack references const commonLambdaLayer = LayerVersion.fromLayerVersionArn( - this, + scope, 'mls-common-lambda-layer', - StringParameter.valueForStringParameter(this, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) + StringParameter.valueForStringParameter(scope, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) ); - const restApi = RestApi.fromRestApiAttributes(this, 'RestApi', { + const restApi = RestApi.fromRestApiAttributes(scope, 'RestApi', { restApiId: props.restApiId, rootResourceId: props.rootResourceId, }); diff --git a/lib/constructs/api/groupsConstruct.ts b/lib/constructs/api/groupsConstruct.ts index f57e66ff..cb192dd4 100644 --- a/lib/constructs/api/groupsConstruct.ts +++ b/lib/constructs/api/groupsConstruct.ts @@ -28,12 +28,12 @@ export class GroupsApiConstruct extends Construct { // Get common layer based on arn from SSM due to issues with cross stack references const commonLambdaLayer = LayerVersion.fromLayerVersionArn( - this, + scope, 'mls-common-lambda-layer', - StringParameter.valueForStringParameter(this, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) + StringParameter.valueForStringParameter(scope, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) ); - const restApi = RestApi.fromRestApiAttributes(this, 'RestApi', { + const restApi = RestApi.fromRestApiAttributes(scope, 'RestApi', { restApiId: props.restApiId, rootResourceId: props.rootResourceId, }); diff --git a/lib/constructs/api/inferenceConstruct.ts b/lib/constructs/api/inferenceConstruct.ts index bf591aaa..c015a082 100644 --- a/lib/constructs/api/inferenceConstruct.ts +++ b/lib/constructs/api/inferenceConstruct.ts @@ -28,12 +28,12 @@ export class InferenceApiConstruct extends Construct { // Get common layer based on arn from SSM due to issues with cross stack references const commonLambdaLayer = LayerVersion.fromLayerVersionArn( - this, + scope, 'mls-common-lambda-layer', - StringParameter.valueForStringParameter(this, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) + StringParameter.valueForStringParameter(scope, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) ); - const restApi = RestApi.fromRestApiAttributes(this, 'RestApi', { + const restApi = RestApi.fromRestApiAttributes(scope, 'RestApi', { restApiId: props.restApiId, rootResourceId: props.rootResourceId, }); diff --git a/lib/constructs/api/jobsConstruct.ts b/lib/constructs/api/jobsConstruct.ts index 193aad9c..aeae10f1 100644 --- a/lib/constructs/api/jobsConstruct.ts +++ b/lib/constructs/api/jobsConstruct.ts @@ -28,12 +28,12 @@ export class JobsApiConstruct extends Construct { // Get common layer based on arn from SSM due to issues with cross stack references const commonLambdaLayer = LayerVersion.fromLayerVersionArn( - this, + scope, 'mls-common-lambda-layer', - StringParameter.valueForStringParameter(this, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) + StringParameter.valueForStringParameter(scope, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) ); - const restApi = RestApi.fromRestApiAttributes(this, 'RestApi', { + const restApi = RestApi.fromRestApiAttributes(scope, 'RestApi', { restApiId: props.restApiId, rootResourceId: props.rootResourceId, }); diff --git a/lib/constructs/api/notebooksConstruct.ts b/lib/constructs/api/notebooksConstruct.ts index 055917d5..ca548ef3 100644 --- a/lib/constructs/api/notebooksConstruct.ts +++ b/lib/constructs/api/notebooksConstruct.ts @@ -30,10 +30,10 @@ export class NotebooksApiConstruct extends Construct { const commonLambdaLayer = LayerVersion.fromLayerVersionArn( scope, 'mls-common-lambda-layer', - StringParameter.valueForStringParameter(this, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) + StringParameter.valueForStringParameter(scope, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) ); - const restApi = RestApi.fromRestApiAttributes(this, 'RestApi', { + const restApi = RestApi.fromRestApiAttributes(scope, 'RestApi', { restApiId: props.restApiId, rootResourceId: props.rootResourceId, }); diff --git a/lib/constructs/api/projectsConstruct.ts b/lib/constructs/api/projectsConstruct.ts index 20159d5f..2146860e 100644 --- a/lib/constructs/api/projectsConstruct.ts +++ b/lib/constructs/api/projectsConstruct.ts @@ -28,12 +28,12 @@ export class ProjectsApiConstruct extends Construct { // Get common layer based on arn from SSM due to issues with cross stack references const commonLambdaLayer = LayerVersion.fromLayerVersionArn( - this, + scope, 'mls-common-lambda-layer', - StringParameter.valueForStringParameter(this, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) + StringParameter.valueForStringParameter(scope, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) ); - const restApi = RestApi.fromRestApiAttributes(this, 'RestApi', { + const restApi = RestApi.fromRestApiAttributes(scope, 'RestApi', { restApiId: props.restApiId, rootResourceId: props.rootResourceId, }); diff --git a/lib/constructs/api/restApiConstruct.ts b/lib/constructs/api/restApiConstruct.ts index 44864bff..0bc83683 100644 --- a/lib/constructs/api/restApiConstruct.ts +++ b/lib/constructs/api/restApiConstruct.ts @@ -97,7 +97,7 @@ export class RestApiConstruct extends Construct { throttlingBurstLimit: 100, }; if (props.mlspaceConfig.ENABLE_ACCESS_LOGGING) { - const apiAccessLogGroup = new LogGroup(this, 'mlspace-APIGWLogGroup', { + const apiAccessLogGroup = new LogGroup(scope, 'mlspace-APIGWLogGroup', { logGroupName: '/aws/apigateway/MLSpace', removalPolicy: RemovalPolicy.DESTROY, }); @@ -139,7 +139,7 @@ export class RestApiConstruct extends Construct { ), }; } - const mlSpaceRestApi = new RestApi(this, 'mlspace-api', { + const mlSpaceRestApi = new RestApi(scope, 'mlspace-api', { restApiName: 'MLSpace API', description: 'The MLSpace API Layer.', endpointConfiguration: { types: [EndpointType.REGIONAL] }, @@ -236,20 +236,20 @@ export class RestApiConstruct extends Construct { } ); - const jwtDependencyLayer = createLambdaLayer(this, 'jwt', undefined, props.mlspaceConfig.JWT_LAYER_PATH); + const jwtDependencyLayer = createLambdaLayer(scope, 'jwt', undefined, props.mlspaceConfig.JWT_LAYER_PATH); // Get common layer based on arn from SSM due to issues with cross stack references const commonLambdaLayer = LayerVersion.fromLayerVersionArn( - this, + scope, 'mls-common-lambda-layer', - StringParameter.valueForStringParameter(this, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) + StringParameter.valueForStringParameter(scope, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) ); let ssmIdPEndpoint; if (props.mlspaceConfig.IDP_ENDPOINT_SSM_PARAM) { - ssmIdPEndpoint = StringParameter.valueForStringParameter(this, props.mlspaceConfig.IDP_ENDPOINT_SSM_PARAM); + ssmIdPEndpoint = StringParameter.valueForStringParameter(scope, props.mlspaceConfig.IDP_ENDPOINT_SSM_PARAM); } - const authorizerLambda = new Function(this, 'MLSpaceAuthorizerLambda', { + const authorizerLambda = new Function(scope, 'MLSpaceAuthorizerLambda', { runtime: props.mlspaceConfig.LAMBDA_RUNTIME, architecture: props.mlspaceConfig.LAMBDA_ARCHITECTURE, handler: 'ml_space_lambda.authorizer.lambda_function.lambda_handler', @@ -271,13 +271,12 @@ export class RestApiConstruct extends Construct { securityGroups: props.lambdaSecurityGroups, }); - this.mlspaceRequestAuthorizer = new RequestAuthorizer(this, 'MLSpaceAPIGWAuthorizer', { + this.mlspaceRequestAuthorizer = new RequestAuthorizer(scope, 'MLSpaceAPIGWAuthorizer', { handler: authorizerLambda, resultsCacheTtl: Duration.seconds(0), identitySources: [IdentitySource.header('Authorization')], }); - // TODO: I probably messed something up here. This did not originally exist before Stack -> Construct this.mlspaceRequestAuthorizer._attachToApi(mlSpaceRestApi); // Dynamic config relies on api URL and we don't want to do this in a separate stack @@ -297,12 +296,12 @@ export class RestApiConstruct extends Construct { // MLSpace static react app const websiteBucket = Bucket.fromBucketName( - this, + scope, 'mlspace-static-website-bucket', props.websiteBucketName ); - const frontEndDeployment = new BucketDeployment(this, 'MLSpaceFrontEndDeployment', { + const frontEndDeployment = new BucketDeployment(scope, 'MLSpaceFrontEndDeployment', { sources: [ Source.asset(props.frontEndAssetsPath), Source.data('env.js', `window.env = ${JSON.stringify(appEnvironmentConfig)}`), @@ -312,7 +311,7 @@ export class RestApiConstruct extends Construct { prune: true, role: props.mlspaceConfig.BUCKET_DEPLOYMENT_ROLE_ARN ? Role.fromRoleArn( - this, + scope, 'mlspace-website-deploy-role', props.mlspaceConfig.BUCKET_DEPLOYMENT_ROLE_ARN, { diff --git a/lib/constructs/api/translateConstruct.ts b/lib/constructs/api/translateConstruct.ts index fe0664e6..3f2f19db 100644 --- a/lib/constructs/api/translateConstruct.ts +++ b/lib/constructs/api/translateConstruct.ts @@ -28,12 +28,12 @@ export class TranslateApiConstruct extends Construct { // Get common layer based on arn from SSM due to issues with cross stack references const commonLambdaLayer = LayerVersion.fromLayerVersionArn( - this, + scope, 'mls-common-lambda-layer', - StringParameter.valueForStringParameter(this, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) + StringParameter.valueForStringParameter(scope, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) ); - const restApi = RestApi.fromRestApiAttributes(this, 'RestApi', { + const restApi = RestApi.fromRestApiAttributes(scope, 'RestApi', { restApiId: props.restApiId, rootResourceId: props.rootResourceId, }); diff --git a/lib/constructs/iamConstruct.ts b/lib/constructs/iamConstruct.ts index 641b713d..1be3bd94 100644 --- a/lib/constructs/iamConstruct.ts +++ b/lib/constructs/iamConstruct.ts @@ -124,9 +124,9 @@ export class IAMConstruct extends Construct { if (props.mlspaceConfig.KMS_INSTANCE_CONDITIONS_POLICY_ARN) { - this.mlspaceKmsInstanceConditionsPolicy = ManagedPolicy.fromManagedPolicyArn(this, 'mlspace-kms-instance-constraint-policy', props.mlspaceConfig.KMS_INSTANCE_CONDITIONS_POLICY_ARN); + this.mlspaceKmsInstanceConditionsPolicy = ManagedPolicy.fromManagedPolicyArn(scope, 'mlspace-kms-instance-constraint-policy', props.mlspaceConfig.KMS_INSTANCE_CONDITIONS_POLICY_ARN); } else { - this.mlspaceKmsInstanceConditionsPolicy = new ManagedPolicy(this, 'mlspace-kms-instance-constraint-policy', { + this.mlspaceKmsInstanceConditionsPolicy = new ManagedPolicy(scope, 'mlspace-kms-instance-constraint-policy', { managedPolicyName: `${props.mlspaceConfig.IAM_RESOURCE_PREFIX}-kms-instance-constraint-policy`, statements: [ new PolicyStatement({ @@ -548,7 +548,7 @@ export class IAMConstruct extends Construct { ]; }; - const notebookPolicy = new ManagedPolicy(this, 'mlspace-notebook-policy', { + const notebookPolicy = new ManagedPolicy(scope, 'mlspace-notebook-policy', { statements: notebookPolicyStatements(scope.partition, Aws.REGION), description: 'Enables general MLSpace actions in notebooks and across the entire application.' }); @@ -560,24 +560,24 @@ export class IAMConstruct extends Construct { if (props.mlspaceConfig.MANAGE_IAM_ROLES) { if (props.mlspaceConfig.ENDPOINT_CONFIG_INSTANCE_CONSTRAINT_POLICY_ARN) { - this.mlspaceEndpointConfigInstanceConstraintPolicy = ManagedPolicy.fromManagedPolicyArn(this, 'mlspace-endpoint-config-instance-constraint', props.mlspaceConfig.ENDPOINT_CONFIG_INSTANCE_CONSTRAINT_POLICY_ARN); + this.mlspaceEndpointConfigInstanceConstraintPolicy = ManagedPolicy.fromManagedPolicyArn(scope, 'mlspace-endpoint-config-instance-constraint', props.mlspaceConfig.ENDPOINT_CONFIG_INSTANCE_CONSTRAINT_POLICY_ARN); } else { /* * WARNING: @see instanceConstraintPolicyStatement */ - this.mlspaceEndpointConfigInstanceConstraintPolicy = new ManagedPolicy(this, 'mlspace-endpoint-config-instance-constraint', { + this.mlspaceEndpointConfigInstanceConstraintPolicy = new ManagedPolicy(scope, 'mlspace-endpoint-config-instance-constraint', { managedPolicyName: `${props.mlspaceConfig.IAM_RESOURCE_PREFIX}-endpoint-instance-constraint`, statements: instanceConstraintPolicyStatement(scope.partition, Aws.REGION, {CreateEndpointConfig: 'endpoint-config'}) }); } if (props.mlspaceConfig.JOB_INSTANCE_CONSTRAINT_POLICY_ARN) { - this.mlspaceJobInstanceConstraintPolicy = ManagedPolicy.fromManagedPolicyArn(this, 'mlspace-job-instance-constraint', props.mlspaceConfig.JOB_INSTANCE_CONSTRAINT_POLICY_ARN); + this.mlspaceJobInstanceConstraintPolicy = ManagedPolicy.fromManagedPolicyArn(scope, 'mlspace-job-instance-constraint', props.mlspaceConfig.JOB_INSTANCE_CONSTRAINT_POLICY_ARN); } else { /* * WARNING: @see instanceConstraintPolicyStatement */ - this.mlspaceJobInstanceConstraintPolicy = new ManagedPolicy(this, 'mlspace-job-instance-constraint', { + this.mlspaceJobInstanceConstraintPolicy = new ManagedPolicy(scope, 'mlspace-job-instance-constraint', { managedPolicyName: `${props.mlspaceConfig.IAM_RESOURCE_PREFIX}-job-instance-constraint`, statements: [ instanceConstraintPolicyStatement(scope.partition, Aws.REGION, { @@ -595,7 +595,7 @@ export class IAMConstruct extends Construct { // If roles are manually created use the existing role if (props.mlspaceConfig.NOTEBOOK_ROLE_ARN) { this.mlSpaceNotebookRole = Role.fromRoleArn( - this, + scope, mlSpaceNotebookRoleName, props.mlspaceConfig.NOTEBOOK_ROLE_ARN ); @@ -610,7 +610,7 @@ export class IAMConstruct extends Construct { ) : new ServicePrincipal('sagemaker.amazonaws.com'); - this.mlSpaceNotebookRole = new Role(this, mlSpaceNotebookRoleName, { + this.mlSpaceNotebookRole = new Role(scope, mlSpaceNotebookRoleName, { roleName: mlSpaceNotebookRoleName, assumedBy: notebookPolicyAllowPrinciples, managedPolicies: notebookManagedPolicies, @@ -627,7 +627,7 @@ export class IAMConstruct extends Construct { // If role was manually created if (props.mlspaceConfig.PERMISSIONS_BOUNDARY_POLICY_NAME) { this.mlSpacePermissionsBoundary = ManagedPolicy.fromManagedPolicyName( - this, + scope, 'mlspace-existing-boundary', props.mlspaceConfig.PERMISSIONS_BOUNDARY_POLICY_NAME ); @@ -640,7 +640,7 @@ export class IAMConstruct extends Construct { // Permission boundary policy that ensures IAM policies never exceed these permissions this.mlSpacePermissionsBoundary = new ManagedPolicy( - this, + scope, 'mlspace-project-user-role-boundary', { managedPolicyName: 'mlspace-project-user-permission-boundary', @@ -1087,12 +1087,12 @@ export class IAMConstruct extends Construct { }; if (props.mlspaceConfig.APP_ROLE_ARN) { - this.mlSpaceAppRole = Role.fromRoleArn(this, 'mlspace-app-role', props.mlspaceConfig.APP_ROLE_ARN); + this.mlSpaceAppRole = Role.fromRoleArn(scope, 'mlspace-app-role', props.mlspaceConfig.APP_ROLE_ARN); } else { // ML Space Application role - const appPolicy = new ManagedPolicy(this, 'mlspace-app-policy', { + const appPolicy = new ManagedPolicy(scope, 'mlspace-app-policy', { statements: appPolicyAndStatements(scope.partition, Aws.REGION, mlSpaceAppRoleName) }); @@ -1102,7 +1102,7 @@ export class IAMConstruct extends Construct { new ServicePrincipal('translate.amazonaws.com') ) : new ServicePrincipal('lambda.amazonaws.com'); - this.mlSpaceAppRole = new Role(this, 'mlspace-app-role', { + this.mlSpaceAppRole = new Role(scope, 'mlspace-app-role', { roleName: mlSpaceAppRoleName, assumedBy: appPolicyAllowPrinciples, managedPolicies: [ @@ -1123,9 +1123,9 @@ export class IAMConstruct extends Construct { * These actions include cleaning up resources for deleted projects and suspended users. */ if (props.mlspaceConfig.SYSTEM_ROLE_ARN) { - this.mlSpaceSystemRole = Role.fromRoleArn(this, mlspaceSystemRoleName, props.mlspaceConfig.SYSTEM_ROLE_ARN); + this.mlSpaceSystemRole = Role.fromRoleArn(scope, mlspaceSystemRoleName, props.mlspaceConfig.SYSTEM_ROLE_ARN); } else { - const systemPolicy = new ManagedPolicy(this, 'mlspace-system-policy', { + const systemPolicy = new ManagedPolicy(scope, 'mlspace-system-policy', { statements: appPolicyAndStatements(scope.partition, Aws.REGION, mlspaceSystemRoleName), }); const systemPolicyAllowPrinciples = props.enableTranslate @@ -1134,7 +1134,7 @@ export class IAMConstruct extends Construct { new ServicePrincipal('translate.amazonaws.com') ) : new ServicePrincipal('lambda.amazonaws.com'); - this.mlSpaceSystemRole = new Role(this, mlspaceSystemRoleName, { + this.mlSpaceSystemRole = new Role(scope, mlspaceSystemRoleName, { roleName: mlspaceSystemRoleName, assumedBy: systemPolicyAllowPrinciples, managedPolicies: [ @@ -1155,12 +1155,12 @@ export class IAMConstruct extends Construct { */ if (props.mlspaceConfig.S3_READER_ROLE_ARN) { this.s3ReaderRole = Role.fromRoleArn( - this, + scope, 'mlspace-s3-reader-role', props.mlspaceConfig.S3_READER_ROLE_ARN ); } else { - const s3WebsiteReadOnlyPolicy = new ManagedPolicy(this, 'mlspace-website-read-policy', { + const s3WebsiteReadOnlyPolicy = new ManagedPolicy(scope, 'mlspace-website-read-policy', { statements: [ new PolicyStatement({ effect: Effect.ALLOW, @@ -1169,7 +1169,7 @@ export class IAMConstruct extends Construct { }), ], }); - this.s3ReaderRole = new Role(this, 'mlspace-s3-reader-role', { + this.s3ReaderRole = new Role(scope, 'mlspace-s3-reader-role', { assumedBy: new ServicePrincipal('apigateway.amazonaws.com'), roleName: 'mlspace-s3-reader-Role', managedPolicies: [s3WebsiteReadOnlyPolicy], @@ -1184,12 +1184,12 @@ export class IAMConstruct extends Construct { */ if (props.mlspaceConfig.ENABLE_ACCESS_LOGGING) { if (props.mlspaceConfig.APIGATEWAY_CLOUDWATCH_ROLE_ARN) { - new CfnAccount(this, 'mlspace-cwl-api-gateway-account', { + new CfnAccount(scope, 'mlspace-cwl-api-gateway-account', { cloudWatchRoleArn: props.mlspaceConfig.APIGATEWAY_CLOUDWATCH_ROLE_ARN, }); } else { // Create CW Role - const apiGatewayCloudWatchRole = new Role(this, 'mlspace-cwl-role', { + const apiGatewayCloudWatchRole = new Role(scope, 'mlspace-cwl-role', { assumedBy: new ServicePrincipal('apigateway.amazonaws.com'), managedPolicies: [ ManagedPolicy.fromAwsManagedPolicyName( @@ -1198,7 +1198,7 @@ export class IAMConstruct extends Construct { ], }); - new CfnAccount(this, 'mlspace-cwl-api-gateway-account', { + new CfnAccount(scope, 'mlspace-cwl-api-gateway-account', { cloudWatchRoleArn: apiGatewayCloudWatchRole.roleArn, }); } @@ -1212,14 +1212,14 @@ export class IAMConstruct extends Construct { */ if (props.mlspaceConfig.EMR_DEFAULT_ROLE_ARN) { const existingEmrServiceRole = Role.fromRoleArn( - this, + scope, 'mlspace-emr_defaultrole', props.mlspaceConfig.EMR_DEFAULT_ROLE_ARN ); this.emrServiceRoleName = existingEmrServiceRole.roleName; } else { const serviceRoleName = 'EMR_DefaultRole'; - new Role(this, 'mlspace-emr_defaultrole', { + new Role(scope, 'mlspace-emr_defaultrole', { assumedBy: new ServicePrincipal('elasticmapreduce.amazonaws.com'), roleName: serviceRoleName, managedPolicies: [ @@ -1240,14 +1240,14 @@ export class IAMConstruct extends Construct { */ if (props.mlspaceConfig.EMR_EC2_INSTANCE_ROLE_ARN) { const existingEmrEC2Role = Role.fromRoleArn( - this, + scope, 'mlspace-emr_ec2_defaultrole', props.mlspaceConfig.EMR_EC2_INSTANCE_ROLE_ARN ); this.emrEC2RoleName = existingEmrEC2Role.roleName; } else { const emrEC2RoleName = 'EMR_EC2_DefaultRole'; - const ec2EMRRole = new Role(this, 'mlspace-emr_ec2_defaultrole', { + const ec2EMRRole = new Role(scope, 'mlspace-emr_ec2_defaultrole', { assumedBy: new ServicePrincipal('ec2.amazonaws.com'), roleName: emrEC2RoleName, managedPolicies: [ @@ -1258,7 +1258,7 @@ export class IAMConstruct extends Construct { description: 'Provides needed permissions for running an EMR Cluster.', }); - new CfnInstanceProfile(this, 'mlspace-emr-instance-profile', { + new CfnInstanceProfile(scope, 'mlspace-emr-instance-profile', { roles: [ec2EMRRole.roleName], instanceProfileName: ec2EMRRole.roleName, }); diff --git a/lib/constructs/infra/coreConstruct.ts b/lib/constructs/infra/coreConstruct.ts index 5119c252..a5571928 100644 --- a/lib/constructs/infra/coreConstruct.ts +++ b/lib/constructs/infra/coreConstruct.ts @@ -71,8 +71,8 @@ export class CoreConstruct extends Construct { const logsServicePrincipal = new ServicePrincipal('logs.amazonaws.com'); if (props.mlspaceConfig.NOTIFICATION_DISTRO) { - new Subscription(this, 'Subscription', { - topic: new Topic(this, 'mlspace-topic'), + new Subscription(scope, 'Subscription', { + topic: new Topic(scope, 'mlspace-topic'), endpoint: props.notificationDistro, protocol: SubscriptionProtocol.EMAIL, }); @@ -80,7 +80,7 @@ export class CoreConstruct extends Construct { let accessLogBucket = undefined; if (props.mlspaceConfig.ENABLE_ACCESS_LOGGING) { - accessLogBucket = new Bucket(this, 'mlspace-access-logs-bucket', { + accessLogBucket = new Bucket(scope, 'mlspace-access-logs-bucket', { bucketName: props.accessLogsBucketName, encryption: BucketEncryption.S3_MANAGED, publicReadAccess: false, @@ -91,7 +91,7 @@ export class CoreConstruct extends Construct { } // Config Bucket (holds emr config/notebook parameters) - const configBucket = new Bucket(this, 'mlspace-config-bucket', { + const configBucket = new Bucket(scope, 'mlspace-config-bucket', { bucketName: props.configBucketName, ...props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN ? {encryptionKey: props.encryptionKey} : {encryption: BucketEncryption.S3_MANAGED}, removalPolicy: RemovalPolicy.DESTROY, @@ -104,15 +104,15 @@ export class CoreConstruct extends Construct { // Publish notebook config // This is a pretty ugly hack but at the moment you can't use json data with // cross stack parameters - https://github.com/aws/aws-cdk/issues/21503 - const mlspaceNotebookRole = new StringParameter(this, 'dynamic-config-notebook-role', { + const mlspaceNotebookRole = new StringParameter(scope, 'dynamic-config-notebook-role', { parameterName: 'notebook-param-notebook-role-arn', stringValue: props.mlSpaceNotebookRole.roleArn, }); - const secGroupId = new StringParameter(this, 'dynamic-config-security-group', { + const secGroupId = new StringParameter(scope, 'dynamic-config-security-group', { parameterName: 'notebook-param-vpc-security-group', stringValue: props.mlSpaceDefaultSecurityGroupId, }); - const subnetIds = new StringParameter(this, 'dynamic-config-subnets', { + const subnetIds = new StringParameter(scope, 'dynamic-config-subnets', { parameterName: 'notebook-param-subnet-ids', stringValue: props.mlSpaceVPC.isolatedSubnets .concat(props.mlSpaceVPC.privateSubnets) @@ -120,7 +120,7 @@ export class CoreConstruct extends Construct { .join(','), }); - const kmsKeyId = new StringParameter(this, 'dynamic-config-kms-id', { + const kmsKeyId = new StringParameter(scope, 'dynamic-config-kms-id', { parameterName: 'notebook-param-kms-id', stringValue: props.encryptionKey.keyId, }); @@ -133,7 +133,7 @@ export class CoreConstruct extends Construct { pSMSDataBucketName: props.dataBucketName, }; - const configDeployment = new BucketDeployment(this, 'MLSpaceConfigDeployment', { + const configDeployment = new BucketDeployment(scope, 'MLSpaceConfigDeployment', { sources: [ Source.jsonData(props.mlspaceConfig.NOTEBOOK_PARAMETERS_FILE_NAME, notebookParams), Source.asset('./lib/resources/config'), @@ -141,14 +141,14 @@ export class CoreConstruct extends Construct { destinationBucket: configBucket, prune: true, role: props.mlspaceConfig.BUCKET_DEPLOYMENT_ROLE_ARN - ? Role.fromRoleArn(this, 'mlspace-config-deploy-role', props.mlspaceConfig.BUCKET_DEPLOYMENT_ROLE_ARN, { + ? Role.fromRoleArn(scope, 'mlspace-config-deploy-role', props.mlspaceConfig.BUCKET_DEPLOYMENT_ROLE_ARN, { mutable: false, }) : undefined, }); // Static Site - const websiteBucket = new Bucket(this, 'mlspace-website-bucket', { + const websiteBucket = new Bucket(scope, 'mlspace-website-bucket', { bucketName: props.websiteBucketName, accessControl: BucketAccessControl.BUCKET_OWNER_FULL_CONTROL, encryption: BucketEncryption.S3_MANAGED, @@ -179,7 +179,7 @@ export class CoreConstruct extends Construct { websiteBucket.grantRead(new ServicePrincipal('apigateway.amazonaws.com')); // Data Bucket - const dataBucket = new Bucket(this, 'mlspace-data-bucket', { + const dataBucket = new Bucket(scope, 'mlspace-data-bucket', { bucketName: props.dataBucketName, ...props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN ? {encryptionKey: props.encryptionKey} : {encryption: BucketEncryption.S3_MANAGED}, removalPolicy: RemovalPolicy.DESTROY, @@ -197,7 +197,7 @@ export class CoreConstruct extends Construct { serverAccessLogsPrefix: accessLogBucket ? 'mlspace-data-bucket' : undefined, }); - const exampleDataDeployment = new BucketDeployment(this, 'MLSpaceExampleDataDeployment', { + const exampleDataDeployment = new BucketDeployment(scope, 'MLSpaceExampleDataDeployment', { sources: [ Source.jsonData(props.mlspaceConfig.NOTEBOOK_PARAMETERS_FILE_NAME, notebookParams), Source.asset('lib/resources/sagemaker/global/'), @@ -207,7 +207,7 @@ export class CoreConstruct extends Construct { prune: false, role: props.mlspaceConfig.BUCKET_DEPLOYMENT_ROLE_ARN ? Role.fromRoleArn( - this, + scope, 'mlspace-example-data-deploy-role', props.mlspaceConfig.BUCKET_DEPLOYMENT_ROLE_ARN, { @@ -217,16 +217,16 @@ export class CoreConstruct extends Construct { : undefined, }); - const commonLambdaLayer = createLambdaLayer(this, 'common', undefined, props.mlspaceConfig.COMMON_LAYER_PATH); + const commonLambdaLayer = createLambdaLayer(scope, 'common', undefined, props.mlspaceConfig.COMMON_LAYER_PATH); // Save common layer arn to SSM to avoid issue related to cross stack references - new StringParameter(this, 'VersionArn', { + new StringParameter(scope, 'VersionArn', { parameterName: props.mlspaceConfig.COMMON_LAYER_ARN_PARAM, stringValue: commonLambdaLayer.layerVersion.layerVersionArn, }); // Lambda for populating the initial allowed instances in the app config - const appConfigLambda = new Function(this, 'appConfigDeployment', { + const appConfigLambda = new Function(scope, 'appConfigDeployment', { functionName: 'mls-lambda-app-config-deployment', description: 'Populates the initial app config', @@ -249,7 +249,7 @@ export class CoreConstruct extends Construct { }); // Lambda for cleaning up permissions that have been deprecated from the application - const permissionCleanupLambda = new Function(this, 'permissionCleanupLambda', { + const permissionCleanupLambda = new Function(scope, 'permissionCleanupLambda', { functionName: 'mls-lambda-permission-cleanup', description: 'Clears out deprecated permissions from tables', @@ -264,7 +264,7 @@ export class CoreConstruct extends Construct { securityGroups: props.lambdaSecurityGroups, }); - const dynamicRolesAttachPoliciesOnDeployLambda = new Function(this, 'drAttachPoliciesOnDeployLambda', { + const dynamicRolesAttachPoliciesOnDeployLambda = new Function(scope, 'drAttachPoliciesOnDeployLambda', { functionName: 'mls-lambda-dr-attach-policies-on-deploy', description: 'Attaches policies from notebook role to all dynamic user roles.', runtime: props.mlspaceConfig.LAMBDA_RUNTIME, @@ -287,7 +287,7 @@ export class CoreConstruct extends Construct { }); // run dynamicRolesAttachPoliciesOnDeployLambda every deploy - new AwsCustomResource(this, 'drAttachPoliciesOnDeploy', { + new AwsCustomResource(scope, 'drAttachPoliciesOnDeploy', { onCreate: { service: 'Lambda', action: 'invoke', @@ -300,7 +300,7 @@ export class CoreConstruct extends Construct { role: props.mlSpaceAppRole }); - const updateInstanceKmsConditionsLambda = new Function(this, 'updateInstanceKmsConditionsLambda', { + const updateInstanceKmsConditionsLambda = new Function(scope, 'updateInstanceKmsConditionsLambda', { functionName: 'mls-lambda-instance-kms-conditions', description: '', runtime: props.mlspaceConfig.LAMBDA_RUNTIME, @@ -319,7 +319,7 @@ export class CoreConstruct extends Construct { }); // run updateInstanceKmsConditionsLambda every deploy - new AwsCustomResource(this, 'kms-key-constraints', { + new AwsCustomResource(scope, 'kms-key-constraints', { onCreate: { service: 'Lambda', action: 'invoke', @@ -333,14 +333,14 @@ export class CoreConstruct extends Construct { }); // schedule updateInstanceKmsConditionsLambda to run every day - const updateInstanceKmsConditionsLambdaScheduleRule = new Rule(this, 'updateInstanceKmsConditionsLambdaScheduleRule', { + const updateInstanceKmsConditionsLambdaScheduleRule = new Rule(scope, 'updateInstanceKmsConditionsLambdaScheduleRule', { schedule: Schedule.cron({hour: '2', minute: '45'}) }); updateInstanceKmsConditionsLambdaScheduleRule.addTarget(new LambdaFunction(updateInstanceKmsConditionsLambda)); - const notifierLambdaLayer = createLambdaLayer(this, 'common', 'notifier', props.mlspaceConfig.COMMON_LAYER_PATH); + const notifierLambdaLayer = createLambdaLayer(scope, 'common', 'notifier', props.mlspaceConfig.COMMON_LAYER_PATH); - const s3NotificationLambda = new Function(this, 's3Notifier', { + const s3NotificationLambda = new Function(scope, 's3Notifier', { functionName: 'mls-lambda-s3-notifier', description: 'S3 event notification function to handle ddb actions in response to dataset file actions', @@ -375,7 +375,7 @@ export class CoreConstruct extends Construct { new LambdaDestination(s3NotificationLambda) ); - const terminateResourcesLambda = new Function(this, 'resourceTerminator', { + const terminateResourcesLambda = new Function(scope, 'resourceTerminator', { functionName: 'mls-lambda-resource-terminator', description: 'Sweeper function that stops/terminates resources based on scheduled configuration', @@ -395,14 +395,14 @@ export class CoreConstruct extends Construct { }); const ruleName = 'mlspace-rule-terminate-resources'; - new Rule(this, ruleName, { + new Rule(scope, ruleName, { schedule: Schedule.rate(Duration.minutes(props.mlspaceConfig.RESOURCE_TERMINATION_INTERVAL)), targets: [new LambdaFunction(terminateResourcesLambda)], ruleName: ruleName, }); // Logs Bucket - const cwlBucket = new Bucket(this, 'mlspace-logs-bucket', { + const cwlBucket = new Bucket(scope, 'mlspace-logs-bucket', { bucketName: props.cwlBucketName, removalPolicy: RemovalPolicy.DESTROY, ...props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN ? {encryptionKey: props.encryptionKey} : {encryption: BucketEncryption.S3_MANAGED}, @@ -441,7 +441,7 @@ export class CoreConstruct extends Construct { // Cloudtrail setup if (props.mlspaceConfig.CREATE_MLSPACE_CLOUDTRAIL_TRAIL) { - new Trail(this, 'mlspace-cloudtrail', { + new Trail(scope, 'mlspace-cloudtrail', { trailName: 'mlspace-cloudtrail', isMultiRegionTrail: true, includeGlobalServiceEvents: true, @@ -452,7 +452,7 @@ export class CoreConstruct extends Construct { // Datasets Table const datasetScopeAttribute = { name: 'scope', type: AttributeType.STRING }; const datasetNameAttribute = { name: 'name', type: AttributeType.STRING }; - new Table(this, 'mlspace-ddb-datasets', { + new Table(scope, 'mlspace-ddb-datasets', { tableName: props.mlspaceConfig.DATASETS_TABLE_NAME, partitionKey: datasetScopeAttribute, sortKey: datasetNameAttribute, @@ -461,7 +461,7 @@ export class CoreConstruct extends Construct { }); // Projects Table - new Table(this, 'mlspace-ddb-projects', { + new Table(scope, 'mlspace-ddb-projects', { tableName: props.mlspaceConfig.PROJECTS_TABLE_NAME, partitionKey: { name: 'name', type: AttributeType.STRING }, billingMode: BillingMode.PAY_PER_REQUEST, @@ -471,7 +471,7 @@ export class CoreConstruct extends Construct { // Project Users Table const projectAttribute = { name: 'project', type: AttributeType.STRING }; const userAttribute = { name: 'user', type: AttributeType.STRING }; - const projectUsersTable = new Table(this, 'mlspace-ddb-project-users', { + const projectUsersTable = new Table(scope, 'mlspace-ddb-project-users', { tableName: props.mlspaceConfig.PROJECT_USERS_TABLE_NAME, partitionKey: projectAttribute, sortKey: userAttribute, @@ -487,7 +487,7 @@ export class CoreConstruct extends Construct { }); // Groups Table - new Table(this, 'mlspace-ddb-groups', { + new Table(scope, 'mlspace-ddb-groups', { tableName: props.mlspaceConfig.GROUPS_TABLE_NAME, partitionKey: { name: 'name', type: AttributeType.STRING }, billingMode: BillingMode.PAY_PER_REQUEST, @@ -497,7 +497,7 @@ export class CoreConstruct extends Construct { // Group Datasets Table const groupAttribute = { name: 'group', type: AttributeType.STRING }; const groupDatasetAttribute = { name: 'dataset', type: AttributeType.STRING }; - const groupDatasetTable = new Table(this, 'mlspace-ddb-group-datasets', { + const groupDatasetTable = new Table(scope, 'mlspace-ddb-group-datasets', { tableName: props.mlspaceConfig.GROUP_DATASETS_TABLE_NAME, partitionKey: groupAttribute, sortKey: groupDatasetAttribute, @@ -514,7 +514,7 @@ export class CoreConstruct extends Construct { // Group Users Table const groupUserAttribute = { name: 'user', type: AttributeType.STRING }; - const groupUsersTable = new Table(this, 'mlspace-ddb-group-users', { + const groupUsersTable = new Table(scope, 'mlspace-ddb-group-users', { tableName: props.mlspaceConfig.GROUP_USERS_TABLE_NAME, partitionKey: groupAttribute, sortKey: groupUserAttribute, @@ -532,7 +532,7 @@ export class CoreConstruct extends Construct { // Group Membership History Table const groupMembershipHistoryAttribute = { name: 'group', type: AttributeType.STRING }; const groupMembershipHistorySortAttribute = { name: 'actionedAt', type: AttributeType.NUMBER }; - new Table(this, 'mlspace-ddb-group-membership-history', { + new Table(scope, 'mlspace-ddb-group-membership-history', { tableName: props.mlspaceConfig.GROUPS_MEMBERSHIP_HISTORY_TABLE_NAME, partitionKey: groupMembershipHistoryAttribute, sortKey: groupMembershipHistorySortAttribute, @@ -541,7 +541,7 @@ export class CoreConstruct extends Construct { }); // Users Table - new Table(this, 'mlspace-ddb-users', { + new Table(scope, 'mlspace-ddb-users', { tableName: props.mlspaceConfig.USERS_TABLE_NAME, partitionKey: { name: 'username', type: AttributeType.STRING }, billingMode: BillingMode.PAY_PER_REQUEST, @@ -549,7 +549,7 @@ export class CoreConstruct extends Construct { }); // Project Groups Table - const projectGroupsTable = new Table(this, 'mlspace-ddb-project-groups', { + const projectGroupsTable = new Table(scope, 'mlspace-ddb-project-groups', { tableName: props.mlspaceConfig.PROJECT_GROUPS_TABLE_NAME, partitionKey: projectAttribute, sortKey: groupAttribute, @@ -567,7 +567,7 @@ export class CoreConstruct extends Construct { // Resource Termination Schedule Table const resourceIdAttribute = { name: 'resourceId', type: AttributeType.STRING }; const resourceTypeAttribute = { name: 'resourceType', type: AttributeType.STRING }; - new Table(this, 'mlspace-ddb-resource-schedule', { + new Table(scope, 'mlspace-ddb-resource-schedule', { tableName: props.mlspaceConfig.RESOURCE_SCHEDULE_TABLE_NAME, partitionKey: resourceIdAttribute, sortKey: resourceTypeAttribute, @@ -576,7 +576,7 @@ export class CoreConstruct extends Construct { }); // Resources Metadata Table - const resourcesMetadataTable = new Table(this, 'mlspace-resource-metadata', { + const resourcesMetadataTable = new Table(scope, 'mlspace-resource-metadata', { tableName: props.mlspaceConfig.RESOURCE_METADATA_TABLE_NAME, partitionKey: resourceTypeAttribute, sortKey: resourceIdAttribute, @@ -597,7 +597,7 @@ export class CoreConstruct extends Construct { }); // App Configuration Table - new Table(this, 'mlspace-ddb-app-configuration', { + new Table(scope, 'mlspace-ddb-app-configuration', { tableName: props.mlspaceConfig.APP_CONFIGURATION_TABLE_NAME, partitionKey: { name: 'configScope', type: AttributeType.STRING }, sortKey: { name: 'versionId', type: AttributeType.NUMBER }, @@ -606,7 +606,7 @@ export class CoreConstruct extends Construct { }); // Populate the App Config table with default config - new AwsCustomResource(this, 'mlspace-init-ddb-app-config', { + new AwsCustomResource(scope, 'mlspace-init-ddb-app-config', { onCreate: { service: 'DynamoDB', action: 'putItem', @@ -619,7 +619,7 @@ export class CoreConstruct extends Construct { role: props.mlSpaceAppRole }); - new AwsCustomResource(this, 'initial-app-config-deployment-001', { + new AwsCustomResource(scope, 'initial-app-config-deployment-001', { onCreate: { service: 'Lambda', action: 'invoke', @@ -632,7 +632,7 @@ export class CoreConstruct extends Construct { role: props.mlSpaceAppRole }); - new AwsCustomResource(this, 'cleanup-deprecated-permissions', { + new AwsCustomResource(scope, 'cleanup-deprecated-permissions', { onCreate: { service: 'Lambda', action: 'invoke', @@ -646,7 +646,7 @@ export class CoreConstruct extends Construct { }); // EMR Security Configuration - new CfnSecurityConfiguration(this, 'mlspace-emr-security-config', { + new CfnSecurityConfiguration(scope, 'mlspace-emr-security-config', { name: props.mlspaceConfig.EMR_SECURITY_CONFIG_NAME, securityConfiguration: { InstanceMetadataServiceConfiguration: { @@ -656,7 +656,7 @@ export class CoreConstruct extends Construct { }, }); - const resourceMetadataLambda = new Function(this, 'mlspace-resource-metadata-lambda', { + const resourceMetadataLambda = new Function(scope, 'mlspace-resource-metadata-lambda', { functionName: 'mls-lambda-resource-metadata', description: 'Lambda to process event bridge notifications and update corresponding entries in the mlspace resource metadata ddb table.', @@ -677,7 +677,7 @@ export class CoreConstruct extends Construct { }); // Event bridge rule for resource metadata capture - new Rule(this, 'mlspace-resource-metadata-rule', { + new Rule(scope, 'mlspace-resource-metadata-rule', { ruleName: 'mlspace-resource-metadata-sync', eventPattern: { account: [scope.account], @@ -699,7 +699,7 @@ export class CoreConstruct extends Construct { targets: [new LambdaFunction(resourceMetadataLambda)], }); - new Rule(this, 'mlspace-cloudtrail-metadata-rule', { + new Rule(scope, 'mlspace-cloudtrail-metadata-rule', { ruleName: 'mlspace-cloudtrail-metadata-sync', eventPattern: { account: [scope.account], diff --git a/lib/constructs/infra/sagemakerConstruct.ts b/lib/constructs/infra/sagemakerConstruct.ts index 95eb18f2..c1c7687e 100644 --- a/lib/constructs/infra/sagemakerConstruct.ts +++ b/lib/constructs/infra/sagemakerConstruct.ts @@ -29,7 +29,7 @@ export class SagemakerConstruct extends Construct { constructor (scope: Stack, id: string, props: SagemakerStackProp) { super(scope, id); - new CfnNotebookInstanceLifecycleConfig(this, 'mlspace-notebook-lifecycle-config', { + new CfnNotebookInstanceLifecycleConfig(scope, 'mlspace-notebook-lifecycle-config', { notebookInstanceLifecycleConfigName: props.mlspaceConfig.MLSPACE_LIFECYCLE_CONFIG_NAME, onCreate: [ { diff --git a/lib/stacks/api/admin.ts b/lib/stacks/api/admin.ts index 78b2a36a..a9f4d7ea 100644 --- a/lib/stacks/api/admin.ts +++ b/lib/stacks/api/admin.ts @@ -26,7 +26,7 @@ export class AdminApiStack extends Stack { }); // eslint-disable-next-line @typescript-eslint/no-unused-vars - const adminApiConstruct = new AdminApiConstruct(this, id + 'Resources', props); + const adminApiConstruct = new AdminApiConstruct(this, id, props); } } diff --git a/lib/stacks/api/apiDeployment.ts b/lib/stacks/api/apiDeployment.ts index f0993433..de8b57c2 100644 --- a/lib/stacks/api/apiDeployment.ts +++ b/lib/stacks/api/apiDeployment.ts @@ -29,7 +29,7 @@ export class ApiDeploymentStack extends Stack { }); // eslint-disable-next-line @typescript-eslint/no-unused-vars - const apiDeploymentConstruct = new ApiDeploymentConstruct(this, id + 'Resources', props); + const apiDeploymentConstruct = new ApiDeploymentConstruct(this, id, props); } } \ No newline at end of file diff --git a/lib/stacks/api/appConfiguration.ts b/lib/stacks/api/appConfiguration.ts index 1e707270..98690fcd 100644 --- a/lib/stacks/api/appConfiguration.ts +++ b/lib/stacks/api/appConfiguration.ts @@ -26,7 +26,7 @@ export class AppConfigurationApiStack extends Stack { }); // eslint-disable-next-line @typescript-eslint/no-unused-vars - const appConfigurationApiConstruct = new AppConfigurationApiConstruct(this, id + 'Resources', props); + const appConfigurationApiConstruct = new AppConfigurationApiConstruct(this, id, props); } } diff --git a/lib/stacks/api/datasets.ts b/lib/stacks/api/datasets.ts index c07ffe99..ae9e15f6 100644 --- a/lib/stacks/api/datasets.ts +++ b/lib/stacks/api/datasets.ts @@ -26,7 +26,7 @@ export class DatasetsApiStack extends Stack { }); // eslint-disable-next-line @typescript-eslint/no-unused-vars - const datasetsApiConstruct = new DatasetsApiConstruct(this, id + 'Resources', props); + const datasetsApiConstruct = new DatasetsApiConstruct(this, id, props); } } diff --git a/lib/stacks/api/emr.ts b/lib/stacks/api/emr.ts index b01fd464..90bd6e75 100644 --- a/lib/stacks/api/emr.ts +++ b/lib/stacks/api/emr.ts @@ -26,7 +26,7 @@ export class EmrApiStack extends Stack { }); // eslint-disable-next-line @typescript-eslint/no-unused-vars - const emrApiConstruct = new EmrApiConstruct(this, id + 'Resources', props); + const emrApiConstruct = new EmrApiConstruct(this, id, props); } } diff --git a/lib/stacks/api/groupMembershipHistory.ts b/lib/stacks/api/groupMembershipHistory.ts index 90af4c91..ba7c28d0 100644 --- a/lib/stacks/api/groupMembershipHistory.ts +++ b/lib/stacks/api/groupMembershipHistory.ts @@ -26,7 +26,7 @@ export class GroupMembershipHistoryApiStack extends Stack { }); // eslint-disable-next-line @typescript-eslint/no-unused-vars - const groupMembershipHistoryApiConstruct = new GroupMembershipHistoryApiConstruct(this, id + 'Resources', props); + const groupMembershipHistoryApiConstruct = new GroupMembershipHistoryApiConstruct(this, id, props); } } diff --git a/lib/stacks/api/groups.ts b/lib/stacks/api/groups.ts index 7cbd8ce6..5b0c5808 100644 --- a/lib/stacks/api/groups.ts +++ b/lib/stacks/api/groups.ts @@ -26,7 +26,7 @@ export class GroupsApiStack extends Stack { }); // eslint-disable-next-line @typescript-eslint/no-unused-vars - const groupsApiConstruct = new GroupsApiConstruct(this, id + 'Resources', props); + const groupsApiConstruct = new GroupsApiConstruct(this, id, props); } } diff --git a/lib/stacks/api/inference.ts b/lib/stacks/api/inference.ts index 99237432..d1279ba5 100644 --- a/lib/stacks/api/inference.ts +++ b/lib/stacks/api/inference.ts @@ -26,7 +26,7 @@ export class InferenceApiStack extends Stack { }); // eslint-disable-next-line @typescript-eslint/no-unused-vars - const inferenceApiConstruct = new InferenceApiConstruct(this, id + 'Resources', props); + const inferenceApiConstruct = new InferenceApiConstruct(this, id, props); } } diff --git a/lib/stacks/api/jobs.ts b/lib/stacks/api/jobs.ts index 221e2294..fecbae9a 100644 --- a/lib/stacks/api/jobs.ts +++ b/lib/stacks/api/jobs.ts @@ -26,7 +26,7 @@ export class JobsApiStack extends Stack { }); // eslint-disable-next-line @typescript-eslint/no-unused-vars - const jobsApiConstruct = new JobsApiConstruct(this, id + 'Resources', props); + const jobsApiConstruct = new JobsApiConstruct(this, id, props); } } diff --git a/lib/stacks/api/notebooks.ts b/lib/stacks/api/notebooks.ts index 4b1d7012..ca7f6bbc 100644 --- a/lib/stacks/api/notebooks.ts +++ b/lib/stacks/api/notebooks.ts @@ -26,7 +26,7 @@ export class NotebooksApiStack extends Stack { }); // eslint-disable-next-line @typescript-eslint/no-unused-vars - const notebooksApiConstruct = new NotebooksApiConstruct(this, id + 'Resources', props); + const notebooksApiConstruct = new NotebooksApiConstruct(this, id, props); } } diff --git a/lib/stacks/api/projects.ts b/lib/stacks/api/projects.ts index 0cea21c6..4ec8646f 100644 --- a/lib/stacks/api/projects.ts +++ b/lib/stacks/api/projects.ts @@ -26,7 +26,7 @@ export class ProjectsApiStack extends Stack { }); // eslint-disable-next-line @typescript-eslint/no-unused-vars - const projectsApiConstruct = new ProjectsApiConstruct(this, id + 'Resources', props); + const projectsApiConstruct = new ProjectsApiConstruct(this, id, props); } } diff --git a/lib/stacks/api/restApi.ts b/lib/stacks/api/restApi.ts index cce2a4d9..72be3066 100644 --- a/lib/stacks/api/restApi.ts +++ b/lib/stacks/api/restApi.ts @@ -75,7 +75,7 @@ export class RestApiStack extends Stack { ...props, }); - const restApiConstruct = new RestApiConstruct(this, id + 'Resources', props); + const restApiConstruct = new RestApiConstruct(this, id, props); this.mlspaceRequestAuthorizer = restApiConstruct.mlspaceRequestAuthorizer; this.mlSpaceRestApiId = restApiConstruct.mlSpaceRestApiId; diff --git a/lib/stacks/api/translate.ts b/lib/stacks/api/translate.ts index 19a0bf6a..a194d47c 100644 --- a/lib/stacks/api/translate.ts +++ b/lib/stacks/api/translate.ts @@ -26,7 +26,7 @@ export class TranslateApiStack extends Stack { }); // eslint-disable-next-line @typescript-eslint/no-unused-vars - const translateApiConstruct = new TranslateApiConstruct(this, id + 'Resources', props); + const translateApiConstruct = new TranslateApiConstruct(this, id, props); } } diff --git a/lib/stacks/iam.ts b/lib/stacks/iam.ts index f8e28dee..3b05ec65 100644 --- a/lib/stacks/iam.ts +++ b/lib/stacks/iam.ts @@ -54,7 +54,7 @@ export class IAMStack extends Stack { ...props, }); - const iamConstruct = new IAMConstruct(this, name + 'Resources', props); + const iamConstruct = new IAMConstruct(this, name, props); this.mlSpaceAppRole = iamConstruct.mlSpaceAppRole; this.mlSpaceNotebookRole = iamConstruct.mlSpaceNotebookRole; diff --git a/lib/stacks/infra/core.ts b/lib/stacks/infra/core.ts index fe17ab58..d3dd1c86 100644 --- a/lib/stacks/infra/core.ts +++ b/lib/stacks/infra/core.ts @@ -50,7 +50,7 @@ export class CoreStack extends Stack { }); // eslint-disable-next-line @typescript-eslint/no-unused-vars - const coreConstruct = new CoreConstruct(this, name + 'Resources', props); + const coreConstruct = new CoreConstruct(this, name, props); } } diff --git a/lib/stacks/infra/sagemaker.ts b/lib/stacks/infra/sagemaker.ts index 04013c51..16f08cfc 100644 --- a/lib/stacks/infra/sagemaker.ts +++ b/lib/stacks/infra/sagemaker.ts @@ -31,7 +31,7 @@ export class SagemakerStack extends Stack { }); // eslint-disable-next-line @typescript-eslint/no-unused-vars - const sagemakerConstruct = new SagemakerConstruct(this, name + 'Resources', props); + const sagemakerConstruct = new SagemakerConstruct(this, name, props); } } diff --git a/lib/stacks/kms.ts b/lib/stacks/kms.ts index b568d26d..105086dd 100644 --- a/lib/stacks/kms.ts +++ b/lib/stacks/kms.ts @@ -33,7 +33,7 @@ export class KMSStack extends Stack { ...props, }); - const kmsConstruct = new KMSConstruct(this, name + 'Resources', props); + const kmsConstruct = new KMSConstruct(this, name, props); this.masterKey = kmsConstruct.masterKey; diff --git a/lib/stacks/vpc.ts b/lib/stacks/vpc.ts index cdb6fbed..693b7469 100644 --- a/lib/stacks/vpc.ts +++ b/lib/stacks/vpc.ts @@ -41,7 +41,7 @@ export class VPCStack extends Stack { ...props, }); - const vpcConstruct = new VPCConstruct(this, name + 'Resources', props); + const vpcConstruct = new VPCConstruct(this, name, props); this.vpc = vpcConstruct.vpc; this.vpcSecurityGroupId = vpcConstruct.vpcSecurityGroupId; From 6fbb6d10754490fb7548636a993956cd04f98f07 Mon Sep 17 00:00:00 2001 From: Robert Szot Date: Mon, 30 Jun 2025 10:46:28 -0400 Subject: [PATCH 05/32] fix govcloud partition name in config file --- cdk.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cdk.json b/cdk.json index a9d968da..44c155ae 100644 --- a/cdk.json +++ b/cdk.json @@ -34,7 +34,7 @@ "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, "@aws-cdk/core:target-partitions": [ "aws", - "aws-gov", + "aws-us-gov", "aws-iso", "aws-iso-b" ] From 02fa8800dbb694d939854e287993134b928ceaf0 Mon Sep 17 00:00:00 2001 From: Evan Stohlmann Date: Tue, 26 Aug 2025 11:50:37 -0600 Subject: [PATCH 06/32] updating deps --- frontend/package-lock.json | 330 ++++++++++++++++++++----------------- package-lock.json | 110 +++++++------ 2 files changed, 238 insertions(+), 202 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index aded3415..5824790e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -319,16 +319,11 @@ } }, "node_modules/@aws-cdk/asset-awscli-v1": { - "version": "2.2.222", - "resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.222.tgz", - "integrity": "sha512-9qjd91FwBYmxjfF3ckieTKrmmvIBZdSe1Daf/hRGxAPnhtH9Fm5Y3Oi0dJD2tRw0ufyM6AbvX9zgejcTqXc+LQ==", - "dev": true - }, - "node_modules/@aws-cdk/asset-kubectl-v20": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@aws-cdk/asset-kubectl-v20/-/asset-kubectl-v20-2.1.3.tgz", - "integrity": "sha512-cDG1w3ieM6eOT9mTefRuTypk95+oyD7P5X/wRltwmYxU7nZc3+076YEVS6vrjDKr3ADYbfn0lDKpfB1FBtO9CQ==", - "dev": true + "version": "2.2.242", + "resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.242.tgz", + "integrity": "sha512-4c1bAy2ISzcdKXYS1k4HYZsNrgiwbiDzj36ybwFVxEWZXVAP0dimQTCaB9fxu7sWzEjw3d+eaw6Fon+QTfTIpQ==", + "dev": true, + "license": "Apache-2.0" }, "node_modules/@aws-cdk/asset-node-proxy-agent-v6": { "version": "2.1.0", @@ -337,17 +332,21 @@ "dev": true }, "node_modules/@aws-cdk/cloud-assembly-schema": { - "version": "39.2.15", - "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-39.2.15.tgz", - "integrity": "sha512-roeUKO5QR9JLnNEULg0RiS1ac6PZ9qsPaOcAJXCP0D1NLLECdxwwqJvLbhV91pCWrGTeWY5OhLtlL5OPS6Ycvg==", + "version": "48.5.0", + "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-48.5.0.tgz", + "integrity": "sha512-IH9KWy+Cxz9v/1h0qfbviXhhXFH86wV+lUHB2EKA0Sum0E1nGBM0jCgJjhdw809Mu/Xta+ggE2ceoSG4sftTCA==", "bundleDependencies": [ "jsonschema", "semver" ], "dev": true, + "license": "Apache-2.0", "dependencies": { "jsonschema": "~1.4.1", - "semver": "^7.7.1" + "semver": "^7.7.2" + }, + "engines": { + "node": ">= 18.0.0" } }, "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/jsonschema": { @@ -360,7 +359,7 @@ } }, "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/semver": { - "version": "7.7.1", + "version": "7.7.2", "dev": true, "inBundle": true, "license": "ISC", @@ -372,13 +371,14 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", + "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" @@ -653,17 +653,19 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -691,23 +693,25 @@ } }, "node_modules/@babel/helpers": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.7.tgz", - "integrity": "sha512-8NHiL98vsi0mbPQmYAGWwfcFaOy4j2HY49fXJCfuDcdE7fMIsH9a7GdaeXpIBsbT7307WU8KCMp5pUVDNL4f9A==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.3.tgz", + "integrity": "sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw==", + "license": "MIT", "dependencies": { - "@babel/template": "^7.25.9", - "@babel/types": "^7.26.7" + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.2" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.7.tgz", - "integrity": "sha512-kEvgGGgEjRUutvdVvZhbn/BxVt+5VSpwXz1j3WYXQbXDo8KzFOPNG2GQbdAiNq8g6wn1yKk7C/qrke03a84V+w==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz", + "integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==", + "license": "MIT", "dependencies": { - "@babel/types": "^7.26.7" + "@babel/types": "^7.28.2" }, "bin": { "parser": "bin/babel-parser.js" @@ -2254,24 +2258,23 @@ } }, "node_modules/@babel/runtime": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.7.tgz", - "integrity": "sha512-AOPI3D+a8dXnja+iwsUqGRjr1BbZIe771sXdapOtYI531gSqpi92vXivKcq2asu/DFpdl1ceFAKZyRzK2PCVcQ==", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.3.tgz", + "integrity": "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==", + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/template": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", - "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.25.9", - "@babel/parser": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -2295,12 +2298,13 @@ } }, "node_modules/@babel/types": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.7.tgz", - "integrity": "sha512-t8kDRGrKXyp6+tjUh7hw2RLyclsW4TRoRvRHtSyAX9Bb5ldlFh+90YAYY6awRXrlB4G5G2izNeGySpATlFzmOg==", + "version": "7.28.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", + "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -3257,10 +3261,11 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -3376,10 +3381,11 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -8044,9 +8050,9 @@ } }, "node_modules/aws-cdk-lib": { - "version": "2.177.0", - "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.177.0.tgz", - "integrity": "sha512-nTnHAwjZaPJ5gfJjtzE/MyK6q0a66nWthoJl7l8srucRb+I30dczhbbXor6QCdVpJaTRAEliMOMq23aglsAQbg==", + "version": "2.212.0", + "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.212.0.tgz", + "integrity": "sha512-7vy3/fSwmkJe6hmPpX2DXeDIr/VhMjhOPRH4Y0IUjC0c+W6S4XwQU2urRq3DFJRKRWXDwKidqMZlF1m0ZY1wMw==", "bundleDependencies": [ "@balena/dockerignore", "case", @@ -8061,25 +8067,25 @@ "mime-types" ], "dev": true, + "license": "Apache-2.0", "dependencies": { - "@aws-cdk/asset-awscli-v1": "^2.2.208", - "@aws-cdk/asset-kubectl-v20": "^2.1.3", + "@aws-cdk/asset-awscli-v1": "2.2.242", "@aws-cdk/asset-node-proxy-agent-v6": "^2.1.0", - "@aws-cdk/cloud-assembly-schema": "^39.2.0", + "@aws-cdk/cloud-assembly-schema": "^48.3.0", "@balena/dockerignore": "^1.0.2", "case": "1.6.3", - "fs-extra": "^11.2.0", + "fs-extra": "^11.3.1", "ignore": "^5.3.2", - "jsonschema": "^1.4.1", + "jsonschema": "^1.5.0", "mime-types": "^2.1.35", "minimatch": "^3.1.2", "punycode": "^2.3.1", - "semver": "^7.6.3", - "table": "^6.8.2", + "semver": "^7.7.2", + "table": "^6.9.0", "yaml": "1.10.2" }, "engines": { - "node": ">= 14.15.0" + "node": ">= 18.0.0" }, "peerDependencies": { "constructs": "^10.0.0" @@ -8147,7 +8153,7 @@ "license": "MIT" }, "node_modules/aws-cdk-lib/node_modules/brace-expansion": { - "version": "1.1.11", + "version": "1.1.12", "dev": true, "inBundle": true, "license": "MIT", @@ -8202,13 +8208,23 @@ "license": "MIT" }, "node_modules/aws-cdk-lib/node_modules/fast-uri": { - "version": "3.0.3", + "version": "3.0.6", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "inBundle": true, "license": "BSD-3-Clause" }, "node_modules/aws-cdk-lib/node_modules/fs-extra": { - "version": "11.2.0", + "version": "11.3.1", "dev": true, "inBundle": true, "license": "MIT", @@ -8252,7 +8268,7 @@ "license": "MIT" }, "node_modules/aws-cdk-lib/node_modules/jsonfile": { - "version": "6.1.0", + "version": "6.2.0", "dev": true, "inBundle": true, "license": "MIT", @@ -8264,7 +8280,7 @@ } }, "node_modules/aws-cdk-lib/node_modules/jsonschema": { - "version": "1.4.1", + "version": "1.5.0", "dev": true, "inBundle": true, "license": "MIT", @@ -8330,7 +8346,7 @@ } }, "node_modules/aws-cdk-lib/node_modules/semver": { - "version": "7.6.3", + "version": "7.7.2", "dev": true, "inBundle": true, "license": "ISC", @@ -8385,7 +8401,7 @@ } }, "node_modules/aws-cdk-lib/node_modules/table": { - "version": "6.8.2", + "version": "6.9.0", "dev": true, "inBundle": true, "license": "BSD-3-Clause", @@ -8428,12 +8444,13 @@ } }, "node_modules/axios": { - "version": "1.7.9", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", - "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", + "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", + "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", + "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, @@ -8882,10 +8899,11 @@ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" }, "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -8994,7 +9012,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", - "dev": true, "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" @@ -9067,9 +9084,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001697", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001697.tgz", - "integrity": "sha512-GwNPlWJin8E+d7Gxq96jxM6w0w+VFeyyXRsjU58emtkYqnbwHqXm5uT2uCmO0RQE9htWknOP4xtBlLmM/gWxvQ==", + "version": "1.0.30001737", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001737.tgz", + "integrity": "sha512-BiloLiXtQNrY5UyF0+1nSJLXUENuhka2pzy2Fx5pGxqavdrxSCW4U6Pn/PoG3Efspi2frRbHpBV2XsrPE6EDlw==", "funding": [ { "type": "opencollective", @@ -9083,7 +9100,8 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/case-sensitive-paths-webpack-plugin": { "version": "2.4.0", @@ -9437,16 +9455,17 @@ } }, "node_modules/compression": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.5.tgz", - "integrity": "sha512-bQJ0YRck5ak3LgtnpKkiabX5pNF7tMUh1BSy2ZBOTh0Dim0BUu6aPPwByIns6/A5Prh8PufSPerMDUklpzes2Q==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", "dev": true, + "license": "MIT", "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", - "on-headers": "~1.0.2", + "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" }, @@ -10684,7 +10703,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", @@ -10895,7 +10913,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "engines": { "node": ">= 0.4" } @@ -10904,7 +10921,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "engines": { "node": ">= 0.4" } @@ -10966,7 +10982,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "dependencies": { "es-errors": "^1.3.0" }, @@ -10978,7 +10993,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", @@ -11390,10 +11404,11 @@ } }, "node_modules/eslint-plugin-import/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -11495,10 +11510,11 @@ } }, "node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -11561,10 +11577,11 @@ } }, "node_modules/eslint-plugin-react/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -11893,10 +11910,11 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -12647,10 +12665,11 @@ } }, "node_modules/fork-ts-checker-webpack-plugin/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -12748,12 +12767,15 @@ } }, "node_modules/form-data": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", - "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { @@ -12905,7 +12927,6 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz", "integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==", - "dev": true, "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-define-property": "^1.0.1", @@ -12943,7 +12964,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" @@ -13044,9 +13064,10 @@ "dev": true }, "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -13149,7 +13170,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -13246,7 +13266,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -13258,7 +13277,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "dependencies": { "has-symbols": "^1.0.3" }, @@ -13848,10 +13866,11 @@ } }, "node_modules/http-proxy-middleware": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz", - "integrity": "sha512-fgVY8AV7qU7z/MmXJ/rxwbrtQH4jBQ9m7kp3llF0liB7glmFeVZFBepQb32T3y8n8k2+AEYuMPCpinYW+/CuRA==", + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", + "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==", "dev": true, + "license": "MIT", "dependencies": { "@types/http-proxy": "^1.17.8", "http-proxy": "^1.18.1", @@ -14809,10 +14828,11 @@ } }, "node_modules/jake/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -16491,14 +16511,17 @@ } }, "node_modules/jsdom/node_modules/form-data": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.2.tgz", - "integrity": "sha512-sJe+TQb2vIaIyO783qN6BlMYWMw3WBOHA1Ay2qxsnjuafEOQFJ2JakedOQirT6D5XPRxDvS7AHYyem9fTpb4LQ==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.4.tgz", + "integrity": "sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==", "dev": true, + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -16753,9 +16776,10 @@ } }, "node_modules/linkifyjs": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.2.0.tgz", - "integrity": "sha512-pCj3PrQyATaoTYKHrgWRF3SJwsm61udVh+vuls/Rl6SptiDhgE7ziUIudAedRY9QEfynmM7/RmLEfPUyw1HPCw==" + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz", + "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==", + "license": "MIT" }, "node_modules/lint-staged": { "version": "15.4.3", @@ -17252,7 +17276,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "engines": { "node": ">= 0.4" } @@ -18620,10 +18643,11 @@ } }, "node_modules/on-headers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", - "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -22901,10 +22925,11 @@ } }, "node_modules/recursive-readdir/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -23039,11 +23064,6 @@ "node": ">=4" } }, - "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" - }, "node_modules/regenerator-transform": { "version": "0.15.2", "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz", @@ -25368,9 +25388,10 @@ } }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -26281,10 +26302,11 @@ } }, "node_modules/vitepress/node_modules/vite": { - "version": "5.4.14", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.14.tgz", - "integrity": "sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==", + "version": "5.4.19", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", + "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", "dev": true, + "license": "MIT", "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/package-lock.json b/package-lock.json index e0fd10a3..ce28bc14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,16 +35,11 @@ } }, "node_modules/@aws-cdk/asset-awscli-v1": { - "version": "2.2.222", - "resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.222.tgz", - "integrity": "sha512-9qjd91FwBYmxjfF3ckieTKrmmvIBZdSe1Daf/hRGxAPnhtH9Fm5Y3Oi0dJD2tRw0ufyM6AbvX9zgejcTqXc+LQ==", - "dev": true - }, - "node_modules/@aws-cdk/asset-kubectl-v20": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@aws-cdk/asset-kubectl-v20/-/asset-kubectl-v20-2.1.3.tgz", - "integrity": "sha512-cDG1w3ieM6eOT9mTefRuTypk95+oyD7P5X/wRltwmYxU7nZc3+076YEVS6vrjDKr3ADYbfn0lDKpfB1FBtO9CQ==", - "dev": true + "version": "2.2.242", + "resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.242.tgz", + "integrity": "sha512-4c1bAy2ISzcdKXYS1k4HYZsNrgiwbiDzj36ybwFVxEWZXVAP0dimQTCaB9fxu7sWzEjw3d+eaw6Fon+QTfTIpQ==", + "dev": true, + "license": "Apache-2.0" }, "node_modules/@aws-cdk/asset-node-proxy-agent-v6": { "version": "2.1.0", @@ -53,17 +48,21 @@ "dev": true }, "node_modules/@aws-cdk/cloud-assembly-schema": { - "version": "39.2.15", - "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-39.2.15.tgz", - "integrity": "sha512-roeUKO5QR9JLnNEULg0RiS1ac6PZ9qsPaOcAJXCP0D1NLLECdxwwqJvLbhV91pCWrGTeWY5OhLtlL5OPS6Ycvg==", + "version": "48.5.0", + "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-48.5.0.tgz", + "integrity": "sha512-IH9KWy+Cxz9v/1h0qfbviXhhXFH86wV+lUHB2EKA0Sum0E1nGBM0jCgJjhdw809Mu/Xta+ggE2ceoSG4sftTCA==", "bundleDependencies": [ "jsonschema", "semver" ], "dev": true, + "license": "Apache-2.0", "dependencies": { "jsonschema": "~1.4.1", - "semver": "^7.7.1" + "semver": "^7.7.2" + }, + "engines": { + "node": ">= 18.0.0" } }, "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/jsonschema": { @@ -76,7 +75,7 @@ } }, "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/semver": { - "version": "7.7.1", + "version": "7.7.2", "dev": true, "inBundle": true, "license": "ISC", @@ -1906,10 +1905,11 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -1952,10 +1952,11 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -3338,9 +3339,9 @@ } }, "node_modules/aws-cdk-lib": { - "version": "2.177.0", - "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.177.0.tgz", - "integrity": "sha512-nTnHAwjZaPJ5gfJjtzE/MyK6q0a66nWthoJl7l8srucRb+I30dczhbbXor6QCdVpJaTRAEliMOMq23aglsAQbg==", + "version": "2.212.0", + "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.212.0.tgz", + "integrity": "sha512-7vy3/fSwmkJe6hmPpX2DXeDIr/VhMjhOPRH4Y0IUjC0c+W6S4XwQU2urRq3DFJRKRWXDwKidqMZlF1m0ZY1wMw==", "bundleDependencies": [ "@balena/dockerignore", "case", @@ -3355,25 +3356,25 @@ "mime-types" ], "dev": true, + "license": "Apache-2.0", "dependencies": { - "@aws-cdk/asset-awscli-v1": "^2.2.208", - "@aws-cdk/asset-kubectl-v20": "^2.1.3", + "@aws-cdk/asset-awscli-v1": "2.2.242", "@aws-cdk/asset-node-proxy-agent-v6": "^2.1.0", - "@aws-cdk/cloud-assembly-schema": "^39.2.0", + "@aws-cdk/cloud-assembly-schema": "^48.3.0", "@balena/dockerignore": "^1.0.2", "case": "1.6.3", - "fs-extra": "^11.2.0", + "fs-extra": "^11.3.1", "ignore": "^5.3.2", - "jsonschema": "^1.4.1", + "jsonschema": "^1.5.0", "mime-types": "^2.1.35", "minimatch": "^3.1.2", "punycode": "^2.3.1", - "semver": "^7.6.3", - "table": "^6.8.2", + "semver": "^7.7.2", + "table": "^6.9.0", "yaml": "1.10.2" }, "engines": { - "node": ">= 14.15.0" + "node": ">= 18.0.0" }, "peerDependencies": { "constructs": "^10.0.0" @@ -3441,7 +3442,7 @@ "license": "MIT" }, "node_modules/aws-cdk-lib/node_modules/brace-expansion": { - "version": "1.1.11", + "version": "1.1.12", "dev": true, "inBundle": true, "license": "MIT", @@ -3496,13 +3497,23 @@ "license": "MIT" }, "node_modules/aws-cdk-lib/node_modules/fast-uri": { - "version": "3.0.3", + "version": "3.0.6", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "inBundle": true, "license": "BSD-3-Clause" }, "node_modules/aws-cdk-lib/node_modules/fs-extra": { - "version": "11.2.0", + "version": "11.3.1", "dev": true, "inBundle": true, "license": "MIT", @@ -3546,7 +3557,7 @@ "license": "MIT" }, "node_modules/aws-cdk-lib/node_modules/jsonfile": { - "version": "6.1.0", + "version": "6.2.0", "dev": true, "inBundle": true, "license": "MIT", @@ -3558,7 +3569,7 @@ } }, "node_modules/aws-cdk-lib/node_modules/jsonschema": { - "version": "1.4.1", + "version": "1.5.0", "dev": true, "inBundle": true, "license": "MIT", @@ -3624,7 +3635,7 @@ } }, "node_modules/aws-cdk-lib/node_modules/semver": { - "version": "7.6.3", + "version": "7.7.2", "dev": true, "inBundle": true, "license": "ISC", @@ -3679,7 +3690,7 @@ } }, "node_modules/aws-cdk-lib/node_modules/table": { - "version": "6.8.2", + "version": "6.9.0", "dev": true, "inBundle": true, "license": "BSD-3-Clause", @@ -3725,10 +3736,11 @@ "dev": true }, "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -4096,10 +4108,11 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -4419,10 +4432,11 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" From d2a80480042dd095712c6a125543e7392bad4940 Mon Sep 17 00:00:00 2001 From: Evan Stohlmann Date: Tue, 26 Aug 2025 11:54:05 -0600 Subject: [PATCH 07/32] updating deps --- cypress/package-lock.json | 62 +++++++++++++++++++++++++++------------ 1 file changed, 44 insertions(+), 18 deletions(-) diff --git a/cypress/package-lock.json b/cypress/package-lock.json index 8ee9e0d6..ed0f4767 100644 --- a/cypress/package-lock.json +++ b/cypress/package-lock.json @@ -20,13 +20,11 @@ } }, "node_modules/@babel/runtime": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.7.tgz", - "integrity": "sha512-AOPI3D+a8dXnja+iwsUqGRjr1BbZIe771sXdapOtYI531gSqpi92vXivKcq2asu/DFpdl1ceFAKZyRzK2PCVcQ==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.3.tgz", + "integrity": "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==", "dev": true, - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -1015,6 +1013,21 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", @@ -1148,12 +1161,15 @@ } }, "node_modules/form-data": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", - "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { @@ -1296,6 +1312,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -2406,12 +2437,6 @@ "react-dom": ">=16.6.0" } }, - "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "dev": true - }, "node_modules/request-progress": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/request-progress/-/request-progress-3.0.0.tgz", @@ -2704,9 +2729,10 @@ "integrity": "sha512-uzhJ02RaMzgQR3yPoeE65DrcHI6LoM4saUqXOt/b5hmb3+mc4YWpdSeAQqVqRUlQ14q8ZuLRWyBR1ictK1dzzg==" }, "node_modules/tmp": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", - "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "license": "MIT", "engines": { "node": ">=14.14" } From 519089f107363969e3686484aa57050e4fcf2638 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 11 Sep 2025 06:31:17 +0000 Subject: [PATCH 08/32] Bump vite from 5.4.19 to 5.4.20 in /frontend Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.19 to 5.4.20. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/v5.4.20/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v5.4.20/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 5.4.20 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- frontend/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5824790e..3a73911d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -26302,9 +26302,9 @@ } }, "node_modules/vitepress/node_modules/vite": { - "version": "5.4.19", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", - "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", + "version": "5.4.20", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.20.tgz", + "integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==", "dev": true, "license": "MIT", "dependencies": { From 958b5489ef9958838a61be4cb0345acfa9f108af Mon Sep 17 00:00:00 2001 From: Dustin Sweigart Date: Wed, 10 Sep 2025 15:04:20 +0000 Subject: [PATCH 09/32] added VPC endpoints for EMR and Translate --- bin/mlspace-cdk.ts | 10 ++++++---- lib/constructs/vpcConstruct.ts | 14 ++++++++++++++ lib/stacks/vpc.ts | 3 ++- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/bin/mlspace-cdk.ts b/bin/mlspace-cdk.ts index 720dbae3..bcddd6e4 100644 --- a/bin/mlspace-cdk.ts +++ b/bin/mlspace-cdk.ts @@ -53,14 +53,19 @@ const app = new App(); const stacks = []; const isIso = ['us-iso-east-1', 'us-isob-east-1'].includes(config.AWS_REGION); +//Translate is not currently available in us-isob-east1 +const enableTranslate = !['us-isob-east-1'].includes(config.AWS_REGION); + const vpcStack = new VPCStack(app, 'mlspace-vpc', { env: envProperties, deployCFNEndpoint: true, deployCWEndpoint: true, deployCWLEndpoint: true, deployDDBEndpoint: true, + deployEMREndpoint: true, deployS3Endpoint: true, deploySTSEndpoint: true, + deployTranslateEndpoint: enableTranslate, isIso, mlspaceConfig: config }); @@ -79,9 +84,6 @@ const websiteBucketName = `${config.WEBSITE_BUCKET_NAME}-${config.AWS_ACCOUNT}`; const cwlBucketName = `${config.LOGS_BUCKET_NAME}-${config.AWS_ACCOUNT}`; const accessLogsBucketName = `${config.ACCESS_LOGS_BUCKET_NAME}-${config.AWS_ACCOUNT}`; -//Translate is not currently available in us-isob-east1 -const enableTranslate = !['us-isob-east-1'].includes(config.AWS_REGION); - const iamStack = new IAMStack(app, 'mlspace-iam', { env: envProperties, dataBucketName, @@ -238,4 +240,4 @@ stacks.forEach((resource) => { Tags.of(resource).add('user', 'MLSpaceApplication'); Tags.of(resource).add('project', 'MLSpaceInfrastructure'); } -}); \ No newline at end of file +}); diff --git a/lib/constructs/vpcConstruct.ts b/lib/constructs/vpcConstruct.ts index 5dcec0bb..5678dba2 100644 --- a/lib/constructs/vpcConstruct.ts +++ b/lib/constructs/vpcConstruct.ts @@ -142,6 +142,20 @@ export class VPCConstruct extends Construct { privateDnsEnabled: true, }); } + + if (props.deployTranslateEndpoint && !props.isIso) { + this.vpc.addInterfaceEndpoint('mlspace-translate-interface-endpoint', { + service: InterfaceVpcEndpointAwsService.TRANSLATE, + privateDnsEnabled: true, + }); + } + + if (props.deployEMREndpoint && !props.isIso) { + this.vpc.addInterfaceEndpoint('mlspace-emr-interface-endpoint', { + service: InterfaceVpcEndpointAwsService.EMR, + privateDnsEnabled: true, + }); + } } this.vpcSecurityGroup = SecurityGroup.fromSecurityGroupId( diff --git a/lib/stacks/vpc.ts b/lib/stacks/vpc.ts index 693b7469..f8178fe2 100644 --- a/lib/stacks/vpc.ts +++ b/lib/stacks/vpc.ts @@ -24,8 +24,10 @@ export type VPCStackProps = { readonly deployCWEndpoint: boolean; readonly deployCWLEndpoint: boolean; readonly deployDDBEndpoint: boolean; + readonly deployEMREndpoint: boolean; readonly deployS3Endpoint: boolean; readonly deploySTSEndpoint: boolean; + readonly deployTranslateEndpoint: boolean; readonly isIso?: boolean; readonly mlspaceConfig: MLSpaceConfig; } & StackProps; @@ -46,6 +48,5 @@ export class VPCStack extends Stack { this.vpc = vpcConstruct.vpc; this.vpcSecurityGroupId = vpcConstruct.vpcSecurityGroupId; this.vpcSecurityGroup = vpcConstruct.vpcSecurityGroup; - } } From 4fcf3c1fa616675f01c52d67f10b4f563a508e8e Mon Sep 17 00:00:00 2001 From: Dustin Sweigart Date: Fri, 12 Sep 2025 03:46:56 +0000 Subject: [PATCH 10/32] added using kms key for dynamo db tables --- lib/constructs/infra/coreConstruct.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/lib/constructs/infra/coreConstruct.ts b/lib/constructs/infra/coreConstruct.ts index a5571928..1daa99a5 100644 --- a/lib/constructs/infra/coreConstruct.ts +++ b/lib/constructs/infra/coreConstruct.ts @@ -457,7 +457,7 @@ export class CoreConstruct extends Construct { partitionKey: datasetScopeAttribute, sortKey: datasetNameAttribute, billingMode: BillingMode.PAY_PER_REQUEST, - encryption: TableEncryption.AWS_MANAGED, + ...props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN ? {encryptionKey: props.encryptionKey} : {encryption: TableEncryption.AWS_MANAGED}, }); // Projects Table @@ -465,7 +465,7 @@ export class CoreConstruct extends Construct { tableName: props.mlspaceConfig.PROJECTS_TABLE_NAME, partitionKey: { name: 'name', type: AttributeType.STRING }, billingMode: BillingMode.PAY_PER_REQUEST, - encryption: TableEncryption.AWS_MANAGED, + ...props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN ? {encryptionKey: props.encryptionKey} : {encryption: TableEncryption.AWS_MANAGED}, }); // Project Users Table @@ -476,7 +476,7 @@ export class CoreConstruct extends Construct { partitionKey: projectAttribute, sortKey: userAttribute, billingMode: BillingMode.PAY_PER_REQUEST, - encryption: TableEncryption.AWS_MANAGED, + ...props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN ? {encryptionKey: props.encryptionKey} : {encryption: TableEncryption.AWS_MANAGED}, }); projectUsersTable.addGlobalSecondaryIndex({ @@ -491,7 +491,7 @@ export class CoreConstruct extends Construct { tableName: props.mlspaceConfig.GROUPS_TABLE_NAME, partitionKey: { name: 'name', type: AttributeType.STRING }, billingMode: BillingMode.PAY_PER_REQUEST, - encryption: TableEncryption.AWS_MANAGED, + ...props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN ? {encryptionKey: props.encryptionKey} : {encryption: TableEncryption.AWS_MANAGED}, }); // Group Datasets Table @@ -502,7 +502,7 @@ export class CoreConstruct extends Construct { partitionKey: groupAttribute, sortKey: groupDatasetAttribute, billingMode: BillingMode.PAY_PER_REQUEST, - encryption: TableEncryption.AWS_MANAGED, + ...props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN ? {encryptionKey: props.encryptionKey} : {encryption: TableEncryption.AWS_MANAGED}, }); groupDatasetTable.addGlobalSecondaryIndex({ @@ -519,7 +519,7 @@ export class CoreConstruct extends Construct { partitionKey: groupAttribute, sortKey: groupUserAttribute, billingMode: BillingMode.PAY_PER_REQUEST, - encryption: TableEncryption.AWS_MANAGED, + ...props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN ? {encryptionKey: props.encryptionKey} : {encryption: TableEncryption.AWS_MANAGED}, }); groupUsersTable.addGlobalSecondaryIndex({ @@ -537,7 +537,7 @@ export class CoreConstruct extends Construct { partitionKey: groupMembershipHistoryAttribute, sortKey: groupMembershipHistorySortAttribute, billingMode: BillingMode.PAY_PER_REQUEST, - encryption: TableEncryption.AWS_MANAGED, + ...props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN ? {encryptionKey: props.encryptionKey} : {encryption: TableEncryption.AWS_MANAGED}, }); // Users Table @@ -545,7 +545,7 @@ export class CoreConstruct extends Construct { tableName: props.mlspaceConfig.USERS_TABLE_NAME, partitionKey: { name: 'username', type: AttributeType.STRING }, billingMode: BillingMode.PAY_PER_REQUEST, - encryption: TableEncryption.AWS_MANAGED, + ...props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN ? {encryptionKey: props.encryptionKey} : {encryption: TableEncryption.AWS_MANAGED}, }); // Project Groups Table @@ -554,7 +554,7 @@ export class CoreConstruct extends Construct { partitionKey: projectAttribute, sortKey: groupAttribute, billingMode: BillingMode.PAY_PER_REQUEST, - encryption: TableEncryption.AWS_MANAGED, + ...props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN ? {encryptionKey: props.encryptionKey} : {encryption: TableEncryption.AWS_MANAGED}, }); projectGroupsTable.addGlobalSecondaryIndex({ @@ -572,7 +572,7 @@ export class CoreConstruct extends Construct { partitionKey: resourceIdAttribute, sortKey: resourceTypeAttribute, billingMode: BillingMode.PAY_PER_REQUEST, - encryption: TableEncryption.AWS_MANAGED, + ...props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN ? {encryptionKey: props.encryptionKey} : {encryption: TableEncryption.AWS_MANAGED}, }); // Resources Metadata Table @@ -581,7 +581,7 @@ export class CoreConstruct extends Construct { partitionKey: resourceTypeAttribute, sortKey: resourceIdAttribute, billingMode: BillingMode.PAY_PER_REQUEST, - encryption: TableEncryption.AWS_MANAGED, + ...props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN ? {encryptionKey: props.encryptionKey} : {encryption: TableEncryption.AWS_MANAGED}, }); resourcesMetadataTable.addLocalSecondaryIndex({ @@ -602,7 +602,7 @@ export class CoreConstruct extends Construct { partitionKey: { name: 'configScope', type: AttributeType.STRING }, sortKey: { name: 'versionId', type: AttributeType.NUMBER }, billingMode: BillingMode.PAY_PER_REQUEST, - encryption: TableEncryption.AWS_MANAGED, + ...props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN ? {encryptionKey: props.encryptionKey} : {encryption: TableEncryption.AWS_MANAGED}, }); // Populate the App Config table with default config From 8a6c69fb5dcfbd51fe16fe353ca4ca33403367d0 Mon Sep 17 00:00:00 2001 From: Dustin Sweigart Date: Fri, 12 Sep 2025 16:57:55 +0000 Subject: [PATCH 11/32] added additional config for using KMS CMK for DDB --- .devcontainer/devcontainer.json | 13 +++++++------ lib/constants.ts | 6 +++++- lib/constructs/infra/coreConstruct.ts | 24 ++++++++++++------------ lib/utils/configTypes.ts | 3 +++ 4 files changed, 27 insertions(+), 19 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a0179b47..c1d4707f 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -7,14 +7,14 @@ "features": { "ghcr.io/devcontainers/features/aws-cli:1": {}, "ghcr.io/devcontainers/features/node:1": {}, - "ghcr.io/devcontainers-contrib/features/aws-cdk:2": {}, + "ghcr.io/devcontainers-extra/features/aws-cdk:2": {}, "ghcr.io/devcontainers/features/docker-in-docker:2": {}, "ghcr.io/devcontainers/features/git:1": {}, "ghcr.io/dhoeric/features/act:1": {}, - "ghcr.io/devcontainers-contrib/features/pre-commit:2": {}, - "ghcr.io/devcontainers-contrib/features/black:2": {}, - "ghcr.io/devcontainers-contrib/features/flake8:2": {}, - "ghcr.io/devcontainers-contrib/features/isort:2": {} + "ghcr.io/devcontainers-extra/features/pre-commit:2": {}, + "ghcr.io/devcontainers-extra/features/black:2": {}, + "ghcr.io/devcontainers-extra/features/flake8:2": {}, + "ghcr.io/devcontainers-extra/features/isort:2": {} }, "customizations": { "vscode": { @@ -26,7 +26,8 @@ }, "mounts": [ - "source=${localEnv:HOME}${localEnv:USERPROFILE}/.ssh,target=/home/vscode/.ssh,readonly,type=bind" + "source=${localEnv:HOME}${localEnv:USERPROFILE}/.ssh,target=/home/vscode/.ssh,readonly,type=bind", + "source=${localEnv:HOME}${localEnv:USERPROFILE}/.midway,target=/home/vscode/.midway,readonly,type=bind" ], // Features to add to the dev container. More info: https://containers.dev/features. diff --git a/lib/constants.ts b/lib/constants.ts index fed3f168..4562ffb9 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -34,7 +34,7 @@ export const GROUP_USERS_TABLE_NAME = 'mlspace-group-users'; export const CONFIG_BUCKET_NAME = 'mlspace-config'; export const DATA_BUCKET_NAME = 'mlspace-data'; export const LOGS_BUCKET_NAME = 'mlspace-logs'; -export const ACCESS_LOGS_BUCKET_NAME = 'mlspace-access-logs'; +export const ACCESS_LOGS_BUCKET_NAME = 'mlspace-access-logs-alpha'; export const WEBSITE_BUCKET_NAME = 'mlspace-website'; export const MLSPACE_LIFECYCLE_CONFIG_NAME = 'mlspace-notebook-lifecycle-config'; export const NOTEBOOK_PARAMETERS_FILE_NAME = 'notebook-params.json'; @@ -140,3 +140,7 @@ export const LAMBDA_ARCHITECTURE = Architecture.X86_64; export const LAMBDA_RUNTIME = Runtime.PYTHON_3_11; export const SHOW_MIGRATION_OPTIONS = false; + +// Set this to true to enable customer-managed KMS encryption for DynamoDB tables +// Requires EXISTING_KMS_MASTER_KEY_ARN to be set. Defaults to false for backward compatibility. +export const ENABLE_DDB_KMS_CMK_ENCRYPTION = false; diff --git a/lib/constructs/infra/coreConstruct.ts b/lib/constructs/infra/coreConstruct.ts index 1daa99a5..e4a7f740 100644 --- a/lib/constructs/infra/coreConstruct.ts +++ b/lib/constructs/infra/coreConstruct.ts @@ -457,7 +457,7 @@ export class CoreConstruct extends Construct { partitionKey: datasetScopeAttribute, sortKey: datasetNameAttribute, billingMode: BillingMode.PAY_PER_REQUEST, - ...props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN ? {encryptionKey: props.encryptionKey} : {encryption: TableEncryption.AWS_MANAGED}, + ...(props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN && props.mlspaceConfig.ENABLE_DDB_KMS_CMK_ENCRYPTION) ? {encryptionKey: props.encryptionKey} : {encryption: TableEncryption.AWS_MANAGED}, }); // Projects Table @@ -465,7 +465,7 @@ export class CoreConstruct extends Construct { tableName: props.mlspaceConfig.PROJECTS_TABLE_NAME, partitionKey: { name: 'name', type: AttributeType.STRING }, billingMode: BillingMode.PAY_PER_REQUEST, - ...props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN ? {encryptionKey: props.encryptionKey} : {encryption: TableEncryption.AWS_MANAGED}, + ...(props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN && props.mlspaceConfig.ENABLE_DDB_KMS_CMK_ENCRYPTION) ? {encryptionKey: props.encryptionKey} : {encryption: TableEncryption.AWS_MANAGED}, }); // Project Users Table @@ -476,7 +476,7 @@ export class CoreConstruct extends Construct { partitionKey: projectAttribute, sortKey: userAttribute, billingMode: BillingMode.PAY_PER_REQUEST, - ...props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN ? {encryptionKey: props.encryptionKey} : {encryption: TableEncryption.AWS_MANAGED}, + ...(props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN && props.mlspaceConfig.ENABLE_DDB_KMS_CMK_ENCRYPTION) ? {encryptionKey: props.encryptionKey} : {encryption: TableEncryption.AWS_MANAGED}, }); projectUsersTable.addGlobalSecondaryIndex({ @@ -491,7 +491,7 @@ export class CoreConstruct extends Construct { tableName: props.mlspaceConfig.GROUPS_TABLE_NAME, partitionKey: { name: 'name', type: AttributeType.STRING }, billingMode: BillingMode.PAY_PER_REQUEST, - ...props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN ? {encryptionKey: props.encryptionKey} : {encryption: TableEncryption.AWS_MANAGED}, + ...(props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN && props.mlspaceConfig.ENABLE_DDB_KMS_CMK_ENCRYPTION) ? {encryptionKey: props.encryptionKey} : {encryption: TableEncryption.AWS_MANAGED}, }); // Group Datasets Table @@ -502,7 +502,7 @@ export class CoreConstruct extends Construct { partitionKey: groupAttribute, sortKey: groupDatasetAttribute, billingMode: BillingMode.PAY_PER_REQUEST, - ...props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN ? {encryptionKey: props.encryptionKey} : {encryption: TableEncryption.AWS_MANAGED}, + ...(props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN && props.mlspaceConfig.ENABLE_DDB_KMS_CMK_ENCRYPTION) ? {encryptionKey: props.encryptionKey} : {encryption: TableEncryption.AWS_MANAGED}, }); groupDatasetTable.addGlobalSecondaryIndex({ @@ -519,7 +519,7 @@ export class CoreConstruct extends Construct { partitionKey: groupAttribute, sortKey: groupUserAttribute, billingMode: BillingMode.PAY_PER_REQUEST, - ...props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN ? {encryptionKey: props.encryptionKey} : {encryption: TableEncryption.AWS_MANAGED}, + ...(props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN && props.mlspaceConfig.ENABLE_DDB_KMS_CMK_ENCRYPTION) ? {encryptionKey: props.encryptionKey} : {encryption: TableEncryption.AWS_MANAGED}, }); groupUsersTable.addGlobalSecondaryIndex({ @@ -537,7 +537,7 @@ export class CoreConstruct extends Construct { partitionKey: groupMembershipHistoryAttribute, sortKey: groupMembershipHistorySortAttribute, billingMode: BillingMode.PAY_PER_REQUEST, - ...props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN ? {encryptionKey: props.encryptionKey} : {encryption: TableEncryption.AWS_MANAGED}, + ...(props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN && props.mlspaceConfig.ENABLE_DDB_KMS_CMK_ENCRYPTION) ? {encryptionKey: props.encryptionKey} : {encryption: TableEncryption.AWS_MANAGED}, }); // Users Table @@ -545,7 +545,7 @@ export class CoreConstruct extends Construct { tableName: props.mlspaceConfig.USERS_TABLE_NAME, partitionKey: { name: 'username', type: AttributeType.STRING }, billingMode: BillingMode.PAY_PER_REQUEST, - ...props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN ? {encryptionKey: props.encryptionKey} : {encryption: TableEncryption.AWS_MANAGED}, + ...(props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN && props.mlspaceConfig.ENABLE_DDB_KMS_CMK_ENCRYPTION) ? {encryptionKey: props.encryptionKey} : {encryption: TableEncryption.AWS_MANAGED}, }); // Project Groups Table @@ -554,7 +554,7 @@ export class CoreConstruct extends Construct { partitionKey: projectAttribute, sortKey: groupAttribute, billingMode: BillingMode.PAY_PER_REQUEST, - ...props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN ? {encryptionKey: props.encryptionKey} : {encryption: TableEncryption.AWS_MANAGED}, + ...(props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN && props.mlspaceConfig.ENABLE_DDB_KMS_CMK_ENCRYPTION) ? {encryptionKey: props.encryptionKey} : {encryption: TableEncryption.AWS_MANAGED}, }); projectGroupsTable.addGlobalSecondaryIndex({ @@ -572,7 +572,7 @@ export class CoreConstruct extends Construct { partitionKey: resourceIdAttribute, sortKey: resourceTypeAttribute, billingMode: BillingMode.PAY_PER_REQUEST, - ...props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN ? {encryptionKey: props.encryptionKey} : {encryption: TableEncryption.AWS_MANAGED}, + ...(props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN && props.mlspaceConfig.ENABLE_DDB_KMS_CMK_ENCRYPTION) ? {encryptionKey: props.encryptionKey} : {encryption: TableEncryption.AWS_MANAGED}, }); // Resources Metadata Table @@ -581,7 +581,7 @@ export class CoreConstruct extends Construct { partitionKey: resourceTypeAttribute, sortKey: resourceIdAttribute, billingMode: BillingMode.PAY_PER_REQUEST, - ...props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN ? {encryptionKey: props.encryptionKey} : {encryption: TableEncryption.AWS_MANAGED}, + ...(props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN && props.mlspaceConfig.ENABLE_DDB_KMS_CMK_ENCRYPTION) ? {encryptionKey: props.encryptionKey} : {encryption: TableEncryption.AWS_MANAGED}, }); resourcesMetadataTable.addLocalSecondaryIndex({ @@ -602,7 +602,7 @@ export class CoreConstruct extends Construct { partitionKey: { name: 'configScope', type: AttributeType.STRING }, sortKey: { name: 'versionId', type: AttributeType.NUMBER }, billingMode: BillingMode.PAY_PER_REQUEST, - ...props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN ? {encryptionKey: props.encryptionKey} : {encryption: TableEncryption.AWS_MANAGED}, + ...(props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN && props.mlspaceConfig.ENABLE_DDB_KMS_CMK_ENCRYPTION) ? {encryptionKey: props.encryptionKey} : {encryption: TableEncryption.AWS_MANAGED}, }); // Populate the App Config table with default config diff --git a/lib/utils/configTypes.ts b/lib/utils/configTypes.ts index 34cc5bfb..40b38db1 100644 --- a/lib/utils/configTypes.ts +++ b/lib/utils/configTypes.ts @@ -34,6 +34,7 @@ import { EMR_SECURITY_CONFIG_NAME, EMR_EC2_SSH_KEY, ENABLE_ACCESS_LOGGING, + ENABLE_DDB_KMS_CMK_ENCRYPTION, EXISTING_KMS_MASTER_KEY_ARN, EXISTING_VPC_DEFAULT_SECURITY_GROUP, EXISTING_VPC_ID, @@ -126,6 +127,7 @@ export type MLSpaceConfig = { ADDITIONAL_LAMBDA_ENVIRONMENT_VARS: { [key: string]: string } MANAGE_IAM_ROLES: boolean, ENABLE_ACCESS_LOGGING: boolean, + ENABLE_DDB_KMS_CMK_ENCRYPTION: boolean, CREATE_MLSPACE_CLOUDTRAIL_TRAIL: boolean, RESOURCE_TERMINATION_INTERVAL: number, NEW_USERS_SUSPENDED: boolean, @@ -217,6 +219,7 @@ export function generateConfig (accountId?: string) { ADDITIONAL_LAMBDA_ENVIRONMENT_VARS: ADDITIONAL_LAMBDA_ENVIRONMENT_VARS, MANAGE_IAM_ROLES: MANAGE_IAM_ROLES, ENABLE_ACCESS_LOGGING: ENABLE_ACCESS_LOGGING, + ENABLE_DDB_KMS_CMK_ENCRYPTION: ENABLE_DDB_KMS_CMK_ENCRYPTION, CREATE_MLSPACE_CLOUDTRAIL_TRAIL: CREATE_MLSPACE_CLOUDTRAIL_TRAIL, RESOURCE_TERMINATION_INTERVAL: RESOURCE_TERMINATION_INTERVAL, LAMBDA_ARCHITECTURE: LAMBDA_ARCHITECTURE, From 586122a84e24705019059adf73ec319a5241811e Mon Sep 17 00:00:00 2001 From: Dustin Sweigart Date: Fri, 12 Sep 2025 17:26:51 +0000 Subject: [PATCH 12/32] default DDB KMS CMK encryption to true for best security practices --- .devcontainer/devcontainer.json | 3 +-- lib/constants.ts | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c1d4707f..eeffc958 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -26,8 +26,7 @@ }, "mounts": [ - "source=${localEnv:HOME}${localEnv:USERPROFILE}/.ssh,target=/home/vscode/.ssh,readonly,type=bind", - "source=${localEnv:HOME}${localEnv:USERPROFILE}/.midway,target=/home/vscode/.midway,readonly,type=bind" + "source=${localEnv:HOME}${localEnv:USERPROFILE}/.ssh,target=/home/vscode/.ssh,readonly,type=bind" ], // Features to add to the dev container. More info: https://containers.dev/features. diff --git a/lib/constants.ts b/lib/constants.ts index 4562ffb9..ad64cbc0 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -34,7 +34,7 @@ export const GROUP_USERS_TABLE_NAME = 'mlspace-group-users'; export const CONFIG_BUCKET_NAME = 'mlspace-config'; export const DATA_BUCKET_NAME = 'mlspace-data'; export const LOGS_BUCKET_NAME = 'mlspace-logs'; -export const ACCESS_LOGS_BUCKET_NAME = 'mlspace-access-logs-alpha'; +export const ACCESS_LOGS_BUCKET_NAME = 'mlspace-access-logs'; export const WEBSITE_BUCKET_NAME = 'mlspace-website'; export const MLSPACE_LIFECYCLE_CONFIG_NAME = 'mlspace-notebook-lifecycle-config'; export const NOTEBOOK_PARAMETERS_FILE_NAME = 'notebook-params.json'; @@ -143,4 +143,4 @@ export const SHOW_MIGRATION_OPTIONS = false; // Set this to true to enable customer-managed KMS encryption for DynamoDB tables // Requires EXISTING_KMS_MASTER_KEY_ARN to be set. Defaults to false for backward compatibility. -export const ENABLE_DDB_KMS_CMK_ENCRYPTION = false; +export const ENABLE_DDB_KMS_CMK_ENCRYPTION = true; \ No newline at end of file From 8ec83026c6fafb791e83d1916c102438cabbc906 Mon Sep 17 00:00:00 2001 From: Dustin Sweigart Date: Fri, 10 Oct 2025 11:09:58 -0400 Subject: [PATCH 13/32] fix bedrock permissions (#330) --- .../src/ml_space_lambda/utils/iam_manager.py | 318 +++++++++++------- lib/constructs/iamConstruct.ts | 55 ++- 2 files changed, 220 insertions(+), 153 deletions(-) diff --git a/backend/src/ml_space_lambda/utils/iam_manager.py b/backend/src/ml_space_lambda/utils/iam_manager.py index 67b8df03..9de1cb31 100644 --- a/backend/src/ml_space_lambda/utils/iam_manager.py +++ b/backend/src/ml_space_lambda/utils/iam_manager.py @@ -33,8 +33,8 @@ IAM_ROLE_NAME_MAX_LENGTH = 64 IAM_POLICY_NAME_MAX_LENGTH = 128 -USER_POLICY_VERSION = 1 -PROJECT_POLICY_VERSION = 1 +USER_POLICY_VERSION = 2 +PROJECT_POLICY_VERSION = 2 DYNAMIC_USER_ROLE_TAG = {"Key": "dynamic-user-role", "Value": "true"} group_user_dao = GroupUserDAO() @@ -48,104 +48,6 @@ def __init__(self, iam_client=None, sts_client=None): self.aws_partition = boto3.Session().get_partition_for_region(boto3.Session().region_name) self.iam_client = iam_client if iam_client else boto3.client("iam", config=retry_config) - # If you update this you need to increment the PROJECT_POLICY_VERSION value - self.project_policy = """{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "s3:GetObject", - "s3:DeleteObject", - "s3:PutObject", - "s3:PutObjectTagging" - ], - "Resource": "arn:$PARTITION:s3:::$BUCKET_NAME/project/$PROJECT_NAME/*" - }, - { - "Effect": "Deny", - "Action": [ - "sagemaker:CreateEndpoint" - ], - "Resource": "arn:$PARTITION:sagemaker:*:*:endpoint/*", - "Condition": { - "StringNotEqualsIgnoreCase": { - "aws:RequestTag/project": "$PROJECT_NAME" - } - } - }, - { - "Effect": "Allow", - "Action": [ - "sagemaker:CreateEndpoint" - ], - "Resource": "arn:$PARTITION:sagemaker:*:*:endpoint-config/*" - }, - { - "Effect": "Deny", - "Action": [ - "sagemaker:CreateModel", - "sagemaker:CreateEndpointConfig", - "sagemaker:CreateTrainingJob", - "sagemaker:CreateProcessingJob", - "sagemaker:CreateHyperParameterTuningJob", - "sagemaker:CreateTransformJob", - "sagemaker:DeleteModel", - "sagemaker:DescribeModel", - "sagemaker:DeleteEndpoint", - "sagemaker:DescribeEndpoint", - "sagemaker:InvokeEndpoint", - "sagemaker:DeleteEndpointConfig", - "sagemaker:DescribeEndpointConfig", - "sagemaker:DescribeLabelingJob", - "sagemaker:StopLabelingJob", - "sagemaker:DescribeTrainingJob", - "sagemaker:StopTrainingJob", - "sagemaker:DescribeProcessingJob", - "sagemaker:StopProcessingJob", - "sagemaker:DescribeHyperParameterTuningJob", - "sagemaker:StopHyperParameterTuningJob", - "sagemaker:DescribeTransformJob", - "sagemaker:StopTransformJob", - "sagemaker:UpdateEndpoint", - "sagemaker:UpdateEndpointWeightsAndCapacities", - "bedrock:Associate*", - "bedrock:Create*", - "bedrock:BatchDelete*", - "bedrock:Delete*", - "bedrock:Put*", - "bedrock:Retrieve*", - "bedrock:Start*", - "bedrock:Update*", - "bedrock:Apply*", - "bedrock:Detect*", - "bedrock:List*", - "bedrock:Get*", - "bedrock:Invoke*", - "bedrock:Retrieve*" - ], - "Resource": "*", - "Condition": { - "StringNotEqualsIgnoreCase": { - "aws:RequestTag/project": "$PROJECT_NAME", - "aws:ResourceTag/project": "$PROJECT_NAME" - } - } - }, - { - "Effect": "Allow", - "Action": "s3:ListBucket", - "Resource": "arn:$PARTITION:s3:::$BUCKET_NAME", - "Condition": { - "StringLike": { - "s3:prefix": "project/$PROJECT_NAME/*" - } - } - } - ] - } - """ - env_variables = get_environment_variables() self.data_bucket = env_variables[EnvVariable.DATA_BUCKET] self.system_tag = env_variables[EnvVariable.SYSTEM_TAG] @@ -365,7 +267,7 @@ def _create_iam_role(self, iam_role_name: str, project_name: str, username: str) "Statement": [ { "Effect": "Allow", - "Principal": {"Service": "sagemaker.amazonaws.com"}, + "Principal": {"Service": ["sagemaker.amazonaws.com", "bedrock.amazonaws.com"]}, "Action": "sts:AssumeRole", } ], @@ -439,12 +341,140 @@ def _detach_iam_policies(self, iam_role_name: str) -> List[str]: return detached_iam_policies def _generate_project_policy(self, project: str) -> str: - return ( - self.project_policy.replace("\n", "") - .replace("$PROJECT_NAME", project) - .replace("$BUCKET_NAME", self.data_bucket) - .replace("$PARTITION", self.aws_partition) - ) + # If you update this you need to increment the PROJECT_POLICY_VERSION value + project_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["s3:GetObject", "s3:DeleteObject", "s3:PutObject", "s3:PutObjectTagging"], + "Resource": f"arn:{self.aws_partition}:s3:::{self.data_bucket}/project/{project}/*", + }, + { + "Effect": "Deny", + "Action": ["sagemaker:CreateEndpoint"], + "Resource": f"arn:{self.aws_partition}:sagemaker:*:*:endpoint/*", + "Condition": {"StringNotEqualsIgnoreCase": {"aws:RequestTag/project": project}}, + }, + { + "Effect": "Allow", + "Action": ["sagemaker:CreateEndpoint"], + "Resource": f"arn:{self.aws_partition}:sagemaker:*:*:endpoint-config/*", + }, + { + "Effect": "Deny", + "Action": [ + "sagemaker:CreateModel", + "sagemaker:CreateEndpointConfig", + "sagemaker:CreateTrainingJob", + "sagemaker:CreateProcessingJob", + "sagemaker:CreateHyperParameterTuningJob", + "sagemaker:CreateTransformJob", + "sagemaker:DeleteModel", + "sagemaker:DescribeModel", + "sagemaker:DeleteEndpoint", + "sagemaker:DescribeEndpoint", + "sagemaker:InvokeEndpoint", + "sagemaker:DeleteEndpointConfig", + "sagemaker:DescribeEndpointConfig", + "sagemaker:DescribeLabelingJob", + "sagemaker:StopLabelingJob", + "sagemaker:DescribeTrainingJob", + "sagemaker:StopTrainingJob", + "sagemaker:DescribeProcessingJob", + "sagemaker:StopProcessingJob", + "sagemaker:DescribeHyperParameterTuningJob", + "sagemaker:StopHyperParameterTuningJob", + "sagemaker:DescribeTransformJob", + "sagemaker:StopTransformJob", + "sagemaker:UpdateEndpoint", + "sagemaker:UpdateEndpointWeightsAndCapacities", + ], + "Resource": "*", + "Condition": { + "StringNotEqualsIgnoreCase": {"aws:RequestTag/project": project, "aws:ResourceTag/project": project} + }, + }, + { + "Effect": "Allow", + "Action": "s3:ListBucket", + "Resource": f"arn:{self.aws_partition}:s3:::{self.data_bucket}", + "Condition": {"StringLike": {"s3:prefix": f"project/{project}/*"}}, + }, + { + "Sid": "DenyBedrockCreateWithoutMLSpaceTag", + "Effect": "Deny", + "Action": [ + "bedrock:Create*", + "bedrock:InvokeDataAutomationAsync", + "bedrock:PutResourcePolicy", + "bedrock:InvokeModel", + ], + "NotResource": [ + "arn:*:bedrock:*:*:data-automation-profile/*", + "arn:*:bedrock:*:*:bedrock-marketplace-model-endpoint/*", + "arn:*:bedrock:*:*:flow-execution/*", + "arn:*:bedrock:*:*:guardrail-profile/*", + "arn:*:bedrock:*:*:prompt-router/*", + "arn:*:bedrock:*:*:inference-profile/*", + "arn:*:bedrock:*:*:default-prompt-router/*", + "arn:*:bedrock:*::foundation-model/*", + ], + "Condition": { + "StringNotEquals": { + "aws:RequestTag/project": f"{project}", + "aws:RequestTag/system": f"{self.system_tag}", + } + }, + }, + { + "Sid": "DenyBedrockActionsWithoutSystemMLSpaceTag", + "Effect": "Deny", + "NotAction": [ + "bedrock:Create*", + "bedrock:InvokeDataAutomationAsync", + "bedrock:PutResourcePolicy", + "bedrock:InvokeModel", + ], + "Resource": [ + "arn:*:bedrock:*:*:agent-alias/*/*", + "arn:*:bedrock:*:*:agent/*", + "arn:*:bedrock:*:*:application-inference-profile/*", + "arn:*:bedrock:*:*:async-invoke/*", + "arn:*:bedrock:*:*:automated-reasoning-policy-version/*", + "arn:*:bedrock:*:*:automated-reasoning-policy/*", + "arn:*:bedrock:*:*:blueprint/*", + "arn:*:bedrock:*:*:custom-model-deployment/*", + "arn:*:bedrock:*:*:custom-model/*", + "arn:*:bedrock:*:*:data-automation-invocation-job/*", + "arn:*:bedrock:*:*:data-automation-project/*", + "arn:*:bedrock:*:*:evaluation-job/*", + "arn:*:bedrock:*:*:flow-alias/*", + "arn:*:bedrock:*:*:flow/*", + "arn:*:bedrock:*:*:guardrail/*", + "arn:*:bedrock:*:*:imported-model/*", + "arn:*:bedrock:*:*:knowledge-base/*", + "arn:*:bedrock:*:*:model-copy-job/*", + "arn:*:bedrock:*:*:model-customization-job/*", + "arn:*:bedrock:*:*:model-evaluation-job/*", + "arn:*:bedrock:*:*:model-import-job/*", + "arn:*:bedrock:*:*:model-invocation-job/*", + "arn:*:bedrock:*:*:prompt-version/*", + "arn:*:bedrock:*:*:prompt/*", + "arn:*:bedrock:*:*:provisioned-model/*", + "arn:*:bedrock:*:*:session/*", + ], + "Condition": { + "StringNotEquals": { + "aws:ResourceTag/project": f"{project}", + "aws:ResourceTag/system": f"{self.system_tag}", + } + }, + }, + ], + } + + return json.dumps(project_policy) def _generate_user_policy(self, user: str) -> str: resource_arns = [ @@ -530,24 +560,70 @@ def _generate_user_policy(self, user: str) -> str: "sagemaker:StopTransformJob", "sagemaker:UpdateEndpoint", "sagemaker:UpdateEndpointWeightsAndCapacities", - "bedrock:Associate*", - "bedrock:Create*", - "bedrock:BatchDelete*", - "bedrock:Delete*", - "bedrock:Put*", - "bedrock:Retrieve*", - "bedrock:Start*", - "bedrock:Update*", - "bedrock:Apply*", - "bedrock:Detect*", - "bedrock:List*", - "bedrock:Get*", - "bedrock:Invoke*", - "bedrock:Retrieve*", ], "Resource": "*", "Condition": {"StringNotEqualsIgnoreCase": {"aws:RequestTag/user": user, "aws:ResourceTag/user": user}}, }, + { + "Sid": "DenyBedrockCreateWithoutMLSpaceTag", + "Effect": "Deny", + "Action": [ + "bedrock:Create*", + "bedrock:InvokeDataAutomationAsync", + "bedrock:PutResourcePolicy", + "bedrock:InvokeModel", + ], + "NotResource": [ + "arn:*:bedrock:*:*:data-automation-profile/*", + "arn:*:bedrock:*:*:bedrock-marketplace-model-endpoint/*", + "arn:*:bedrock:*:*:flow-execution/*", + "arn:*:bedrock:*:*:guardrail-profile/*", + "arn:*:bedrock:*:*:prompt-router/*", + "arn:*:bedrock:*:*:inference-profile/*", + "arn:*:bedrock:*:*:default-prompt-router/*", + "arn:*:bedrock:*::foundation-model/*", + ], + "Condition": {"StringNotEquals": {"aws:RequestTag/user": f"{user}"}}, + }, + { + "Sid": "DenyBedrockActionsWithoutSystemMLSpaceTag", + "Effect": "Deny", + "NotAction": [ + "bedrock:Create*", + "bedrock:InvokeDataAutomationAsync", + "bedrock:PutResourcePolicy", + "bedrock:InvokeModel", + ], + "Resource": [ + "arn:*:bedrock:*:*:agent-alias/*/*", + "arn:*:bedrock:*:*:agent/*", + "arn:*:bedrock:*:*:application-inference-profile/*", + "arn:*:bedrock:*:*:async-invoke/*", + "arn:*:bedrock:*:*:automated-reasoning-policy-version/*", + "arn:*:bedrock:*:*:automated-reasoning-policy/*", + "arn:*:bedrock:*:*:blueprint/*", + "arn:*:bedrock:*:*:custom-model-deployment/*", + "arn:*:bedrock:*:*:custom-model/*", + "arn:*:bedrock:*:*:data-automation-invocation-job/*", + "arn:*:bedrock:*:*:data-automation-project/*", + "arn:*:bedrock:*:*:evaluation-job/*", + "arn:*:bedrock:*:*:flow-alias/*", + "arn:*:bedrock:*:*:flow/*", + "arn:*:bedrock:*:*:guardrail/*", + "arn:*:bedrock:*:*:imported-model/*", + "arn:*:bedrock:*:*:knowledge-base/*", + "arn:*:bedrock:*:*:model-copy-job/*", + "arn:*:bedrock:*:*:model-customization-job/*", + "arn:*:bedrock:*:*:model-evaluation-job/*", + "arn:*:bedrock:*:*:model-import-job/*", + "arn:*:bedrock:*:*:model-invocation-job/*", + "arn:*:bedrock:*:*:prompt-version/*", + "arn:*:bedrock:*:*:prompt/*", + "arn:*:bedrock:*:*:provisioned-model/*", + "arn:*:bedrock:*:*:session/*", + ], + "Condition": {"StringNotEquals": {"aws:ResourceTag/user": f"{user}"}}, + }, ], } diff --git a/lib/constructs/iamConstruct.ts b/lib/constructs/iamConstruct.ts index 1be3bd94..8f703c77 100644 --- a/lib/constructs/iamConstruct.ts +++ b/lib/constructs/iamConstruct.ts @@ -361,37 +361,13 @@ export class IAMConstruct extends Construct { }, }), /** - * Bedrock Permissions + * Allow bedrock resources. By default we are permissive as the permissions are scoped down by + * the permission boundary and user/project policies. */ new PolicyStatement({ effect: Effect.ALLOW, - actions: [ - // mutating - 'bedrock:Associate*', - 'bedrock:Create*', - 'bedrock:BatchDelete*', - 'bedrock:Delete*', - 'bedrock:Put*', - 'bedrock:Retrieve*', - 'bedrock:Start*', - 'bedrock:Update*', - - // non-mutating - 'bedrock:Apply*', - 'bedrock:Detect*', - 'bedrock:List*', - 'bedrock:Get*', - 'bedrock:Invoke*', - 'bedrock:Retrieve*', - ], - resources: [`arn:${partition}:sagemaker:${region}:${scope.account}:*`], - conditions: { - Null: { - ...requestTagsConditions, - ...resourceTagsConditions, - }, - ...requestSystemTagEqualsConditions[SystemTagCondition.Equals] - }, + actions: ['bedrock:*'], + resources: ['*'], }), ]; @@ -634,9 +610,10 @@ export class IAMConstruct extends Construct { } else { // If roles are dynamically managed // Translate Permissions Principles - const passRolePrincipals = props.enableTranslate - ? ['sagemaker.amazonaws.com', 'translate.amazonaws.com'] - : 'sagemaker.amazonaws.com'; + const passRolePrincipals = ['sagemaker.amazonaws.com', 'bedrock.amazonaws.com']; + if (props.enableTranslate) { + passRolePrincipals.push('translate.amazonaws.com'); + } // Permission boundary policy that ensures IAM policies never exceed these permissions this.mlSpacePermissionsBoundary = new ManagedPolicy( @@ -704,7 +681,21 @@ export class IAMConstruct extends Construct { }, }, }), - ...notebookPolicyStatements('*', '*', true), + // Bedrock permissions - allow all actions except tagging + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['bedrock:*'], + resources: ['*'], + conditions: { + 'ForAllValues:StringNotLike': { + 'bedrock:Action': ['bedrock:TagResource', 'bedrock:UntagResource'] + } + } + }), + ...notebookPolicyStatements('*', '*', true).filter((policyStatement) => { + // don't pull in any bedrock statements from the notebook policy since we defined our own above + return policyStatement.actions.findIndex((action) => action.startsWith('bedrock')) === -1; + }), ], } ); From ed812947bf3e0364666a702cc9414319c23d8ce0 Mon Sep 17 00:00:00 2001 From: Dustin Sweigart Date: Fri, 10 Oct 2025 11:16:12 -0400 Subject: [PATCH 14/32] Enhance documentation with architecture diagram and security updates (#329) * updated docs for bedrock * removing old bedrock policies from docs --- frontend/docs/admin-guide/getting-started.md | 6 +- frontend/docs/admin-guide/security/intro.md | 14 +++ .../security/policies/notebook-policy-raw.md | 36 +----- .../security/policies/notebook-policy.md | 50 +------- .../security/policies/project-policy-raw.json | 16 +-- .../security/policies/project-policy.md | 17 +-- .../project-user-permission-boundary-raw.json | 85 +++++++++++++- .../project-user-permission-boundary.md | 110 +++++++++++++++++- .../security/policies/user-policy-raw.json | 88 +++++++++++--- .../security/policies/user-policy.md | 98 +++++++++++++--- .../img/getting-started/detailed-arch.png | Bin 0 -> 316418 bytes 11 files changed, 374 insertions(+), 146 deletions(-) create mode 100644 frontend/docs/img/getting-started/detailed-arch.png diff --git a/frontend/docs/admin-guide/getting-started.md b/frontend/docs/admin-guide/getting-started.md index e9d91b62..affebdda 100644 --- a/frontend/docs/admin-guide/getting-started.md +++ b/frontend/docs/admin-guide/getting-started.md @@ -88,10 +88,14 @@ The {{ $params.APPLICATION_NAME }} web application is a React Redux-based TypeSc The frontend leverages a number of open-source libraries to both speed up development and reinforce best practices. The UI is built around [CloudScape](https://cloudscape.design/), an open-source component library developed by AWS that aims to assist in building accessible user interfaces that share a similar UX with the AWS console. In order to seamlessly integrate with spec-compliant OIDC providers, `react-oidc-context` is used. This library handles the PKCE authentication flow as well as periodic token refreshing. _Eslint_ and _Husky_ are used to enforce common coding standards, consistent styling, and best practices throughout the codebase. -## Architecture +## High Level Architecture Diagram ![{{ $params.APPLICATION_NAME }} Architecture diagram](../img/getting-started/arch.png) +## Detailed Architecture Diagram + +![{{ $params.APPLICATION_NAME }} Detailed Architecture diagram](../img/getting-started/detailed-arch.png) + ### DynamoDB There are 7 DynamoDB tables used to persist various types of metadata for {{ $params.APPLICATION_NAME }}. diff --git a/frontend/docs/admin-guide/security/intro.md b/frontend/docs/admin-guide/security/intro.md index 10438a87..01972c8b 100644 --- a/frontend/docs/admin-guide/security/intro.md +++ b/frontend/docs/admin-guide/security/intro.md @@ -39,6 +39,20 @@ For a more comprehensive understanding of permissions boundaries, please refer t When a user's membership changes with a Group, Dataset, or Project, MLSpace immediately removes any unnecessary roles, scopes down IAM policies to ensure least-privilege permissions, and restricts access to resources, ensuring that access is only granted to what is required for the user's new role. This proactive approach helps maintain the security and integrity of your AWS account by minimizing the attack surface and reducing the risk of unauthorized access. +Here's the updated structured version: + +## Logging Architecture + +MLSpace employs a multi-layered logging strategy: + +**Application Logging:** The application performs informational logging throughout its operations to support monitoring and troubleshooting. + +**Audit Logging:** MLSpace primarily relies on AWS CloudTrail for audit purposes. CloudTrail records most attributable actions along with the IAM role used to execute them, providing comprehensive accountability. + +**Storage Access Logging:** S3 bucket access logging is enabled across all buckets, with logs aggregated in a central logging S3 bucket for unified access tracking. + +**Default Configuration:** MLSpace automatically creates a CloudTrail trail that writes to a CloudWatch logs bucket during initial setup. This behavior is configurable via the `CREATE_MLSPACE_CLOUDTRAIL_TRAIL` parameter, which is set to `true` by default. + ## Role Descriptions diff --git a/frontend/docs/admin-guide/security/policies/notebook-policy-raw.md b/frontend/docs/admin-guide/security/policies/notebook-policy-raw.md index 03ea8590..c073b0db 100644 --- a/frontend/docs/admin-guide/security/policies/notebook-policy-raw.md +++ b/frontend/docs/admin-guide/security/policies/notebook-policy-raw.md @@ -44,6 +44,7 @@ }, { "Action": [ + "bedrock:*", "comprehend:BatchDetect*", "comprehend:Detect*", "ec2:DescribeDhcpOptions", @@ -185,41 +186,6 @@ "Resource": "arn:aws:s3:::mlspace-data-012345678910", "Effect": "Allow" }, - { - "Condition": { - "Null": { - "aws:RequestTag/user": "true", - "aws:RequestTag/project": "true", - "aws:ResourceTag/user": "true", - "aws:ResourceTag/system": "true", - "aws:ResourceTag/project": "true", - }, - "StringNotEqualsIgnoreCase": { - "aws:RequestTag/system": "MLSpace" - } - }, - "Action": [ - // mutating - "bedrock:Associate*", - "bedrock:Create*", - "bedrock:BatchDelete*", - "bedrock:Delete*", - "bedrock:Put*", - "bedrock:Retrieve*", - "bedrock:Start*", - "bedrock:Update*", - - // non-mutating - "bedrock:Apply*", - "bedrock:Detect*", - "bedrock:List*", - "bedrock:Get*", - "bedrock:Invoke*", - "bedrock:Retrieve*", - ], - "Resource": "arn:aws:sagemaker:us-east-1:012345678910:*", - "Effect": "Allow" - }, { "Condition": { "StringEquals": { diff --git a/frontend/docs/admin-guide/security/policies/notebook-policy.md b/frontend/docs/admin-guide/security/policies/notebook-policy.md index ac07ff7e..8fdf53a6 100644 --- a/frontend/docs/admin-guide/security/policies/notebook-policy.md +++ b/frontend/docs/admin-guide/security/policies/notebook-policy.md @@ -78,6 +78,11 @@ This statement authorizes the Notebook to perform various read operations on mul // General Permissions - Read Only + Metric Write permissions { "Action": [ + /** + * Allow all Bedrock access as it will be scoped down by the User and Project policy + */ + "bedrock:*", + /** * */ @@ -372,49 +377,4 @@ The statement includes conditions that enforce additional constraints, ensuring ], "Effect": "Deny" }, -``` - -## Statement 14 - -This statement allows the necessary permissions for interacting with Amazon Bedrock if it is tagged appropriately. The policy ensures that all Bedrock resources are properly tagged with user, system, and project information, maintaining consistent resource management and adhering to organizational tagging standards within the MLSpace environment. - -The statement includes conditions that enforce additional constraints, ensuring adherence to security requirements, networking configurations, and appropriate tagging for assured attribution. These measures help maintain consistent access control and adhere to the principle of least privilege within the MLSpace environment. - -```json:line-numbers - // HPO Permissions - { - "Condition": { - "Null": { - "aws:RequestTag/user": "true", - "aws:RequestTag/project": "true", - "aws:ResourceTag/user": "true", - "aws:ResourceTag/system": "true", - "aws:ResourceTag/project": "true", - }, - "StringNotEqualsIgnoreCase": { - "aws:RequestTag/system": "MLSpace" - } - }, - "Action": [ - // mutating - "bedrock:Associate*", - "bedrock:Create*", - "bedrock:BatchDelete*", - "bedrock:Delete*", - "bedrock:Put*", - "bedrock:Retrieve*", - "bedrock:Start*", - "bedrock:Update*", - - // non-mutating - "bedrock:Apply*", - "bedrock:Detect*", - "bedrock:List*", - "bedrock:Get*", - "bedrock:Invoke*", - "bedrock:Retrieve*", - ], - "Resource": "arn:aws:sagemaker:us-east-1:012345678910:*", - "Effect": "Allow" - }, ``` \ No newline at end of file diff --git a/frontend/docs/admin-guide/security/policies/project-policy-raw.json b/frontend/docs/admin-guide/security/policies/project-policy-raw.json index 0637242a..7b057c90 100644 --- a/frontend/docs/admin-guide/security/policies/project-policy-raw.json +++ b/frontend/docs/admin-guide/security/policies/project-policy-raw.json @@ -57,21 +57,7 @@ "sagemaker:DescribeTransformJob", "sagemaker:StopTransformJob", "sagemaker:UpdateEndpoint", - "sagemaker:UpdateEndpointWeightsAndCapacities", - "bedrock:Associate*", - "bedrock:Create*", - "bedrock:BatchDelete*", - "bedrock:Delete*", - "bedrock:Put*", - "bedrock:Retrieve*", - "bedrock:Start*", - "bedrock:Update*", - "bedrock:Apply*", - "bedrock:Detect*", - "bedrock:List*", - "bedrock:Get*", - "bedrock:Invoke*", - "bedrock:Retrieve*" + "sagemaker:UpdateEndpointWeightsAndCapacities" ], "Resource": "*", "Condition": { diff --git a/frontend/docs/admin-guide/security/policies/project-policy.md b/frontend/docs/admin-guide/security/policies/project-policy.md index 8e0e0c36..9cb944a0 100644 --- a/frontend/docs/admin-guide/security/policies/project-policy.md +++ b/frontend/docs/admin-guide/security/policies/project-policy.md @@ -91,26 +91,13 @@ These actions grants a role the ability to create the specified SageMaker and Be "sagemaker:DescribeTransformJob", "sagemaker:StopTransformJob", "sagemaker:UpdateEndpoint", - "sagemaker:UpdateEndpointWeightsAndCapacities", - "bedrock:Associate*", - "bedrock:Create*", - "bedrock:BatchDelete*", - "bedrock:Delete*", - "bedrock:Put*", - "bedrock:Start*", - "bedrock:Update*", - "bedrock:Apply*", - "bedrock:Detect*", - "bedrock:List*", - "bedrock:Get*", - "bedrock:Invoke*", - "bedrock:Retrieve*" + "sagemaker:UpdateEndpointWeightsAndCapacities" ], "Resource": "*", "Condition": { "StringNotEqualsIgnoreCase": { "aws:RequestTag/project": "Project001", - "aws:ResourceTag/project": "Project001v20241002" + "aws:ResourceTag/project": "Project001" } } }, diff --git a/frontend/docs/admin-guide/security/policies/project-user-permission-boundary-raw.json b/frontend/docs/admin-guide/security/policies/project-user-permission-boundary-raw.json index 4e9ec3a7..da7c625b 100644 --- a/frontend/docs/admin-guide/security/policies/project-user-permission-boundary-raw.json +++ b/frontend/docs/admin-guide/security/policies/project-user-permission-boundary-raw.json @@ -230,6 +230,19 @@ "Resource": "arn:*:s3:::mlspace-data-012345678910", "Effect": "Allow" }, + { + "Condition": { + "ForAllValues:StringNotLike": { + "bedrock:Action": [ + "bedrock:TagResource", + "bedrock:UntagResource" + ] + } + }, + "Action": "bedrock:*", + "Resource": "*", + "Effect": "Allow" + }, { "Condition": { "StringEquals": { @@ -283,6 +296,76 @@ "Action": "sagemaker:CreateTransformJob", "Resource": "arn:*:sagemaker:*:012345678910:transform-job/*", "Effect": "Allow" - } + }, + { + "Sid": "DenyBedrockCreateWithoutMLSpaceTag", + "Effect": "Deny", + "Action": [ + "bedrock:Create*", + "bedrock:InvokeDataAutomationAsync", + "bedrock:PutResourcePolicy", + "bedrock:InvokeModel" + ], + "NotResource": [ + "arn:*:bedrock:*:*:data-automation-profile/*", + "arn:*:bedrock:*:*:bedrock-marketplace-model-endpoint/*", + "arn:*:bedrock:*:*:flow-execution/*", + "arn:*:bedrock:*:*:guardrail-profile/*", + "arn:*:bedrock:*:*:prompt-router/*", + "arn:*:bedrock:*:*:inference-profile/*", + "arn:*:bedrock:*:*:default-prompt-router/*", + "arn:*:bedrock:*::foundation-model/*" + ], + "Condition": { + "StringNotEquals": { + "aws:RequestTag/project": "pmo20251008", + "aws:RequestTag/system": "MLSpace" + } + } + }, + { + "Sid": "DenyBedrockActionsWithoutSystemMLSpaceTag", + "Effect": "Deny", + "NotAction": [ + "bedrock:Create*", + "bedrock:InvokeDataAutomationAsync", + "bedrock:PutResourcePolicy", + "bedrock:InvokeModel" + ], + "Resource": [ + "arn:*:bedrock:*:*:agent-alias/*/*", + "arn:*:bedrock:*:*:agent/*", + "arn:*:bedrock:*:*:application-inference-profile/*", + "arn:*:bedrock:*:*:async-invoke/*", + "arn:*:bedrock:*:*:automated-reasoning-policy-version/*", + "arn:*:bedrock:*:*:automated-reasoning-policy/*", + "arn:*:bedrock:*:*:blueprint/*", + "arn:*:bedrock:*:*:custom-model-deployment/*", + "arn:*:bedrock:*:*:custom-model/*", + "arn:*:bedrock:*:*:data-automation-invocation-job/*", + "arn:*:bedrock:*:*:data-automation-project/*", + "arn:*:bedrock:*:*:evaluation-job/*", + "arn:*:bedrock:*:*:flow-alias/*", + "arn:*:bedrock:*:*:flow/*", + "arn:*:bedrock:*:*:guardrail/*", + "arn:*:bedrock:*:*:imported-model/*", + "arn:*:bedrock:*:*:knowledge-base/*", + "arn:*:bedrock:*:*:model-copy-job/*", + "arn:*:bedrock:*:*:model-customization-job/*", + "arn:*:bedrock:*:*:model-evaluation-job/*", + "arn:*:bedrock:*:*:model-import-job/*", + "arn:*:bedrock:*:*:model-invocation-job/*", + "arn:*:bedrock:*:*:prompt-version/*", + "arn:*:bedrock:*:*:prompt/*", + "arn:*:bedrock:*:*:provisioned-model/*", + "arn:*:bedrock:*:*:session/*" + ], + "Condition": { + "StringNotEquals": { + "aws:ResourceTag/project": "pmo20251008", + "aws:ResourceTag/system": "MLSpace" + } + } + } ] } \ No newline at end of file diff --git a/frontend/docs/admin-guide/security/policies/project-user-permission-boundary.md b/frontend/docs/admin-guide/security/policies/project-user-permission-boundary.md index 8b7e54a9..88f6ced1 100644 --- a/frontend/docs/admin-guide/security/policies/project-user-permission-boundary.md +++ b/frontend/docs/admin-guide/security/policies/project-user-permission-boundary.md @@ -352,6 +352,26 @@ This set of permissions allows users to view and list the contents of the global ### Statement 17 +This set of permissions allows users to access all Bedrock APIs with the exception of tagging and untagging resources. + +```json:line-numbers + { + "Condition": { + "ForAllValues:StringNotLike": { + "bedrock:Action": [ + "bedrock:TagResource", + "bedrock:UntagResource" + ] + } + }, + "Action": "bedrock:*", + "Resource": "*", + "Effect": "Allow" + } +``` + +### Statement 18 + These actions provide the capability to assign MLSpace-prefixed roles to Translate when executing and managing jobs. ```json:line-numbers @@ -367,7 +387,7 @@ These actions provide the capability to assign MLSpace-prefixed roles to Transla } ``` -### Statement 18 +### Statement 19 These actions authorize users to create Amazon SageMaker Endpoint Configurations, contingent upon the inclusion of User, System, and Project tags in the request. This tagging requirement ensures proper resource management, auditing, and access control. @@ -386,7 +406,7 @@ These actions authorize users to create Amazon SageMaker Endpoint Configurations } ``` -### Statement 19 +### Statement 20 These actions authorize users to initiate Hyperparameter Optimization (HPO) and Training jobs in Amazon SageMaker, subject to specific conditions. The request must include SageMaker VPC information, as well as User, System, and Project tags. This tagging requirement ensures proper resource management, auditing, and access control. @@ -413,7 +433,7 @@ These actions authorize users to initiate Hyperparameter Optimization (HPO) and } ``` -### Statement 20 +### Statement 21 These actions authorize users to create Transform jobs in Amazon SageMaker, subject to a specific condition. The request must include User, System, and Project tags. This requirement ensures proper resource attribution, facilitates effective management, and maintains compliance with organizational tagging policies. This tagging requirement ensures proper resource management, auditing, and access control. @@ -432,6 +452,90 @@ These actions authorize users to create Transform jobs in Amazon SageMaker, subj } ``` +### Statement 22 + +These actions authorize users to create Bedrock resources, subject to a specific condition. The request must include System, and Project tags. This requirement ensures proper resource attribution, facilitates effective management, and maintains compliance with organizational tagging policies. This tagging requirement ensures proper resource management, auditing, and access control. + +```json:line-numbers + { + "Sid": "DenyBedrockCreateWithoutMLSpaceTag", + "Effect": "Deny", + "Action": [ + "bedrock:Create*", + "bedrock:InvokeDataAutomationAsync", + "bedrock:PutResourcePolicy", + "bedrock:InvokeModel" + ], + "NotResource": [ + "arn:*:bedrock:*:*:data-automation-profile/*", + "arn:*:bedrock:*:*:bedrock-marketplace-model-endpoint/*", + "arn:*:bedrock:*:*:flow-execution/*", + "arn:*:bedrock:*:*:guardrail-profile/*", + "arn:*:bedrock:*:*:prompt-router/*", + "arn:*:bedrock:*:*:inference-profile/*", + "arn:*:bedrock:*:*:default-prompt-router/*", + "arn:*:bedrock:*::foundation-model/*" + ], + "Condition": { + "StringNotEquals": { + "aws:RequestTag/project": "pmo20251008", + "aws:RequestTag/system": "MLSpace" + } + } + } +``` + +### Statement 23 + +These actions authorize users to access Bedrock resources, subject to a specific condition. The request must include System, and Project tags. This requirement ensures proper resource attribution, facilitates effective management, and maintains compliance with organizational tagging policies. This tagging requirement ensures proper resource management, auditing, and access control. + +```json:line-numbers + { + "Sid": "DenyBedrockActionsWithoutSystemMLSpaceTag", + "Effect": "Deny", + "NotAction": [ + "bedrock:Create*", + "bedrock:InvokeDataAutomationAsync", + "bedrock:PutResourcePolicy", + "bedrock:InvokeModel" + ], + "Resource": [ + "arn:*:bedrock:*:*:agent-alias/*/*", + "arn:*:bedrock:*:*:agent/*", + "arn:*:bedrock:*:*:application-inference-profile/*", + "arn:*:bedrock:*:*:async-invoke/*", + "arn:*:bedrock:*:*:automated-reasoning-policy-version/*", + "arn:*:bedrock:*:*:automated-reasoning-policy/*", + "arn:*:bedrock:*:*:blueprint/*", + "arn:*:bedrock:*:*:custom-model-deployment/*", + "arn:*:bedrock:*:*:custom-model/*", + "arn:*:bedrock:*:*:data-automation-invocation-job/*", + "arn:*:bedrock:*:*:data-automation-project/*", + "arn:*:bedrock:*:*:evaluation-job/*", + "arn:*:bedrock:*:*:flow-alias/*", + "arn:*:bedrock:*:*:flow/*", + "arn:*:bedrock:*:*:guardrail/*", + "arn:*:bedrock:*:*:imported-model/*", + "arn:*:bedrock:*:*:knowledge-base/*", + "arn:*:bedrock:*:*:model-copy-job/*", + "arn:*:bedrock:*:*:model-customization-job/*", + "arn:*:bedrock:*:*:model-evaluation-job/*", + "arn:*:bedrock:*:*:model-import-job/*", + "arn:*:bedrock:*:*:model-invocation-job/*", + "arn:*:bedrock:*:*:prompt-version/*", + "arn:*:bedrock:*:*:prompt/*", + "arn:*:bedrock:*:*:provisioned-model/*", + "arn:*:bedrock:*:*:session/*" + ], + "Condition": { + "StringNotEquals": { + "aws:ResourceTag/project": "pmo20251008", + "aws:ResourceTag/system": "MLSpace" + } + } + } +``` + ## Full Policy <<< ./project-user-permission-boundary-raw.json \ No newline at end of file diff --git a/frontend/docs/admin-guide/security/policies/user-policy-raw.json b/frontend/docs/admin-guide/security/policies/user-policy-raw.json index c2b2ba73..e6c739c8 100644 --- a/frontend/docs/admin-guide/security/policies/user-policy-raw.json +++ b/frontend/docs/admin-guide/security/policies/user-policy-raw.json @@ -88,21 +88,7 @@ "sagemaker:DescribeTransformJob", "sagemaker:StopTransformJob", "sagemaker:UpdateEndpoint", - "sagemaker:UpdateEndpointWeightsAndCapacities", - "bedrock:Associate*", - "bedrock:Create*", - "bedrock:BatchDelete*", - "bedrock:Delete*", - "bedrock:Put*", - "bedrock:Retrieve*", - "bedrock:Start*", - "bedrock:Update*", - "bedrock:Apply*", - "bedrock:Detect*", - "bedrock:List*", - "bedrock:Get*", - "bedrock:Invoke*", - "bedrock:Retrieve*" + "sagemaker:UpdateEndpointWeightsAndCapacities" ], "Resource": "*", "Condition": { @@ -111,6 +97,76 @@ "aws:ResourceTag/user": "jdoe" } } - } + }, + { + "Sid": "DenyBedrockCreateWithoutMLSpaceTag", + "Effect": "Deny", + "Action": [ + "bedrock:Create*", + "bedrock:InvokeDataAutomationAsync", + "bedrock:PutResourcePolicy", + "bedrock:InvokeModel" + ], + "NotResource": [ + "arn:*:bedrock:*:*:data-automation-profile/*", + "arn:*:bedrock:*:*:bedrock-marketplace-model-endpoint/*", + "arn:*:bedrock:*:*:flow-execution/*", + "arn:*:bedrock:*:*:guardrail-profile/*", + "arn:*:bedrock:*:*:prompt-router/*", + "arn:*:bedrock:*:*:inference-profile/*", + "arn:*:bedrock:*:*:default-prompt-router/*", + "arn:*:bedrock:*::foundation-model/*" + ], + "Condition": { + "StringNotEquals": { + "aws:RequestTag/project": "pmo20251008", + "aws:RequestTag/system": "MLSpace" + } + } + }, + { + "Sid": "DenyBedrockActionsWithoutSystemMLSpaceTag", + "Effect": "Deny", + "NotAction": [ + "bedrock:Create*", + "bedrock:InvokeDataAutomationAsync", + "bedrock:PutResourcePolicy", + "bedrock:InvokeModel" + ], + "Resource": [ + "arn:*:bedrock:*:*:agent-alias/*/*", + "arn:*:bedrock:*:*:agent/*", + "arn:*:bedrock:*:*:application-inference-profile/*", + "arn:*:bedrock:*:*:async-invoke/*", + "arn:*:bedrock:*:*:automated-reasoning-policy-version/*", + "arn:*:bedrock:*:*:automated-reasoning-policy/*", + "arn:*:bedrock:*:*:blueprint/*", + "arn:*:bedrock:*:*:custom-model-deployment/*", + "arn:*:bedrock:*:*:custom-model/*", + "arn:*:bedrock:*:*:data-automation-invocation-job/*", + "arn:*:bedrock:*:*:data-automation-project/*", + "arn:*:bedrock:*:*:evaluation-job/*", + "arn:*:bedrock:*:*:flow-alias/*", + "arn:*:bedrock:*:*:flow/*", + "arn:*:bedrock:*:*:guardrail/*", + "arn:*:bedrock:*:*:imported-model/*", + "arn:*:bedrock:*:*:knowledge-base/*", + "arn:*:bedrock:*:*:model-copy-job/*", + "arn:*:bedrock:*:*:model-customization-job/*", + "arn:*:bedrock:*:*:model-evaluation-job/*", + "arn:*:bedrock:*:*:model-import-job/*", + "arn:*:bedrock:*:*:model-invocation-job/*", + "arn:*:bedrock:*:*:prompt-version/*", + "arn:*:bedrock:*:*:prompt/*", + "arn:*:bedrock:*:*:provisioned-model/*", + "arn:*:bedrock:*:*:session/*" + ], + "Condition": { + "StringNotEquals": { + "aws:ResourceTag/project": "pmo20251008", + "aws:ResourceTag/system": "MLSpace" + } + } + } ] } \ No newline at end of file diff --git a/frontend/docs/admin-guide/security/policies/user-policy.md b/frontend/docs/admin-guide/security/policies/user-policy.md index cb2f24f0..f160707f 100644 --- a/frontend/docs/admin-guide/security/policies/user-policy.md +++ b/frontend/docs/admin-guide/security/policies/user-policy.md @@ -143,21 +143,7 @@ These actions grants a role the ability to create the specified SageMaker and Be "sagemaker:DescribeTransformJob", "sagemaker:StopTransformJob", "sagemaker:UpdateEndpoint", - "sagemaker:UpdateEndpointWeightsAndCapacities", - "bedrock:Associate*", - "bedrock:Create*", - "bedrock:BatchDelete*", - "bedrock:Delete*", - "bedrock:Put*", - "bedrock:Retrieve*", - "bedrock:Start*", - "bedrock:Update*", - "bedrock:Apply*", - "bedrock:Detect*", - "bedrock:List*", - "bedrock:Get*", - "bedrock:Invoke*", - "bedrock:Retrieve*", + "sagemaker:UpdateEndpointWeightsAndCapacities" ], "Resource": "*", "Condition": { @@ -169,6 +155,88 @@ These actions grants a role the ability to create the specified SageMaker and Be } ``` +### Statement 8 + +These actions authorize users to create Bedrock resources, subject to a specific condition. The request must include User tags. This requirement ensures proper resource attribution, facilitates effective management, and maintains compliance with organizational tagging policies. This tagging requirement ensures proper resource management, auditing, and access control. + +```json:line-numbers + { + "Sid": "DenyBedrockCreateWithoutMLSpaceTag", + "Effect": "Deny", + "Action": [ + "bedrock:Create*", + "bedrock:InvokeDataAutomationAsync", + "bedrock:PutResourcePolicy", + "bedrock:InvokeModel" + ], + "NotResource": [ + "arn:*:bedrock:*:*:data-automation-profile/*", + "arn:*:bedrock:*:*:bedrock-marketplace-model-endpoint/*", + "arn:*:bedrock:*:*:flow-execution/*", + "arn:*:bedrock:*:*:guardrail-profile/*", + "arn:*:bedrock:*:*:prompt-router/*", + "arn:*:bedrock:*:*:inference-profile/*", + "arn:*:bedrock:*:*:default-prompt-router/*", + "arn:*:bedrock:*::foundation-model/*" + ], + "Condition": { + "StringNotEquals": { + "aws:RequestTag/user": "jdoe" + } + } + } +``` + +### Statement 9 + +These actions authorize users to access Bedrock resources, subject to a specific condition. The request must include User tags. This requirement ensures proper resource attribution, facilitates effective management, and maintains compliance with organizational tagging policies. This tagging requirement ensures proper resource management, auditing, and access control. + +```json:line-numbers + { + "Sid": "DenyBedrockActionsWithoutSystemMLSpaceTag", + "Effect": "Deny", + "NotAction": [ + "bedrock:Create*", + "bedrock:InvokeDataAutomationAsync", + "bedrock:PutResourcePolicy", + "bedrock:InvokeModel" + ], + "Resource": [ + "arn:*:bedrock:*:*:agent-alias/*/*", + "arn:*:bedrock:*:*:agent/*", + "arn:*:bedrock:*:*:application-inference-profile/*", + "arn:*:bedrock:*:*:async-invoke/*", + "arn:*:bedrock:*:*:automated-reasoning-policy-version/*", + "arn:*:bedrock:*:*:automated-reasoning-policy/*", + "arn:*:bedrock:*:*:blueprint/*", + "arn:*:bedrock:*:*:custom-model-deployment/*", + "arn:*:bedrock:*:*:custom-model/*", + "arn:*:bedrock:*:*:data-automation-invocation-job/*", + "arn:*:bedrock:*:*:data-automation-project/*", + "arn:*:bedrock:*:*:evaluation-job/*", + "arn:*:bedrock:*:*:flow-alias/*", + "arn:*:bedrock:*:*:flow/*", + "arn:*:bedrock:*:*:guardrail/*", + "arn:*:bedrock:*:*:imported-model/*", + "arn:*:bedrock:*:*:knowledge-base/*", + "arn:*:bedrock:*:*:model-copy-job/*", + "arn:*:bedrock:*:*:model-customization-job/*", + "arn:*:bedrock:*:*:model-evaluation-job/*", + "arn:*:bedrock:*:*:model-import-job/*", + "arn:*:bedrock:*:*:model-invocation-job/*", + "arn:*:bedrock:*:*:prompt-version/*", + "arn:*:bedrock:*:*:prompt/*", + "arn:*:bedrock:*:*:provisioned-model/*", + "arn:*:bedrock:*:*:session/*" + ], + "Condition": { + "StringNotEquals": { + "aws:ResourceTag/user": "jdoe" + } + } + } +``` + ## Full Policy <<< ./user-policy-raw.json diff --git a/frontend/docs/img/getting-started/detailed-arch.png b/frontend/docs/img/getting-started/detailed-arch.png new file mode 100644 index 0000000000000000000000000000000000000000..ca8cf19de95a73b93382db5331dbff859e4e4462 GIT binary patch literal 316418 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%0A5JELQPML!+-{wzGP-3N$bHmRpDgH-7C#L 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 z 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 Date: Fri, 10 Oct 2025 15:34:06 +0000 Subject: [PATCH 15/32] updated code release action --- .github/workflows/code.release.branch.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/code.release.branch.yml b/.github/workflows/code.release.branch.yml index 035957f0..e52f5134 100644 --- a/.github/workflows/code.release.branch.yml +++ b/.github/workflows/code.release.branch.yml @@ -21,17 +21,15 @@ jobs: ref: develop - name: Create Release Branch and Update Version run: | - git config --global user.email "evmann@amazon.com" - git config --global user.name "github_actions_mlspace" RELEASE_TAG=${{ github.event.inputs.release_tag }} git checkout -b release/${{ github.event.inputs.release_tag }} echo "$( jq --arg version ${RELEASE_TAG:1} '.version = $version' frontend/package.json )" > frontend/package.json git commit -a -m "Updating version for release ${{ github.event.inputs.release_tag }}" git push origin release/${{ github.event.inputs.release_tag }} env: - GITHUB_TOKEN: ${{ secrets.LEAD_ACCESS_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Draft Pull Request run: | gh pr create -d --title "Release ${{github.event.inputs.release_tag}} into Main" --body "Release ${{github.event.inputs.release_tag}} PR into Main" --base main --head release/${{ github.event.inputs.release_tag }} env: - GH_TOKEN: ${{ github.token }} \ No newline at end of file + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file From dee5e6359fabed9dc82138b1acbd251fa2e8a551 Mon Sep 17 00:00:00 2001 From: Dustin Sweigart Date: Fri, 10 Oct 2025 15:38:53 +0000 Subject: [PATCH 16/32] added user/email to release action --- .github/workflows/code.release.branch.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/code.release.branch.yml b/.github/workflows/code.release.branch.yml index e52f5134..55335f69 100644 --- a/.github/workflows/code.release.branch.yml +++ b/.github/workflows/code.release.branch.yml @@ -21,6 +21,8 @@ jobs: ref: develop - name: Create Release Branch and Update Version run: | + git config --global user.name "${GITHUB_ACTOR}" + git config --global user.email "${GITHUB_ACTOR}@users.noreply.github.com" RELEASE_TAG=${{ github.event.inputs.release_tag }} git checkout -b release/${{ github.event.inputs.release_tag }} echo "$( jq --arg version ${RELEASE_TAG:1} '.version = $version' frontend/package.json )" > frontend/package.json From c94ea5dda380a41851a4a15c14292bc3973f4ffe Mon Sep 17 00:00:00 2001 From: Dustin Sweigart Date: Mon, 13 Oct 2025 15:53:27 +0000 Subject: [PATCH 17/32] updated release notes --- frontend/src/release-notes.md | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/frontend/src/release-notes.md b/frontend/src/release-notes.md index 9be13911..d13d2ea6 100644 --- a/frontend/src/release-notes.md +++ b/frontend/src/release-notes.md @@ -1,23 +1,18 @@ -# v1.6.10 +# v1.6.11 -## Security +## Features +* **Bedrock IAM Policies**: Updated existing IAM policies to address Amazon Bedrock API updates. MLSpace customers can continue to directly call service APIs directly from their notebooks, enabling seamless integration with Bedrock’s foundation models and generative AI capabilities. -* **Default VPC Endpoints**: When MLSpace provisions a VPC, it now automatically includes endpoints for **Amazon Translate** and **Amazon EMR**, ensuring that traffic to these AWS services remains private within the AWS network. -* **DynamoDB Encryption with KMS**: MLSpace now encrypts DynamoDB tables using a **customer-managed KMS key (CMK)** if provided, giving customers direct control over encryption. This behavior is enabled by default and can be disabled by setting the `ENABLE_DDB_KMS_CMK_ENCRYPTION` flag to **false**. -* Updated dependencies with the latest security patches. - -## Bug Fixes - -* Fixed an issue affecting GovCloud partition handling. - -## Special Thanks - -* 🎉 Special thanks to [@szotrj](https://github.com/awslabs/mlspace/pull/318) for contributing their first PR! +## Documentation +* Updated Bedrock policy documentation to reflect updated IAM policies. +* Added an updated detailed architecture diagram to showcase MLSpace’s infrastructure and component relationships. +* Expanded documentation to cover how MLSpace stores auditable logs, providing greater transparency into logging mechanisms. ## Acknowledgements * @bedanley * @dustins * @estohlmann +* @jmharold -**Full Changelog**: [v1.6.9...v1.6.10](https://github.com/awslabs/mlspace/compare/v1.6.9...v1.6.10) \ No newline at end of file +**Full Changelog**: [v1.6.10...v1.6.11](https://github.com/awslabs/mlspace/compare/v1.6.10...v1.6.11) \ No newline at end of file From 7a23a89e2236e23944a348acbf42343f3e67076e Mon Sep 17 00:00:00 2001 From: Dustin Sweigart Date: Mon, 13 Oct 2025 19:14:35 +0000 Subject: [PATCH 18/32] added upcoming to release notes --- frontend/src/release-notes.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/src/release-notes.md b/frontend/src/release-notes.md index d13d2ea6..3769293b 100644 --- a/frontend/src/release-notes.md +++ b/frontend/src/release-notes.md @@ -8,6 +8,11 @@ * Added an updated detailed architecture diagram to showcase MLSpace’s infrastructure and component relationships. * Expanded documentation to cover how MLSpace stores auditable logs, providing greater transparency into logging mechanisms. + ## Upcoming +* **Bedrock VPC Endpoints**: Addition of a VPC endpoint for Amazon Bedrock to ensure traffic remains private within the customer's AWS network for enhanced security. +* **Bedrock Configuration Parameter**: New configuration parameter to allow customers to disable Bedrock capabilities in notebooks if desired. +* **GroundTruth Label Verification**: Addition of GroundTruth Label Verification jobs to the UI, enabling users to review and validate labeled data directly from the MLSpace interface. + ## Acknowledgements * @bedanley From bdef19766baba17545022819f777ed4f0c605374 Mon Sep 17 00:00:00 2001 From: Dustin Sweigart Date: Tue, 25 Nov 2025 16:25:43 +0000 Subject: [PATCH 19/32] added flake for development environment setup --- .envrc | 1 + .gitignore | 1 + flake.lock | 61 ++++++++++++++++++++++++++++++++++++++++++++ flake.nix | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 137 insertions(+) create mode 100644 .envrc create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/.envrc b/.envrc new file mode 100644 index 00000000..3550a30f --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore index d2e5b150..fdb99b48 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.direnv node_modules build cdk.out/ diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..5aec80c8 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1761468971, + "narHash": "sha256-vY2OLVg5ZTobdroQKQQSipSIkHlxOTrIF1fsMzPh8w8=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "78e34d1667d32d8a0ffc3eba4591ff256e80576e", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..f3fd0f82 --- /dev/null +++ b/flake.nix @@ -0,0 +1,74 @@ +{ + # mlspace Development Environment Flake + description = "Development environment for mlspace"; + + inputs = { + # Use the unstable channel for latest package versions + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; + + # Utility functions for creating flakes that work across multiple systems + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + # Generate outputs for all default systems (x86_64-linux, aarch64-linux, x86_64-darwin, aarch64-darwin) + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + { + # Formatter for this flake (run with `nix fmt`) + formatter = pkgs.nixpkgs-fmt; + + # Default development shell (enter with `nix develop`) + devShells.default = pkgs.mkShell { + # Core development tools needed for mlspace + packages = with pkgs; [ + awscli2 # AWS command-line interface for deployment and management + jq # JSON processor for parsing AWS responses and configuration + pre-commit # Git hook framework for code quality checks + podman + python311Full # Python runtime for mlspace backend services + nodejs # Node.js runtime for CDK infrastructure and frontend tooling + nodePackages.aws-cdk # AWS CDK CLI, the command line tool for CDK apps + uv # Fast Python package installer and virtual environment manager + yq # YAML processor for configuration management + ]; + + # Script that runs when entering the development shell + shellHook = '' + echo "Welcome to the mlspace development environment!" + echo "Python: $(python --version)" + echo "Node: $(node --version)" + echo "" + + # Set up Python virtual environment using uv + if [ ! -d .venv ]; then + echo "Creating Python virtual environment with uv..." + uv venv + else + echo "Using existing Python virtual environment." + fi + + # Ensure we start fresh if another venv is active + if [ -n "$VIRTUAL_ENV" ]; then + echo "Deactivating existing virtual environment..." + deactivate + fi + + # Activate the project virtual environment + source .venv/bin/activate + + # Install Node.js dependencies + echo "Installing Node.js dependencies..." + npm install + + # Configure git hooks for pre-commit + # Unset any existing hooks path to ensure pre-commit can manage hooks + git config --unset-all core.hooksPath 2>/dev/null || true + pre-commit install + ''; + }; + } + ); +} From 7682a3bc0125ac8f20a3cdf0a3ad27057d9e77a0 Mon Sep 17 00:00:00 2001 From: emacthecav <17402068+emacthecav@users.noreply.github.com> Date: Mon, 12 Jan 2026 10:56:47 -0500 Subject: [PATCH 20/32] Updated dataset description regex to be more permissive (#340) Co-authored-by: emmcd --- backend/src/ml_space_lambda/dataset/lambda_functions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/ml_space_lambda/dataset/lambda_functions.py b/backend/src/ml_space_lambda/dataset/lambda_functions.py index 0069159c..a87024bc 100644 --- a/backend/src/ml_space_lambda/dataset/lambda_functions.py +++ b/backend/src/ml_space_lambda/dataset/lambda_functions.py @@ -49,7 +49,7 @@ iam = boto3.client("iam", config=retry_config) iam_manager = IAMManager(iam) -dataset_description_regex = re.compile(r"[^\w\-\s'.]") +dataset_description_regex = re.compile(r"[^ -~]") def get_dataset_prefix(scope, dataset_name): From c2e5d9eb33fec72f6f04d65a61482defe1c116f6 Mon Sep 17 00:00:00 2001 From: Dustin Sweigart Date: Thu, 22 Jan 2026 11:30:08 -0500 Subject: [PATCH 21/32] Add enhanced authentication to support additional auth flows (#341) --- .eslintrc | 5 + .github/workflows/code.deploy.development.yml | 4 +- .github/workflows/code.test-and-lint.yml | 2 +- .kiro/specs/bff-authentication/design.md | 2057 ++++ .../specs/bff-authentication/requirements.md | 102 + .kiro/specs/bff-authentication/tasks.md | 161 + .kiro/steering/task-coding-standards.md | 56 + BFF_AUTHENTICATION_DOCS_README.md | 243 + CHANGES_SUMMARY.md | 112 + README.md | 25 +- backend/requirements.txt | 6 +- backend/src/ml_space_lambda/auth/__init__.py | 15 + .../ml_space_lambda/auth/handlers/__init__.py | 15 + .../auth/handlers/oidc_handler.py | 545 ++ .../ml_space_lambda/auth/lambda_functions.py | 1490 +++ .../auth/models/auth_models.py | 104 + .../ml_space_lambda/auth/models/key_models.py | 241 + .../ml_space_lambda/auth/session/__init__.py | 15 + .../auth/session/encryption.py | 159 + .../auth/session/key_manager.py | 408 + .../ml_space_lambda/auth/session/manager.py | 487 + .../ml_space_lambda/auth/session/validator.py | 224 + .../ml_space_lambda/auth/utils/__init__.py | 15 + .../src/ml_space_lambda/auth/utils/cookies.py | 274 + .../auth/utils/key_rotation.py | 459 + .../src/ml_space_lambda/auth/utils/otac.py | 200 + .../auth/utils/rotation_handlers.py | 235 + .../src/ml_space_lambda/auth/utils/state.py | 179 + .../authorizer/lambda_function.py | 723 +- .../data_access_objects/user.py | 37 +- .../dataset/lambda_functions.py | 5 - backend/src/ml_space_lambda/enums.py | 2 +- .../ml_space_lambda/user/lambda_functions.py | 27 +- .../ml_space_lambda/utils/mlspace_config.py | 2 +- backend/test/auth/__init__.py | 15 + backend/test/auth/handlers/__init__.py | 15 + .../test/auth/handlers/test_oidc_handler.py | 568 ++ backend/test/auth/models/test_key_models.py | 200 + backend/test/auth/session/__init__.py | 15 + backend/test/auth/session/test_encryption.py | 113 + backend/test/auth/session/test_key_manager.py | 579 ++ backend/test/auth/session/test_manager.py | 419 + backend/test/auth/session/test_validator.py | 376 + backend/test/auth/test_lambda_functions.py | 1456 +++ backend/test/auth/utils/__init__.py | 15 + backend/test/auth/utils/test_cookies.py | 121 + backend/test/auth/utils/test_key_rotation.py | 477 + backend/test/auth/utils/test_otac.py | 154 + .../test/auth/utils/test_rotation_handlers.py | 508 + backend/test/auth/utils/test_state.py | 316 + backend/test/authorizer/test_authorizer.py | 357 +- backend/test/config/test_describe_config.py | 2 +- backend/test/conftest.py | 3 + backend/test/dataset/test_edit_dataset.py | 13 - backend/test/user/test_create_user.py | 203 +- backend/test/utils/test_mlspace_config.py | 2 +- backend/test_session_authorizer.py | 137 + bin/mlspace-cdk.ts | 3 +- design/auth-enhancement.md | 130 + docs/BFF_AUTHENTICATION_KEY_ROTATION.md | 289 + docs/KEY_MANAGEMENT_REFACTOR_USAGE.md | 233 + docs/VERSIONED_KEY_MANAGEMENT_GUIDE.md | 195 + flake.lock | 6 +- frontend/docs/.vitepress/config.mts | 4 + .../auth-configuration-reference.md | 396 + .../bff-authentication-migration.md | 607 ++ .../docs/admin-guide/bff-authentication.md | 540 ++ .../docs/admin-guide/configure-cognito.md | 14 +- frontend/docs/admin-guide/custom-domain.md | 290 + frontend/docs/admin-guide/install.md | 18 +- frontend/package-lock.json | 8280 +++++++++++------ frontend/package.json | 1 - frontend/src/App.tsx | 4 +- frontend/src/config/oidc.config.ts | 48 - frontend/src/contexts/AuthContext.tsx | 251 + frontend/src/contexts/AuthSyncManager.test.ts | 161 + .../create/batch-translate-create.tsx | 5 +- .../dataset/create/dataset-create.tsx | 4 +- .../dataset/update/dataset-update.tsx | 4 +- .../jobs/hpo/create/hpo-job-create.tsx | 5 +- .../notebook/notebook.reducer.spec.ts | 7 +- .../project/detail/project-detail.actions.tsx | 5 +- frontend/src/index.tsx | 20 +- frontend/src/routes.tsx | 18 +- frontend/src/shared/auth/README.md | 315 + frontend/src/shared/auth/auth.css | 231 + frontend/src/shared/auth/components.test.tsx | 163 + frontend/src/shared/auth/components.tsx | 365 + frontend/src/shared/auth/hooks.ts | 108 + frontend/src/shared/auth/index.ts | 56 + frontend/src/shared/layout/header/header.tsx | 8 +- frontend/src/shared/util/auth-utils.spec.ts | 54 +- frontend/src/shared/util/auth-utils.ts | 6 +- frontend/src/shared/util/axios-utils.spec.ts | 13 +- frontend/src/shared/util/axios-utils.ts | 10 +- frontend/src/types/index.d.ts | 3 - lambda_dependencies/common/create.sh | 4 + lib/constants.ts | 52 +- lib/constructs/api/adminConstruct.ts | 10 - lib/constructs/api/authConstruct.ts | 155 + lib/constructs/api/restApiConstruct.ts | 19 +- lib/constructs/auth/authSecretsConstruct.ts | 206 + lib/constructs/iamConstruct.ts | 10 + lib/constructs/infra/coreConstruct.ts | 25 +- lib/stacks/api/auth.ts | 29 + lib/stacks/api/restApi.ts | 1 - lib/utils/configTypes.ts | 114 +- 107 files changed, 24261 insertions(+), 3932 deletions(-) create mode 100644 .kiro/specs/bff-authentication/design.md create mode 100644 .kiro/specs/bff-authentication/requirements.md create mode 100644 .kiro/specs/bff-authentication/tasks.md create mode 100644 .kiro/steering/task-coding-standards.md create mode 100644 BFF_AUTHENTICATION_DOCS_README.md create mode 100644 CHANGES_SUMMARY.md create mode 100644 backend/src/ml_space_lambda/auth/__init__.py create mode 100644 backend/src/ml_space_lambda/auth/handlers/__init__.py create mode 100644 backend/src/ml_space_lambda/auth/handlers/oidc_handler.py create mode 100644 backend/src/ml_space_lambda/auth/lambda_functions.py create mode 100644 backend/src/ml_space_lambda/auth/models/auth_models.py create mode 100644 backend/src/ml_space_lambda/auth/models/key_models.py create mode 100644 backend/src/ml_space_lambda/auth/session/__init__.py create mode 100644 backend/src/ml_space_lambda/auth/session/encryption.py create mode 100644 backend/src/ml_space_lambda/auth/session/key_manager.py create mode 100644 backend/src/ml_space_lambda/auth/session/manager.py create mode 100644 backend/src/ml_space_lambda/auth/session/validator.py create mode 100644 backend/src/ml_space_lambda/auth/utils/__init__.py create mode 100644 backend/src/ml_space_lambda/auth/utils/cookies.py create mode 100644 backend/src/ml_space_lambda/auth/utils/key_rotation.py create mode 100644 backend/src/ml_space_lambda/auth/utils/otac.py create mode 100644 backend/src/ml_space_lambda/auth/utils/rotation_handlers.py create mode 100644 backend/src/ml_space_lambda/auth/utils/state.py create mode 100644 backend/test/auth/__init__.py create mode 100644 backend/test/auth/handlers/__init__.py create mode 100644 backend/test/auth/handlers/test_oidc_handler.py create mode 100644 backend/test/auth/models/test_key_models.py create mode 100644 backend/test/auth/session/__init__.py create mode 100644 backend/test/auth/session/test_encryption.py create mode 100644 backend/test/auth/session/test_key_manager.py create mode 100644 backend/test/auth/session/test_manager.py create mode 100644 backend/test/auth/session/test_validator.py create mode 100644 backend/test/auth/test_lambda_functions.py create mode 100644 backend/test/auth/utils/__init__.py create mode 100644 backend/test/auth/utils/test_cookies.py create mode 100644 backend/test/auth/utils/test_key_rotation.py create mode 100644 backend/test/auth/utils/test_otac.py create mode 100644 backend/test/auth/utils/test_rotation_handlers.py create mode 100644 backend/test/auth/utils/test_state.py create mode 100644 backend/test_session_authorizer.py create mode 100644 design/auth-enhancement.md create mode 100644 docs/BFF_AUTHENTICATION_KEY_ROTATION.md create mode 100644 docs/KEY_MANAGEMENT_REFACTOR_USAGE.md create mode 100644 docs/VERSIONED_KEY_MANAGEMENT_GUIDE.md create mode 100644 frontend/docs/admin-guide/auth-configuration-reference.md create mode 100644 frontend/docs/admin-guide/bff-authentication-migration.md create mode 100644 frontend/docs/admin-guide/bff-authentication.md create mode 100644 frontend/docs/admin-guide/custom-domain.md delete mode 100644 frontend/src/config/oidc.config.ts create mode 100644 frontend/src/contexts/AuthContext.tsx create mode 100644 frontend/src/contexts/AuthSyncManager.test.ts create mode 100644 frontend/src/shared/auth/README.md create mode 100644 frontend/src/shared/auth/auth.css create mode 100644 frontend/src/shared/auth/components.test.tsx create mode 100644 frontend/src/shared/auth/components.tsx create mode 100644 frontend/src/shared/auth/hooks.ts create mode 100644 frontend/src/shared/auth/index.ts create mode 100644 lib/constructs/api/authConstruct.ts create mode 100644 lib/constructs/auth/authSecretsConstruct.ts create mode 100644 lib/stacks/api/auth.ts diff --git a/.eslintrc b/.eslintrc index 9933748b..5a89f3e8 100644 --- a/.eslintrc +++ b/.eslintrc @@ -67,6 +67,7 @@ "skipWords": [ "azs", "aud", + "bff", "fullname", "isob", "localhost", @@ -110,6 +111,7 @@ "cardinality", "cbow", "centroids", + "cdk", "cfn", "cidr", "checkbox", @@ -176,6 +178,7 @@ "evenodd", "faiss", "fcn", + "fernet", "figlet", "fjlt", "flashbar", @@ -301,11 +304,13 @@ "rmse", "rmsprop", "rowindex", + "rotatable", "runtimes", "sagemaker", "scaler", "scape", "sdk", + "secretsmanager", "selectable", "serializable", "serverless", diff --git a/.github/workflows/code.deploy.development.yml b/.github/workflows/code.deploy.development.yml index daabbba6..092b23c4 100644 --- a/.github/workflows/code.deploy.development.yml +++ b/.github/workflows/code.deploy.development.yml @@ -37,8 +37,8 @@ jobs: { "AWS_ACCOUNT": "${{ vars.AWS_ACCOUNT }}", "AWS_REGION": "${{ vars.AWS_REGION }}", - "OIDC_URL": "${{ secrets.OIDC_URL }}", - "OIDC_CLIENT_NAME": "${{ secrets.OIDC_CLIENT_NAME }}", + "AUTH_OIDC_CLIENT_ID": "${{ vars.AUTH_OIDC_CLIENT_ID }}", + "AUTH_OIDC_URL": "${{ vars.AUTH_OIDC_URL }}", "KEY_MANAGER_ROLE_NAME": "${{ vars.KEY_MANAGER_ROLE_NAME }}" } dir: './lib/' diff --git a/.github/workflows/code.test-and-lint.yml b/.github/workflows/code.test-and-lint.yml index 1cdc5d37..e2054045 100644 --- a/.github/workflows/code.test-and-lint.yml +++ b/.github/workflows/code.test-and-lint.yml @@ -60,7 +60,7 @@ jobs: --cov-report term-missing \ --cov-report html:build/coverage \ --cov-report xml:build/coverage/coverage.xml \ - --cov-fail-under 97 + --cov-fail-under 90 frontend_build: name: FrontEnd Tests runs-on: ubuntu-latest diff --git a/.kiro/specs/bff-authentication/design.md b/.kiro/specs/bff-authentication/design.md new file mode 100644 index 00000000..d61f320e --- /dev/null +++ b/.kiro/specs/bff-authentication/design.md @@ -0,0 +1,2057 @@ +# BFF Authentication Design Document + +## Overview + +This document provides detailed design specifications for implementing the Backend for Frontend (BFF) authentication pattern in MLSpace. The BFF pattern abstracts authentication complexity from the frontend and centralizes all Identity Provider integration in the backend, enabling support for enterprise IdPs that require client secrets or SAML protocol. + +## Detailed API Specifications + +### Authentication Endpoints + +#### GET /auth/login + +Initiates the authentication process by redirecting the user to the configured Identity Provider. + +**Request:** +```http +GET /auth/login?redirectUrl=https%3A%2F%2Fapp.mlspace.com%2Fdashboard +``` + +**Query Parameters:** +- `redirectUrl` (optional): URL to redirect to after successful authentication + +**Response (Redirect):** +```http +HTTP/1.1 302 Found +Location: https://idp.example.com/auth?client_id=...&redirect_uri=...&state=... +Set-Cookie: mlspace_auth_state=; HttpOnly; Secure; SameSite=Strict; Max-Age=600 +``` + +**Error Response:** +```http +HTTP/1.1 400 Bad Request +Content-Type: application/json + +{ + "error": "INVALID_CONFIGURATION", + "message": "Identity Provider not configured for this deployment", + "details": { + "configuredProvider": null, + "supportedProviders": ["oidc", "saml"] + } +} +``` + +#### GET/POST /auth/callback + +Handles the return from Identity Provider after authentication attempt. + +**Request (OIDC):** +```http +GET /auth/callback?code=auth_code&state=encrypted_state +Cookie: mlspace_auth_state= +``` + + + +**Success Response (Single Domain):** +```http +HTTP/1.1 302 Found +Location: https://app.mlspace.com/dashboard +Set-Cookie: mlspace_session=; HttpOnly; Secure; SameSite=Strict; Max-Age=86400 +Set-Cookie: mlspace_auth_state=; HttpOnly; Secure; SameSite=Strict; Max-Age=0 +``` + +**Success Response (Multi-Domain Chain):** +```http +HTTP/1.1 302 Found +Location: https://api.mlspace.com/auth/sync?otac=otac_xyz789&next=notebooks.mlspace.com&final=https://app.mlspace.com/dashboard +Set-Cookie: mlspace_session=; HttpOnly; Secure; SameSite=Strict; Max-Age=86400 +Set-Cookie: mlspace_auth_state=; HttpOnly; Secure; SameSite=Strict; Max-Age=0 +``` + +**Error Response:** +```http +HTTP/1.1 302 Found +Location: https://app.mlspace.com/login?error=authentication_failed&message=Invalid%20credentials +Set-Cookie: mlspace_auth_state=; HttpOnly; Secure; SameSite=Strict; Max-Age=0 +``` + +#### POST /auth/logout + +Terminates the user session and optionally logs out from the Identity Provider. + +**Request:** +```http +POST /auth/logout +Cookie: mlspace_session= + +{ + "logoutFromIdp": true // Optional: whether to logout from IdP as well +} +``` + +**Success Response:** +```http +HTTP/1.1 200 OK +Content-Type: application/json +Set-Cookie: mlspace_session=; HttpOnly; Secure; SameSite=Strict; Max-Age=0 + +{ + "status": "LOGGED_OUT", + "idpLogoutUrl": "https://idp.example.com/logout?post_logout_redirect_uri=..." // Optional +} +``` + +**Error Response:** +```http +HTTP/1.1 400 Bad Request +Content-Type: application/json + +{ + "error": "INVALID_SESSION", + "message": "No active session found" +} +``` + +#### GET /auth/identity + +Retrieves current user identity and authentication status. + +**Request:** +```http +GET /auth/identity +Cookie: mlspace_session= +``` + +**Authenticated Response:** +```http +HTTP/1.1 200 OK +Content-Type: application/json + +{ + "status": "AUTHENTICATED", + "user": { + "id": "user123", + "displayName": "John Doe", + "email": "john.doe@example.com", + "groups": ["admin", "data-scientist"], + "attributes": { + "department": "Engineering", + "role": "Senior Developer" + } + }, + "session": { + "expiresAt": "2024-01-15T10:30:00Z", + "refreshAt": "2024-01-15T09:30:00Z", + "provider": "oidc" + } +} +``` + +**Unauthenticated Response:** +```http +HTTP/1.1 401 Unauthorized +Content-Type: application/json + +{ + "status": "UNAUTHENTICATED", + "error": "SESSION_EXPIRED", + "message": "Session has expired or is invalid" +} +``` + +**Token Refresh Response:** +```http +HTTP/1.1 200 OK +Content-Type: application/json +Set-Cookie: mlspace_session=; HttpOnly; Secure; SameSite=Strict; Max-Age=86400 + +{ + "status": "AUTHENTICATED", + "user": { /* same as above */ }, + "session": { + "expiresAt": "2024-01-15T11:30:00Z", + "refreshAt": "2024-01-15T10:30:00Z", + "provider": "oidc", + "refreshed": true + } +} +``` + +### Cross-Domain Synchronization Flow + +The cross-domain synchronization automatically chains multiple domains after successful authentication, ensuring all configured domains receive session cookies without additional user interaction. + +**Authentication Flow with Multi-Domain Sync:** +1. User authenticates on Domain A via `/auth/callback` +2. Domain A creates session and checks configuration for additional sync domains +3. If sync domains exist, Domain A generates OTAC and redirects to first sync domain +4. Each sync domain validates OTAC, sets session cookie, and continues chain +5. Final domain redirects to original destination + +**Data Storage Pattern:** +Both sessions and OTAC records use the same DynamoDB table with prefixed keys: +- Session records: `session:` → Contains user data and IdP tokens +- OTAC records: `otac:` → Contains reference to original session ID +- All domains set the same `mlspace_session` cookie value (the session ID) +- OTAC records are deleted after single use and have short TTL (5 minutes) + +#### Internal: POST /auth/sync/initiate + +Called internally by `/auth/callback` when multi-domain sync is configured. + +**Internal Request:** +```http +POST /auth/sync/initiate +Content-Type: application/json + +{ + "sessionId": "session_abc123", + "syncDomains": ["api.mlspace.com", "notebooks.mlspace.com"], + "finalRedirectUrl": "https://app.mlspace.com/dashboard" +} +``` + +**Internal Response:** +```http +HTTP/1.1 200 OK +Content-Type: application/json + +{ + "otac": "otac_xyz789", + "chainUrl": "https://api.mlspace.com/auth/sync?otac=otac_xyz789&next=notebooks.mlspace.com&final=https://app.mlspace.com/dashboard", + "expiresAt": "2024-01-15T08:35:00Z" +} +``` + +#### GET /auth/sync + +Handles OTAC validation and cookie synchronization in the domain chain. + +**Request:** +```http +GET /auth/sync?otac=otac_xyz789&next=notebooks.mlspace.com&final=https://app.mlspace.com/dashboard +``` + +**Success Response (Continue Chain):** +```http +HTTP/1.1 302 Found +Location: https://notebooks.mlspace.com/auth/sync?otac=otac_abc456&final=https://app.mlspace.com/dashboard +Set-Cookie: mlspace_session=; HttpOnly; Secure; SameSite=Strict; Max-Age=86400; Domain=api.mlspace.com +``` + +**Success Response (End Chain):** +```http +HTTP/1.1 302 Found +Location: https://app.mlspace.com/dashboard +Set-Cookie: mlspace_session=; HttpOnly; Secure; SameSite=Strict; Max-Age=86400; Domain=notebooks.mlspace.com +``` + +**Error Response:** +```http +HTTP/1.1 400 Bad Request +Content-Type: application/json + +{ + "error": "INVALID_OTAC", + "message": "One-time authentication code is invalid or expired", + "details": { + "otac": "otac_xyz789", + "domain": "api.mlspace.com" + } +} +``` + +### Error Response Schema + +All API endpoints follow a consistent error response format: + +```json +{ + "error": "ERROR_CODE", + "message": "Human-readable error description", + "details": { + // Additional context-specific error information + }, + "timestamp": "2024-01-15T08:30:00Z", + "requestId": "req-12345" +} +``` + +**Common Error Codes:** +- `INVALID_CONFIGURATION`: IdP not properly configured +- `AUTHENTICATION_FAILED`: IdP rejected authentication +- `SESSION_EXPIRED`: User session has expired +- `INVALID_SESSION`: Session cookie is malformed or invalid +- `TOKEN_REFRESH_FAILED`: Unable to refresh IdP tokens +- `INVALID_OTAC`: Cross-domain sync code is invalid +- `INTERNAL_ERROR`: Unexpected server error + +### Request/Response Headers + +**Security Headers (All Responses):** +```http +Strict-Transport-Security: max-age=31536000; includeSubDomains +X-Content-Type-Options: nosniff +X-Frame-Options: DENY +X-XSS-Protection: 1; mode=block +Content-Security-Policy: default-src 'self' +``` + +**CORS Headers (Cross-Origin Requests):** +```http +Access-Control-Allow-Origin: https://app.mlspace.com +Access-Control-Allow-Credentials: true +Access-Control-Allow-Methods: GET, POST, OPTIONS +Access-Control-Allow-Headers: Content-Type, Authorization +``` + +## Database Schema Design + +### DynamoDB Table Structure + +**Table Name:** `mlspace-auth-sessions` + +**Primary Key:** +- Partition Key: `pk` (String) - Record identifier with type prefix +- No Sort Key needed for this access pattern + +**Attributes:** +- `pk` (String) - Primary key with prefixes: `session:` or `otac:` +- `ttl` (Number) - Unix timestamp for automatic record expiration +- `data` (Map) - Structured session or OTAC data +- `raw_data` (String) - Base64 encoded raw IdP response (sessions only) +- `created_at` (String) - ISO 8601 timestamp +- `updated_at` (String) - ISO 8601 timestamp + +### Session Record Schema + +**Key Pattern:** `session:` + +```json +{ + "pk": "session:550e8400-e29b-41d4-a716-446655440000", + "ttl": 1705312200, + "created_at": "2024-01-15T08:30:00Z", + "updated_at": "2024-01-15T08:30:00Z", + "data": { + "user": { + "id": "user123", + "displayName": "John Doe", + "email": "john.doe@example.com", + "groups": ["admin", "data-scientist"], + "attributes": { + "department": "Engineering", + "role": "Senior Developer" + } + }, + "session": { + "provider": "oidc", + "expiresAt": "2024-01-15T10:30:00Z", + "refreshAt": "2024-01-15T09:30:00Z", + "refreshToken": "encrypted_refresh_token", + "accessToken": "encrypted_access_token", + "idToken": "encrypted_id_token" + }, + "metadata": { + "loginDomain": "app.mlspace.com", + "syncedDomains": ["api.mlspace.com", "notebooks.mlspace.com"] + } + }, + "raw_data": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." // Base64 encoded original IdP response +} +``` + +### OTAC Record Schema + +**Key Pattern:** `otac:` + +```json +{ + "pk": "otac:123e4567-e89b-12d3-a456-426614174000", + "ttl": 1705308900, + "created_at": "2024-01-15T08:30:00Z", + "data": { + "sessionId": "session:550e8400-e29b-41d4-a716-446655440000", + "remainingDomains": ["notebooks.mlspace.com"], + "finalRedirectUrl": "https://app.mlspace.com/dashboard", + "usedAt": null // Set when OTAC is consumed + } +} +``` + +### TTL Configuration + +**Session Records:** +- Default TTL: 24 hours (86400 seconds) +- Extended TTL: Based on IdP refresh token lifetime (up to 7 days) +- Configurable per deployment via environment variables + +**OTAC Records:** +- Fixed TTL: 5 minutes (300 seconds) +- Non-configurable for security reasons +- Automatically deleted after single use + +### Indexes + +**No additional indexes required** - All access patterns use the primary key: +- Session lookup: `session:` +- OTAC validation: `otac:` +- DynamoDB handles TTL cleanup automatically + +### Query Patterns and Performance + +#### Primary Access Patterns + +1. **Session Validation** (High Frequency) + ``` + GetItem: pk = "session:" + Consistency: Eventually Consistent + Expected RPS: 1000+ + ``` + +2. **OTAC Validation** (Low Frequency) + ``` + GetItem: pk = "otac:" + Consistency: Strong Consistent (security requirement) + Expected RPS: 10-50 + ``` + +3. **Session Update** (Medium Frequency) + ``` + UpdateItem: pk = "session:" + Update: tokens, updated_at, metadata + Expected RPS: 100-500 + ``` + +4. **OTAC Creation** (Low Frequency) + ``` + PutItem: pk = "otac:" + Condition: attribute_not_exists(pk) + Expected RPS: 10-50 + ``` + +5. **OTAC Consumption** (Low Frequency) + ``` + UpdateItem: pk = "otac:" + Condition: attribute_not_exists(usedAt) + Update: usedAt = current_timestamp + Expected RPS: 10-50 + ``` + +#### Performance Considerations + +**Read Capacity:** +- Provisioned: 100 RCU (burst to 300) +- On-Demand: Recommended for variable workloads +- Session validation is the primary read pattern + +**Write Capacity:** +- Provisioned: 50 WCU (burst to 150) +- On-Demand: Recommended for variable workloads +- Session updates and OTAC operations + +**Item Size Optimization:** +- Session records: ~2-4KB (well under 400KB limit) +- OTAC records: ~200-500 bytes +- Raw IdP data stored as compressed Base64 + +### Data Encryption + +**Encryption at Rest:** +- DynamoDB encryption enabled with AWS managed keys +- Sensitive tokens encrypted with application-level encryption before storage + +**Encryption in Transit:** +- All DynamoDB API calls use TLS 1.2+ +- Application-level encryption for sensitive fields + +**Token Encryption Schema:** +```json +{ + "accessToken": "AES256:iv:encrypted_data", + "refreshToken": "AES256:iv:encrypted_data", + "idToken": "AES256:iv:encrypted_data" +} +``` + +### Cleanup and Maintenance + +**Automatic Cleanup:** +- TTL handles expired records automatically +- No manual cleanup required for normal operations + +**Manual Cleanup Operations:** +- Emergency session invalidation: Delete by session ID +- User logout: Update TTL to immediate expiration +- Security incident: Batch delete by user ID (requires scan) + +**Monitoring:** +- CloudWatch metrics for read/write capacity +- Custom metrics for session creation/expiration rates +- Alarms for unusual access patterns + +## Frontend Architecture + +### React Authentication Context Provider + +The new authentication system replaces the existing OIDC-specific context provider with a backend-focused authentication provider that communicates exclusively with the BFF API endpoints. + +#### AuthContext Interface + +```typescript +interface AuthUser { + id: string; + displayName: string; + email: string; + groups: string[]; + attributes: Record; +} + +interface AuthSession { + expiresAt: string; + refreshAt: string; + provider: string; + refreshed?: boolean; +} + +interface AuthState { + status: 'loading' | 'authenticated' | 'unauthenticated'; + user: AuthUser | null; + session: AuthSession | null; + error: string | null; +} + +interface AuthContextValue extends AuthState { + // Actions + login: (redirectUrl?: string) => void; + logout: (logoutFromIdp?: boolean) => Promise; + refresh: () => Promise; + clearError: () => void; +} +``` + +#### AuthProvider Implementation + +```typescript +import axios from 'axios'; // Direct axios for auth endpoints (no Bearer token needed) +import React, { useState, useEffect, useRef, useContext, useMemo } from 'react'; + +interface AuthProviderProps { + children: React.ReactNode; + checkInterval?: number; // Default: 60000ms (1 minute) + refreshThreshold?: number; // Default: 300000ms (5 minutes before expiry) +} + +export const AuthProvider: React.FC = ({ + children, + checkInterval = 60000, + refreshThreshold = 300000 +}) => { + const [state, setState] = useState({ + status: 'loading', + user: null, + session: null, + error: null + }); + + // Cross-tab synchronization + const syncManagerRef = useRef(null); + + useEffect(() => { + syncManagerRef.current = new AuthSyncManager(checkAuthStatus); + return () => { + syncManagerRef.current?.destroy(); + }; + }, []); + + // Initial authentication check on mount + useEffect(() => { + checkAuthStatus(); + }, []); + + // Periodic session validation + useEffect(() => { + const interval = setInterval(checkAuthStatus, checkInterval); + return () => clearInterval(interval); + }, [checkInterval]); + + const checkAuthStatus = async () => { + try { + // Use direct axios for auth endpoints - no Authorization header needed + const response = await axios.get('/auth/identity', { + withCredentials: true, + baseURL: window.location.origin // Auth endpoints are on same domain + }); + + const wasAuthenticated = state.status === 'authenticated'; + + setState({ + status: 'authenticated', + user: response.data.user, + session: response.data.session, + error: null + }); + + // Broadcast auth state changes to other tabs + if (!wasAuthenticated || response.data.session.refreshed) { + syncManagerRef.current?.broadcast({ type: 'AUTH_STATE_CHANGED' }); + } + + // Check if refresh is needed + if (shouldRefresh(response.data.session)) { + await refresh(); + } + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 401) { + const wasAuthenticated = state.status === 'authenticated'; + + setState({ + status: 'unauthenticated', + user: null, + session: null, + error: null + }); + + // Broadcast session expiration to other tabs + if (wasAuthenticated) { + syncManagerRef.current?.broadcast({ type: 'SESSION_EXPIRED' }); + } + } else { + setState(prev => ({ + ...prev, + status: 'unauthenticated', + error: 'Failed to check authentication status' + })); + } + } + }; + + const login = (redirectUrl?: string) => { + const loginUrl = new URL('/auth/login', window.location.origin); + if (redirectUrl) { + loginUrl.searchParams.set('redirectUrl', redirectUrl); + } + window.location.href = loginUrl.toString(); + }; + + const logout = async (logoutFromIdp = false) => { + try { + // Use direct axios for auth endpoints - no Authorization header needed + const response = await axios.post('/auth/logout', + { logoutFromIdp }, + { + withCredentials: true, + baseURL: window.location.origin + } + ); + + setState({ + status: 'unauthenticated', + user: null, + session: null, + error: null + }); + + // Notify other tabs + syncManagerRef.current?.broadcast({ type: 'LOGOUT_INITIATED' }); + + // Redirect to IdP logout if provided + if (response.data.idpLogoutUrl) { + window.location.href = response.data.idpLogoutUrl; + } + } catch (error) { + setState(prev => ({ + ...prev, + error: 'Failed to logout' + })); + } + }; + + const refresh = async () => { + await checkAuthStatus(); + }; + + const shouldRefresh = (session: AuthSession): boolean => { + const refreshTime = new Date(session.refreshAt).getTime(); + const now = Date.now(); + return now >= refreshTime - refreshThreshold; + }; + + return ( + setState(prev => ({ ...prev, error: null })) + }}> + {children} + + ); +}; +``` + +### Component Integration Patterns + +#### Protected Route Component + +```typescript +interface ProtectedRouteProps { + children: React.ReactNode; + fallback?: React.ReactNode; + requiredGroups?: string[]; +} + +export const ProtectedRoute: React.FC = ({ + children, + fallback =
Loading...
, + requiredGroups = [] +}) => { + const { status, user, login } = useAuth(); + + if (status === 'loading') { + return <>{fallback}; + } + + if (status === 'unauthenticated') { + login(window.location.pathname); + return <>{fallback}; + } + + if (requiredGroups.length > 0 && user) { + const hasRequiredGroup = requiredGroups.some(group => + user.groups.includes(group) + ); + + if (!hasRequiredGroup) { + return
Access denied. Required groups: {requiredGroups.join(', ')}
; + } + } + + return <>{children}; +}; +``` + +#### Authentication Hook + +```typescript +export const useAuth = (): AuthContextValue => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; + +// Convenience hooks for common patterns +export const useUser = () => { + const { user } = useAuth(); + return user; +}; + +export const useAuthStatus = () => { + const { status } = useAuth(); + return status; +}; + +export const useRequireAuth = () => { + const { status, login } = useAuth(); + + useEffect(() => { + if (status === 'unauthenticated') { + login(); + } + }, [status, login]); + + return status === 'authenticated'; +}; +``` + +### Cross-Tab Session Synchronization + +The authentication provider uses the BroadcastChannel API to synchronize authentication state across multiple browser tabs in real-time. + +#### Synchronization Events + +```typescript +type AuthBroadcastMessage = + | { type: 'AUTH_STATE_CHANGED' } + | { type: 'SESSION_EXPIRED' } + | { type: 'LOGOUT_INITIATED' }; + +class AuthSyncManager { + private channel: BroadcastChannel; + + constructor(private onStateChange: () => void) { + this.channel = new BroadcastChannel('mlspace-auth'); + this.channel.addEventListener('message', this.handleMessage); + } + + private handleMessage = (event: MessageEvent) => { + switch (event.data.type) { + case 'AUTH_STATE_CHANGED': + case 'SESSION_EXPIRED': + this.onStateChange(); + break; + case 'LOGOUT_INITIATED': + // Immediate logout without API call (already done in originating tab) + this.onStateChange(); + break; + } + }; + + broadcast(message: AuthBroadcastMessage) { + this.channel.postMessage(message); + } + + destroy() { + this.channel.removeEventListener('message', this.handleMessage); + this.channel.close(); + } +} + +// Usage in AuthProvider: +// - Created in useEffect with checkAuthStatus callback +// - Used to broadcast logout events: syncManagerRef.current?.broadcast({ type: 'LOGOUT_INITIATED' }) +// - Automatically triggers checkAuthStatus when other tabs change auth state +``` + +### Error Handling and User Experience + +#### Error Boundary for Authentication + +```typescript +interface AuthErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +export class AuthErrorBoundary extends React.Component< + React.PropsWithChildren<{}>, + AuthErrorBoundaryState +> { + constructor(props: React.PropsWithChildren<{}>) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): AuthErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error('Authentication error:', error, errorInfo); + + // Report to monitoring service + if (window.analytics) { + window.analytics.track('Auth Error', { + error: error.message, + stack: error.stack, + componentStack: errorInfo.componentStack + }); + } + } + + render() { + if (this.state.hasError) { + return ( +
+

Authentication Error

+

Something went wrong with authentication. Please try refreshing the page.

+ +
+ ); + } + + return this.props.children; + } +} +``` + +#### Loading States and Transitions + +```typescript +interface LoadingStateProps { + status: AuthContextValue['status']; + children: React.ReactNode; +} + +export const AuthLoadingWrapper: React.FC = ({ + status, + children +}) => { + if (status === 'loading') { + return ( +
+
+

Checking authentication...

+
+ ); + } + + return <>{children}; +}; +``` + +#### Session Expiration Handling + +```typescript +export const SessionExpirationNotice: React.FC = () => { + const { status, session, login } = useAuth(); + const [showWarning, setShowWarning] = useState(false); + + useEffect(() => { + if (status === 'authenticated' && session) { + const expiryTime = new Date(session.expiresAt).getTime(); + const warningTime = expiryTime - (5 * 60 * 1000); // 5 minutes before expiry + const now = Date.now(); + + if (now >= warningTime) { + setShowWarning(true); + } else { + const timeout = setTimeout(() => setShowWarning(true), warningTime - now); + return () => clearTimeout(timeout); + } + } + }, [status, session]); + + if (!showWarning || status !== 'authenticated') { + return null; + } + + return ( +
+

Your session will expire soon. Click to extend your session.

+ + +
+ ); +}; +``` + +### Migration from Existing OIDC Provider + +#### API Call Strategy + +**Authentication Endpoints** (`/auth/*`): +- Use direct `axios` import for auth-specific calls +- Cookie-based authentication, no Authorization header +- Set `baseURL: window.location.origin` for same-domain calls + +**Regular API Endpoints** (existing MLSpace APIs): +- Use updated `axios-utils` with Authorization header logic removed +- All API calls will use cookie-based authentication +- Backend authorizer validates session cookies only + +```typescript +// Auth endpoints - direct axios +import axios from 'axios'; +const authResponse = await axios.get('/auth/identity', { + withCredentials: true, + baseURL: window.location.origin +}); + +// Regular API endpoints - use updated axios-utils (no Authorization header) +import axios, { axiosCatch } from '../../shared/util/axios-utils'; +const apiResponse = await axios.get('/api/projects').catch(axiosCatch); +``` + +#### Required axios-utils Updates + +The `axios-utils.ts` file will need to be updated to remove OIDC token handling: + +```typescript +// Remove this logic from config() function: +const oidcString = sessionStorage.getItem( + `oidc.user:${window.env.OIDC_URL}:${window.env.OIDC_CLIENT_NAME}` +); +const token = oidcString ? JSON.parse(oidcString).id_token : ''; +requestConfig.headers['Authorization'] = `Bearer ${token}`; + +// Add withCredentials for cookie support: +requestConfig.withCredentials = true; +``` + +#### Compatibility Layer + +```typescript +// Temporary compatibility layer for existing components +export const LegacyAuthAdapter: React.FC<{ children: React.ReactNode }> = ({ + children +}) => { + const auth = useAuth(); + + // Map new auth context to legacy interface + const legacyContext = useMemo(() => ({ + isAuthenticated: auth.status === 'authenticated', + user: auth.user, + login: auth.login, + logout: () => auth.logout(), + // Map other legacy properties as needed + }), [auth]); + + return ( + + {children} + + ); +}; +``` + +## Security Model + +### Cookie Configuration + +#### Session Cookie Specification + +**Cookie Name:** `mlspace_session` + +**Cookie Attributes:** +```http +Set-Cookie: mlspace_session=; + HttpOnly; + Secure; + SameSite=Strict; + Path=/; + Max-Age=86400; + Domain= +``` + +**Attribute Details:** + +| Attribute | Value | Purpose | +|-----------|-------|---------| +| `HttpOnly` | true | Prevents JavaScript access, mitigates XSS attacks | +| `Secure` | true | Only transmitted over HTTPS, prevents MITM attacks | +| `SameSite` | Strict | Prevents CSRF attacks, cookie only sent for same-site requests | +| `Path` | / | Cookie available for all paths on the domain | +| `Max-Age` | 86400 (24h) | Cookie expiration time in seconds | +| `Domain` | Configurable | Set to deployment domain for multi-subdomain support | + +#### State Cookie Specification + +**Cookie Name:** `mlspace_auth_state` + +Used temporarily during authentication flow to prevent CSRF attacks. + +**Cookie Attributes:** +```http +Set-Cookie: mlspace_auth_state=; + HttpOnly; + Secure; + SameSite=Strict; + Path=/auth; + Max-Age=600 +``` + +**Key Differences from Session Cookie:** +- Shorter lifetime (10 minutes) +- Limited to `/auth` path +- Deleted after authentication completes + +### Session Token Generation and Validation + +#### Session ID Generation + +```python +import secrets +import uuid + +def generate_session_id() -> str: + """ + Generate cryptographically secure session identifier. + + Returns: + Session ID in format: session: + """ + # Use UUID v4 for uniqueness + session_uuid = str(uuid.uuid4()) + return f"session:{session_uuid}" + +# Example: session:550e8400-e29b-41d4-a716-446655440000 +``` + +**Security Properties:** +- 128-bit entropy from UUID v4 +- Cryptographically random +- Unpredictable and non-sequential +- No embedded user information + +#### OTAC Generation + +```python +def generate_otac() -> str: + """ + Generate one-time authentication code for cross-domain sync. + + Returns: + OTAC in format: otac: + """ + # 32 bytes = 256 bits of entropy + random_bytes = secrets.token_urlsafe(32) + return f"otac:{random_bytes}" + +# Example: otac:xK7j9mP2qR5tY8wZ3nB6vC1dF4gH0jL +``` + +**Security Properties:** +- 256-bit entropy +- URL-safe encoding +- Single-use only +- Short TTL (5 minutes) + +#### Session Validation Flow + +```python +from typing import Optional +import time + +async def validate_session(session_id: str) -> Optional[dict]: + """ + Validate session cookie and retrieve session data. + + Args: + session_id: Session identifier from cookie + + Returns: + Session data if valid, None otherwise + """ + # 1. Validate format + if not session_id.startswith('session:'): + return None + + # 2. Retrieve from DynamoDB + session_record = await dynamodb.get_item(pk=session_id) + if not session_record: + return None + + # 3. Check TTL expiration + if session_record['ttl'] < int(time.time()): + return None + + # 4. Validate session expiration + session_expires = parse_iso8601(session_record['data']['session']['expiresAt']) + if session_expires < datetime.now(timezone.utc): + return None + + # 5. Return session data + return session_record['data'] +``` + +### Token Encryption + +Sensitive IdP tokens are encrypted before storage in DynamoDB. + +#### Encryption Scheme + +```python +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +import base64 +import os + +class TokenEncryption: + def __init__(self, encryption_key: bytes): + """ + Initialize token encryption with AES-256-GCM. + + Args: + encryption_key: 32-byte encryption key from AWS Secrets Manager + """ + self.cipher = AESGCM(encryption_key) + + def encrypt_token(self, token: str) -> str: + """ + Encrypt token using AES-256-GCM. + + Returns: + Encrypted token in format: AES256:: + """ + # Generate random 96-bit nonce + nonce = os.urandom(12) + + # Encrypt with authenticated encryption + ciphertext = self.cipher.encrypt(nonce, token.encode('utf-8'), None) + + # Encode for storage + iv_b64 = base64.b64encode(nonce).decode('utf-8') + ct_b64 = base64.b64encode(ciphertext).decode('utf-8') + + return f"AES256:{iv_b64}:{ct_b64}" + + def decrypt_token(self, encrypted_token: str) -> str: + """ + Decrypt token using AES-256-GCM. + + Args: + encrypted_token: Encrypted token string + + Returns: + Decrypted token + """ + # Parse encrypted token + parts = encrypted_token.split(':') + if parts[0] != 'AES256' or len(parts) != 3: + raise ValueError("Invalid encrypted token format") + + nonce = base64.b64decode(parts[1]) + ciphertext = base64.b64decode(parts[2]) + + # Decrypt + plaintext = self.cipher.decrypt(nonce, ciphertext, None) + return plaintext.decode('utf-8') +``` + +**Key Management:** +- Encryption key stored in AWS Secrets Manager +- Key rotation supported via versioning +- Separate keys per environment (dev, staging, prod) + +### Cross-Domain Security Considerations + +#### Domain Configuration + +```typescript +interface DomainConfig { + primaryDomain: string; // app.mlspace.com + syncDomains: string[]; // [api.mlspace.com, notebooks.mlspace.com] + allowedOrigins: string[]; // For CORS validation +} +``` + +#### Cookie Domain Strategy + +**Option 1: Subdomain Sharing (Recommended)** +```http +Set-Cookie: mlspace_session=; Domain=.mlspace.com +``` +- Cookie shared across all subdomains +- Simpler implementation +- No OTAC chain needed for subdomains + +**Option 2: Explicit Domain Cookies (More Secure)** +```http +Set-Cookie: mlspace_session=; Domain=app.mlspace.com +Set-Cookie: mlspace_session=; Domain=api.mlspace.com +``` +- Cookie isolated per domain +- Requires OTAC chain for synchronization +- Better security isolation + +#### OTAC Security + +**Validation Requirements:** +1. **Single-use**: OTAC deleted or marked used after first validation +2. **Short TTL**: 5-minute expiration +3. **Strong consistency**: DynamoDB strong consistent reads +4. **Domain validation**: Verify requesting domain is in allowed list + +```python +async def validate_otac(otac: str, requesting_domain: str) -> Optional[str]: + """ + Validate OTAC and return session ID. + + Args: + otac: One-time authentication code + requesting_domain: Domain making the request + + Returns: + Session ID if valid, None otherwise + """ + # 1. Strong consistent read + otac_record = await dynamodb.get_item( + pk=otac, + consistent_read=True + ) + + if not otac_record: + return None + + # 2. Check if already used + if otac_record['data'].get('usedAt'): + return None + + # 3. Check TTL + if otac_record['ttl'] < int(time.time()): + return None + + # 4. Validate domain + if requesting_domain not in config.syncDomains: + return None + + # 5. Mark as used (conditional update) + try: + await dynamodb.update_item( + pk=otac, + update_expression="SET #data.usedAt = :timestamp", + condition_expression="attribute_not_exists(#data.usedAt)", + expression_attribute_names={"#data": "data"}, + expression_attribute_values={":timestamp": datetime.now().isoformat()} + ) + except ConditionalCheckFailedException: + # OTAC was already used (race condition) + return None + + # 6. Return session ID + return otac_record['data']['sessionId'] +``` + +### CSRF Protection + +#### State Parameter Pattern + +The authentication flow uses encrypted state parameters to prevent CSRF attacks. + +**State Generation:** +```python +from cryptography.fernet import Fernet +import json +import time + +class StateManager: + def __init__(self, secret_key: bytes): + self.cipher = Fernet(secret_key) + + def create_state(self, redirect_url: str, nonce: str) -> str: + """ + Create encrypted state parameter for auth flow. + + Args: + redirect_url: Where to redirect after auth + nonce: Random nonce for CSRF protection + + Returns: + Encrypted state string + """ + state_data = { + 'redirect_url': redirect_url, + 'nonce': nonce, + 'timestamp': int(time.time()), + 'domain': 'app.mlspace.com' + } + + state_json = json.dumps(state_data) + encrypted = self.cipher.encrypt(state_json.encode('utf-8')) + return encrypted.decode('utf-8') + + def validate_state(self, encrypted_state: str, cookie_nonce: str) -> Optional[dict]: + """ + Validate and decrypt state parameter. + + Args: + encrypted_state: State from query parameter + cookie_nonce: Nonce from cookie + + Returns: + State data if valid, None otherwise + """ + try: + # Decrypt state + decrypted = self.cipher.decrypt(encrypted_state.encode('utf-8')) + state_data = json.loads(decrypted.decode('utf-8')) + + # Validate timestamp (10 minute window) + if int(time.time()) - state_data['timestamp'] > 600: + return None + + # Validate nonce matches cookie + if state_data['nonce'] != cookie_nonce: + return None + + return state_data + except Exception: + return None +``` + +#### SameSite Cookie Protection + +The `SameSite=Strict` attribute provides primary CSRF protection: +- Cookies not sent on cross-site requests +- Prevents CSRF attacks from external sites +- No additional CSRF tokens needed for same-site requests + +#### Defense in Depth + +Additional CSRF protections: +1. **State parameter validation**: Encrypted state with nonce +2. **Origin header validation**: Check Origin/Referer headers +3. **Short-lived state cookies**: 10-minute expiration + +### Security Headers + +All authentication endpoints return comprehensive security headers: + +```python +SECURITY_HEADERS = { + 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains; preload', + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'X-XSS-Protection': '1; mode=block', + 'Content-Security-Policy': "default-src 'self'; frame-ancestors 'none'", + 'Referrer-Policy': 'strict-origin-when-cross-origin', + 'Permissions-Policy': 'geolocation=(), microphone=(), camera=()' +} +``` + +### Audit Logging + +All authentication events are logged to CloudWatch for security monitoring: + +```python +import json +from datetime import datetime + +def log_auth_event(event_type: str, details: dict): + """ + Log authentication event for audit trail. + + Args: + event_type: Type of auth event + details: Event details + """ + log_entry = { + 'timestamp': datetime.now().isoformat(), + 'event_type': event_type, + 'details': details + } + + print(json.dumps(log_entry)) + +# Event types: +# - AUTH_LOGIN_INITIATED +# - AUTH_LOGIN_SUCCESS +# - AUTH_LOGIN_FAILED +# - AUTH_SESSION_CREATED +# - AUTH_SESSION_REFRESHED +# - AUTH_SESSION_EXPIRED +# - AUTH_LOGOUT +# - AUTH_OTAC_CREATED +# - AUTH_OTAC_VALIDATED +``` + +## CDK Infrastructure + +### Lambda Function Organization + +#### Directory Structure + +``` +backend/src/ml_space_lambda/ +├── auth/ +│ ├── __init__.py +│ ├── login.py # GET /auth/login handler +│ ├── callback.py # GET/POST /auth/callback handler +│ ├── logout.py # POST /auth/logout handler +│ ├── identity.py # GET /auth/identity handler +│ ├── sync.py # GET /auth/sync handler +│ ├── handlers/ +│ │ ├── __init__.py +│ │ ├── oidc_handler.py # OIDC authentication implementation +│ │ └── base_handler.py # Abstract base class for auth handlers +│ ├── session/ +│ │ ├── __init__.py +│ │ ├── manager.py # Session management logic +│ │ ├── validator.py # Session validation +│ │ └── encryption.py # Token encryption utilities +│ └── utils/ +│ ├── __init__.py +│ ├── state.py # State parameter management +│ ├── otac.py # OTAC generation and validation +│ └── cookies.py # Cookie utilities +``` + +#### Lambda Function Registration + +Auth endpoints are registered using the existing `registerAPIEndpoint` utility: + +```typescript +// In the stack where API endpoints are registered +import { registerAPIEndpoint, MLSpacePythonLambdaFunction } from '../utils/apiFunction'; + +// Common environment variables for all auth endpoints +const authCommonEnv = { + SESSION_TABLE_NAME: sessionTable.tableName, + IDP_TYPE: mlspaceConfig.AUTH_IDP_TYPE, + PRIMARY_DOMAIN: mlspaceConfig.AUTH_PRIMARY_DOMAIN || '', + SYNC_DOMAINS: mlspaceConfig.AUTH_SYNC_DOMAINS || '', + SESSION_TTL_HOURS: mlspaceConfig.AUTH_SESSION_TTL_HOURS.toString(), + ENCRYPTION_KEY_PARAM: 'mlspace/auth/encryption-key', +}; + +// OIDC-specific environment variables +const oidcEnv = { + OIDC_URL: mlspaceConfig.AUTH_OIDC_URL, + OIDC_CLIENT_ID: mlspaceConfig.AUTH_OIDC_CLIENT_ID, + OIDC_CLIENT_SECRET_PARAM: 'mlspace/auth/oidc-client-secret', +}; + +// Define auth endpoint functions +const authEndpoints: MLSpacePythonLambdaFunction[] = [ + { + name: 'login', + resource: 'auth', + description: 'Initiates authentication flow by redirecting to IdP', + path: 'auth/login', + method: 'POST', + noAuthorizer: true, + environment: { ...authCommonEnv, ...oidcEnv }, + }, + { + name: 'callback', + resource: 'auth', + description: 'Handles IdP callback and creates session', + path: 'auth/callback', + method: 'GET', + noAuthorizer: true, + environment: { ...authCommonEnv, ...oidcEnv }, + }, + { + name: 'callback_post', + id: 'auth-callback-post', + resource: 'auth', + description: 'Handles POST callback (for future IdP support)', + path: 'auth/callback', + method: 'POST', + noAuthorizer: true, + environment: authCommonEnv, + }, + { + name: 'logout', + resource: 'auth', + description: 'Terminates user session and optionally logs out from IdP', + path: 'auth/logout', + method: 'POST', + noAuthorizer: true, + environment: { + SESSION_TABLE_NAME: authCommonEnv.SESSION_TABLE_NAME, + IDP_TYPE: authCommonEnv.IDP_TYPE, + OIDC_URL: oidcEnv.OIDC_URL, + }, + }, + { + name: 'identity', + resource: 'auth', + description: 'Returns current user identity and session status', + path: 'auth/identity', + method: 'GET', + noAuthorizer: true, + environment: { ...authCommonEnv, ...oidcEnv }, + }, + { + name: 'sync', + resource: 'auth', + description: 'Handles cross-domain cookie synchronization', + path: 'auth/sync', + method: 'GET', + noAuthorizer: true, + environment: { + SESSION_TABLE_NAME: authCommonEnv.SESSION_TABLE_NAME, + PRIMARY_DOMAIN: authCommonEnv.PRIMARY_DOMAIN, + SYNC_DOMAINS: authCommonEnv.SYNC_DOMAINS, + }, + }, +]; + +// Register each endpoint +if (mlspaceConfig.BFF_ENABLE_AUTH) { + authEndpoints.forEach((endpoint) => { + registerAPIEndpoint( + stack, + api, + authorizer, + lambdaExecutionRole, + appRoleName, + notebookRoleName, + lambdaSourcePath, + layers, + endpoint, + vpc, + securityGroups, + mlspaceConfig, + permissionsBoundaryArn + ); + }); +} +``` + +**Lambda Handler Structure:** + +Each Lambda handler follows the MLSpace pattern: + +```python +# backend/src/ml_space_lambda/auth/lambda_functions.py +def login(event, context): + """GET /auth/login handler""" + # Implementation + pass + +def callback(event, context): + """GET /auth/callback handler""" + # Implementation + pass + +def callback_post(event, context): + """POST /auth/callback handler (for future IdP support)""" + # Implementation + pass + +def logout(event, context): + """POST /auth/logout handler""" + # Implementation + pass + +def identity(event, context): + """GET /auth/identity handler""" + # Implementation + pass + +def sync(event, context): + """GET /auth/sync handler""" + # Implementation + pass +``` + + + +### DynamoDB Table Definition + +Following MLSpace's existing table creation pattern: + +```typescript +// In the existing stack where other DynamoDB tables are created +if (mlspaceConfig.BFF_ENABLE_AUTH) { + const sessionTable = new Table(scope, 'mlspace-ddb-auth-sessions', { + tableName: mlspaceConfig.AUTH_SESSION_TABLE_NAME, + partitionKey: { + name: 'pk', + type: AttributeType.STRING, + }, + billingMode: BillingMode.PAY_PER_REQUEST, + ...(mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN && mlspaceConfig.ENABLE_DDB_KMS_CMK_ENCRYPTION) + ? {encryptionKey: props.encryptionKey} + : {encryption: TableEncryption.AWS_MANAGED}, + timeToLiveAttribute: 'ttl', + }); +} +``` + +**Key Features:** +- Follows MLSpace's conditional encryption pattern +- Uses customer-managed KMS key if configured, otherwise AWS-managed encryption +- Pay-per-request billing for variable auth workloads +- TTL attribute for automatic session cleanup +- Table name from constants (AUTH_SESSION_TABLE_NAME) + +### Authorizer Updates + +The existing Lambda authorizer needs to be updated to validate session cookies. + +```typescript +// lib/constructs/auth/authorizerConstruct.ts +import { Duration } from 'aws-cdk-lib'; +import { AuthorizationType, RequestAuthorizer } from 'aws-cdk-lib/aws-apigateway'; +import { Table } from 'aws-cdk-lib/aws-dynamodb'; +import { ISecurityGroup, IVpc } from 'aws-cdk-lib/aws-ec2'; +import { Code, Function, Runtime } from 'aws-cdk-lib/aws-lambda'; +import { Construct } from 'constructs'; + +export interface AuthorizerConstructProps { + readonly lambdaSourcePath: string; + readonly sessionTable: Table; + readonly vpc: IVpc; + readonly securityGroups: ISecurityGroup[]; +} + +export class AuthorizerConstruct extends Construct { + public readonly authorizer: RequestAuthorizer; + + constructor(scope: Construct, id: string, props: AuthorizerConstructProps) { + super(scope, id); + + // Updated authorizer Lambda + const authorizerFunction = new Function(this, 'AuthorizerFunction', { + runtime: props.mlspaceConfig.LAMBDA_RUNTIME, + code: Code.fromAsset(props.lambdaSourcePath), + handler: 'ml_space_lambda.authorizer.lambda_functions.handler', + timeout: Duration.seconds(10), + memorySize: 256, + vpc: props.vpc, + securityGroups: props.securityGroups, + environment: { + SESSION_TABLE_NAME: props.sessionTable.tableName, + }, + }); + + // Grant DynamoDB read permissions + props.sessionTable.grantReadData(authorizerFunction); + + // Create request authorizer + this.authorizer = new RequestAuthorizer(this, 'RequestAuthorizer', { + handler: authorizerFunction, + identitySources: ['method.request.header.Cookie'], + resultsCacheTtl: Duration.seconds(300), // Cache for 5 minutes + }); + } +} +``` + +### Secrets Management + +```typescript +// lib/constructs/auth/secretsConstruct.ts +import { RemovalPolicy } from 'aws-cdk-lib'; +import { IKey } from 'aws-cdk-lib/aws-kms'; +import { Secret } from 'aws-cdk-lib/aws-secretsmanager'; +import { Construct } from 'constructs'; + +export interface AuthSecretsConstructProps { + readonly encryptionKey: IKey; +} + +export class AuthSecretsConstruct extends Construct { + public readonly authSecrets: Secret; + + constructor(scope: Construct, id: string, props: AuthSecretsConstructProps) { + super(scope, id); + + // Create secret for auth configuration + this.authSecrets = new Secret(this, 'AuthSecrets', { + secretName: 'mlspace/auth/config', + description: 'Authentication configuration including IdP credentials and encryption keys', + encryptionKey: props.encryptionKey, + removalPolicy: RemovalPolicy.RETAIN, + generateSecretString: { + secretStringTemplate: JSON.stringify({ + idpType: 'oidc', + oidcClientId: 'REPLACE_ME', + oidcClientSecret: 'REPLACE_ME', + oidcIssuerUrl: 'REPLACE_ME', + encryptionKey: 'REPLACE_ME', // 32-byte base64 encoded key + stateEncryptionKey: 'REPLACE_ME', // Fernet key + }), + generateStringKey: 'placeholder', + }, + }); + } +} +``` + +### IAM Roles and Permissions + +```typescript +// lib/constructs/auth/iamConstruct.ts +import { Effect, PolicyStatement, Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam'; +import { Construct } from 'constructs'; + +export class AuthIamConstruct extends Construct { + public readonly lambdaExecutionRole: Role; + + constructor(scope: Construct, id: string) { + super(scope, id); + + // Lambda execution role + this.lambdaExecutionRole = new Role(this, 'AuthLambdaExecutionRole', { + assumedBy: new ServicePrincipal('lambda.amazonaws.com'), + description: 'Execution role for authentication Lambda functions', + }); + + // CloudWatch Logs permissions + this.lambdaExecutionRole.addToPolicy( + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 'logs:CreateLogGroup', + 'logs:CreateLogStream', + 'logs:PutLogEvents', + ], + resources: ['arn:aws:logs:*:*:*'], + }) + ); + + // VPC permissions (if using VPC) + this.lambdaExecutionRole.addToPolicy( + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 'ec2:CreateNetworkInterface', + 'ec2:DescribeNetworkInterfaces', + 'ec2:DeleteNetworkInterface', + ], + resources: ['*'], + }) + ); + } +} +``` + +### Environment Configuration + +Configuration follows MLSpace's existing pattern using `lib/config.json` and `lib/constants.ts`. + +#### Constants Updates (lib/constants.ts) + +```typescript +// Authentication configuration +export const AUTH_SESSION_TABLE_NAME = 'mlspace-auth-sessions'; + +// Authentication configuration (replaces legacy OIDC_* settings) +export const AUTH_IDP_TYPE = 'oidc'; // Currently only 'oidc' is supported +export const AUTH_OIDC_URL = ''; // OIDC issuer URL (replaces OIDC_URL) +export const AUTH_OIDC_CLIENT_ID = ''; // OIDC client ID (replaces OIDC_CLIENT_NAME) +export const AUTH_OIDC_CLIENT_SECRET = ''; // OIDC client secret (if using confidential client flow) + +// Domain configuration for cross-domain cookie sync +export const AUTH_PRIMARY_DOMAIN = ''; // Optional: Override API Gateway domain for cookies +export const AUTH_SYNC_DOMAINS = ''; // Optional: Comma-separated list of additional domains for cookie sync + +// Session configuration +export const AUTH_SESSION_TTL_HOURS = 24; // Session duration in hours +``` + +#### Config File Updates (lib/config.json) + +```json +{ + "AWS_ACCOUNT": "427935540279", + "AWS_REGION": "us-east-1", + "KEY_MANAGER_ROLE_NAME": "Admin", + "AUTH_IDP_TYPE": "oidc", + "AUTH_OIDC_URL": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_gGfd5y9Fd", + "AUTH_OIDC_CLIENT_ID": "2voakvh33rj8st1qr004sisvtt", + "AUTH_OIDC_CLIENT_SECRET": "", + "AUTH_PRIMARY_DOMAIN": "", + "AUTH_SYNC_DOMAINS": "", + "AUTH_SESSION_TTL_HOURS": 24 +} +``` + +#### Configuration Loading + +```typescript +// lib/utils/configTypes.ts (additions) +export interface MLSpaceConfig { + // ... existing properties ... + + // Authentication (BFF pattern - replaces legacy OIDC) + AUTH_IDP_TYPE: string; // 'oidc', 'saml', or 'custom' + AUTH_OIDC_URL: string; + AUTH_OIDC_CLIENT_ID: string; + AUTH_OIDC_CLIENT_SECRET: string; + AUTH_PRIMARY_DOMAIN: string; + AUTH_SYNC_DOMAINS: string; + AUTH_SESSION_TTL_HOURS: number; +} +``` + +#### Lambda Environment Variables + +```typescript +// Lambda functions receive these environment variables from config +const authEnvironment = { + SESSION_TABLE_NAME: AUTH_SESSION_TABLE_NAME, + IDP_TYPE: config.BFF_IDP_TYPE || 'oidc', // 'oidc', 'saml', or 'custom' + OIDC_URL: config.OIDC_URL, + OIDC_CLIENT_NAME: config.OIDC_CLIENT_NAME, + OIDC_CLIENT_SECRET_PARAM: 'mlspace/auth/oidc-client-secret', // SSM Parameter + PRIMARY_DOMAIN: config.BFF_PRIMARY_DOMAIN || '', // Defaults to API Gateway domain + SYNC_DOMAINS: config.BFF_SYNC_DOMAINS || '', // Comma-separated + SESSION_TTL_HOURS: config.BFF_SESSION_TTL_HOURS.toString(), + ENCRYPTION_KEY_PARAM: 'mlspace/auth/encryption-key', // SSM Parameter +}; +``` + +### Complete Stack Integration + +```typescript +// In the existing MLSpace stack where API endpoints are registered +import { registerAPIEndpoint } from '../utils/apiFunction'; + +// Create session table +const sessionTable = new Table(this, 'mlspace-ddb-auth-sessions', { + tableName: mlspaceConfig.AUTH_SESSION_TABLE_NAME, + partitionKey: { + name: 'pk', + type: AttributeType.STRING, + }, + billingMode: BillingMode.PAY_PER_REQUEST, + ...(mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN && mlspaceConfig.ENABLE_DDB_KMS_CMK_ENCRYPTION) + ? {encryptionKey: props.encryptionKey} + : {encryption: TableEncryption.AWS_MANAGED}, + timeToLiveAttribute: 'ttl', +}); + +// Grant Lambda execution role permissions to session table +sessionTable.grantReadWriteData(lambdaExecutionRole); + +// Register auth endpoints using existing pattern +const authEndpoints: MLSpacePythonLambdaFunction[] = [ + // ... endpoint definitions from Lambda Function Registration section ... +]; + +authEndpoints.forEach((endpoint) => { + registerAPIEndpoint( + this, + api, + authorizer, + lambdaExecutionRole, + appRoleName, + notebookRoleName, + lambdaSourcePath, + layers, + endpoint, + vpc, + securityGroups, + mlspaceConfig, + permissionsBoundaryArn + ); +}); + +// Existing API endpoint registrations continue as normal... +``` + +**Authorizer Updates:** + +The existing authorizer Lambda (`backend/src/ml_space_lambda/authorizer/lambda_functions.py`) needs to be updated to: +1. Extract session cookie from request headers +2. Validate session from DynamoDB +3. Populate authContext with user information from session +4. Return 401 if session is invalid or expired + +### Deployment Configuration + +Configuration is managed through `lib/config.json` per environment: + +```json +// lib/config.json (example for GeoAxis integration) +{ + "AWS_ACCOUNT": "123456789012", + "AWS_REGION": "us-east-1", + "OIDC_URL": "https://geoaxis.gxaccess.com", + "KEY_MANAGER_ROLE_NAME": "Admin", + "AUTH_IDP_TYPE": "oidc", + "AUTH_OIDC_URL": "https://geoaxis.gxaccess.com", + "AUTH_OIDC_CLIENT_ID": "mlspace-client-id", + "AUTH_OIDC_CLIENT_SECRET": "stored-in-ssm", + "AUTH_PRIMARY_DOMAIN": "", + "AUTH_SYNC_DOMAINS": "", + "AUTH_SESSION_TTL_HOURS": 24 +} +``` + +**Configuration Notes:** +- `AUTH_IDP_TYPE`: Defaults to `'oidc'` (currently only OIDC is supported) +- `AUTH_OIDC_URL`: OIDC issuer URL (replaces legacy `OIDC_URL`) +- `AUTH_OIDC_CLIENT_ID`: OIDC client ID (replaces legacy `OIDC_CLIENT_NAME`) +- `AUTH_OIDC_CLIENT_SECRET`: Actual secret stored in SSM Parameter Store at `mlspace/auth/oidc-client-secret` +- `AUTH_PRIMARY_DOMAIN`: Leave empty to use API Gateway domain automatically +- `AUTH_SYNC_DOMAINS`: Leave empty for single-domain deployments (typical for MLSpace) + +### Migration from Legacy OIDC Configuration + +**Configuration Key Mapping:** + +| Legacy Key | New Key | Notes | +|------------|---------|-------| +| `OIDC_URL` | `AUTH_OIDC_URL` | OIDC issuer URL | +| `OIDC_CLIENT_NAME` | `AUTH_OIDC_CLIENT_ID` | OIDC client identifier | +| N/A | `AUTH_OIDC_CLIENT_SECRET` | New: for confidential client flows | +| N/A | `AUTH_IDP_TYPE` | New: defaults to 'oidc' | +| N/A | `AUTH_PRIMARY_DOMAIN` | New: optional domain override | +| N/A | `AUTH_SYNC_DOMAINS` | New: for multi-domain deployments | +| N/A | `AUTH_SESSION_TTL_HOURS` | New: session duration (default 24h) | + +**Migration Steps:** + +1. **Update lib/config.json:** + ```json + // Before + { + "OIDC_URL": "https://idp.example.com", + "OIDC_CLIENT_NAME": "my-client-id" + } + + // After + { + "AUTH_IDP_TYPE": "oidc", + "AUTH_OIDC_URL": "https://idp.example.com", + "AUTH_OIDC_CLIENT_ID": "my-client-id", + "AUTH_OIDC_CLIENT_SECRET": "", + "AUTH_SESSION_TTL_HOURS": 24 + } + ``` + +2. **Update lib/constants.ts:** + - Remove: `OIDC_URL`, `OIDC_CLIENT_NAME`, `INTERNAL_OIDC_URL`, `OIDC_VERIFY_SSL`, `OIDC_VERIFY_SIGNATURE`, `OIDC_REDIRECT_URI` + - Add: `AUTH_IDP_TYPE`, `AUTH_OIDC_URL`, `AUTH_OIDC_CLIENT_ID`, `AUTH_OIDC_CLIENT_SECRET`, `AUTH_SESSION_TTL_HOURS`, `AUTH_PRIMARY_DOMAIN`, `AUTH_SYNC_DOMAINS` + +3. **Store OIDC client secret in SSM Parameter Store** (if using confidential client flow): + ```bash + aws ssm put-parameter \ + --name mlspace/auth/oidc-client-secret \ + --value "your-client-secret" \ + --type SecureString \ + --description "OIDC client secret for BFF authentication" + ``` + +4. **Update frontend:** + - Remove OIDC context provider + - Add new BFF authentication context provider + - Update axios-utils to remove Authorization header logic + +5. **Deploy infrastructure:** + - CDK will create session table + - Auth endpoints will be registered + - Authorizer will be updated to validate session cookies + +## Remaining Design Areas + +The following areas still need detailed design specifications: + +### 6. Error Handling Flows +- [ ] Comprehensive error scenarios and recovery +- [ ] Logging and monitoring strategies +- [ ] User-facing error messages and redirects +- [ ] Debugging and troubleshooting guides + +### 7. Configuration Management +- [ ] IdP type selection and configuration schema +- [ ] Environment variable management +- [ ] Deployment-specific settings +- [ ] Runtime configuration updates + +### 3. Frontend Architecture +- [ ] React authentication context provider design +- [ ] Component integration patterns +- [ ] Cross-tab session synchronization +- [ ] Error handling and user experience flows + +### 4. Security Model +- [ ] Cookie configuration and security flags +- [ ] Session token generation and validation +- [ ] Cross-domain security considerations +- [ ] CSRF protection mechanisms + +### 5. CDK Infrastructure +- [ ] Lambda function organization and deployment +- [ ] API Gateway routing and configuration +- [ ] IAM roles and permissions +- [ ] Environment-specific configuration management + +### 6. Error Handling Flows +- [ ] Comprehensive error scenarios and recovery +- [ ] Logging and monitoring strategies +- [ ] User-facing error messages and redirects +- [ ] Debugging and troubleshooting guides + +### 7. Configuration Management +- [ ] IdP type selection and configuration schema +- [ ] Environment variable management +- [ ] Deployment-specific settings +- [ ] Runtime configuration updates \ No newline at end of file diff --git a/.kiro/specs/bff-authentication/requirements.md b/.kiro/specs/bff-authentication/requirements.md new file mode 100644 index 00000000..d5ada835 --- /dev/null +++ b/.kiro/specs/bff-authentication/requirements.md @@ -0,0 +1,102 @@ +# Requirements Document + +## Introduction + +This feature implements a Backend for Frontend (BFF) authentication pattern to abstract authentication details away from the frontend and centralize authentication handling in the backend. The current system uses direct OIDC authentication in the frontend with PKCE flow, which limits integration with Identity Providers that require client secrets or use SAML protocol. The BFF pattern will enable support for GeoAxis and other enterprise IdPs while providing better security, simplified frontend code, and improved session management by handling all authentication flows server-side. + +## Glossary + +- **BFF_Service**: The backend service that handles all authentication flows and acts as a proxy between the frontend and Identity Providers +- **Frontend_Client**: The React application that will use session-based authentication instead of direct OIDC tokens +- **Identity_Provider**: The external authentication provider (OIDC, SAML, or custom) configured for the deployment +- **Session_Store**: The DynamoDB table that stores session information and IdP response data +- **Auth_Handler**: The Lambda functions that handle IdP-specific authentication protocols (oidc-handler, saml-handler, custom-handler) +- **Legacy_Authorizer**: The existing Lambda authorizer that will be updated to validate session cookies instead of OIDC tokens +- **MLSpace_Session**: The session object that contains user identity and authentication state +- **OTAC**: One Time Authentication Code used for cross-domain cookie synchronization + +## Requirements + +### Requirement 1 + +**User Story:** As a frontend developer, I want the authentication complexity abstracted away from the client, so that I can focus on application features without managing IdP-specific protocols directly. + +#### Acceptance Criteria + +1. WHEN the Frontend_Client loads, THE BFF_Service SHALL handle all Identity_Provider authentication flows without exposing tokens to the browser +2. WHEN a user initiates login, THE system SHALL redirect to /auth/login endpoint which handles Identity_Provider redirection +3. WHEN authentication is successful, THE BFF_Service SHALL establish a secure session using HTTP-only cookies +4. WHEN the Frontend_Client makes API requests, THE Legacy_Authorizer SHALL validate session cookies and provide authentication context +5. WHEN a user logs out, THE /auth/logout endpoint SHALL invalidate the session and clear all authentication state + +### Requirement 2 + +**User Story:** As a security administrator, I want all authentication tokens to be handled server-side, so that sensitive credentials are never exposed to the browser environment. + +#### Acceptance Criteria + +1. WHEN Identity_Provider tokens are received, THE Auth_Handler SHALL store them securely in Session_Store with appropriate TTL +2. WHEN the Frontend_Client communicates with the backend, THE system SHALL use HTTP-only, Secure cookies containing session identifiers only +3. WHEN tokens need refreshing, THE system SHALL handle refresh flows transparently during /auth/identity calls +4. WHEN sessions expire, THE /auth/identity endpoint SHALL return HTTP 401 with UNAUTHENTICATED status +5. WHEN storing session data, THE Session_Store SHALL use DynamoDB encryption at rest and separate raw IdP data from processed session objects + +### Requirement 3 + +**User Story:** As a system architect, I want the BFF to integrate seamlessly with existing backend services, so that current functionality remains unchanged during the transition. + +#### Acceptance Criteria + +1. WHEN the Legacy_Authorizer receives requests, THE system SHALL validate session cookies and retrieve user information from Session_Store +2. WHEN processing authenticated requests, THE Legacy_Authorizer SHALL populate the same authContext structure that existing Lambda functions expect +3. WHEN user information is needed, THE system SHALL provide the same user data format that current APIs consume +4. WHEN the system processes API requests, THE existing Lambda functions SHALL continue to work without modification +5. WHEN deploying authentication, THE system SHALL support configuration-based selection of authentication method (legacy OIDC, BFF with OIDC, BFF with SAML, BFF with custom) + +### Requirement 4 + +**User Story:** As a user, I want seamless authentication experience with automatic session management, so that I don't have to repeatedly log in or manage tokens manually. + +#### Acceptance Criteria + +1. WHEN a user's session is valid, THE Frontend_Client SHALL automatically include session cookies in all API requests +2. WHEN a session expires, THE /auth/identity endpoint SHALL attempt automatic token refresh based on Identity_Provider refresh token capabilities before requiring re-authentication +3. WHEN automatic refresh fails, THE system SHALL return UNAUTHENTICATED status and redirect the user to /auth/login +4. WHEN a user closes and reopens the application, THE system SHALL maintain their authenticated session using persistent HTTP-only cookies +5. WHEN multiple browser tabs are open, THE system SHALL synchronize authentication state using MessageChannel API for real-time cross-tab communication + +### Requirement 5 + +**User Story:** As a developer, I want comprehensive session management APIs, so that I can build robust authentication flows and handle edge cases properly. + +#### Acceptance Criteria + +1. WHEN checking authentication status, THE /auth/identity endpoint SHALL return current session state with user information or UNAUTHENTICATED status +2. WHEN session validation occurs, THE /auth/identity endpoint SHALL return user displayName, email, and other identity attributes in consistent JSON format +3. WHEN errors occur during authentication, THE /auth/callback endpoint SHALL handle failures gracefully and redirect with appropriate error messaging +4. WHEN debugging authentication issues, THE Auth_Handler functions SHALL provide comprehensive logging without exposing sensitive IdP tokens +5. WHEN monitoring system health, THE system SHALL log authentication success rates and session management metrics to CloudWatch + +### Requirement 6 + +**User Story:** As a system administrator, I want configurable session management and IdP integration, so that I can tune security parameters and support multiple Identity Providers based on organizational requirements. + +#### Acceptance Criteria + +1. WHEN configuring session duration, THE Session_Store SHALL use TTL values based on Identity_Provider token expiration with configurable default fallback values +2. WHEN setting cookie security, THE system SHALL enforce Secure and SameSite=Strict flags on all session cookies +3. WHEN configuring Identity Providers, THE system SHALL support multiple Auth_Handler implementations (OIDC, SAML, custom) based on deployment configuration +4. WHEN handling cross-domain deployments, THE system SHALL support OTAC-based cookie synchronization across different domains +5. WHEN managing session cleanup, THE Session_Store SHALL automatically expire session records based on TTL and support manual session invalidation + +### Requirement 7 + +**User Story:** As an integrator, I want support for enterprise Identity Providers that require client secrets or SAML protocol, so that I can integrate MLSpace with organizational authentication systems like GeoAxis. + +#### Acceptance Criteria + +1. WHEN configuring OIDC authentication, THE oidc-handler SHALL support both PKCE flow (current) and authorization code flow with client secrets +2. WHEN integrating with SAML providers, THE saml-handler SHALL process SAML assertions and extract user identity information +3. WHEN using custom authentication protocols, THE custom-handler SHALL provide extensible integration points for customer-specific implementations +4. WHEN processing IdP responses, THE Auth_Handler SHALL extract and normalize user identity data into the MLSpace_Session format +5. WHEN handling IdP-specific errors, THE system SHALL provide appropriate error handling and logging for each authentication protocol \ No newline at end of file diff --git a/.kiro/specs/bff-authentication/tasks.md b/.kiro/specs/bff-authentication/tasks.md new file mode 100644 index 00000000..53fe2223 --- /dev/null +++ b/.kiro/specs/bff-authentication/tasks.md @@ -0,0 +1,161 @@ +# BFF Authentication Implementation Plan + +This implementation plan breaks down the BFF authentication feature into discrete, manageable tasks. Each task builds incrementally on previous work to implement the Backend for Frontend authentication pattern. + +## Implementation Tasks + +- [x] 1. Update configuration and constants + - Update `lib/constants.ts` to add AUTH_* constants and remove legacy OIDC_* constants + - Update `lib/config.json` with AUTH_* configuration + - Update `lib/utils/configTypes.ts` to add AUTH_* properties to MLSpaceConfig interface + - _Requirements: 6.1, 6.3_ + +- [x] 2. Create DynamoDB session table + - Add session table creation in the stack where other DynamoDB tables are created + - Use MLSpace's conditional encryption pattern (KMS if configured, otherwise AWS-managed) + - Configure TTL attribute for automatic session cleanup + - Grant Lambda execution role read/write permissions to session table + - _Requirements: 2.1, 2.5, 6.1_ + +- [x] 3. Implement backend session management utilities + - Create `backend/src/ml_space_lambda/auth/session/manager.py` for session CRUD operations + - Create `backend/src/ml_space_lambda/auth/session/validator.py` for session validation logic + - Create `backend/src/ml_space_lambda/auth/session/encryption.py` for token encryption using AES-256-GCM + - Create `backend/src/ml_space_lambda/auth/utils/state.py` for state parameter management + - Create `backend/src/ml_space_lambda/auth/utils/otac.py` for OTAC generation and validation + - Create `backend/src/ml_space_lambda/auth/utils/cookies.py` for cookie utilities + - _Requirements: 2.1, 2.2, 2.5, 6.1_ + +- [x] 4. Implement OIDC authentication handler + - Create `backend/src/ml_space_lambda/auth/handlers/base_handler.py` abstract base class + - Create `backend/src/ml_space_lambda/auth/handlers/oidc_handler.py` using authlib + - Implement authorization code flow with client secret support + - Implement token refresh logic + - Extract and normalize user identity from OIDC tokens + - _Requirements: 7.1, 7.4_ + +- [x] 5. Implement /auth/login endpoint + - Create `backend/src/ml_space_lambda/auth/lambda_functions.py` with `login` handler + - Generate and encrypt state parameter + - Set state cookie with appropriate security flags + - Redirect to OIDC provider with authorization request + - Handle configuration errors gracefully + - _Requirements: 1.2, 5.3_ + +- [x] 6. Implement /auth/callback endpoint + - Add `callback` handler to `lambda_functions.py` for GET requests + - Add `callback_post` handler for POST requests (future IdP support) + - Validate state parameter against state cookie + - Exchange authorization code for tokens via OIDC handler + - Create session record in DynamoDB with encrypted tokens + - Set session cookie with HttpOnly, Secure, SameSite=Strict flags + - Clear state cookie + - Check for multi-domain sync configuration and initiate OTAC chain if needed + - Redirect to final destination or error page + - _Requirements: 1.3, 2.1, 2.2, 4.4, 6.2, 6.4_ + +- [x] 7. Implement /auth/logout endpoint + - Add `logout` handler to `lambda_functions.py` + - Validate session cookie + - Delete session record from DynamoDB + - Clear session cookie + - Optionally redirect to IdP logout endpoint + - _Requirements: 1.5, 5.3_ + +- [x] 8. Implement /auth/identity endpoint + - Add `identity` handler to `lambda_functions.py` + - Validate session cookie and retrieve session from DynamoDB + - Check if token refresh is needed based on refreshAt timestamp + - Attempt token refresh if needed using OIDC handler + - Return user identity and session information + - Return 401 UNAUTHENTICATED if session invalid or expired + - _Requirements: 4.2, 4.3, 5.1, 5.2_ + +- [x] 9. Implement /auth/sync endpoint for cross-domain cookie synchronization + - Add `sync` handler to `lambda_functions.py` + - Validate OTAC with strong consistency read from DynamoDB + - Mark OTAC as used with conditional update + - Retrieve session ID from OTAC record + - Set session cookie for current domain + - Generate new OTAC for next domain in chain if applicable + - Redirect to next domain or final destination + - _Requirements: 6.4_ + +- [x] 10. Register auth endpoints in CDK stack + - Define `authCommonEnv` and `oidcEnv` environment variable objects + - Create array of auth endpoint definitions using MLSpacePythonLambdaFunction interface + - Register each endpoint using `registerAPIEndpoint` utility + - Set `noAuthorizer: true` for all auth endpoints + - _Requirements: 1.2, 1.4, 6.3_ + +- [x] 11. Update Lambda authorizer to validate session cookies + - Modify `backend/src/ml_space_lambda/authorizer/lambda_functions.py` + - Extract session cookie from request headers + - Validate session from DynamoDB + - Populate authContext with user information from session + - Return 401 if session invalid or expired + - Remove legacy OIDC token validation logic + - _Requirements: 1.4, 3.1, 3.2, 3.3, 3.4_ + +- [x] 12. Update axios-utils to remove Authorization header logic + - Modify `frontend/src/shared/util/axios-utils.ts` + - Remove OIDC token extraction from sessionStorage + - Remove Authorization header setting + - Add `withCredentials: true` to config for cookie support + - Keep baseURL, error handling, and project header utilities + - _Requirements: 1.1, 4.1_ + +- [x] 13. Implement frontend AuthContext and AuthProvider + - Create `frontend/src/contexts/AuthContext.tsx` with AuthUser, AuthSession, AuthState, and AuthContextValue interfaces + - Implement AuthProvider component with session validation logic + - Implement checkAuthStatus function to call /auth/identity + - Implement login function to redirect to /auth/login + - Implement logout function to call /auth/logout + - Implement automatic token refresh based on refreshAt threshold + - Add periodic session validation with configurable interval + - _Requirements: 1.1, 4.1, 4.2, 4.3, 5.1_ + +- [x] 14. Implement cross-tab session synchronization + - Create AuthSyncManager class using BroadcastChannel API + - Integrate AuthSyncManager into AuthProvider + - Broadcast AUTH_STATE_CHANGED when session changes + - Broadcast SESSION_EXPIRED when session expires + - Broadcast LOGOUT_INITIATED when user logs out + - Handle incoming broadcast messages to trigger checkAuthStatus + - _Requirements: 4.5_ + +- [x] 15. Create authentication hooks and utilities + - Create `useAuth` hook to access AuthContext + - Create `useUser` convenience hook + - Create `useAuthStatus` convenience hook + - Create `useRequireAuth` hook for automatic redirect + - Create ProtectedRoute component for route-level guards + - Create AuthLoadingWrapper component for loading states + - Create SessionExpirationNotice component for expiration warnings + - _Requirements: 1.1, 4.2, 4.3_ + +- [x] 16. Create authentication error boundary + - Create AuthErrorBoundary component to catch authentication errors + - Log errors to console and monitoring service + - Display user-friendly error message with refresh option + - _Requirements: 5.3_ + +- [x] 17. Update application to use new AuthProvider + - Replace existing OIDC context provider with AuthProvider in app root + - Wrap application with AuthErrorBoundary + - Update any components that directly access OIDC context to use new useAuth hook + - Test authentication flow end-to-end + - _Requirements: 1.1, 3.5_ + +- [x] 18. Checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [x] 19. Update deployment documentation + - Document new AUTH_* configuration parameters + - Document migration steps from legacy OIDC configuration + - Document SSM parameter setup for client secret + - Document how to configure multi-domain cookie synchronization + - _Requirements: 6.3_ + +- [x] 20. Final checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. diff --git a/.kiro/steering/task-coding-standards.md b/.kiro/steering/task-coding-standards.md new file mode 100644 index 00000000..2f2af8d7 --- /dev/null +++ b/.kiro/steering/task-coding-standards.md @@ -0,0 +1,56 @@ +--- +inclusion: always +--- + +# Task Coding Standards + +## Library Usage Consistency + +When working with libraries, always use their provided objects and data structures instead of falling back to plain dictionaries or primitive types: + +- Use library-specific classes, objects, and data structures +- Leverage type safety and validation provided by library objects +- Avoid converting library objects to plain dicts unless absolutely necessary +- When serialization is needed, use the library's built-in serialization methods + +**Examples:** +- Use `dataclasses` or `pydantic` models instead of plain dicts +- Use `datetime` objects instead of string dates +- Use library-specific request/response objects instead of raw JSON + +## Enum Usage + +Prefer enums over repeated string literals throughout the codebase: + +- Create enums for any set of string constants that appear multiple times +- Use enums for status values, types, categories, and configuration options +- Import and reference enums consistently across modules +- Consider using `StrEnum` for string-based enums when appropriate + +**Examples:** +```python +# Good +class TaskStatus(StrEnum): + PENDING = "pending" + IN_PROGRESS = "in_progress" + COMPLETED = "completed" + +# Avoid +status = "pending" # repeated throughout code +``` + +## Function Size Management + +Keep functions focused and reasonably sized: + +- Break down large functions into smaller, single-purpose functions +- Aim for functions that can be easily understood and tested +- Extract complex logic into helper functions +- Use descriptive function names that clearly indicate purpose +- Consider the single responsibility principle + +**Guidelines:** +- If a function has more than 20-30 lines, consider refactoring +- If a function has multiple levels of nesting, extract inner logic +- If a function handles multiple concerns, split into separate functions +- Use early returns to reduce nesting depth \ No newline at end of file diff --git a/BFF_AUTHENTICATION_DOCS_README.md b/BFF_AUTHENTICATION_DOCS_README.md new file mode 100644 index 00000000..22529bf3 --- /dev/null +++ b/BFF_AUTHENTICATION_DOCS_README.md @@ -0,0 +1,243 @@ +# Enhanced Authentication Documentation + +This document summarizes the deployment documentation created for the enhanced authentication feature. + +## OIDC Client Secret Configuration + +### Secrets Manager Integration +The OIDC client secret is now stored in AWS Secrets Manager instead of Systems Manager Parameter Store for enhanced security: + +- **Secret Name**: `mlspace/auth/oidc-client-secret` +- **Configuration Parameter**: `AUTH_OIDC_CLIENT_SECRET_NAME` (default: `mlspace/auth/oidc-client-secret`) +- **Optional Deployment Configuration**: `AUTH_OIDC_CLIENT_SECRET_VALUE` + +### Deployment-Time Configuration +You can set the OIDC client secret during deployment by adding it to your `lib/config.json`: + +```json +{ + "AUTH_OIDC_CLIENT_SECRET_VALUE": "your-actual-client-secret-here" +} +``` + +If not provided during deployment, the secret will be created with a placeholder that you can update manually via AWS Console or CLI. + +### Manual Configuration +If you need to update the client secret after deployment: + +```bash +aws secretsmanager update-secret \ + --secret-id mlspace/auth/oidc-client-secret \ + --secret-string '{"client_secret":"your-new-secret","configured":true}' +``` + +## Complete AUTH_* Parameter List + +All authentication configuration now uses `AUTH_*` parameters. **Legacy `OIDC_*` parameters are deprecated and not supported.** + +### Required Parameters +- **AUTH_IDP_TYPE**: Identity Provider type (currently only `"oidc"` is supported) +- **AUTH_OIDC_URL**: OIDC issuer URL (replaces `OIDC_URL`) +- **AUTH_OIDC_CLIENT_ID**: OIDC client identifier (replaces `OIDC_CLIENT_NAME`) + +### Optional Parameters +- **AUTH_OIDC_CLIENT_SECRET_NAME**: Secrets Manager secret name for OIDC client secret (default: `mlspace/auth/oidc-client-secret`) +- **AUTH_OIDC_CLIENT_SECRET_VALUE**: Optional OIDC client secret value for deployment-time configuration +- **AUTH_OIDC_USE_PKCE**: Whether to use PKCE flow (default: `true`) +- **AUTH_OIDC_VERIFY_SSL**: Whether to verify SSL certificates for OIDC requests (default: `true`) +- **AUTH_OIDC_VERIFY_SIGNATURE**: Whether to verify OIDC token signatures (default: `true`) +- **AUTH_SESSION_TTL_HOURS**: Session duration in hours (default: `24`) +- **AUTH_SYNC_DOMAINS**: Optional comma-separated list of additional domains for cookie sync +- **AUTH_SESSION_TABLE_NAME**: DynamoDB table name for authentication sessions (default: `mlspace-auth-sessions`) +- **AUTH_TOKEN_ENCRYPTION_KEY_SECRET_NAME**: Secrets Manager secret name for token encryption keys (default: `mlspace/auth/token-encryption-keys`) +- **AUTH_STATE_ENCRYPTION_KEY_SECRET_NAME**: Secrets Manager secret name for state encryption key (default: `mlspace/auth/state-encryption-key`) + +## Created Documentation Files + +### 1. Enhanced Authentication Configuration Guide +**File**: `frontend/docs/admin-guide/bff-authentication.md` +**Purpose**: Comprehensive configuration guide for enhanced authentication +**Contents**: +- Overview of enhanced authentication benefits +- Complete AUTH_* parameter reference +- Configuration setup instructions +- SSM parameter setup for client secrets +- Multi-domain cookie synchronization configuration +- Migration instructions from legacy OIDC +- Troubleshooting guide +- Security considerations +- Performance monitoring + +### 2. Enhanced Authentication Migration Guide +**File**: `frontend/docs/admin-guide/bff-authentication-migration.md` +**Purpose**: Detailed step-by-step migration instructions +**Contents**: +- Pre-migration assessment and planning +- Step-by-step migration procedure +- Configuration file updates +- OIDC provider configuration changes +- Deployment and testing procedures +- Rollback procedures +- Troubleshooting common migration issues +- Post-migration optimization +- Migration checklist + +### 3. AUTH_* Configuration Reference +**File**: `frontend/docs/admin-guide/auth-configuration-reference.md` +**Purpose**: Quick reference for all AUTH_* parameters +**Contents**: +- Complete parameter reference with types and examples +- Environment-specific configuration examples +- Configuration validation rules and scripts +- Legacy to new parameter mapping +- Troubleshooting configuration issues +- Security considerations for parameters + +### 4. Updated Install Guide +**File**: `frontend/docs/admin-guide/install.md` (updated) +**Changes**: +- Added warning about enhanced authentication for new deployments +- Updated OIDC parameter descriptions to indicate legacy status +- Added references to enhanced authentication documentation +- Maintained backward compatibility information + +### 5. Updated VitePress Navigation +**File**: `frontend/docs/.vitepress/config.mts` (updated) +**Changes**: +- Added "Enhanced Authentication Configuration" to System Administrator Guide +- Added "Enhanced Authentication Migration" to System Administrator Guide +- Added "AUTH_* Configuration Reference" to Advanced Configuration + +## Documentation Structure + +``` +frontend/docs/admin-guide/ +├── install.md (updated) +├── bff-authentication.md (new) +├── bff-authentication-migration.md (new) +└── auth-configuration-reference.md (new) +``` + +## Key Features Documented + +### Configuration Parameters +- **AUTH_IDP_TYPE**: Identity Provider type selection (currently only `"oidc"` is supported) +- **AUTH_OIDC_URL**: OIDC issuer URL (replaces deprecated `OIDC_URL`) +- **AUTH_OIDC_CLIENT_ID**: OIDC client identifier (replaces deprecated `OIDC_CLIENT_NAME`) +- **AUTH_OIDC_CLIENT_SECRET_NAME**: Secrets Manager secret name for OIDC client secret +- **AUTH_OIDC_CLIENT_SECRET_VALUE**: Optional deployment-time client secret configuration +- **AUTH_OIDC_USE_PKCE**: Whether to use PKCE flow (default: `true`) +- **AUTH_OIDC_VERIFY_SSL**: Whether to verify SSL certificates (default: `true`) +- **AUTH_OIDC_VERIFY_SIGNATURE**: Whether to verify OIDC token signatures (default: `true`) +- **AUTH_SESSION_TTL_HOURS**: Session duration configuration (default: `24`) +- **AUTH_SYNC_DOMAINS**: Multi-domain cookie synchronization +- **AUTH_SESSION_TABLE_NAME**: DynamoDB table name for sessions +- **AUTH_TOKEN_ENCRYPTION_KEY_SECRET_NAME**: Versioned token encryption keys (rotatable) +- **AUTH_STATE_ENCRYPTION_KEY_SECRET_NAME**: State encryption key + +### Secrets Manager Setup +- Client secret storage in `mlspace/auth/oidc-client-secret` +- Token encryption keys in `mlspace/auth/token-encryption-keys` (versioned for rotation) +- State encryption key in `mlspace/auth/state-encryption-key` +- IAM permissions for Lambda access +- Security best practices + +### Deprecated Parameters +**All legacy `OIDC_*` parameters are deprecated and not supported:** +- `OIDC_URL` → Use `AUTH_OIDC_URL` +- `OIDC_CLIENT_NAME` → Use `AUTH_OIDC_CLIENT_ID` +- `OIDC_REDIRECT_URL` → No longer needed (automatic `/auth/callback`) +- `OIDC_VERIFY_SSL` → Use `AUTH_OIDC_VERIFY_SSL` +- `OIDC_VERIFY_SIGNATURE` → Use `AUTH_OIDC_VERIFY_SIGNATURE` +- `IDP_ENDPOINT_SSM_PARAM` → No longer needed +- `INTERNAL_OIDC_URL` → No longer needed with server-side authentication + +### Multi-Domain Cookie Synchronization +- OTAC (One-Time Authentication Code) flow +- Cross-domain security considerations +- Configuration examples +- Troubleshooting sync issues + +### Migration Process +- Pre-migration assessment +- Configuration file updates +- OIDC provider reconfiguration +- Deployment procedures +- Testing and validation +- Rollback procedures + +### Security Considerations +- HttpOnly and Secure cookie flags +- Token encryption in DynamoDB +- Cross-domain security +- Monitoring and alerting + +## Usage Instructions + +### For New Deployments +1. Follow the [Enhanced Authentication Configuration Guide](frontend/docs/admin-guide/bff-authentication.md) +2. Use the [AUTH_* Configuration Reference](frontend/docs/admin-guide/auth-configuration-reference.md) for parameter details + +### For Existing Deployments +1. Review the [Enhanced Authentication Migration Guide](frontend/docs/admin-guide/bff-authentication-migration.md) +2. Follow the step-by-step migration process +3. Use the troubleshooting sections for common issues + +### For Quick Reference +- Use the [AUTH_* Configuration Reference](frontend/docs/admin-guide/auth-configuration-reference.md) for parameter lookup +- Check the migration guide for legacy parameter mapping + +## Integration with Existing Documentation + +The new documentation integrates seamlessly with existing MLSpace documentation: +- References existing security documentation +- Links to install guide for prerequisites +- Maintains consistency with existing documentation style +- Uses VitePress features like tabs and warnings + +## Validation and Testing + +All documentation includes: +- Configuration validation scripts +- Testing procedures +- Troubleshooting guides +- Performance monitoring instructions +- Security validation steps + +## Maintenance + +The documentation should be updated when: +- New AUTH_* parameters are added +- SAML support is implemented +- Additional IdP types are supported +- Security requirements change +- Performance optimization recommendations change + +## Requirements Satisfied + +This documentation satisfies all requirements from task 19: + +✅ **Document new AUTH_* configuration parameters** +- Complete parameter reference with types, defaults, and examples +- Environment-specific configuration examples +- Validation rules and scripts + +✅ **Document migration steps from legacy OIDC configuration** +- Detailed step-by-step migration guide +- Pre-migration assessment procedures +- Configuration file update instructions +- Rollback procedures + +✅ **Document SSM parameter setup for client secret** +- Complete SSM parameter creation instructions +- IAM permission requirements +- Security best practices +- Troubleshooting SSM access issues + +✅ **Document how to configure multi-domain cookie synchronization** +- OTAC flow explanation +- Configuration examples +- Security considerations +- Troubleshooting sync issues + +The documentation is comprehensive, well-structured, and provides both high-level guidance and detailed technical instructions for all aspects of enhanced authentication configuration and deployment. \ No newline at end of file diff --git a/CHANGES_SUMMARY.md b/CHANGES_SUMMARY.md new file mode 100644 index 00000000..d26bc758 --- /dev/null +++ b/CHANGES_SUMMARY.md @@ -0,0 +1,112 @@ +# User ID Field Implementation Summary + +## Overview +Added a durable `id` field to the UserModel to store the IdP's "sub" claim, ensuring consistent user identification across authentication sessions. + +## Changes Made + +### 1. UserModel Updates (`backend/src/ml_space_lambda/data_access_objects/user.py`) + +**Added `id` field:** +- New optional parameter in `__init__`: `id: Optional[str] = None` +- Stores the IdP's durable identifier (e.g., OIDC "sub" claim) +- Gracefully handles missing field for backward compatibility + +**Updated `to_dict()` method:** +- Only includes `id` in the dictionary if it's set (not None) +- Prevents unnecessary null values in DynamoDB + +**Updated `from_dict()` method:** +- Gracefully handles missing `id` field with `dict_object.get("id", None)` +- Ensures backward compatibility with existing user records + +### 2. UserDAO Updates (`backend/src/ml_space_lambda/data_access_objects/user.py`) + +**Updated `update()` method:** +- Conditionally adds `id` to the update expression if it's set +- Enables backfilling the `id` field for existing users on their next login +- Maintains backward compatibility + +### 3. OIDC Handler Updates (`backend/src/ml_space_lambda/auth/handlers/oidc_handler.py`) + +**Updated `normalize_user_data()` method:** +- Uses OIDC "sub" claim as the durable identifier +- Uses "preferred_username" for the username (sanitized) +- Sanitizes special characters (`,`, `=`, ` `) by replacing with `-` +- Falls back to email prefix if preferred_username is missing +- Stores "sub" claim in attributes for reference +- Matches frontend user creation pattern from `oidc.config.ts` + +**Username mapping:** +- `user_data.id` = sanitized preferred_username (for MLSpace username) +- `user_data.attributes["sub"]` = IdP's sub claim (durable identifier) + +### 4. Auth Lambda Updates (`backend/src/ml_space_lambda/auth/lambda_functions.py`) + +**Updated `_ensure_user_exists()` function:** +- Extracts "sub" claim from `user_data.attributes` +- For existing users: backfills `id` field if missing +- For new users: sets `id` field to the IdP's "sub" claim +- Maintains consistency with frontend user creation pattern +- Sets `username` and `display_name` matching the frontend implementation + +**User creation consistency:** +- `username`: sanitized preferred_username (matching frontend) +- `display_name`: name claim from IdP (matching frontend) +- `email`: email claim from IdP +- `id`: sub claim from IdP (new field) + +### 5. Enum Updates (`backend/src/ml_space_lambda/enums.py`) + +**Added missing enum value:** +- `NEW_USERS_SUSPENDED = "NEW_USERS_SUSPENDED"` for backward compatibility +- Resolves existing inconsistency in the codebase + +### 6. Test Updates (`backend/test/auth/test_lambda_functions.py`) + +**Updated test mock data:** +- Added `"sub": "idp-sub-12345"` to mock user attributes +- Ensures tests properly validate the new id field functionality + +## Behavior + +### New Users +When a user logs in for the first time: +1. OIDC handler extracts "sub" claim and sanitizes preferred_username +2. `_ensure_user_exists()` creates a new UserModel with: + - `username`: sanitized preferred_username + - `display_name`: name from IdP + - `id`: sub claim from IdP +3. User is created in DynamoDB with all fields + +### Existing Users +When an existing user logs in: +1. OIDC handler extracts "sub" claim and sanitizes preferred_username +2. `_ensure_user_exists()` retrieves existing user +3. If `id` field is missing, it's backfilled with the sub claim +4. `last_login` timestamp is updated +5. User record is updated in DynamoDB + +### Backward Compatibility +- Existing users without `id` field: gracefully handled, field is None +- `to_dict()` excludes `id` if None (no null values in DynamoDB) +- `from_dict()` handles missing `id` field gracefully +- Update operation only includes `id` if it's set + +## Testing + +All tests pass successfully: +- ✅ User DAO tests (9/9 passed) +- ✅ Auth lambda tests (10/10 passed for login/callback) +- ✅ UserModel with id field +- ✅ UserModel without id field (backward compatibility) +- ✅ OIDC normalize_user_data with sub claim +- ✅ Username sanitization + +## Migration Path + +No migration required! The implementation is fully backward compatible: +1. Existing users continue to work without the `id` field +2. On next login, the `id` field is automatically backfilled +3. New users get the `id` field from the start +4. No database schema changes needed (DynamoDB is schemaless) diff --git a/README.md b/README.md index dbcae47a..3c2be5eb 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ In order to build and deploy MLSpace to your AWS account you will need the follo In addition to the required software you will also need to have the following information: - AWS account Id and region you'll be deploying MLSpace into (you'll need admin credentials or similar) -- Identity provider (IdP) information including the OIDC endpoint and client name +- Identity provider (IdP) information including the OIDC endpoint, client ID, and client secret ## Configuring MLSpace @@ -70,8 +70,9 @@ If selecting Basic Config, the properties you will be prompted for are: - AWS account ID: the AWS account ID for the account MLSpace will be deployed into - AWS region: the region that MLSpace resources will be deployed into -- OIDC URL: the OIDC endpoint that will be used for MLSpace authentication -- OIDC Client Name: the OIDC client name that should be used by MLSpace for authentication +- AUTH_OIDC_URL: the OIDC endpoint that will be used for MLSpace authentication +- AUTH_OIDC_CLIENT_ID: the OIDC client ID that should be used by MLSpace for authentication +- AUTH_OIDC_CLIENT_SECRET_VALUE: (optional) the OIDC client secret value for confidential clients If selecting Advanced Config you will be prompted for the same properties Basic Config prompts for, as well as other optional values. Anything not specified will use the defaults in `constants.ts` and/or provisioned by MLSpace. @@ -110,9 +111,9 @@ Configure MLSpace using Option 2 if: - you wish to have your configuration changes in a file that's committed to git - will have to resolve conflicts when upgrading MLSpace -If you are pre-creating roles you will need to ensure that the required role ARNs (`APP_ROLE_ARN`, `NOTEBOOK_ROLE_ARN`, and `SYSTSTEM_ROLE_ARN`), policy ARNs ( `ENDPOINT_CONFIG_INSTANCE_CONSTRAINT_POLICY_ARN`, `JOB_INSTANCE_CONSTRAINT_POLICY_ARN`, and `KMS_INSTANCE_CONDITIONS_POLICY_ARN`), role names (`KEY_MANAGER_ROLE_NAME` if `EXISTING_KMS_MASTER_KEY_ARN` is not set), and `AWS_ACCOUNT` (used to ensure unique S3 bucket names) have been properly set in `lib/constants.ts`. +If you are pre-creating roles you will need to ensure that the required role ARNs (`APP_ROLE_ARN`, `NOTEBOOK_ROLE_ARN`, and `SYSTEM_ROLE_ARN`), policy ARNs ( `ENDPOINT_CONFIG_INSTANCE_CONSTRAINT_POLICY_ARN`, `JOB_INSTANCE_CONSTRAINT_POLICY_ARN`, and `KMS_INSTANCE_CONDITIONS_POLICY_ARN`), role names (`KEY_MANAGER_ROLE_NAME` if `EXISTING_KMS_MASTER_KEY_ARN` is not set), and `AWS_ACCOUNT` (used to ensure unique S3 bucket names) have been properly set in `lib/constants.ts`. -You will also need to set `OIDC_URL` and `OIDC_CLIENT_NAME` with the correct values based on your chosen IdP. These property must be set prior to deploying MLSpace. +You will also need to set `AUTH_OIDC_URL` and `AUTH_OIDC_CLIENT_ID` with the correct values based on your chosen IdP. If using a confidential client, you should also set `AUTH_OIDC_CLIENT_SECRET_VALUE`. These properties must be set prior to deploying MLSpace. To see the full list of configurable properties and their descriptions, see the [Configurable deployment parameters section](Configurable deployment parameters). @@ -157,8 +158,8 @@ If the config-helper doesn't provide the level of customization you need for you | AWS_ACCOUNT | The account number that MLSpace is being deployed into. Used to disambiguated S3 buckets within a region. | - | | AWS_REGION | The region that MLSpace is being deployed into. This is only needed when you are using an existing VPC or KMS key and `EXISTING_KMS_MASTER_KEY_ARN` or `EXISTING_VPC_ID` is set. | - | | KEY_MANAGER_ROLE_NAME | Name of the IAM role with permissions to manage the KMS Key. If this property is set you _do not_ need to set `EXISTING_KMS_MASTER_KEY_ARN`. | - | -| OIDC_URL | The OIDC endpoint that will be used for MLSpace authentication | - | -| OIDC_CLIENT_NAME | The OIDC client name that should be used by MLSpace for authentication | - | +| AUTH_OIDC_URL | The OIDC endpoint that will be used for MLSpace authentication | - | +| AUTH_OIDC_CLIENT_ID | The OIDC client ID that should be used by MLSpace for authentication | - |
@@ -169,10 +170,12 @@ If the config-helper doesn't provide the level of customization you need for you | Variable | Description | Default | |------------------------------------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------:|------------------------------------:| -| IDP_ENDPOINT_SSM_PARAM | If set, MLSpace will use the value of this parameter as the `OIDC_URL`. During deployment the value of this parameter will be read from SSM. This value takes precedence over `OIDC_URL` if both are set. | - | -| OIDC_REDIRECT_URL | The redirect URL that should be used after succesfully authenticating with the OIDC provider. This will default to the API gateway URL generated by the CDK deployment but can be manually set if you're using custom DNS | - | -| OIDC_VERIFY_SSL | Whether or not calls to the OIDC endpoint specified in the `OIDC_URL` environment variable should validate the server certificate | `true` | -| OIDC_VERIFY_SIGNATURE | Whether or not the lambda authorizer should verify the JWT token signature | `true` | +| AUTH_OIDC_CLIENT_SECRET_VALUE | The OIDC client secret value for confidential clients. If not set, MLSpace will use PKCE for public clients | - | +| AUTH_OIDC_VERIFY_SSL | Whether or not calls to the OIDC endpoint specified in the `AUTH_OIDC_URL` environment variable should validate the server certificate | `true` | +| AUTH_OIDC_VERIFY_SIGNATURE | Whether or not the lambda authorizer should verify the JWT token signature | `true` | +| AUTH_OIDC_USE_PKCE | Whether to use PKCE (Proof Key for Code Exchange) for OIDC authentication | `false` | +| AUTH_SESSION_TTL_HOURS | The time-to-live (TTL) in hours for authentication sessions | `24` | +| AUTH_SYNC_DOMAINS | Comma-separated list of domains to sync authentication state across | - | | ADDITIONAL_LAMBDA_ENVIRONMENT_VARS | A map of key value pairs which will be set as environment variables on every MLSpace lambda | `{}` | | RESOURCE_TERMINATION_INTERVAL | Interval (in minutes) to run the resource termination cleanup lambda | `60` | | BACKGROUND_REFRESH_INTERVAL | Interval (in seconds) to run background resource data updates | `60` | diff --git a/backend/requirements.txt b/backend/requirements.txt index 7a325a86..521f4ae5 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,4 +3,8 @@ PyJWT==2.6.0 moto==4.0.8 dynamodb-json==1.3 cachetools==5.3.2 -pyseto==1.7.8 \ No newline at end of file +pyseto==1.7.8 +authlib==1.2.1 +pydantic==2.5.3 +requests==2.32.5 +cryptography==42.0.8 \ No newline at end of file diff --git a/backend/src/ml_space_lambda/auth/__init__.py b/backend/src/ml_space_lambda/auth/__init__.py new file mode 100644 index 00000000..f9de63fb --- /dev/null +++ b/backend/src/ml_space_lambda/auth/__init__.py @@ -0,0 +1,15 @@ +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# diff --git a/backend/src/ml_space_lambda/auth/handlers/__init__.py b/backend/src/ml_space_lambda/auth/handlers/__init__.py new file mode 100644 index 00000000..f9de63fb --- /dev/null +++ b/backend/src/ml_space_lambda/auth/handlers/__init__.py @@ -0,0 +1,15 @@ +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# diff --git a/backend/src/ml_space_lambda/auth/handlers/oidc_handler.py b/backend/src/ml_space_lambda/auth/handlers/oidc_handler.py new file mode 100644 index 00000000..6e1180b4 --- /dev/null +++ b/backend/src/ml_space_lambda/auth/handlers/oidc_handler.py @@ -0,0 +1,545 @@ +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +""" +OIDC authentication handler using authlib. + +Implements OpenID Connect authentication flows including authorization code flow +with client secrets and token refresh capabilities. +""" + +import base64 +import json +import logging +from typing import Dict, List, Optional, Tuple +from urllib.parse import urlencode + +import requests +from authlib.integrations.requests_client import OAuth2Session +from authlib.jose import JsonWebToken +from authlib.oauth2.rfc6749 import OAuth2Token +from authlib.oidc.discovery import OpenIDProviderMetadata, get_well_known_url +from pydantic import BaseModel, Field + +from ml_space_lambda.auth.models.auth_models import AuthenticationResult, IdPTokens, UserData + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + + +class OIDCConfig(BaseModel): + """ + OIDC-specific configuration. + """ + + issuer_url: str = Field(..., description="OIDC issuer URL") + client_id: str = Field(..., description="OIDC client ID") + client_secret: Optional[str] = Field(None, description="OIDC client secret (for confidential clients)") + scopes: List[str] = Field(default=["openid", "profile", "email"], description="OAuth2 scopes to request") + use_pkce: bool = Field(default=True, description="Whether to use PKCE flow (recommended even with client_secret)") + verify_ssl: bool = Field(default=True, description="Whether to verify SSL certificates for OIDC requests") + verify_signature: bool = Field(default=True, description="Whether to verify JWT token signatures") + + +class OIDCHandler: + """ + OIDC authentication handler supporting both PKCE and client secret flows. + + Uses authlib's built-in OIDC capabilities for proper OpenID Connect handling. + + Supports: + - Authorization code flow with client secrets (for enterprise IdPs) + - PKCE flow (for public clients - current MLSpace behavior) + - Token refresh + - User info retrieval + - OpenID Connect Discovery + - JWT ID token validation + """ + + def __init__(self, config: OIDCConfig): + """ + Initialize OIDC handler with configuration. + + Args: + config: OIDC configuration (Pydantic model) + """ + self.config = config + + # Initialize authlib components + # Common JWT algorithms for OIDC + self.jwt = JsonWebToken(["RS256", "HS256", "ES256"]) + + # Discover OIDC endpoints and metadata + self._discover_endpoints() + + # Create OAuth2 session for token operations + self._create_oauth_session() + + logger.info(f"OIDC handler initialized for issuer: {self.config.issuer_url}") + + def _discover_endpoints(self): + """ + Discover OIDC endpoints using authlib's discovery utilities. + + Raises: + Exception: If discovery fails or required endpoints are missing + """ + try: + # Use authlib's get_well_known_url and OpenIDProviderMetadata + well_known_url = get_well_known_url(self.config.issuer_url, external=True) + response = requests.get(well_known_url, timeout=10, verify=self.config.verify_ssl) + response.raise_for_status() + + # Create OpenIDProviderMetadata object using authlib + self.server_metadata = OpenIDProviderMetadata(response.json()) + + # Extract endpoints from server metadata + self.authorization_endpoint = self.server_metadata.get("authorization_endpoint") + self.token_endpoint = self.server_metadata.get("token_endpoint") + self.userinfo_endpoint = self.server_metadata.get("userinfo_endpoint") + self.jwks_uri = self.server_metadata.get("jwks_uri") + self.end_session_endpoint = self.server_metadata.get("end_session_endpoint") + + # Validate required endpoints + if not all([self.authorization_endpoint, self.token_endpoint]): + raise ValueError("Missing required OIDC endpoints in discovery document") + + logger.info("OIDC endpoints discovered successfully using authlib") + + except Exception as e: + logger.error(f"OIDC discovery failed: {e}") + raise Exception(f"Failed to discover OIDC endpoints: {e}") + + def _create_oauth_session(self): + """ + Create OAuth2 session for token operations using authlib. + """ + # Create a requests session with SSL verification setting + session = requests.Session() + session.verify = self.config.verify_ssl + + self.oauth_session = OAuth2Session( + client_id=self.config.client_id, + client_secret=self.config.client_secret, + scope=" ".join(self.config.scopes), + token_endpoint=self.token_endpoint, + token_endpoint_auth_method="client_secret_post" if self.config.client_secret else None, + ) + # Set the session on the OAuth2Session to use our configured session + self.oauth_session.session = session + + def get_authorization_url(self, state: str, redirect_uri: str, code_verifier: Optional[str] = None) -> str: + """ + Generate OIDC authorization URL using authlib. + + Args: + state: CSRF protection state parameter + redirect_uri: Where IdP should redirect after authentication + code_verifier: PKCE code verifier (required if use_pkce is True) + + Returns: + Authorization URL for user redirection + + Raises: + Exception: If URL generation fails or code_verifier is missing when PKCE is enabled + """ + try: + session_params = { + "client_id": self.config.client_id, + "scope": " ".join(self.config.scopes), + "redirect_uri": redirect_uri, + } + authorization_params = {"state": state} + + # Generate authorization URL with PKCE if enabled + if self.config.use_pkce: + if not code_verifier: + raise ValueError("code_verifier is required when PKCE is enabled") + + session_params.update({"code_challenge_method": "S256"}) + + authorization_params.update({"code_verifier": code_verifier}) + + logger.info("Generated OIDC authorization URL with PKCE") + else: + logger.info("Generated OIDC authorization URL") + + # Create temporary OAuth2 session for URL generation + client = OAuth2Session(**session_params) + authorization_url, _ = client.create_authorization_url(self.authorization_endpoint, **authorization_params) + + return authorization_url + + except Exception as e: + logger.error(f"Failed to generate authorization URL: {e}") + raise Exception(f"Authorization URL generation failed: {e}") + + def handle_callback(self, code: str, redirect_uri: str, code_verifier: Optional[str] = None) -> AuthenticationResult: + """ + Process OIDC callback and exchange authorization code for tokens using authlib. + + Args: + code: Authorization code from IdP callback + redirect_uri: Redirect URI used in authorization request + code_verifier: PKCE code verifier (required if use_pkce is True) + + Returns: + AuthenticationResult with user data and tokens + + Raises: + Exception: If code_verifier is missing when PKCE is enabled + """ + try: + if not code: + return AuthenticationResult(success=False, error="Missing authorization code in callback") + + # Exchange authorization code for tokens using authlib + oauth_token = self._exchange_code_for_tokens(code, redirect_uri, code_verifier) + logger.info(f"oauth_token={json.dumps(oauth_token)}") + + # Convert OAuth2Token to our IdPTokens model + tokens = self._oauth_token_to_idp_tokens(oauth_token) + logger.info(f"tokens={tokens.model_dump_json()}") + + # Get user information from tokens + user_data = self._get_user_info_from_oauth_token(oauth_token) + logger.info(f"user_data={user_data.model_dump_json()}") + + # Encode raw response for debugging + raw_response = base64.b64encode( + json.dumps({"tokens": tokens.model_dump(), "user_data": user_data.model_dump()}).encode() + ).decode() + + logger.info(f"OIDC authentication successful for user: {user_data.id}") + + return AuthenticationResult(success=True, user_data=user_data, tokens=tokens, raw_response=raw_response) + + except Exception as e: + logger.error(f"OIDC callback handling failed: {e}") + return AuthenticationResult(success=False, error=f"Authentication failed: {str(e)}") + + def refresh_tokens(self, refresh_token: str) -> AuthenticationResult: + """ + Refresh OIDC tokens using authlib's OAuth2Session. + + Args: + refresh_token: Valid refresh token + + Returns: + AuthenticationResult with new tokens and updated user data + """ + try: + # Use authlib's refresh token method + oauth_token = self.oauth_session.refresh_token(self.token_endpoint, refresh_token=refresh_token) + + # Convert OAuth2Token to our IdPTokens model + tokens = self._oauth_token_to_idp_tokens(oauth_token) + + # Get updated user information + user_data = self._get_user_info_from_oauth_token(oauth_token) + + # Encode raw response for debugging + raw_response = base64.b64encode( + json.dumps({"tokens": tokens.model_dump(), "user_data": user_data.model_dump()}).encode() + ).decode() + + logger.info(f"OIDC token refresh successful for user: {user_data.id}") + + return AuthenticationResult(success=True, user_data=user_data, tokens=tokens, raw_response=raw_response) + + except Exception as e: + logger.error(f"Token refresh failed: {e}") + return AuthenticationResult(success=False, error=f"Token refresh failed: {str(e)}") + + def get_user_info(self, access_token: str) -> UserData: + """ + Retrieve user information using authlib's UserInfo handling. + + Args: + access_token: Valid access token + + Returns: + User information as UserData model + """ + try: + if not self.userinfo_endpoint: + # Fall back to empty user data if no userinfo endpoint + return UserData(id="", displayName="", email="") + + # Use authlib's OAuth2Session to get user info + oauth_token = OAuth2Token({"access_token": access_token, "token_type": "Bearer"}) + + # Create a temporary session with the token + session = OAuth2Session(client_id=self.config.client_id, token=oauth_token) + # Apply SSL verification setting + session.session = requests.Session() + session.session.verify = self.config.verify_ssl + + # Get user info using authlib + resp = session.get(self.userinfo_endpoint) + + if resp.status_code == 200: + user_info_data = resp.json() + return self.normalize_user_data(user_info_data) + else: + logger.warning(f"UserInfo request failed: {resp.status_code}") + return UserData(id="", displayName="", email="") + + except Exception as e: + logger.error(f"Failed to get user info: {e}") + return UserData(id="", displayName="", email="") + + def validate_token(self, token: str) -> bool: + """ + Validate an OIDC access token. + + Args: + token: Access token to validate + + Returns: + True if token is valid, False otherwise + """ + try: + # For OIDC, we can validate by making a userinfo request + # This is a simple validation - more sophisticated validation + # would involve JWT signature verification + user_info = self.get_user_info(token) + return bool(user_info.id) + + except Exception: + return False + + def get_logout_url(self, post_logout_redirect_uri: Optional[str] = None) -> Optional[str]: + """ + Get OIDC logout URL for single sign-out. + + Args: + post_logout_redirect_uri: Where to redirect after IdP logout + + Returns: + Logout URL if supported, None otherwise + """ + if not self.end_session_endpoint: + return None + + params = {} + if post_logout_redirect_uri: + params["post_logout_redirect_uri"] = post_logout_redirect_uri + + if params: + return f"{self.end_session_endpoint}?{urlencode(params)}" + else: + return self.end_session_endpoint + + def normalize_user_data(self, raw_user_data: Dict) -> UserData: + """ + Normalize OIDC user data into MLSpace format. + + Args: + raw_user_data: Raw user data from OIDC IdP + + Returns: + Normalized user data with MLSpace standard fields + """ + # OIDC standard claims mapping + # Use "sub" as the durable identifier (OIDC standard) + sub = raw_user_data.get("sub", "") + + # Use preferred_username for the username, sanitized to remove special characters + preferred_username = raw_user_data.get("preferred_username", "") + if preferred_username: + # Sanitize username by replacing problematic characters with dashes + user_id = preferred_username.replace(",", "-").replace("=", "-").replace(" ", "-") + else: + # Fallback to email prefix if preferred_username not available + user_id = raw_user_data.get("email", "").split("@")[0] + + # Use name claim for display name, fallback to constructing from given/family names + display_name = ( + raw_user_data.get("name") or raw_user_data.get("given_name", "") + " " + raw_user_data.get("family_name", "") + ).strip() + + # If no name available, use the username + if not display_name: + display_name = user_id + + email = raw_user_data.get("email", "") + + # Extract groups from various possible claims + groups = [] + for group_claim in ["groups", "roles", "authorities", "memberOf"]: + if group_claim in raw_user_data: + claim_value = raw_user_data[group_claim] + if isinstance(claim_value, list): + groups.extend(claim_value) + elif isinstance(claim_value, str): + groups.append(claim_value) + + # Additional attributes (excluding standard claims) + standard_claims = { + "sub", + "name", + "given_name", + "family_name", + "email", + "preferred_username", + "groups", + "roles", + "authorities", + "memberOf", + "iss", + "aud", + "exp", + "iat", + "auth_time", + } + + attributes = { + key: str(value) for key, value in raw_user_data.items() if key not in standard_claims and not key.startswith("_") + } + + # Store the sub claim in attributes for reference + if sub: + attributes["sub"] = sub + + return UserData( + id=user_id, # Sanitized username for MLSpace + displayName=display_name, + email=email, + groups=list(set(groups)), # Remove duplicates + attributes=attributes, + ) + + def extract_token_expiration(self, tokens: IdPTokens) -> Tuple[Optional[int], Optional[int]]: + """ + Extract OIDC token expiration information. + + Args: + tokens: Token dictionary from OIDC IdP + + Returns: + Tuple of (access_token_expires_in_seconds, refresh_token_expires_in_seconds) + """ + # Use Pydantic model fields directly + access_expires = tokens.expires_in or 3600 # Default 1 hour + refresh_expires = tokens.refresh_expires_in + + return access_expires, refresh_expires + + def _exchange_code_for_tokens(self, auth_code: str, redirect_uri: str, code_verifier: Optional[str] = None) -> OAuth2Token: + """ + Exchange authorization code for tokens using authlib's OAuth2Session. + + Args: + auth_code: Authorization code from callback + redirect_uri: Redirect URI used in authorization request + code_verifier: PKCE code verifier (required if use_pkce is True) + + Returns: + OAuth2Token from authlib + + Raises: + Exception: If token exchange fails or code_verifier is missing when PKCE is enabled + """ + try: + fetch_token_params = { + "code": auth_code, + "redirect_uri": redirect_uri, + } + + # Add code_verifier for PKCE flow + if self.config.use_pkce: + if not code_verifier: + raise ValueError("code_verifier is required when PKCE is enabled") + fetch_token_params["code_verifier"] = code_verifier + + # Use authlib's fetch_token method + oauth_token = self.oauth_session.fetch_token(self.token_endpoint, **fetch_token_params) + + logger.info("Token exchange successful using authlib") + return oauth_token + + except Exception as e: + logger.error(f"Token exchange failed: {e}") + raise Exception(f"Token exchange failed: {str(e)}") + + def _oauth_token_to_idp_tokens(self, oauth_token: OAuth2Token) -> IdPTokens: + """ + Convert authlib's OAuth2Token to our IdPTokens model. + + Args: + oauth_token: OAuth2Token from authlib + + Returns: + IdPTokens model + """ + return IdPTokens( + access_token=oauth_token.get("access_token"), + refresh_token=oauth_token.get("refresh_token"), + id_token=oauth_token.get("id_token"), + token_type=oauth_token.get("token_type", "Bearer"), + expires_in=oauth_token.get("expires_in"), + refresh_expires_in=oauth_token.get("refresh_expires_in"), + scope=oauth_token.get("scope"), + ) + + def _get_user_info_from_oauth_token(self, oauth_token: OAuth2Token) -> UserData: + """ + Get user information from OAuth2Token using authlib's JWT and UserInfo handling. + + Args: + oauth_token: OAuth2Token from authlib + + Returns: + Combined user information from ID token and UserInfo endpoint + """ + user_data = {} + + # Extract claims from ID token if present using authlib's JWT handling + id_token = oauth_token.get("id_token") + if id_token: + try: + # Use authlib's JWT to decode ID token + # Verify signature based on configuration + decode_options = {"verify_signature": self.config.verify_signature} + id_claims = self.jwt.decode(id_token, options=decode_options) + user_data.update(id_claims) + logger.debug("Successfully extracted claims from ID token using authlib") + except Exception as e: + logger.warning(f"Failed to parse ID token with authlib: {e}") + + # Get additional user info from UserInfo endpoint + access_token = oauth_token.get("access_token") + if access_token and self.userinfo_endpoint: + try: + # Use authlib's OAuth2Session to get user info + session = OAuth2Session(client_id=self.config.client_id, token=oauth_token) + # Apply SSL verification setting + session.session = requests.Session() + session.session.verify = self.config.verify_ssl + + resp = session.get(self.userinfo_endpoint) + if resp.status_code == 200: + userinfo_data = resp.json() + # Merge with existing data, prioritizing UserInfo endpoint data + user_data.update(userinfo_data) + logger.debug("Successfully retrieved user info from UserInfo endpoint") + else: + logger.warning(f"UserInfo request failed: {resp.status_code}") + except Exception as e: + logger.warning(f"Failed to get user info from UserInfo endpoint: {e}") + + return self.normalize_user_data(user_data) diff --git a/backend/src/ml_space_lambda/auth/lambda_functions.py b/backend/src/ml_space_lambda/auth/lambda_functions.py new file mode 100644 index 00000000..5a3aa3cf --- /dev/null +++ b/backend/src/ml_space_lambda/auth/lambda_functions.py @@ -0,0 +1,1490 @@ +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +""" +BFF Authentication Lambda Functions. + +Implements Backend for Frontend authentication endpoints including login, +callback, logout, identity, and cross-domain synchronization. +""" + +import json +import logging +import os +import time +from datetime import datetime, timedelta, timezone +from enum import Enum +from typing import Dict, List, Optional, Tuple +from urllib.parse import urlparse + +import boto3 +from authlib.common.security import generate_token + +from ml_space_lambda.auth.handlers.oidc_handler import OIDCConfig, OIDCHandler +from ml_space_lambda.auth.models.auth_models import AuthError, AuthStatus, IdentityResponse, SessionInfo, UserData +from ml_space_lambda.auth.session.key_manager import VersionedKeyManager, VersionedTokenEncryption +from ml_space_lambda.auth.session.manager import OTACManager, SessionManager +from ml_space_lambda.auth.utils.cookies import ( + clear_session_cookie, + clear_state_cookie, + create_json_response, + create_redirect_response, + create_session_cookie, + create_state_cookie, + extract_domain_from_host, + get_cookie_value, + should_set_secure_flag, +) +from ml_space_lambda.auth.utils.otac import build_domain_list, build_sync_chain_url, should_initiate_sync +from ml_space_lambda.auth.utils.state import StateManager, decode_state_key_from_storage +from ml_space_lambda.data_access_objects.user import TIMEZONE_PREFERENCE_KEY, UserDAO, UserModel +from ml_space_lambda.enums import TimezonePreference +from ml_space_lambda.utils.mlspace_config import get_environment_variables + +logger = logging.getLogger(__name__) + +# Initialize AWS clients +ssm_client = boto3.client("ssm") +secrets_client = boto3.client("secretsmanager") + + +class IdPType(str, Enum): + """Identity Provider type enumeration.""" + + OIDC = "oidc" + SAML = "saml" + + +def _get_auth_config() -> Dict[str, str]: + """ + Get authentication configuration from environment variables. + + Returns: + Dictionary of authentication configuration + + Raises: + Exception: If required configuration is missing or invalid + """ + config = { + "idp_type": os.environ.get("AUTH_IDP_TYPE", IdPType.OIDC), + "oidc_url": os.environ.get("AUTH_OIDC_URL", ""), + "oidc_client_id": os.environ.get("AUTH_OIDC_CLIENT_ID", ""), + "oidc_client_secret_name": os.environ.get("AUTH_OIDC_CLIENT_SECRET_NAME", ""), + "oidc_use_pkce": os.environ.get("AUTH_OIDC_USE_PKCE", "true").lower() == "true", + "oidc_verify_ssl": os.environ.get("AUTH_OIDC_VERIFY_SSL", "true").lower() == "true", + "oidc_verify_signature": os.environ.get("AUTH_OIDC_VERIFY_SIGNATURE", "true").lower() == "true", + "state_encryption_key_secret_name": os.environ.get("AUTH_STATE_ENCRYPTION_KEY_SECRET_NAME", ""), + "token_encryption_key_secret_name": os.environ.get("AUTH_TOKEN_ENCRYPTION_KEY_SECRET_NAME", ""), + "session_table_name": os.environ.get("AUTH_SESSION_TABLE_NAME", ""), + "sync_domains": os.environ.get("AUTH_SYNC_DOMAINS", ""), + } + + # Validate IdP type first + if config["idp_type"] != IdPType.OIDC: + raise Exception(f"Unsupported IdP type: {config['idp_type']}. Only '{IdPType.OIDC.value}' is currently supported.") + + # Validate required configuration for OIDC + if not config["oidc_url"]: + raise Exception("AUTH_OIDC_URL environment variable is required") + + if not config["oidc_client_id"]: + raise Exception("AUTH_OIDC_CLIENT_ID environment variable is required") + + if not config["state_encryption_key_secret_name"]: + raise Exception("AUTH_STATE_ENCRYPTION_KEY_SECRET_NAME environment variable is required") + + if not config["token_encryption_key_secret_name"]: + raise Exception("AUTH_TOKEN_ENCRYPTION_KEY_SECRET_NAME environment variable is required") + + if not config["session_table_name"]: + raise Exception("AUTH_SESSION_TABLE_NAME environment variable is required") + + return config + + +def _get_ssm_parameter(parameter_name: str, decrypt: bool = True) -> str: + """ + Get parameter value from AWS Systems Manager Parameter Store. + + Args: + parameter_name: SSM parameter name + decrypt: Whether to decrypt SecureString parameters + + Returns: + Parameter value + + Raises: + Exception: If parameter retrieval fails + """ + try: + response = ssm_client.get_parameter(Name=parameter_name, WithDecryption=decrypt) + return response["Parameter"]["Value"] + except Exception as e: + logger.error(f"Failed to get SSM parameter {parameter_name}: {e}") + raise Exception(f"Configuration error: Unable to retrieve {parameter_name}") + + +def _get_secret_value(secret_arn: str, key: str = "key") -> str: + """ + Get secret value from AWS Secrets Manager. + + Args: + secret_arn: Secret ARN or name + key: JSON key to extract from the secret (default: "key") + + Returns: + Secret value for the specified key + + Raises: + Exception: If secret retrieval fails + """ + try: + response = secrets_client.get_secret_value(SecretId=secret_arn) + return json.loads(response["SecretString"])[key] + + except Exception as e: + logger.error(f"Failed to get secret {secret_arn}: {e}") + raise Exception(f"Configuration error: Unable to retrieve secret {secret_arn}") + + +def _get_versioned_secret_value(secret_arn: str, key: str = "key") -> str: + """ + Get secret value from AWS Secrets Manager. + + Expects the new versioned format (VersionedKeyData structure). + + Args: + secret_arn: Secret ARN or name + key: Unused parameter (kept for API compatibility) + + Returns: + Current key from versioned secret + + Raises: + Exception: If secret retrieval fails + """ + try: + response = secrets_client.get_secret_value(SecretId=secret_arn) + + # Always expect versioned format + from ml_space_lambda.auth.models.key_models import VersionedKeyData + + key_data = VersionedKeyData.from_secrets_manager_format(response["SecretString"]) + return key_data.get_current_key() + + except Exception as e: + logger.error(f"Failed to get secret {secret_arn}: {e}") + raise Exception(f"Configuration error: Unable to retrieve secret {secret_arn}") + + +def _create_auth_handler(config: Dict[str, str]) -> OIDCHandler: + """ + Create authentication handler based on configuration. + + Args: + config: Authentication configuration + + Returns: + Configured OIDC authentication handler + + Raises: + Exception: If handler creation fails or IdP type is not supported + """ + # This function currently only supports OIDC + # The IdP type validation should have already been done in _get_auth_config() + # but we double-check here for safety + if config["idp_type"] != IdPType.OIDC: + raise Exception(f"Unsupported IdP type: {config['idp_type']}. Only '{IdPType.OIDC.value}' is currently supported.") + + # OIDC-specific handler creation + # Get OIDC client secret if configured + client_secret = None + if config["oidc_client_secret_name"]: + try: + client_secret = _get_secret_value(config["oidc_client_secret_name"], "client_secret") + except Exception as e: + logger.warning(f"Failed to retrieve OIDC client secret: {e}") + # Continue without client secret (PKCE flow) + + # Create OIDC configuration + oidc_config = OIDCConfig( + issuer_url=config["oidc_url"], + client_id=config["oidc_client_id"], + client_secret=client_secret, + scopes=["openid", "profile", "email"], + use_pkce=config["oidc_use_pkce"], + verify_ssl=config["oidc_verify_ssl"], + verify_signature=config["oidc_verify_signature"], + ) + + return OIDCHandler(oidc_config) + + +def _create_state_manager(config: Dict[str, str]) -> StateManager: + """ + Create state manager for CSRF protection. + + Args: + config: Authentication configuration + + Returns: + Configured state manager + + Raises: + Exception: If state manager creation fails + """ + try: + # Get state encryption key from Secrets Manager + encoded_key = _get_versioned_secret_value(config["state_encryption_key_secret_name"]) + encryption_key = decode_state_key_from_storage(encoded_key) + + return StateManager(encryption_key) + except Exception as e: + logger.error(f"Failed to create state manager: {e}") + raise Exception("Configuration error: Unable to initialize state management") + + +def _create_token_encryption(config: Dict[str, str]) -> VersionedTokenEncryption: + """ + Create versioned token encryption instance for securing IdP tokens. + + Args: + config: Authentication configuration + + Returns: + Configured versioned token encryption instance + + Raises: + Exception: If token encryption creation fails + """ + try: + # Use versioned key manager for token encryption + key_manager = VersionedKeyManager(secret_arn=config["token_encryption_key_secret_name"], key_type="token") + return VersionedTokenEncryption(key_manager) + except Exception as e: + logger.error(f"Failed to create versioned token encryption: {e}") + raise Exception("Configuration error: Unable to initialize token encryption") + + +def _create_session_manager(config: Dict[str, str]) -> SessionManager: + """ + Create session manager for session storage. + + Args: + config: Authentication configuration + + Returns: + Configured session manager + + Raises: + Exception: If session manager creation fails + """ + try: + token_encryption = _create_token_encryption(config) + return SessionManager(table_name=config["session_table_name"], encryption=token_encryption) + except Exception as e: + logger.error(f"Failed to create session manager: {e}") + raise Exception("Configuration error: Unable to initialize session management") + + +def _create_otac_manager(config: Dict[str, str]) -> OTACManager: + """ + Create OTAC manager for cross-domain synchronization. + + Args: + config: Authentication configuration + + Returns: + Configured OTAC manager + + Raises: + Exception: If OTAC manager creation fails + """ + try: + return OTACManager(table_name=config["session_table_name"]) + except Exception as e: + logger.error(f"Failed to create OTAC manager: {e}") + raise Exception("Configuration error: Unable to initialize OTAC management") + + +def _get_base_url(event: Dict) -> str: + """ + Get base URL for building redirect URIs. + + Uses WEB_CUSTOM_DOMAIN_NAME if configured, otherwise falls back to Host header. + + Args: + event: Lambda event + + Returns: + Base URL (e.g., "https://mlspace.example.com" or "https://api-id.execute-api.region.amazonaws.com/stage") + """ + # Check for custom domain configuration + custom_domain = os.environ.get("WEB_CUSTOM_DOMAIN_NAME", "").strip() + if custom_domain: + # Remove trailing slash if present + return custom_domain.rstrip("/") + + # Fall back to Host header + host = event.get("headers", {}).get("Host") or event.get("headers", {}).get("host", "") + + # Determine protocol + protocol = "https" + if host.startswith("localhost") or "127.0.0.1" in host: + protocol = "http" + + return f"{protocol}://{host}" + + +def _get_redirect_uri(event: Dict) -> str: + """ + Build redirect URI for OIDC callback. + + Args: + event: Lambda event + + Returns: + Callback redirect URI + """ + base_url = _get_base_url(event) + return f"{base_url}/auth/callback" + + +def _validate_redirect_url(redirect_url: str, host_header: str) -> bool: + """ + Validate that redirect URL is safe and belongs to the same origin. + + Args: + redirect_url: URL to validate + host_header: Host header from request + + Returns: + True if redirect URL is valid, False otherwise + """ + if not redirect_url: + return False + + try: + parsed = urlparse(redirect_url) + + # Allow relative URLs + if not parsed.netloc: + return redirect_url.startswith("/") and not redirect_url.startswith("//") + + # For absolute URLs, check that host matches + return parsed.netloc.lower() == host_header.lower() + except Exception: + return False + + +def login(event, context): + """ + Handle GET /auth/login - Initiate authentication flow. + + Generates state parameter, sets state cookie, and redirects to IdP. + + Args: + event: Lambda event containing request data + context: Lambda context + + Returns: + Redirect response to IdP authorization endpoint + """ + try: + # Get authentication configuration + config = _get_auth_config() + + # Create authentication handler + auth_handler = _create_auth_handler(config) + + # Create state manager + state_manager = _create_state_manager(config) + + # Get redirect URL from query parameters + query_params = event.get("queryStringParameters") or {} + redirect_url = query_params.get("redirectUrl", "/") + host_header = event.get("headers", {}).get("Host") or event.get("headers", {}).get("host", "") + + # Validate redirect URL + if not _validate_redirect_url(redirect_url, host_header): + logger.warning(f"Invalid redirect URL: {redirect_url}") + redirect_url = "/" + + # Get domain for state and cookies + domain = extract_domain_from_host(host_header) + + # Generate nonce for state parameter + nonce = state_manager.generate_nonce() + + # Generate protocol-specific data for OIDC (PKCE code_verifier if enabled) + protocol_data = None + if config["oidc_use_pkce"]: + protocol_data = {"code_verifier": generate_token(48)} + + # Create encrypted state parameter with protocol-specific data + encrypted_state = state_manager.create_state( + redirect_url=redirect_url, domain=domain or host_header, nonce=nonce, protocol_data=protocol_data + ) + + # Get redirect URI for OIDC callback + redirect_uri = _get_redirect_uri(event) + + # Generate authorization URL with code_verifier if PKCE is enabled + code_verifier = protocol_data.get("code_verifier") if protocol_data else None + authorization_url = auth_handler.get_authorization_url( + state=encrypted_state, redirect_uri=redirect_uri, code_verifier=code_verifier + ) + + # Create state cookie + secure_flag = should_set_secure_flag(host_header) + state_cookie = create_state_cookie(nonce=nonce, max_age_seconds=600, secure=secure_flag, same_site="Lax") # 10 minutes + + logger.info(f"Login initiated for domain: {domain}, redirecting to IdP") + + # Return redirect response with state cookie + return create_redirect_response(location=authorization_url, cookies=[state_cookie], status_code=302) + + except Exception as e: + logger.error(f"Login failed: {e}") + + # Return error response + error_message = str(e) + is_config_error = ( + "environment variable is required" in error_message + or "Configuration error" in error_message + or "not configured" in error_message + or "Failed to discover OIDC endpoints" in error_message + or "Invalid URL" in error_message + or "No scheme supplied" in error_message + or "Unsupported IdP type" in error_message + ) + + error_response = { + "error": AuthError.INVALID_CONFIGURATION if is_config_error else AuthError.INTERNAL_ERROR, + "message": error_message, + "timestamp": context.aws_request_id if context else None, + } + + return create_json_response(body=error_response, status_code=400 if is_config_error else 500) + + +def _validate_callback_parameters(event): + """ + Validate callback parameters and handle early error cases. + + Args: + event: Lambda event containing callback data + + Returns: + Tuple of (auth_code, encrypted_state, error_response) + If error_response is not None, should return it immediately + """ + query_params = event.get("queryStringParameters") or {} + auth_code = query_params.get("code") + encrypted_state = query_params.get("state") + error_param = query_params.get("error") + error_description = query_params.get("error_description", "") + + # Check for IdP error response + if error_param: + logger.warning(f"IdP returned error: {error_param} - {error_description}") + error_url = f"/?error=authentication_failed&message={error_param}" + return None, None, create_redirect_response(location=error_url, status_code=302) + + # Validate required parameters + if not auth_code or not encrypted_state: + logger.warning("Missing required callback parameters") + error_url = "/?error=invalid_request&message=Missing required parameters" + return None, None, create_redirect_response(location=error_url, status_code=302) + + return auth_code, encrypted_state, None + + +def _validate_state_parameter(event, state_manager, encrypted_state): + """ + Validate state parameter against state cookie. + + Args: + event: Lambda event containing headers + state_manager: StateManager instance + encrypted_state: Encrypted state from query parameters + + Returns: + Tuple of (state_data, error_response) + If error_response is not None, should return it immediately + """ + # Extract state cookie + cookie_header = event.get("headers", {}).get("Cookie") or event.get("headers", {}).get("cookie", "") + state_nonce = get_cookie_value(cookie_header, "mlspace_auth_state") + + # Validate state parameter + state_data = state_manager.validate_state(encrypted_state, state_nonce) + if not state_data: + logger.warning("Invalid or expired state parameter") + error_url = "/?error=invalid_state&message=Authentication request expired or invalid" + return None, create_redirect_response(location=error_url, status_code=302) + + return state_data, None + + +def _exchange_code_for_tokens(auth_handler, auth_code, state_data, event): + """ + Exchange authorization code for tokens via OIDC handler. + + Args: + auth_handler: OIDC handler instance + auth_code: Authorization code from callback + state_data: Validated state data containing protocol_data if present + event: Lambda event for building redirect URI + + Returns: + Tuple of (auth_result, error_response) + If error_response is not None, should return it immediately + """ + # Get redirect URI for token exchange + redirect_uri = _get_redirect_uri(event) + + # Extract code_verifier from protocol_data if present (for PKCE flow) + protocol_data = state_data.get("protocol_data", {}) + code_verifier = protocol_data.get("code_verifier") + + # Exchange authorization code for tokens + auth_result = auth_handler.handle_callback(code=auth_code, redirect_uri=redirect_uri, code_verifier=code_verifier) + + if not auth_result.success: + logger.error(f"Token exchange failed: {auth_result.error}") + error_url = f"/?error=token_exchange_failed&message={auth_result.error}" + return None, create_redirect_response(location=error_url, status_code=302) + + return auth_result, None + + +def _ensure_user_exists(user_data: UserData) -> None: + """ + Ensure user exists in the system, creating or updating as needed. + + Creates users with username and display_name matching the pattern from the frontend + (oidc.config.ts onSigninCallback). Updates existing users to backfill the id field + from the IdP's "sub" claim. + + Args: + user_data: User data from authentication result + """ + user_dao = UserDAO() + + # Extract the sub claim from attributes (stored by normalize_user_data) + idp_sub = user_data.attributes.get("sub", "") + + # Check if user already exists + existing_user = user_dao.get(user_data.id) + + if existing_user: + # Update existing user: set last login and backfill id field if missing + existing_user.last_login = int(time.time()) + + # Backfill the id field with the IdP's sub claim if not already set + if not existing_user.id and idp_sub: + existing_user.id = idp_sub + logger.info(f"Backfilled id field for existing user: {user_data.id}") + + user_dao.update(user_data.id, existing_user) + logger.info(f"Updated last login for existing user: {user_data.id}") + else: + # Create new user matching the frontend pattern: + # - username: sanitized preferred_username (user_data.id) + # - display_name: name claim or constructed name (user_data.displayName) + # - email: email claim + # - id: sub claim from IdP + env_vars = get_environment_variables() + suspended_state = env_vars.get("NEW_USERS_SUSPENDED") == "True" + preferences = {TIMEZONE_PREFERENCE_KEY: TimezonePreference.LOCAL} + + new_user = UserModel( + username=user_data.id, # Sanitized preferred_username + email=user_data.email or "", + display_name=user_data.displayName, # Name claim, matching frontend + suspended=suspended_state, + preferences=preferences, + id=idp_sub if idp_sub else None, # IdP's sub claim + ) + user_dao.create(new_user) + logger.info(f"Created new user: {user_data.id} with IdP id: {idp_sub}") + + +def _create_user_session(session_manager, auth_result, auth_handler, config, event): + """ + Create session record in DynamoDB with encrypted tokens and ensure user exists. + + Args: + session_manager: SessionManager instance + auth_result: Authentication result from OIDC handler + auth_handler: OIDC handler for token expiration calculation + config: Authentication configuration + event: Lambda event for domain extraction + + Returns: + Tuple of (session_id, expires_at, login_domain) + """ + # Ensure user exists in the system (create or update) + _ensure_user_exists(auth_result.user_data) + + # Calculate session expiration times + access_expires, refresh_expires = auth_handler.extract_token_expiration(auth_result.tokens) + + # Session expires when refresh token expires (or default to 24 hours if no refresh token) + if not refresh_expires: + refresh_expires = 60 * 60 * 24 + + expires_at = datetime.now(timezone.utc) + timedelta(seconds=refresh_expires) + + # Refresh at access token expiration to keep user info fresh, but not later than 5 minutes before session expires + access_token_expiration = datetime.now(timezone.utc) + timedelta(seconds=access_expires) + five_minutes_before_expiry = expires_at - timedelta(minutes=5) + + # Use the earlier of the two times + refresh_at = min(access_token_expiration, five_minutes_before_expiry) + + # Create session record + login_domain = extract_domain_from_host(event.get("headers", {}).get("Host", "")) + + session_id = session_manager.create_session( + user_data=auth_result.user_data.model_dump(), + tokens=auth_result.tokens.model_dump(), + provider=config["idp_type"], + expires_at=expires_at, + refresh_at=refresh_at, + login_domain=login_domain or "", + synced_domains=[], + raw_idp_response=auth_result.raw_response, + ) + + return session_id, refresh_expires, login_domain + + +def _handle_multi_domain_sync(otac_manager, session_id, config, event, state_data, session_cookie, clear_state): + """ + Handle multi-domain cookie synchronization if configured. + + Args: + otac_manager: OTACManager instance + session_id: Created session ID + config: Authentication configuration + event: Lambda event for domain extraction + state_data: Validated state data containing redirect URL + session_cookie: Session cookie to set + clear_state: State cookie clearing header + + Returns: + Tuple of (should_sync, redirect_response) + If should_sync is True, redirect_response contains the sync chain URL + """ + host_header = event.get("headers", {}).get("Host") or event.get("headers", {}).get("host", "") + domain = extract_domain_from_host(host_header) + + # Check for multi-domain synchronization + sync_domains = build_domain_list(domain or host_header, config.get("sync_domains", "")) + + if should_initiate_sync(sync_domains): + # Create OTAC for cross-domain sync + otac_id = otac_manager.create_otac( + session_id=session_id, remaining_domains=sync_domains, final_redirect_url=state_data["redirect_url"] + ) + + # Build sync chain URL + sync_url = build_sync_chain_url( + current_domain=domain or host_header, + otac=otac_id, + remaining_domains=sync_domains, + final_redirect_url=state_data["redirect_url"], + ) + + logger.info(f"Initiating multi-domain sync for {len(sync_domains)} domains") + + return True, create_redirect_response(location=sync_url, cookies=[session_cookie, clear_state], status_code=302) + + return False, None + + +def callback(event, context): + """ + Handle GET /auth/callback - Process IdP callback after authentication. + + Validates state parameter, exchanges authorization code for tokens, + creates session, and handles multi-domain synchronization. + + Args: + event: Lambda event containing callback data + context: Lambda context + + Returns: + Redirect response to final destination or error page + """ + try: + # Validate callback parameters and handle early errors + auth_code, encrypted_state, error_response = _validate_callback_parameters(event) + if error_response: + return error_response + + # Get authentication configuration and create managers + config = _get_auth_config() + auth_handler = _create_auth_handler(config) + state_manager = _create_state_manager(config) + session_manager = _create_session_manager(config) + otac_manager = _create_otac_manager(config) + + # Validate state parameter + state_data, error_response = _validate_state_parameter(event, state_manager, encrypted_state) + if error_response: + return error_response + + # Exchange authorization code for tokens + auth_result, error_response = _exchange_code_for_tokens(auth_handler, auth_code, state_data, event) + if error_response: + return error_response + + # Create session record + session_id, expires_at, login_domain = _create_user_session(session_manager, auth_result, auth_handler, config, event) + + # Create session cookie + host_header = event.get("headers", {}).get("Host") or event.get("headers", {}).get("host", "") + secure_flag = should_set_secure_flag(host_header) + domain = extract_domain_from_host(host_header) + + session_cookie = create_session_cookie( + session_id=session_id, max_age_seconds=int(expires_at), domain=domain, secure=secure_flag + ) + + # Clear state cookie + clear_state = clear_state_cookie() + + # Handle multi-domain synchronization + should_sync, sync_response = _handle_multi_domain_sync( + otac_manager, session_id, config, event, state_data, session_cookie, clear_state + ) + + if should_sync: + return sync_response + + # Single domain, redirect to final destination + logger.info(f"Authentication successful for user: {auth_result.user_data.id}") + return create_redirect_response( + location=state_data["redirect_url"], cookies=[session_cookie, clear_state], status_code=302 + ) + + except Exception as e: + logger.error(f"Callback processing failed: {e}") + + # Clear state cookie on error + clear_state = clear_state_cookie() + error_url = "/?error=internal_error&message=Authentication processing failed" + + return create_redirect_response(location=error_url, cookies=[clear_state], status_code=302) + + +def callback_post(event, context): + """ + Handle POST /auth/callback - Process IdP callback for SAML and other POST-based flows. + + This handler supports future IdP integrations that use POST callbacks + (such as SAML assertions) instead of GET redirects. + + Args: + event: Lambda event containing POST callback data + context: Lambda context + + Returns: + Redirect response to final destination or error page + """ + try: + # Get authentication configuration + config = _get_auth_config() + + # Currently only OIDC is supported, which uses GET callbacks + if config["idp_type"] != IdPType.SAML: + logger.warning(f"POST callback not supported for IdP type: {config['idp_type']}") + error_url = "/?error=unsupported_callback&message=POST callback not supported for this IdP type" + return create_redirect_response(location=error_url, status_code=302) + + # TODO: Implement SAML POST callback handling + # This would involve: + # 1. Parse SAML assertion from POST body + # 2. Validate SAML signature and assertions + # 3. Extract user data from SAML attributes + # 4. Create session (similar to OIDC callback) + # 5. Handle multi-domain sync if configured + + logger.error("SAML POST callback not yet implemented") + error_url = "/?error=not_implemented&message=SAML authentication not yet supported" + return create_redirect_response(location=error_url, status_code=302) + + except Exception as e: + logger.error(f"POST callback processing failed: {e}") + error_url = "/?error=internal_error&message=Authentication processing failed" + return create_redirect_response(location=error_url, status_code=302) + + +def _parse_logout_request(event) -> Tuple[Optional[str], bool]: + """ + Parse logout request to extract session ID and logout options. + + Args: + event: Lambda event containing logout request + + Returns: + Tuple of (session_id, logout_from_idp) + """ + # Extract session cookie + cookie_header = event.get("headers", {}).get("Cookie") or event.get("headers", {}).get("cookie", "") + session_id = get_cookie_value(cookie_header, "mlspace_session") + + # Parse request body for logout options + body = {} + if event.get("body"): + try: + body = json.loads(event["body"]) + except json.JSONDecodeError: + logger.warning("Invalid JSON in logout request body") + + logout_from_idp = body.get("logoutFromIdp", False) + return session_id, logout_from_idp + + +def _validate_logout_session(session_id: str, session_manager: SessionManager) -> Optional[Dict]: + """ + Validate session exists and is active for logout. + + Args: + session_id: Session identifier to validate + session_manager: Session manager instance + + Returns: + Session data if valid, None otherwise + """ + session_data = session_manager.get_session(session_id) + if not session_data: + logger.warning(f"Invalid or expired session in logout request: {session_id}") + return None + + return session_data + + +def _delete_user_session(session_id: str, session_manager: SessionManager) -> bool: + """ + Delete session record from storage. + + Args: + session_id: Session identifier to delete + session_manager: Session manager instance + + Returns: + True if deletion successful, False otherwise + """ + deletion_success = session_manager.delete_session(session_id) + if not deletion_success: + logger.error(f"Failed to delete session: {session_id}") + return deletion_success + + +def _get_idp_logout_url(config: Dict[str, str], event: Dict) -> Optional[str]: + """ + Generate IdP logout URL for single sign-out. + + Args: + config: Authentication configuration + event: Lambda event for building base URL + + Returns: + IdP logout URL if available, None otherwise + """ + try: + # Create authentication handler to get logout URL + auth_handler = _create_auth_handler(config) + + # Build post-logout redirect URI (back to login page) + base_url = _get_base_url(event) + post_logout_redirect_uri = f"{base_url}/" + + idp_logout_url = auth_handler.get_logout_url(post_logout_redirect_uri) + + if idp_logout_url: + logger.info("IdP logout URL generated for single sign-out") + else: + logger.info("IdP does not support logout endpoint") + + return idp_logout_url + + except Exception as e: + logger.warning(f"Failed to get IdP logout URL: {e}") + return None + + +def _create_logout_error_response(context, event) -> Dict: + """ + Create error response for logout failures with session cookie cleanup. + + Args: + context: Lambda context + event: Lambda event for cookie domain extraction + + Returns: + Error response dictionary with cookies + """ + # Try to clear session cookie even on error + try: + host_header = event.get("headers", {}).get("Host") or event.get("headers", {}).get("host", "") + domain = extract_domain_from_host(host_header) + clear_session = clear_session_cookie(domain=domain) + cookies = [clear_session] + except Exception: + cookies = None + + error_response = { + "error": AuthError.INTERNAL_ERROR, + "message": "Logout processing failed", + "timestamp": context.aws_request_id if context else None, + } + + return create_json_response(body=error_response, status_code=500, cookies=cookies) + + +def logout(event, context): + """ + Handle POST /auth/logout - Terminate user session and optionally logout from IdP. + + Validates session cookie, deletes session record from DynamoDB, + clears session cookie, and optionally redirects to IdP logout endpoint. + + Args: + event: Lambda event containing logout request + context: Lambda context + + Returns: + JSON response with logout status and optional IdP logout URL + """ + try: + # Parse logout request + session_id, logout_from_idp = _parse_logout_request(event) + + # Validate session exists first (before getting config) + if not session_id: + return create_json_response( + body={"error": AuthError.INVALID_SESSION, "message": "No active session found"}, status_code=400 + ) + + # Get authentication configuration and session manager + config = _get_auth_config() + session_manager = _create_session_manager(config) + + # Validate session exists in storage + session_data = _validate_logout_session(session_id, session_manager) + if not session_data: + return create_json_response( + body={"error": AuthError.INVALID_SESSION, "message": "No active session found"}, status_code=400 + ) + + # Delete session record + _delete_user_session(session_id, session_manager) + + # Get host header and clear session cookie + host_header = event.get("headers", {}).get("Host") or event.get("headers", {}).get("host", "") + domain = extract_domain_from_host(host_header) + clear_session = clear_session_cookie(domain=domain) + + # Prepare response + response_body = {"status": "LOGGED_OUT"} + + # Get IdP logout URL if requested + if logout_from_idp: + idp_logout_url = _get_idp_logout_url(config, event) + if idp_logout_url: + response_body["idpLogoutUrl"] = idp_logout_url + + logger.info(f"User logout successful for session: {session_id}") + + # Return success response with cleared session cookie + return create_json_response(body=response_body, status_code=200, cookies=[clear_session]) + + except Exception as e: + logger.error(f"Logout processing failed: {e}") + return _create_logout_error_response(context, event) + + +def _validate_session_cookie(event) -> Optional[str]: + """ + Extract and validate session cookie from request. + + Args: + event: Lambda event containing headers + + Returns: + Session ID if valid cookie found, None otherwise + """ + cookie_header = event.get("headers", {}).get("Cookie") or event.get("headers", {}).get("cookie", "") + return get_cookie_value(cookie_header, "mlspace_session") + + +def _check_token_refresh_needed(session_info: Dict) -> bool: + """ + Check if token refresh is needed based on refreshAt timestamp. + + Args: + session_info: Session information dictionary + + Returns: + True if refresh is needed, False otherwise + """ + refresh_at_str = session_info.get("refreshAt") + if not refresh_at_str: + return False + + try: + refresh_at = datetime.fromisoformat(refresh_at_str) + now = datetime.now(timezone.utc) + return now >= refresh_at + except ValueError: + logger.warning(f"Invalid refreshAt timestamp in session: {refresh_at_str}") + return False + + +def _attempt_token_refresh( + session_id: str, session_info: Dict, config: Dict, session_manager: SessionManager, event: Dict +) -> Tuple[bool, Optional[str], Dict]: + """ + Attempt to refresh tokens if refresh token is available. + + Args: + session_id: Session identifier + session_info: Current session information + config: Authentication configuration + session_manager: Session manager instance + event: Lambda event for cookie creation + + Returns: + Tuple of (refreshed, new_session_cookie, updated_session_info) + """ + refresh_token = session_info.get("refresh_token") + if not refresh_token: + logger.info(f"No refresh token available for session: {session_id}") + return False, None, session_info + + try: + # Create authentication handler for token refresh + auth_handler = _create_auth_handler(config) + + # Attempt token refresh + refresh_result = auth_handler.refresh_tokens(refresh_token) + + if not refresh_result.success: + logger.warning(f"Token refresh failed for session {session_id}: {refresh_result.error}") + return False, None, session_info + + # Calculate new expiration times + access_expires, refresh_expires = auth_handler.extract_token_expiration(refresh_result.tokens) + + # Session expires when refresh token expires (or default to 24 hours if no refresh token) + if refresh_expires: + new_expires_at = datetime.now(timezone.utc) + timedelta(seconds=refresh_expires) + else: + # Default to 24 hours if no refresh token expiration provided + new_expires_at = datetime.now(timezone.utc) + timedelta(hours=24) + + # Refresh at access token expiration to keep user info fresh, but not later than 5 minutes before session expires + access_token_expiration = datetime.now(timezone.utc) + timedelta(seconds=access_expires) + five_minutes_before_expiry = new_expires_at - timedelta(minutes=5) + + # Use the earlier of the two times + new_refresh_at = min(access_token_expiration, five_minutes_before_expiry) + + # Update session with new tokens and user data + update_success = session_manager.refresh_session_with_user_data( + session_id=session_id, + tokens=refresh_result.tokens.model_dump(), + user_data=refresh_result.user_data.model_dump(), + expires_at=new_expires_at, + refresh_at=new_refresh_at, + raw_idp_response=refresh_result.raw_response, + ) + + if not update_success: + logger.error(f"Failed to update session after token refresh: {session_id}") + return False, None, session_info + + # Update session info for response + updated_session_info = session_info.copy() + updated_session_info.update( + { + "expiresAt": new_expires_at.isoformat(), + "refreshAt": new_refresh_at.isoformat(), + "provider": session_info.get("provider", config["idp_type"]), + } + ) + + # Create new session cookie with updated expiration + host_header = event.get("headers", {}).get("Host") or event.get("headers", {}).get("host", "") + secure_flag = should_set_secure_flag(host_header) + domain = extract_domain_from_host(host_header) + + new_session_cookie = create_session_cookie( + session_id=session_id, max_age_seconds=int(access_expires), domain=domain, secure=secure_flag + ) + + logger.info(f"Token refresh successful for session: {session_id}") + return True, new_session_cookie, updated_session_info + + except Exception as e: + logger.error(f"Token refresh attempt failed for session {session_id}: {e}") + return False, None, session_info + + +def _create_identity_response(user_data: Dict, session_info: Dict, config: Dict, refreshed: bool = False) -> IdentityResponse: + """ + Create identity response from session data. + + Args: + user_data: User information dictionary + session_info: Session information dictionary + config: Authentication configuration + refreshed: Whether tokens were refreshed + + Returns: + IdentityResponse model + """ + user = UserData( + id=user_data.get("id", ""), + displayName=user_data.get("displayName", ""), + email=user_data.get("email", ""), + groups=user_data.get("groups", []), + attributes=user_data.get("attributes", {}), + ) + + session = SessionInfo( + expiresAt=session_info.get("expiresAt", ""), + refreshAt=session_info.get("refreshAt", ""), + provider=session_info.get("provider", config["idp_type"]), + refreshed=refreshed if refreshed else None, + ) + + return IdentityResponse(status=AuthStatus.AUTHENTICATED, user=user, session=session) + + +def identity(event, context): + """ + Handle GET /auth/identity - Retrieve current user identity and authentication status. + + Validates session cookie, retrieves session from DynamoDB, checks if token refresh + is needed, attempts token refresh if needed, and returns user identity and session information. + + Args: + event: Lambda event containing identity request + context: Lambda context + + Returns: + JSON response with user identity and session info, or 401 if unauthenticated + """ + try: + # Extract and validate session cookie + session_id = _validate_session_cookie(event) + if not session_id: + logger.info("No session cookie found in identity request") + response = IdentityResponse( + status=AuthStatus.UNAUTHENTICATED, error=AuthError.NO_SESSION, message="No session cookie found" + ) + return create_json_response(body=response.model_dump(exclude_none=True), status_code=401) + + # Get authentication configuration and session manager + config = _get_auth_config() + session_manager = _create_session_manager(config) + + # Retrieve session from DynamoDB + session_data = session_manager.get_session(session_id) + if not session_data: + logger.info(f"Invalid or expired session in identity request: {session_id}") + response = IdentityResponse( + status=AuthStatus.UNAUTHENTICATED, error=AuthError.SESSION_EXPIRED, message="Session has expired or is invalid" + ) + return create_json_response(body=response.model_dump(exclude_none=True), status_code=401) + + # Extract session information + user_data = session_data.get("data", {}).get("user", {}) + session_info = session_data.get("data", {}).get("session", {}) + + # Check if token refresh is needed and attempt refresh + needs_refresh = _check_token_refresh_needed(session_info) + refreshed = False + new_session_cookie = None + + if needs_refresh: + refreshed, new_session_cookie, session_info = _attempt_token_refresh( + session_id, session_info, config, session_manager, event + ) + # Update user_data if refresh was successful + if refreshed: + # Re-fetch session to get updated user data + updated_session_data = session_manager.get_session(session_id) + if updated_session_data: + user_data = updated_session_data.get("data", {}).get("user", user_data) + session_info = updated_session_data.get("data", {}).get("session", session_info) + + # Create and return response + response = _create_identity_response(user_data, session_info, config, refreshed) + + logger.info(f"Identity request successful for user: {user_data.get('id', 'unknown')}") + + cookies = [new_session_cookie] if new_session_cookie else None + return create_json_response(body=response.model_dump(exclude_none=True), status_code=200, cookies=cookies) + + except Exception as e: + logger.error(f"Identity request processing failed: {e}") + + response = IdentityResponse( + status=AuthStatus.UNAUTHENTICATED, + error=AuthError.INTERNAL_ERROR, + message="Failed to process identity request", + timestamp=context.aws_request_id if context else None, + ) + + return create_json_response(body=response.model_dump(exclude_none=True), status_code=401) + + +def _validate_sync_parameters(event) -> Tuple[Optional[str], List[str], Optional[str], Optional[Dict]]: + """ + Validate sync request parameters. + + Args: + event: Lambda event containing sync request + + Returns: + Tuple of (otac, remaining_domains, final_redirect_url, error_response) + If error_response is not None, should return it immediately + """ + from ml_space_lambda.auth.utils.otac import parse_sync_request, validate_otac_format + + query_params = event.get("queryStringParameters") or {} + + # Parse sync parameters + otac, remaining_domains, final_redirect_url = parse_sync_request(query_params) + + # Validate OTAC format + if not otac or not validate_otac_format(otac): + logger.warning("Invalid or missing OTAC in sync request") + error_url = "/?error=invalid_otac&message=Invalid or missing authentication code" + return None, [], None, create_redirect_response(location=error_url, status_code=302) + + # Validate final redirect URL + if not final_redirect_url: + logger.warning("Missing final redirect URL in sync request") + error_url = "/?error=invalid_request&message=Missing final redirect URL" + return None, [], None, create_redirect_response(location=error_url, status_code=302) + + return otac, remaining_domains, final_redirect_url, None + + +def _validate_requesting_domain(event, config) -> Tuple[Optional[str], Optional[Dict]]: + """ + Validate that the requesting domain is allowed for sync operations. + + Args: + event: Lambda event containing request headers + config: Authentication configuration + + Returns: + Tuple of (requesting_domain, error_response) + If error_response is not None, should return it immediately + """ + from ml_space_lambda.auth.utils.otac import build_domain_list, normalize_domain + + host_header = event.get("headers", {}).get("Host") or event.get("headers", {}).get("host", "") + if not host_header: + logger.warning("Missing Host header in sync request") + error_url = "/?error=invalid_request&message=Invalid request" + return None, create_redirect_response(location=error_url, status_code=302) + + requesting_domain = normalize_domain(host_header) + + # Build allowed domains list (current domain + sync domains) + # The host header represents the primary domain where the request is being made + sync_domains = build_domain_list(requesting_domain, config.get("sync_domains", "")) + allowed_domains = [requesting_domain] + sync_domains + + # Validate requesting domain is in allowed list + if requesting_domain not in allowed_domains: + logger.warning(f"Unauthorized domain in sync request: {requesting_domain}") + error_url = "/?error=unauthorized_domain&message=Domain not authorized for sync" + return None, create_redirect_response(location=error_url, status_code=302) + + return requesting_domain, None + + +def _validate_and_consume_otac(otac_manager, otac) -> Tuple[Optional[Dict], Optional[Dict]]: + """ + Validate OTAC and mark it as used. + + Args: + otac_manager: OTACManager instance + otac: OTAC identifier to validate + + Returns: + Tuple of (otac_data, error_response) + If error_response is not None, should return it immediately + """ + otac_data = otac_manager.validate_and_consume_otac(otac) + + if not otac_data: + logger.warning(f"Invalid or expired OTAC: {otac}") + error_url = "/?error=invalid_otac&message=Authentication code is invalid or expired" + return None, create_redirect_response(location=error_url, status_code=302) + + return otac_data, None + + +def _set_session_cookie_for_domain(session_id, event, config) -> str: + """ + Create session cookie for the current domain. + + Args: + session_id: Session identifier + event: Lambda event for domain extraction + config: Authentication configuration + + Returns: + Session cookie header value + """ + host_header = event.get("headers", {}).get("Host") or event.get("headers", {}).get("host", "") + secure_flag = should_set_secure_flag(host_header) + domain = extract_domain_from_host(host_header) + + # Use default session TTL (24 hours) for sync cookies + session_ttl = int(config.get("session_ttl_hours", "24")) * 3600 + + return create_session_cookie(session_id=session_id, max_age_seconds=session_ttl, domain=domain, secure=secure_flag) + + +def _handle_sync_chain_continuation( + otac_manager, otac_data, remaining_domains, event +) -> Tuple[bool, Optional[str], Optional[Dict]]: + """ + Handle continuation of sync chain if more domains remain. + + Args: + otac_manager: OTACManager instance + otac_data: Current OTAC data + remaining_domains: Domains remaining in sync chain + event: Lambda event for domain extraction + + Returns: + Tuple of (should_continue, next_otac, error_response) + If should_continue is True, redirect to next domain + If error_response is not None, should return it immediately + """ + from ml_space_lambda.auth.utils.otac import build_sync_chain_url + + if not remaining_domains: + # End of chain + return False, None, None + + try: + # Create new OTAC for next domain in chain + next_otac = otac_manager.create_otac( + session_id=otac_data["sessionId"], + remaining_domains=remaining_domains[1:], # Remove first domain from remaining list + final_redirect_url=otac_data["finalRedirectUrl"], + ) + + # Build sync chain URL for next domain + host_header = event.get("headers", {}).get("Host") or event.get("headers", {}).get("host", "") + current_domain = extract_domain_from_host(host_header) or host_header + + next_sync_url = build_sync_chain_url( + current_domain=current_domain, + otac=next_otac, + remaining_domains=remaining_domains, + final_redirect_url=otac_data["finalRedirectUrl"], + ) + + logger.info(f"Continuing sync chain to next domain: {remaining_domains[0]}") + return True, next_sync_url, None + + except Exception as e: + logger.error(f"Failed to create OTAC for sync chain continuation: {e}") + error_url = "/?error=sync_failed&message=Failed to continue synchronization" + return False, None, create_redirect_response(location=error_url, status_code=302) + + +def sync(event, context): + """ + Handle GET /auth/sync - Cross-domain cookie synchronization. + + Validates OTAC with strong consistency read from DynamoDB, marks OTAC as used, + retrieves session ID from OTAC record, sets session cookie for current domain, + generates new OTAC for next domain in chain if applicable, and redirects to + next domain or final destination. + + Args: + event: Lambda event containing sync request + context: Lambda context + + Returns: + Redirect response to next domain in chain or final destination + """ + try: + # Validate sync request parameters + otac, remaining_domains, final_redirect_url, error_response = _validate_sync_parameters(event) + if error_response: + return error_response + + # Get authentication configuration and create managers + config = _get_auth_config() + otac_manager = _create_otac_manager(config) + + # Validate requesting domain is authorized + requesting_domain, error_response = _validate_requesting_domain(event, config) + if error_response: + return error_response + + # Validate OTAC and mark as used (strong consistency) + otac_data, error_response = _validate_and_consume_otac(otac_manager, otac) + if error_response: + return error_response + + # Extract session ID from OTAC + session_id = otac_data["sessionId"] + + # Create session cookie for current domain + session_cookie = _set_session_cookie_for_domain(session_id, event, config) + + # Check if sync chain should continue + should_continue, next_sync_url, error_response = _handle_sync_chain_continuation( + otac_manager, otac_data, remaining_domains, event + ) + + if error_response: + return error_response + + if should_continue: + # Continue sync chain to next domain + logger.info(f"Cross-domain sync successful for domain: {requesting_domain}, continuing chain") + return create_redirect_response(location=next_sync_url, cookies=[session_cookie], status_code=302) + else: + # End of chain, redirect to final destination + logger.info(f"Cross-domain sync chain completed for domain: {requesting_domain}") + return create_redirect_response(location=otac_data["finalRedirectUrl"], cookies=[session_cookie], status_code=302) + + except Exception as e: + logger.error(f"Sync processing failed: {e}") + + # Return error redirect + error_url = "/?error=sync_failed&message=Cross-domain synchronization failed" + return create_redirect_response(location=error_url, status_code=302) diff --git a/backend/src/ml_space_lambda/auth/models/auth_models.py b/backend/src/ml_space_lambda/auth/models/auth_models.py new file mode 100644 index 00000000..902ea1e5 --- /dev/null +++ b/backend/src/ml_space_lambda/auth/models/auth_models.py @@ -0,0 +1,104 @@ +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +""" +Shared authentication data models. + +Common data structures used across different authentication handlers and flows. +""" + +from enum import Enum +from typing import Dict, List, Optional + +from pydantic import BaseModel, Field + + +class AuthStatus(str, Enum): + """Authentication status enumeration.""" + + AUTHENTICATED = "AUTHENTICATED" + UNAUTHENTICATED = "UNAUTHENTICATED" + + +class AuthError(str, Enum): + """Authentication error codes.""" + + NO_SESSION = "NO_SESSION" + SESSION_EXPIRED = "SESSION_EXPIRED" + INVALID_SESSION = "INVALID_SESSION" + TOKEN_REFRESH_FAILED = "TOKEN_REFRESH_FAILED" + INTERNAL_ERROR = "INTERNAL_ERROR" + INVALID_CONFIGURATION = "INVALID_CONFIGURATION" + + +class UserData(BaseModel): + """ + Normalized user identity information from Identity Provider. + """ + + id: str = Field(..., description="Unique user identifier") + displayName: str = Field(..., description="User's display name") + email: str = Field(..., description="User's email address") + groups: List[str] = Field(default_factory=list, description="List of group memberships") + attributes: Dict[str, str] = Field(default_factory=dict, description="Additional user attributes") + + +class IdPTokens(BaseModel): + """ + Identity Provider tokens. + """ + + access_token: Optional[str] = Field(None, description="OAuth2 access token") + refresh_token: Optional[str] = Field(None, description="OAuth2 refresh token") + id_token: Optional[str] = Field(None, description="OIDC ID token") + token_type: Optional[str] = Field(None, description="Token type (usually 'Bearer')") + expires_in: Optional[int] = Field(None, description="Access token expiration in seconds") + refresh_expires_in: Optional[int] = Field(None, description="Refresh token expiration in seconds") + scope: Optional[str] = Field(None, description="Granted OAuth2 scopes") + + +class SessionInfo(BaseModel): + """Session information for identity responses.""" + + expiresAt: str = Field(..., description="Session expiration timestamp (ISO 8601)") + refreshAt: str = Field(..., description="Token refresh threshold timestamp (ISO 8601)") + provider: str = Field(..., description="Identity provider type") + refreshed: Optional[bool] = Field(None, description="Whether tokens were refreshed in this request") + + +class IdentityResponse(BaseModel): + """Response model for /auth/identity endpoint.""" + + status: AuthStatus = Field(..., description="Authentication status") + user: Optional[UserData] = Field(None, description="User identity information") + session: Optional[SessionInfo] = Field(None, description="Session information") + error: Optional[AuthError] = Field(None, description="Error code if unauthenticated") + message: Optional[str] = Field(None, description="Human-readable error message") + timestamp: Optional[str] = Field(None, description="Error timestamp") + + +class AuthenticationResult(BaseModel): + """ + Result of an authentication operation. + + Contains user identity information and tokens from the Identity Provider. + """ + + success: bool = Field(..., description="Whether authentication was successful") + user_data: Optional[UserData] = Field(None, description="User identity information") + tokens: Optional[IdPTokens] = Field(None, description="IdP tokens") + error: Optional[str] = Field(None, description="Error message if authentication failed") + raw_response: Optional[str] = Field(None, description="Raw IdP response for debugging (base64 encoded)") diff --git a/backend/src/ml_space_lambda/auth/models/key_models.py b/backend/src/ml_space_lambda/auth/models/key_models.py new file mode 100644 index 00000000..9f3ba3bd --- /dev/null +++ b/backend/src/ml_space_lambda/auth/models/key_models.py @@ -0,0 +1,241 @@ +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +""" +Pydantic models for encryption key management. + +Provides concrete classes for managing versioned encryption keys with +domain-driven design principles. +""" + +from datetime import datetime, timezone +from enum import StrEnum +from typing import Dict, List, Optional + +from pydantic import BaseModel, Field, field_serializer, field_validator + + +class KeyType(StrEnum): + """Enumeration of supported key types.""" + + TOKEN = "token" + STATE = "state" + + +class SecretsManagerStage(StrEnum): + CURRENT = "AWSCURRENT" + PREVIOUS = "AWSPREVIOUS" + PENDING = "AWSPENDING" + + +class VersionedKeyData(BaseModel): + """ + Represents versioned encryption key data with domain methods. + + This class encapsulates the key management logic and provides + methods for rotating, cleaning up, and managing key versions. + """ + + current_version: int = Field(default=1, ge=1, description="Current active key version") + keys: Dict[str, str] = Field(default_factory=dict, description="Mapping of version to encoded key") + key_type: KeyType = Field(description="Type of encryption key") + created_date: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + created_by: str = Field(default="key_rotation_manager") + rotation_date: Optional[datetime] = Field(default=None, description="Last rotation timestamp") + rotated_by: Optional[str] = Field(default=None, description="Entity that performed last rotation") + last_cleanup: Optional[datetime] = Field(default=None, description="Last cleanup timestamp") + + @field_validator("keys") + @classmethod + def validate_keys_not_empty(cls, v): + """Ensure keys dictionary is not empty.""" + if not v: + raise ValueError("Keys dictionary cannot be empty") + return v + + @field_serializer("created_date", "rotation_date", "last_cleanup", when_used="json") + def serialize_datetime(self, value: Optional[datetime]) -> Optional[str]: + """Serialize datetime fields to ISO format.""" + return value.isoformat() if value else None + + def get_current_key(self) -> str: + """ + Get the current active key. + + Returns: + Encoded current key + + Raises: + ValueError: If current version key is not found + """ + current_key = self.keys.get(str(self.current_version)) + if not current_key: + raise ValueError(f"Current key version {self.current_version} not found") + return current_key + + def get_key_by_version(self, version: int) -> Optional[str]: + """ + Get key by specific version. + + Args: + version: Key version to retrieve + + Returns: + Encoded key if found, None otherwise + """ + return self.keys.get(str(version)) + + def add_new_key_version(self, encoded_key: str, rotated_by: str = "key_rotation_manager") -> int: + """ + Add a new key version and make it current. + + Args: + encoded_key: New encoded key to add + rotated_by: Entity performing the rotation + + Returns: + New version number + """ + new_version = self.current_version + 1 + self.keys[str(new_version)] = encoded_key + self.current_version = new_version + self.rotation_date = datetime.now(timezone.utc) + self.rotated_by = rotated_by + + return new_version + + def cleanup_old_versions(self, keep_versions: int = 3) -> List[str]: + """ + Remove old key versions, keeping only the most recent ones. + + Args: + keep_versions: Number of recent versions to keep + + Returns: + List of removed version numbers + """ + if keep_versions < 1: + raise ValueError("Must keep at least 1 version") + + # Determine which versions to keep (most recent) + versions_to_keep = [] + for i in range(keep_versions): + version = self.current_version - i + if version > 0 and str(version) in self.keys: + versions_to_keep.append(str(version)) + + # Identify versions to remove + removed_versions = [v for v in self.keys.keys() if v not in versions_to_keep] + + # Remove old versions + for version in removed_versions: + del self.keys[version] + + # Update cleanup timestamp + if removed_versions: + self.last_cleanup = datetime.now(timezone.utc) + + return removed_versions + + def get_available_versions(self) -> List[int]: + """ + Get list of available key versions. + + Returns: + Sorted list of available version numbers + """ + return sorted([int(v) for v in self.keys.keys()]) + + def get_total_versions(self) -> int: + """ + Get total number of key versions. + + Returns: + Number of available versions + """ + return len(self.keys) + + def to_secrets_manager_format(self) -> str: + """ + Convert to JSON format for AWS Secrets Manager storage. + + Returns: + JSON string representation + """ + return self.model_dump_json() + + @classmethod + def from_secrets_manager_format(cls, json_str: str) -> "VersionedKeyData": + """ + Create instance from AWS Secrets Manager JSON format. + + Args: + json_str: JSON string from Secrets Manager + + Returns: + VersionedKeyData instance + """ + return cls.model_validate_json(json_str) + + @classmethod + def create_initial( + cls, encoded_key: str, key_type: KeyType, created_by: str = "key_rotation_manager" + ) -> "VersionedKeyData": + """ + Create initial versioned key data with first key. + + Args: + encoded_key: Initial encoded key + key_type: Type of key being created + created_by: Entity creating the initial key + + Returns: + New VersionedKeyData instance + """ + return cls(current_version=1, keys={"1": encoded_key}, key_type=key_type, created_by=created_by) + + +class KeyRotationResult(BaseModel): + """Result of a key rotation operation.""" + + success: bool + previous_version: int + new_version: int + rotation_date: datetime + total_versions: int + message: Optional[str] = None + + +class KeyCleanupResult(BaseModel): + """Result of a key cleanup operation.""" + + success: bool + removed_versions: List[str] + kept_versions: List[str] + message: str + + +class KeyStatusResult(BaseModel): + """Status information about key versions.""" + + success: bool + current_version: Optional[int] = None + total_versions: Optional[int] = None + available_versions: Optional[List[int]] = None + key_type: Optional[KeyType] = None + last_rotation: Optional[datetime] = None + last_cleanup: Optional[datetime] = None + error: Optional[str] = None diff --git a/backend/src/ml_space_lambda/auth/session/__init__.py b/backend/src/ml_space_lambda/auth/session/__init__.py new file mode 100644 index 00000000..f9de63fb --- /dev/null +++ b/backend/src/ml_space_lambda/auth/session/__init__.py @@ -0,0 +1,15 @@ +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# diff --git a/backend/src/ml_space_lambda/auth/session/encryption.py b/backend/src/ml_space_lambda/auth/session/encryption.py new file mode 100644 index 00000000..d21d3e3d --- /dev/null +++ b/backend/src/ml_space_lambda/auth/session/encryption.py @@ -0,0 +1,159 @@ +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +""" +Token encryption utilities using PASETO for secure storage of IdP tokens. + +PASETO (Platform-Agnostic Security Tokens) provides authenticated encryption +with a simple, secure API that's specifically designed for token handling. +""" + +import base64 + +import pyseto +from pyseto import Key + + +class TokenEncryption: + """ + Handles encryption and decryption of sensitive tokens using PASETO. + + PASETO provides authenticated encryption specifically designed for tokens, + with built-in versioning and no algorithm confusion vulnerabilities. + """ + + def __init__(self, encryption_key: bytes): + """ + Initialize token encryption with PASETO. + + Args: + encryption_key: 32-byte encryption key from AWS Secrets Manager + """ + if len(encryption_key) != 32: + raise ValueError("Encryption key must be exactly 32 bytes") + + self.key = Key.new(version=4, purpose="local", key=encryption_key) + + def encrypt_token(self, token: str) -> str: + """ + Encrypt token using PASETO v4.local. + + Args: + token: Plain text token to encrypt + + Returns: + Encrypted PASETO token string + + Raises: + ValueError: If token is empty or None + """ + if not token: + raise ValueError("Token cannot be empty or None") + + try: + # PASETO handles all the crypto details (nonce, authentication, etc.) + encrypted = pyseto.encode(self.key, token.encode("utf-8")) + return encrypted.decode("utf-8") + except Exception as e: + raise Exception(f"Token encryption failed: {e}") + + def decrypt_token(self, encrypted_token: str) -> str: + """ + Decrypt PASETO token. + + Args: + encrypted_token: PASETO token string + + Returns: + Decrypted token as plain text + + Raises: + ValueError: If encrypted token format is invalid + Exception: If decryption fails (invalid key, corrupted data, etc.) + """ + if not encrypted_token: + raise ValueError("Encrypted token cannot be empty or None") + + try: + # PASETO handles verification and decryption + decrypted = pyseto.decode(self.key, encrypted_token.encode("utf-8")) + return decrypted.payload.decode("utf-8") + except Exception as e: + raise Exception(f"Token decryption failed: {e}") + + def is_encrypted_token(self, token: str) -> bool: + """ + Check if a token string is in PASETO format. + + Args: + token: Token string to check + + Returns: + True if token appears to be a PASETO token, False otherwise + """ + if not token: + return False + + # PASETO v4.local tokens start with "v4.local." + return token.startswith("v4.local.") + + +def create_encryption_key() -> bytes: + """ + Generate a new 32-byte encryption key for PASETO. + + Returns: + 32-byte cryptographically secure random key + """ + import os + + # Generate 32 random bytes for PASETO v4.local + return os.urandom(32) + + +def encode_key_for_storage(key: bytes) -> str: + """ + Encode encryption key as base64 for storage in AWS Secrets Manager. + + Args: + key: 32-byte encryption key + + Returns: + Base64 encoded key string + """ + return base64.b64encode(key).decode("utf-8") + + +def decode_key_from_storage(encoded_key: str) -> bytes: + """ + Decode base64 encoded key from AWS Secrets Manager. + + Args: + encoded_key: Base64 encoded key string + + Returns: + 32-byte encryption key + + Raises: + ValueError: If key is not valid base64 or not 32 bytes + """ + try: + key = base64.b64decode(encoded_key) + if len(key) != 32: + raise ValueError(f"Decoded key must be 32 bytes, got {len(key)}") + return key + except Exception as e: + raise ValueError(f"Invalid encoded key: {e}") diff --git a/backend/src/ml_space_lambda/auth/session/key_manager.py b/backend/src/ml_space_lambda/auth/session/key_manager.py new file mode 100644 index 00000000..46dad346 --- /dev/null +++ b/backend/src/ml_space_lambda/auth/session/key_manager.py @@ -0,0 +1,408 @@ +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +""" +Key management utilities for encryption key rotation support. + +Provides versioned key management to enable graceful key rotation +without invalidating existing sessions or authentication flows. +""" + +import json +import logging +from typing import Dict, Optional, Tuple + +import boto3 +from cryptography.fernet import Fernet + +from ml_space_lambda.auth.session.encryption import TokenEncryption, decode_key_from_storage + +logger = logging.getLogger(__name__) + + +class VersionedKeyManager: + """ + Manages versioned encryption keys for graceful rotation. + + Supports multiple key versions to allow decryption of data encrypted + with previous keys while using the latest key for new encryption. + """ + + def __init__(self, secret_arn: str, key_type: str = "token"): + """ + Initialize versioned key manager. + + Args: + secret_arn: AWS Secrets Manager ARN containing versioned keys + key_type: Type of key ("token" or "state") + """ + self.secret_arn = secret_arn + self.key_type = key_type + self.secrets_client = boto3.client("secretsmanager") + self._keys_cache: Optional[Dict] = None + + def _load_keys(self) -> Dict: + """ + Load all key versions from Secrets Manager. + + Returns: + Dictionary with key versions and metadata + """ + if self._keys_cache is not None: + return self._keys_cache + + try: + response = self.secrets_client.get_secret_value(SecretId=self.secret_arn) + secret_data = json.loads(response["SecretString"]) + + # Expected format: + # { + # "current_version": 2, + # "keys": { + # "1": "base64-encoded-key-v1", + # "2": "base64-encoded-key-v2" + # }, + # "rotation_date": "2024-01-15T10:30:00Z" + # } + + self._keys_cache = secret_data + return secret_data + + except Exception as e: + logger.error(f"Failed to load keys from {self.secret_arn}: {e}") + raise Exception(f"Key loading failed: {e}") + + def get_current_key(self) -> Tuple[int, bytes]: + """ + Get the current (latest) encryption key. + + Returns: + Tuple of (version, key_bytes) + """ + keys_data = self._load_keys() + current_version = keys_data["current_version"] + + if str(current_version) not in keys_data["keys"]: + raise Exception(f"Current key version {current_version} not found") + + encoded_key = keys_data["keys"][str(current_version)] + + if self.key_type == "token": + key_bytes = decode_key_from_storage(encoded_key) + else: # state key (Fernet key is already base64 encoded) + from ml_space_lambda.auth.utils.state import decode_state_key_from_storage + + key_bytes = decode_state_key_from_storage(encoded_key) + + return current_version, key_bytes + + def get_key_by_version(self, version: int) -> Optional[bytes]: + """ + Get a specific key version. + + Args: + version: Key version number + + Returns: + Key bytes if found, None otherwise + """ + try: + keys_data = self._load_keys() + + if str(version) not in keys_data["keys"]: + return None + + encoded_key = keys_data["keys"][str(version)] + + if self.key_type == "token": + return decode_key_from_storage(encoded_key) + else: # state key + from ml_space_lambda.auth.utils.state import decode_state_key_from_storage + + return decode_state_key_from_storage(encoded_key) + + except Exception: + return None + + def get_all_keys(self) -> Dict[int, bytes]: + """ + Get all available key versions. + + Returns: + Dictionary mapping version numbers to key bytes + """ + keys_data = self._load_keys() + result = {} + + for version_str, encoded_key in keys_data["keys"].items(): + try: + version = int(version_str) + if self.key_type == "token": + key_bytes = decode_key_from_storage(encoded_key) + else: # state key + from ml_space_lambda.auth.utils.state import decode_state_key_from_storage + + key_bytes = decode_state_key_from_storage(encoded_key) + result[version] = key_bytes + except Exception: + logger.warning(f"Failed to decode key version {version_str}") + continue + + return result + + def invalidate_cache(self): + """Invalidate the keys cache to force reload on next access.""" + self._keys_cache = None + + +class VersionedTokenEncryption: + """ + Token encryption with support for multiple key versions. + + Always encrypts with the current key but can decrypt with any available key version. + """ + + def __init__(self, key_manager: VersionedKeyManager): + """ + Initialize versioned token encryption. + + Args: + key_manager: Versioned key manager instance + """ + self.key_manager = key_manager + self._encryptors_cache: Dict[int, TokenEncryption] = {} + + def _get_encryptor(self, version: int) -> Optional[TokenEncryption]: + """ + Get token encryptor for specific key version. + + Args: + version: Key version + + Returns: + TokenEncryption instance or None if key not found + """ + if version in self._encryptors_cache: + return self._encryptors_cache[version] + + key_bytes = self.key_manager.get_key_by_version(version) + if key_bytes is None: + return None + + try: + encryptor = TokenEncryption(key_bytes) + self._encryptors_cache[version] = encryptor + return encryptor + except Exception: + return None + + def encrypt_token(self, token: str) -> str: + """ + Encrypt token with current key version. + + Args: + token: Plain text token + + Returns: + Versioned encrypted token: v{version}:{encrypted_token} + """ + current_version, current_key = self.key_manager.get_current_key() + encryptor = TokenEncryption(current_key) + + encrypted = encryptor.encrypt_token(token) + return f"v{current_version}:{encrypted}" + + def decrypt_token(self, versioned_token: str) -> str: + """ + Decrypt token using appropriate key version. + + Args: + versioned_token: Token in format v{version}:{encrypted_token} + + Returns: + Decrypted token + + Raises: + Exception: If decryption fails or key version not found + """ + # Parse versioned token + try: + version_part, encrypted_part = versioned_token.split(":", 1) + version = int(version_part[1:]) # Remove 'v' prefix + except (ValueError, IndexError): + raise Exception("Invalid versioned token format") + + # Get encryptor for this version + encryptor = self._get_encryptor(version) + if encryptor is None: + raise Exception(f"Key version {version} not available") + + return encryptor.decrypt_token(encrypted_part) + + def is_encrypted_token(self, token: str) -> bool: + """ + Check if token is encrypted (versioned format). + + Args: + token: Token string to check + + Returns: + True if token appears to be encrypted + """ + if not token: + return False + + # Check for versioned format: v{number}:{encrypted_data} + return token.startswith("v") and ":" in token + + +class VersionedStateManager: + """ + State parameter encryption with support for multiple key versions. + """ + + def __init__(self, key_manager: VersionedKeyManager): + """ + Initialize versioned state manager. + + Args: + key_manager: Versioned key manager instance + """ + self.key_manager = key_manager + self._ciphers_cache: Dict[int, Fernet] = {} + + def _get_cipher(self, version: int) -> Optional[Fernet]: + """ + Get Fernet cipher for specific key version. + + Args: + version: Key version + + Returns: + Fernet instance or None if key not found + """ + if version in self._ciphers_cache: + return self._ciphers_cache[version] + + key_bytes = self.key_manager.get_key_by_version(version) + if key_bytes is None: + return None + + try: + cipher = Fernet(key_bytes) + self._ciphers_cache[version] = cipher + return cipher + except Exception: + return None + + def create_state(self, redirect_url: str, domain: str, nonce: Optional[str] = None) -> str: + """ + Create versioned encrypted state parameter. + + Args: + redirect_url: Where to redirect after authentication + domain: Domain initiating authentication + nonce: Optional nonce + + Returns: + Versioned encrypted state: v{version}:{encrypted_state} + """ + import json + import secrets + import time + + if nonce is None: + nonce = secrets.token_urlsafe(32) + + state_data = {"redirect_url": redirect_url, "nonce": nonce, "timestamp": int(time.time()), "domain": domain} + + current_version, current_key = self.key_manager.get_current_key() + cipher = Fernet(current_key) + + state_json = json.dumps(state_data, separators=(",", ":")) + encrypted = cipher.encrypt(state_json.encode("utf-8")) + + return f"v{current_version}:{encrypted.decode('utf-8')}" + + def validate_state(self, versioned_state: str, cookie_nonce: str, max_age_seconds: int = 600) -> Optional[Dict]: + """ + Validate versioned encrypted state parameter. + + Args: + versioned_state: State in format v{version}:{encrypted_state} + cookie_nonce: Nonce from state cookie + max_age_seconds: Maximum age of state parameter + + Returns: + State data if valid, None otherwise + """ + if not versioned_state or not cookie_nonce: + return None + + # Parse versioned state + try: + version_part, encrypted_part = versioned_state.split(":", 1) + version = int(version_part[1:]) # Remove 'v' prefix + except (ValueError, IndexError): + return None + + # Get cipher for this version + cipher = self._get_cipher(version) + if cipher is None: + return None + + return self._validate_with_cipher(cipher, encrypted_part, cookie_nonce, max_age_seconds) + + def _validate_with_cipher( + self, cipher: Fernet, encrypted_state: str, cookie_nonce: str, max_age_seconds: int + ) -> Optional[Dict]: + """ + Validate state with specific cipher. + + Args: + cipher: Fernet cipher instance + encrypted_state: Encrypted state data + cookie_nonce: Nonce from cookie + max_age_seconds: Maximum age + + Returns: + State data if valid, None otherwise + """ + import json + import time + + try: + decrypted = cipher.decrypt(encrypted_state.encode("utf-8")) + state_data = json.loads(decrypted.decode("utf-8")) + + # Validate required fields + required_fields = ["redirect_url", "nonce", "timestamp", "domain"] + if not all(field in state_data for field in required_fields): + return None + + # Validate timestamp + state_age = int(time.time()) - state_data["timestamp"] + if state_age > max_age_seconds: + return None + + # Validate nonce + if state_data["nonce"] != cookie_nonce: + return None + + return state_data + + except Exception: + return None diff --git a/backend/src/ml_space_lambda/auth/session/manager.py b/backend/src/ml_space_lambda/auth/session/manager.py new file mode 100644 index 00000000..07269f0b --- /dev/null +++ b/backend/src/ml_space_lambda/auth/session/manager.py @@ -0,0 +1,487 @@ +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +""" +Session management utilities for BFF authentication. + +Handles CRUD operations for user sessions stored in DynamoDB. +""" + +import time +import uuid +from datetime import datetime, timezone +from typing import Dict, List, Optional + +from ml_space_lambda.data_access_objects.dynamo_data_store import DynamoDBObjectStore + + +class SessionManager: + """ + Manages user authentication sessions in DynamoDB. + + Handles creation, retrieval, update, and deletion of session records + with encrypted token storage. + """ + + def __init__(self, table_name: str, encryption, client=None): + """ + Initialize session manager. + + Args: + table_name: DynamoDB table name for session storage + encryption: Token encryption instance (TokenEncryption or VersionedTokenEncryption) + client: Optional DynamoDB client (for testing) + """ + self.store = DynamoDBObjectStore(table_name, client) + self.encryption = encryption + + def create_session( + self, + user_data: Dict, + tokens: Dict[str, str], + provider: str, + expires_at: datetime, + refresh_at: datetime, + login_domain: str, + synced_domains: Optional[List[str]] = None, + raw_idp_response: Optional[str] = None, + ) -> str: + """ + Create a new user session. + + Args: + user_data: User identity information (id, displayName, email, groups, attributes) + tokens: IdP tokens dict (only refresh_token is stored encrypted; access_token and id_token are not persisted) + provider: Identity provider type (e.g., 'oidc') + expires_at: Session expiration timestamp + refresh_at: Token refresh threshold timestamp + login_domain: Domain where login was initiated + synced_domains: List of domains where session cookies were set + raw_idp_response: Base64 encoded raw IdP response for debugging + + Returns: + Session ID + + Raises: + Exception: If session creation fails + """ + session_id = f"session:{uuid.uuid4()}" + + # Encrypt only the refresh token (needed for token refresh) + encrypted_tokens = {} + if tokens.get("refresh_token"): + encrypted_tokens["refresh_token"] = self.encryption.encrypt_token(tokens["refresh_token"]) + + # Calculate TTL (session expiration + 1 hour buffer for cleanup) + ttl = int(expires_at.timestamp()) + 3600 + + session_record = { + "pk": session_id, + "ttl": ttl, + "created_at": datetime.now(timezone.utc).isoformat(), + "updated_at": datetime.now(timezone.utc).isoformat(), + "data": { + "user": user_data, + "session": { + "provider": provider, + "expiresAt": expires_at.isoformat(), + "refreshAt": refresh_at.isoformat(), + **encrypted_tokens, + }, + "metadata": {"loginDomain": login_domain, "syncedDomains": synced_domains or []}, + }, + } + + # Add raw IdP response if provided + if raw_idp_response: + session_record["raw_data"] = raw_idp_response + + try: + # Create session with condition to prevent overwrites + self.store._create(session_record, condition_expression="attribute_not_exists(pk)") + return session_id + except Exception as e: + raise Exception(f"Failed to create session: {e}") + + def get_session(self, session_id: str) -> Optional[Dict]: + """ + Retrieve session data by session ID. + + Args: + session_id: Session identifier + + Returns: + Session data with decrypted tokens, or None if not found/expired + """ + if not session_id or not session_id.startswith("session:"): + return None + + try: + session_record = self.store._retrieve({"pk": session_id}) + + # Check TTL expiration + if session_record.get("ttl", 0) < int(time.time()): + return None + + # Check session expiration + session_data = session_record.get("data", {}).get("session", {}) + expires_at = datetime.fromisoformat(session_data.get("expiresAt", "")) + if expires_at < datetime.now(timezone.utc): + return None + + # Decrypt tokens + decrypted_session = self._decrypt_session_tokens(session_record) + return decrypted_session + + except Exception: + # Session not found or other error + return None + + def update_session_tokens( + self, session_id: str, tokens: Dict[str, str], expires_at: datetime, refresh_at: datetime + ) -> bool: + """ + Update session tokens after refresh (tokens only, no user data). + + Args: + session_id: Session identifier + tokens: New IdP tokens dict (only refresh_token is stored) + expires_at: New session expiration timestamp + refresh_at: New token refresh threshold timestamp + + Returns: + True if update successful, False otherwise + """ + return self.update_session(session_id=session_id, tokens=tokens, expires_at=expires_at, refresh_at=refresh_at) + + def update_session( + self, + session_id: str, + tokens: Optional[Dict[str, str]] = None, + user_data: Optional[Dict] = None, + expires_at: Optional[datetime] = None, + refresh_at: Optional[datetime] = None, + raw_idp_response: Optional[str] = None, + ) -> bool: + """ + Update session with tokens and/or user data. + + This method handles both token refresh and user profile updates + that might occur when refreshing tokens or re-validating sessions. + + Args: + session_id: Session identifier + tokens: New IdP tokens dict (only refresh_token is stored; optional) + user_data: Updated user information (optional) + expires_at: New session expiration timestamp (optional) + refresh_at: New token refresh threshold timestamp (optional) + raw_idp_response: Updated raw IdP response for debugging (optional) + + Returns: + True if update successful, False otherwise + """ + if not session_id or not session_id.startswith("session:"): + return False + + try: + # Build update expression dynamically + update_parts = [] + expression_values = {} + expression_names = {"#data": "data", "#session": "session", "#user": "user"} + + # Always update the timestamp + update_parts.append("updated_at = :updated_at") + expression_values[":updated_at"] = datetime.now(timezone.utc).isoformat() + + # Update tokens if provided (only refresh_token is stored) + if tokens and tokens.get("refresh_token"): + encrypted_refresh_token = self.encryption.encrypt_token(tokens["refresh_token"]) + update_parts.append("#data.#session.refresh_token = :refresh_token") + expression_values[":refresh_token"] = encrypted_refresh_token + + # Update timestamps if provided + if expires_at: + update_parts.append("#data.#session.expiresAt = :expires_at") + expression_values[":expires_at"] = expires_at.isoformat() + + # Update TTL when expiration changes + new_ttl = int(expires_at.timestamp()) + 3600 + update_parts.append("ttl = :ttl") + expression_values[":ttl"] = new_ttl + + if refresh_at: + update_parts.append("#data.#session.refreshAt = :refresh_at") + expression_values[":refresh_at"] = refresh_at.isoformat() + + # Update user data if provided + if user_data: + # Update individual user fields to preserve existing data + if "id" in user_data: + update_parts.append("#data.#user.id = :user_id") + expression_values[":user_id"] = user_data["id"] + + if "displayName" in user_data: + update_parts.append("#data.#user.displayName = :display_name") + expression_values[":display_name"] = user_data["displayName"] + + if "email" in user_data: + update_parts.append("#data.#user.email = :email") + expression_values[":email"] = user_data["email"] + + if "groups" in user_data: + update_parts.append("#data.#user.groups = :groups") + expression_values[":groups"] = user_data["groups"] + + if "attributes" in user_data: + update_parts.append("#data.#user.attributes = :attributes") + expression_values[":attributes"] = user_data["attributes"] + + # Update raw IdP response if provided + if raw_idp_response: + update_parts.append("raw_data = :raw_data") + expression_values[":raw_data"] = raw_idp_response + + if not update_parts: + # Nothing to update besides timestamp + return True + + update_expression = "SET " + ", ".join(update_parts) + + self.store._update( + {"pk": session_id}, + update_expression, + condition_expression="attribute_exists(pk)", + expression_names=expression_names, + expression_values=expression_values, + ) + return True + + except Exception: + return False + + def refresh_session_with_user_data( + self, + session_id: str, + tokens: Dict[str, str], + user_data: Dict, + expires_at: datetime, + refresh_at: datetime, + raw_idp_response: Optional[str] = None, + ) -> bool: + """ + Refresh session tokens and update user data in one operation. + + This is the recommended method to use when refreshing tokens, + as it ensures both tokens and user profile are kept up-to-date. + + Args: + session_id: Session identifier + tokens: New IdP tokens dict (only refresh_token is stored) + user_data: Updated user information from IdP + expires_at: New session expiration timestamp + refresh_at: New token refresh threshold timestamp + raw_idp_response: Updated raw IdP response for debugging + + Returns: + True if update successful, False otherwise + """ + return self.update_session( + session_id=session_id, + tokens=tokens, + user_data=user_data, + expires_at=expires_at, + refresh_at=refresh_at, + raw_idp_response=raw_idp_response, + ) + + def delete_session(self, session_id: str) -> bool: + """ + Delete a session. + + Args: + session_id: Session identifier + + Returns: + True if deletion successful, False otherwise + """ + if not session_id or not session_id.startswith("session:"): + return False + + try: + self.store._delete({"pk": session_id}) + return True + except Exception: + return False + + def add_synced_domain(self, session_id: str, domain: str) -> bool: + """ + Add a domain to the list of synced domains for cross-domain cookie sync. + + Args: + session_id: Session identifier + domain: Domain to add to synced list + + Returns: + True if update successful, False otherwise + """ + if not session_id or not session_id.startswith("session:"): + return False + + try: + self.store._update( + {"pk": session_id}, + "ADD #data.metadata.syncedDomains :domain SET updated_at = :updated_at", + condition_expression="attribute_exists(pk)", + expression_names={"#data": "data"}, + expression_values={ + ":domain": {domain}, # DynamoDB set type + ":updated_at": datetime.now(timezone.utc).isoformat(), + }, + ) + return True + except Exception: + return False + + def _decrypt_session_tokens(self, session_record: Dict) -> Dict: + """ + Decrypt tokens in session record. + + Args: + session_record: Raw session record from DynamoDB + + Returns: + Session record with decrypted tokens + """ + decrypted_record = session_record.copy() + session_data = decrypted_record.get("data", {}).get("session", {}) + + # Decrypt refresh_token if present + if "refresh_token" in session_data and session_data["refresh_token"]: + try: + if self.encryption.is_encrypted_token(session_data["refresh_token"]): + session_data["refresh_token"] = self.encryption.decrypt_token(session_data["refresh_token"]) + except Exception: + # If decryption fails, remove the token + session_data["refresh_token"] = None + + return decrypted_record + + +class OTACManager: + """ + Manages One-Time Authentication Codes (OTAC) for cross-domain cookie synchronization. + """ + + def __init__(self, table_name: str, client=None): + """ + Initialize OTAC manager. + + Args: + table_name: DynamoDB table name (same as sessions) + client: Optional DynamoDB client (for testing) + """ + self.store = DynamoDBObjectStore(table_name, client) + + def create_otac(self, session_id: str, remaining_domains: List[str], final_redirect_url: str) -> str: + """ + Create a new OTAC for cross-domain synchronization. + + Args: + session_id: Associated session ID + remaining_domains: List of domains still to be synced + final_redirect_url: Final URL to redirect to after sync chain + + Returns: + OTAC identifier + + Raises: + Exception: If OTAC creation fails + """ + otac_id = f"otac:{uuid.uuid4()}" + + # OTAC expires in 5 minutes + ttl = int(time.time()) + 300 + + otac_record = { + "pk": otac_id, + "ttl": ttl, + "created_at": datetime.now(timezone.utc).isoformat(), + "data": { + "sessionId": session_id, + "remainingDomains": remaining_domains, + "finalRedirectUrl": final_redirect_url, + "usedAt": None, + }, + } + + try: + self.store._create(otac_record, condition_expression="attribute_not_exists(pk)") + return otac_id + except Exception as e: + raise Exception(f"Failed to create OTAC: {e}") + + def validate_and_consume_otac(self, otac_id: str) -> Optional[Dict]: + """ + Validate OTAC and mark it as used (single-use). + + Args: + otac_id: OTAC identifier + + Returns: + OTAC data if valid and unused, None otherwise + """ + if not otac_id or not otac_id.startswith("otac:"): + return None + + try: + # Strong consistent read for security + otac_record = self.store.client.get_item( + TableName=self.store.table_name, Key={"pk": {"S": otac_id}}, ConsistentRead=True + ) + + if "Item" not in otac_record: + return None + + # Convert DynamoDB format to dict + from dynamodb_json import json_util as dynamodb_json + + otac_data = dynamodb_json.loads(otac_record["Item"]) + + # Check TTL + if otac_data.get("ttl", 0) < int(time.time()): + return None + + # Check if already used + if otac_data.get("data", {}).get("usedAt"): + return None + + # Mark as used with conditional update + try: + self.store._update( + {"pk": otac_id}, + "SET #data.usedAt = :timestamp", + condition_expression="attribute_not_exists(#data.usedAt)", + expression_names={"#data": "data"}, + expression_values={":timestamp": datetime.now(timezone.utc).isoformat()}, + ) + except Exception: + # OTAC was already used (race condition) + return None + + return otac_data.get("data", {}) + + except Exception: + return None diff --git a/backend/src/ml_space_lambda/auth/session/validator.py b/backend/src/ml_space_lambda/auth/session/validator.py new file mode 100644 index 00000000..faf0e00d --- /dev/null +++ b/backend/src/ml_space_lambda/auth/session/validator.py @@ -0,0 +1,224 @@ +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +""" +Session validation logic for BFF authentication. + +Validates session cookies and determines if sessions need refresh. +""" + +from datetime import datetime, timezone +from typing import Dict, Optional, Tuple + + +class SessionValidator: + """ + Validates user sessions and determines refresh requirements. + """ + + @staticmethod + def validate_session_data(session_data: Optional[Dict]) -> Tuple[bool, Optional[str]]: + """ + Validate session data structure and expiration. + + Args: + session_data: Session data from DynamoDB + + Returns: + Tuple of (is_valid, error_message) + """ + if not session_data: + return False, "Session not found" + + # Check required fields + if "data" not in session_data: + return False, "Invalid session structure" + + data = session_data["data"] + + if "user" not in data or "session" not in data: + return False, "Missing required session fields" + + # Validate user data + user = data["user"] + required_user_fields = ["id", "displayName", "email"] + for field in required_user_fields: + if field not in user: + return False, f"Missing required user field: {field}" + + # Validate session data + session = data["session"] + required_session_fields = ["provider", "expiresAt", "refreshAt"] + for field in required_session_fields: + if field not in session: + return False, f"Missing required session field: {field}" + + # Check expiration + try: + expires_at = datetime.fromisoformat(session["expiresAt"]) + if expires_at < datetime.now(timezone.utc): + return False, "Session expired" + except (ValueError, TypeError): + return False, "Invalid expiration timestamp" + + return True, None + + @staticmethod + def should_refresh_session(session_data: Dict, threshold_seconds: int = 300) -> bool: + """ + Determine if session tokens should be refreshed. + + Args: + session_data: Session data from DynamoDB + threshold_seconds: Seconds before refreshAt to trigger refresh (default 5 minutes) + + Returns: + True if session should be refreshed, False otherwise + """ + if not session_data or "data" not in session_data: + return False + + session = session_data["data"].get("session", {}) + refresh_at_str = session.get("refreshAt") + + if not refresh_at_str: + return False + + try: + refresh_at = datetime.fromisoformat(refresh_at_str) + now = datetime.now(timezone.utc) + + # Check if we're within threshold of refresh time + time_until_refresh = (refresh_at - now).total_seconds() + return time_until_refresh <= threshold_seconds + + except (ValueError, TypeError): + return False + + @staticmethod + def is_session_expired(session_data: Dict) -> bool: + """ + Check if session has expired. + + Args: + session_data: Session data from DynamoDB + + Returns: + True if session is expired, False otherwise + """ + if not session_data or "data" not in session_data: + return True + + session = session_data["data"].get("session", {}) + expires_at_str = session.get("expiresAt") + + if not expires_at_str: + return True + + try: + expires_at = datetime.fromisoformat(expires_at_str) + return expires_at < datetime.now(timezone.utc) + except (ValueError, TypeError): + return True + + @staticmethod + def get_session_info(session_data: Dict) -> Dict: + """ + Extract session information for API responses. + + Args: + session_data: Session data from DynamoDB + + Returns: + Dictionary with user and session information + """ + if not session_data or "data" not in session_data: + return {} + + data = session_data["data"] + user = data.get("user", {}) + session = data.get("session", {}) + + return { + "user": { + "id": user.get("id"), + "displayName": user.get("displayName"), + "email": user.get("email"), + "groups": user.get("groups", []), + "attributes": user.get("attributes", {}), + }, + "session": { + "expiresAt": session.get("expiresAt"), + "refreshAt": session.get("refreshAt"), + "provider": session.get("provider"), + }, + } + + @staticmethod + def extract_session_id_from_cookie(cookie_header: Optional[str], cookie_name: str = "mlspace_session") -> Optional[str]: + """ + Extract session ID from Cookie header. + + Args: + cookie_header: Cookie header value + cookie_name: Name of the session cookie + + Returns: + Session ID if found, None otherwise + """ + if not cookie_header: + return None + + # Parse cookies + cookies = {} + for cookie in cookie_header.split(";"): + cookie = cookie.strip() + if "=" in cookie: + name, value = cookie.split("=", 1) + cookies[name.strip()] = value.strip() + + session_id = cookies.get(cookie_name) + + # Validate session ID format + if session_id and session_id.startswith("session:"): + return session_id + + return None + + @staticmethod + def validate_domain(domain: str, allowed_domains: list) -> bool: + """ + Validate that a domain is in the allowed list. + + Args: + domain: Domain to validate + allowed_domains: List of allowed domains + + Returns: + True if domain is allowed, False otherwise + """ + if not domain or not allowed_domains: + return False + + # Normalize domain (remove protocol, port, path) + normalized_domain = domain.lower().split(":")[0].split("/")[0] + + for allowed in allowed_domains: + allowed_normalized = allowed.lower().split(":")[0].split("/")[0] + if normalized_domain == allowed_normalized: + return True + + return False diff --git a/backend/src/ml_space_lambda/auth/utils/__init__.py b/backend/src/ml_space_lambda/auth/utils/__init__.py new file mode 100644 index 00000000..f9de63fb --- /dev/null +++ b/backend/src/ml_space_lambda/auth/utils/__init__.py @@ -0,0 +1,15 @@ +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# diff --git a/backend/src/ml_space_lambda/auth/utils/cookies.py b/backend/src/ml_space_lambda/auth/utils/cookies.py new file mode 100644 index 00000000..31fb8659 --- /dev/null +++ b/backend/src/ml_space_lambda/auth/utils/cookies.py @@ -0,0 +1,274 @@ +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +""" +Cookie utilities for BFF authentication. + +Provides utilities for creating secure HTTP cookies for session management +and state parameter handling using Python's standard http.cookies library. +""" + +from http.cookies import SimpleCookie +from typing import Dict, Optional + + +def create_session_cookie( + session_id: str, max_age_seconds: int = 86400, domain: Optional[str] = None, secure: bool = True, same_site: str = "Strict" +) -> str: + """ + Create a secure session cookie. + + Args: + session_id: Session identifier + max_age_seconds: Cookie lifetime in seconds (default 24 hours) + domain: Cookie domain (optional) + secure: Whether to set Secure flag (default True) + same_site: SameSite attribute value (default "Strict") + + Returns: + Set-Cookie header value + """ + cookie = SimpleCookie() + cookie["mlspace_session"] = session_id + cookie["mlspace_session"]["httponly"] = True + cookie["mlspace_session"]["max-age"] = max_age_seconds + cookie["mlspace_session"]["path"] = "/" + cookie["mlspace_session"]["samesite"] = same_site + + if secure: + cookie["mlspace_session"]["secure"] = True + + if domain: + cookie["mlspace_session"]["domain"] = domain + + return cookie.output(header="").strip() + + +def create_state_cookie(nonce: str, max_age_seconds: int = 600, secure: bool = True, same_site: str = "Strict") -> str: + """ + Create a state cookie for CSRF protection. + + Args: + nonce: Nonce value for state validation + max_age_seconds: Cookie lifetime in seconds (default 10 minutes) + secure: Whether to set Secure flag (default True) + same_site: SameSite attribute value (default "Strict") + + Returns: + Set-Cookie header value + """ + cookie = SimpleCookie() + cookie["mlspace_auth_state"] = nonce + cookie["mlspace_auth_state"]["httponly"] = True + cookie["mlspace_auth_state"]["max-age"] = max_age_seconds + cookie["mlspace_auth_state"]["path"] = "/auth" + cookie["mlspace_auth_state"]["samesite"] = same_site + + if secure: + cookie["mlspace_auth_state"]["secure"] = True + + return cookie.output(header="").strip() + + +def clear_session_cookie(domain: Optional[str] = None) -> str: + """ + Create a cookie header to clear the session cookie. + + Args: + domain: Cookie domain (optional) + + Returns: + Set-Cookie header value to clear the session cookie + """ + cookie = SimpleCookie() + cookie["mlspace_session"] = "" + cookie["mlspace_session"]["httponly"] = True + cookie["mlspace_session"]["max-age"] = 0 + cookie["mlspace_session"]["path"] = "/" + cookie["mlspace_session"]["samesite"] = "Strict" + cookie["mlspace_session"]["secure"] = True + + if domain: + cookie["mlspace_session"]["domain"] = domain + + return cookie.output(header="").strip() + + +def clear_state_cookie() -> str: + """ + Create a cookie header to clear the state cookie. + + Returns: + Set-Cookie header value to clear the state cookie + """ + cookie = SimpleCookie() + cookie["mlspace_auth_state"] = "" + cookie["mlspace_auth_state"]["httponly"] = True + cookie["mlspace_auth_state"]["max-age"] = 0 + cookie["mlspace_auth_state"]["path"] = "/auth" + cookie["mlspace_auth_state"]["samesite"] = "Strict" + cookie["mlspace_auth_state"]["secure"] = True + + return cookie.output(header="").strip() + + +def parse_cookies(cookie_header: Optional[str]) -> Dict[str, str]: + """ + Parse cookies from Cookie header. + + Args: + cookie_header: Cookie header value + + Returns: + Dictionary of cookie name-value pairs + """ + if not cookie_header: + return {} + + cookie = SimpleCookie() + cookie.load(cookie_header) + + # Extract just the values, not the Morsel objects + return {name: morsel.value for name, morsel in cookie.items()} + + +def get_cookie_value(cookie_header: Optional[str], cookie_name: str) -> Optional[str]: + """ + Extract a specific cookie value from Cookie header. + + Args: + cookie_header: Cookie header value + cookie_name: Name of the cookie to extract + + Returns: + Cookie value if found, None otherwise + """ + cookies = parse_cookies(cookie_header) + return cookies.get(cookie_name) + + +def create_redirect_response(location: str, cookies: Optional[list] = None, status_code: int = 302) -> Dict: + """ + Create a redirect response with optional cookies. + + Args: + location: Redirect URL + cookies: List of Set-Cookie header values + status_code: HTTP status code (default 302) + + Returns: + Lambda response dictionary + """ + headers = {"Location": location, "Cache-Control": "no-store, no-cache", "Pragma": "no-cache"} + + # Add security headers + security_headers = get_security_headers() + headers.update(security_headers) + + response = {"statusCode": status_code, "headers": headers} + + # Add cookies if provided + if cookies: + # Use multiValueHeaders for multiple Set-Cookie headers + response["multiValueHeaders"] = {"Set-Cookie": cookies} + + return response + + +def create_json_response(body: dict, status_code: int = 200, cookies: Optional[list] = None) -> Dict: + """ + Create a JSON response with optional cookies. + + Args: + body: Response body dictionary + status_code: HTTP status code (default 200) + cookies: List of Set-Cookie header values + + Returns: + Lambda response dictionary + """ + import json + + headers = {"Content-Type": "application/json", "Cache-Control": "no-store, no-cache", "Pragma": "no-cache"} + + # Add security headers + security_headers = get_security_headers() + headers.update(security_headers) + + response = {"statusCode": status_code, "headers": headers, "body": json.dumps(body, default=str)} + + # Add cookies if provided + if cookies: + response["multiValueHeaders"] = {"Set-Cookie": cookies} + + return response + + +def get_security_headers() -> Dict[str, str]: + """ + Get standard security headers for all authentication responses. + + Returns: + Dictionary of security headers + """ + return { + "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "X-XSS-Protection": "1; mode=block", + "Content-Security-Policy": "default-src 'self'; frame-ancestors 'none'", + "Referrer-Policy": "strict-origin-when-cross-origin", + } + + +def extract_domain_from_host(host_header: Optional[str]) -> Optional[str]: + """ + Extract domain from Host header for cookie domain setting. + + Args: + host_header: Host header value + + Returns: + Domain for cookie, or None if invalid + """ + if not host_header: + return None + + # Remove port if present (except for localhost) + domain = host_header.split(":")[0] if ":" in host_header and not host_header.startswith("localhost") else host_header + + # Basic validation + if "." not in domain and not domain.startswith("localhost"): + return None + + return domain.lower() + + +def should_set_secure_flag(host_header: Optional[str]) -> bool: + """ + Determine if Secure flag should be set on cookies. + + Args: + host_header: Host header value + + Returns: + True if Secure flag should be set, False for localhost development + """ + if not host_header: + return True + + # Don't set Secure flag for localhost development + return not host_header.lower().startswith("localhost") diff --git a/backend/src/ml_space_lambda/auth/utils/key_rotation.py b/backend/src/ml_space_lambda/auth/utils/key_rotation.py new file mode 100644 index 00000000..13be3558 --- /dev/null +++ b/backend/src/ml_space_lambda/auth/utils/key_rotation.py @@ -0,0 +1,459 @@ +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +""" +Key rotation utilities for encryption key management. + +Provides separate functions for state and token key rotation with +domain-driven design using Pydantic models. +""" + +import json +import logging +from typing import Dict, Optional + +import boto3 + +from ml_space_lambda.auth.models.key_models import ( + KeyRotationResult, + KeyStatusResult, + KeyType, + SecretsManagerStage, + VersionedKeyData, +) +from ml_space_lambda.auth.session.encryption import create_encryption_key, encode_key_for_storage +from ml_space_lambda.auth.utils.state import create_state_encryption_key, encode_state_key_for_storage + +logger = logging.getLogger(__name__) +logger.setLevel(level=logging.INFO) + + +def initialize_state_encryption_key(secret_arn: str) -> Dict: + """ + Initialize state encryption key secret with versioned structure. + + Args: + secret_arn: AWS Secrets Manager ARN for state encryption key + + Returns: + Dictionary with initialization details + + Raises: + Exception: If initialization fails + """ + try: + secrets_client = boto3.client("secretsmanager") + + # Generate initial Fernet key + initial_key = create_state_encryption_key() + encoded_key = encode_state_key_for_storage(initial_key) + + # Create versioned key data using domain model + key_data = VersionedKeyData.create_initial( + encoded_key=encoded_key, key_type=KeyType.STATE, created_by="state_key_initializer" + ) + + # Store in Secrets Manager + secrets_client.update_secret(SecretId=secret_arn, SecretString=key_data.to_secrets_manager_format()) + + logger.info(f"Initialized state encryption key secret: {secret_arn}") + + return { + "success": True, + "key_type": KeyType.STATE, + "initial_version": 1, + "created_date": key_data.created_date.isoformat(), + } + + except Exception as e: + logger.error(f"State key initialization failed: {e}") + raise Exception(f"State key initialization failed: {e}") + + +def initialize_token_encryption_key(secret_arn: str) -> Dict: + """ + Initialize token encryption key secret with versioned structure. + + Args: + secret_arn: AWS Secrets Manager ARN for token encryption key + + Returns: + Dictionary with initialization details + + Raises: + Exception: If initialization fails + """ + try: + secrets_client = boto3.client("secretsmanager") + + # Generate initial PASETO key + initial_key = create_encryption_key() + encoded_key = encode_key_for_storage(initial_key) + + # Create versioned key data using domain model + key_data = VersionedKeyData.create_initial( + encoded_key=encoded_key, key_type=KeyType.TOKEN, created_by="token_key_initializer" + ) + + # Store in Secrets Manager + secrets_client.update_secret(SecretId=secret_arn, SecretString=key_data.to_secrets_manager_format()) + + logger.info(f"Initialized token encryption key secret: {secret_arn}") + + return { + "success": True, + "key_type": KeyType.TOKEN, + "initial_version": 1, + "created_date": key_data.created_date.isoformat(), + } + + except Exception as e: + logger.error(f"Token key initialization failed: {e}") + raise Exception(f"Token key initialization failed: {e}") + + +def rotate_state_encryption_key( + secret_arn: str, + version_stage: str = SecretsManagerStage.PENDING, + version_token: Optional[str] = None, + keep_versions: int = 3, +) -> KeyRotationResult: + """ + Rotate state encryption key for AWS Secrets Manager rotation protocol. + + This function works with Secrets Manager's rotation protocol by creating + a new secret version with the AWSPENDING label. + + Args: + secret_arn: AWS Secrets Manager ARN for state encryption key + token: Version stage (AWSPENDING for new version) + keep_versions: Number of recent versions to keep after rotation + + Returns: + KeyRotationResult with rotation details + + Raises: + Exception: If rotation fails + """ + try: + secrets_client = boto3.client("secretsmanager") + + # Generate new Fernet key + new_key = create_state_encryption_key() + encoded_new_key = encode_state_key_for_storage(new_key) + + try: + # Get current key data (AWSCURRENT version) + response = secrets_client.get_secret_value(SecretId=secret_arn, VersionStage=SecretsManagerStage.CURRENT) + key_data = VersionedKeyData.from_secrets_manager_format(response["SecretString"]) + + # Add new version using domain method + previous_version = key_data.current_version + new_version = key_data.add_new_key_version(encoded_key=encoded_new_key, rotated_by="token_key_rotator") + except Exception: + key_data = VersionedKeyData.create_initial(encoded_new_key, KeyType.STATE) + previous_version = 0 + new_version = key_data.current_version + + # Cleanup old versions automatically + removed_versions = key_data.cleanup_old_versions(keep_versions) + + call_params = { + "SecretId": secret_arn, + "SecretString": key_data.to_secrets_manager_format(), + "VersionStages": [version_stage], + } + + if version_token: + call_params["ClientRequestToken"] = version_token + + # Put the new secret version with AWSPENDING stage + secrets_client.put_secret_value(**call_params) + + logger.info( + f"Token encryption key rotated: v{previous_version} -> v{new_version}, removed {len(removed_versions)} old versions" + ) + + return KeyRotationResult( + success=True, + previous_version=previous_version, + new_version=new_version, + rotation_date=key_data.rotation_date or key_data.created_date, + total_versions=key_data.get_total_versions(), + message=f"Rotated to version {new_version}, removed {len(removed_versions)} old versions", + ) + + except Exception as e: + logger.error(f"State key rotation failed: {e}") + raise Exception(f"State key rotation failed: {e}") + + +def rotate_token_encryption_key( + secret_arn: str, + version_stage: str = SecretsManagerStage.PENDING, + version_token: Optional[str] = None, + keep_versions: int = 3, +) -> KeyRotationResult: + """ + Rotate token encryption key for AWS Secrets Manager rotation protocol. + + This function works with Secrets Manager's rotation protocol by creating + a new secret version with the AWSPENDING label. + + Args: + secret_arn: AWS Secrets Manager ARN for token encryption key + version_stage: Version stage (AWSPENDING for new version) + version_token: Version token (normally from ClientRequestToken) + keep_versions: Number of recent versions to keep after rotation + + Returns: + KeyRotationResult with rotation details + + Raises: + Exception: If rotation fails + """ + try: + secrets_client = boto3.client("secretsmanager") + + # Generate new PASETO key + new_key = create_encryption_key() + encoded_new_key = encode_key_for_storage(new_key) + + try: + # Get current key data (AWSCURRENT version) + response = secrets_client.get_secret_value(SecretId=secret_arn, VersionStage=SecretsManagerStage.CURRENT) + key_data = VersionedKeyData.from_secrets_manager_format(response["SecretString"]) + + # Add new version using domain method + previous_version = key_data.current_version + new_version = key_data.add_new_key_version(encoded_key=encoded_new_key, rotated_by="token_key_rotator") + except Exception: + key_data = VersionedKeyData.create_initial(encoded_new_key, KeyType.TOKEN) + previous_version = 0 + new_version = key_data.current_version + + # Cleanup old versions automatically + removed_versions = key_data.cleanup_old_versions(keep_versions) + + call_params = { + "SecretId": secret_arn, + "SecretString": key_data.to_secrets_manager_format(), + "VersionStages": [version_stage], + } + + if version_token: + call_params["ClientRequestToken"] = version_token + + # Put the new secret version with AWSPENDING stage + secrets_client.put_secret_value(**call_params) + + logger.info( + f"Token encryption key rotated: v{previous_version} -> v{new_version}, removed {len(removed_versions)} old versions" + ) + + return KeyRotationResult( + success=True, + previous_version=previous_version, + new_version=new_version, + rotation_date=key_data.rotation_date or key_data.created_date, + total_versions=key_data.get_total_versions(), + message=f"Rotated to version {new_version}, removed {len(removed_versions)} old versions", + ) + + except Exception as e: + logger.error(f"Token key rotation failed: {e}") + raise Exception(f"Token key rotation failed: {e}") + + +def finalize_secrets_manager_rotation(secret_arn: str) -> None: + """ + Finalize AWS Secrets Manager rotation by moving labels. + + This moves the AWSPENDING version to AWSCURRENT and removes old labels. + + Args: + secret_arn: Secret ARN + version_stage: Version stage to finalize + """ + try: + secrets_client = boto3.client("secretsmanager") + + # Get the version ID of the pending secret + response = secrets_client.describe_secret(SecretId=secret_arn) + pending_version_id = None + current_version_id = None + + logger.info(f"stages = {json.dumps(response.get('VersionIdsToStages', {}))}") + for v_id, v_stages in response.get("VersionIdsToStages", {}).items(): + logger.info(f"checking {v_id}: {v_stages}") + if SecretsManagerStage.CURRENT in v_stages: + logger.info(f"current_version_id={v_id}") + current_version_id = v_id + + if SecretsManagerStage.PENDING in v_stages: + logger.info(f"pending_version_id={v_id}") + pending_version_id = v_id + + if not pending_version_id: + raise Exception(f"No version found with stage {SecretsManagerStage.PENDING}") + + call_params = {} + + # add existing version id with label if it exists + if current_version_id: + call_params["RemoveFromVersionId"] = current_version_id + + logger.info(f"call_params={json.dumps(call_params)}") + + # Move the AWSPENDING version to AWSCURRENT + secrets_client.update_secret_version_stage( + SecretId=secret_arn, VersionStage=SecretsManagerStage.CURRENT, MoveToVersionId=pending_version_id, **call_params + ) + + # Remove AWSPENDING version + if pending_version_id: + secrets_client.update_secret_version_stage( + SecretId=secret_arn, VersionStage=SecretsManagerStage.PENDING, RemoveFromVersionId=pending_version_id + ) + + logger.info(f"Finalized rotation for secret {secret_arn}, version {pending_version_id}") + + # Clean up old secret versions after successful rotation + # cleanup_result = cleanup_old_secret_versions(secret_arn, keep_versions=3) + # if cleanup_result["success"]: + # logger.info(f"Cleaned up {len(cleanup_result['deleted_versions'])} old secret versions") + # else: + # logger.warning(f"Failed to cleanup old versions: {cleanup_result.get('error')}") + + except Exception as e: + logger.error(f"Failed to finalize rotation: {e}") + raise + + +def get_key_status(secret_arn: str, version_stage: str) -> KeyStatusResult: + """ + Get status information about key versions. + + Args: + secret_arn: AWS Secrets Manager ARN + + Returns: + KeyStatusResult with key status information + """ + try: + secrets_client = boto3.client("secretsmanager") + response = secrets_client.get_secret_value(SecretId=secret_arn, VersionStage=version_stage) + key_data = VersionedKeyData.from_secrets_manager_format(response["SecretString"]) + + return KeyStatusResult( + success=True, + current_version=key_data.current_version, + total_versions=key_data.get_total_versions(), + available_versions=key_data.get_available_versions(), + key_type=key_data.key_type, + last_rotation=key_data.rotation_date, + last_cleanup=key_data.last_cleanup, + ) + + except Exception as e: + logger.error(f"Failed to get key status: {e}") + return KeyStatusResult(success=False, error=str(e)) + + +def state_key_rotation_handler(event, context): + """ + Lambda handler for state encryption key rotation. + + Expected event format: + { + "action": "initialize" | "rotate" | "status", + "secret_arn": "arn:aws:secretsmanager:...", + "keep_versions": 3 // for rotate action + } + """ + try: + action = event.get("action") + secret_arn = event.get("secret_arn") + + if not action or not secret_arn: + return {"statusCode": 400, "body": json.dumps({"error": "Missing required parameters: action, secret_arn"})} + + if action == "initialize": + result = initialize_state_encryption_key(secret_arn) + elif action == "rotate": + keep_versions = event.get("keep_versions", 3) + token = event.get("token", SecretsManagerStage.PENDING) + result = rotate_state_encryption_key(secret_arn, token, keep_versions) + # Convert Pydantic model to dict for JSON serialization + if isinstance(result, KeyRotationResult): + result = result.model_dump() + elif action == "status": + version_stage = event.get("version_stage", SecretsManagerStage.CURRENT) + result = get_key_status(secret_arn, version_stage) + # Convert Pydantic model to dict for JSON serialization + if isinstance(result, KeyStatusResult): + result = result.model_dump() + else: + return {"statusCode": 400, "body": json.dumps({"error": f"Unknown action: {action}"})} + + return {"statusCode": 200, "body": json.dumps(result, default=str)} + + except Exception as e: + logger.error(f"State key rotation Lambda failed: {e}") + return {"statusCode": 500, "body": json.dumps({"error": str(e)})} + + +def token_key_rotation_handler(event, context): + """ + Lambda handler for token encryption key rotation. + + Expected event format: + { + "action": "initialize" | "rotate" | "status", + "secret_arn": "arn:aws:secretsmanager:...", + "keep_versions": 3 // for rotate action + } + """ + try: + action = event.get("action") + secret_arn = event.get("secret_arn") + + if not action or not secret_arn: + return {"statusCode": 400, "body": json.dumps({"error": "Missing required parameters: action, secret_arn"})} + + if action == "initialize": + result = initialize_token_encryption_key(secret_arn) + elif action == "rotate": + keep_versions = event.get("keep_versions", 3) + token = event.get("token", SecretsManagerStage.PENDING) + result = rotate_token_encryption_key(secret_arn, token, keep_versions) + # Convert Pydantic model to dict for JSON serialization + if isinstance(result, KeyRotationResult): + result = result.model_dump() + elif action == "status": + version_stage = event.get("version_stage", SecretsManagerStage.CURRENT) + result = get_key_status(secret_arn, version_stage) + # Convert Pydantic model to dict for JSON serialization + if isinstance(result, KeyStatusResult): + result = result.model_dump() + else: + return {"statusCode": 400, "body": json.dumps({"error": f"Unknown action: {action}"})} + + return {"statusCode": 200, "body": json.dumps(result, default=str)} + + except Exception as e: + logger.error(f"Token key rotation Lambda failed: {e}") + return {"statusCode": 500, "body": json.dumps({"error": str(e)})} diff --git a/backend/src/ml_space_lambda/auth/utils/otac.py b/backend/src/ml_space_lambda/auth/utils/otac.py new file mode 100644 index 00000000..629d3ea7 --- /dev/null +++ b/backend/src/ml_space_lambda/auth/utils/otac.py @@ -0,0 +1,200 @@ +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +""" +One-Time Authentication Code (OTAC) utilities for cross-domain cookie synchronization. + +Provides utilities for generating secure OTACs and building synchronization chains +for multi-domain deployments. +""" + +import secrets +from typing import List, Optional, Tuple +from urllib.parse import urlencode, urlparse + + +def generate_otac() -> str: + """ + Generate a cryptographically secure one-time authentication code. + + Returns: + OTAC in format: otac: + """ + # 32 bytes = 256 bits of entropy + random_string = secrets.token_urlsafe(32) + return f"otac:{random_string}" + + +def build_sync_chain_url(current_domain: str, otac: str, remaining_domains: List[str], final_redirect_url: str) -> str: + """ + Build the next URL in the cross-domain synchronization chain. + + Args: + current_domain: Current domain being processed + otac: OTAC for the next domain + remaining_domains: List of domains still to be synced + final_redirect_url: Final URL to redirect to after sync chain + + Returns: + URL for the next domain in the chain + """ + if not remaining_domains: + # End of chain, redirect to final URL + return final_redirect_url + + # Get next domain in chain + next_domain = remaining_domains[0] + remaining_after_next = remaining_domains[1:] + + # Build sync URL for next domain + sync_params = {"otac": otac, "final": final_redirect_url} + + # Add remaining domains if any + if remaining_after_next: + sync_params["next"] = ",".join(remaining_after_next) + + # Ensure domain has protocol + if not next_domain.startswith(("http://", "https://")): + next_domain = f"https://{next_domain}" + + # Build full sync URL + sync_url = f"{next_domain}/auth/sync?{urlencode(sync_params)}" + return sync_url + + +def parse_sync_request(query_params: dict) -> Tuple[Optional[str], List[str], Optional[str]]: + """ + Parse parameters from a cross-domain sync request. + + Args: + query_params: Query parameters from the sync request + + Returns: + Tuple of (otac, remaining_domains, final_redirect_url) + """ + otac = query_params.get("otac") + final_url = query_params.get("final") + + # Parse remaining domains + remaining_domains = [] + next_param = query_params.get("next") + if next_param: + remaining_domains = [domain.strip() for domain in next_param.split(",") if domain.strip()] + + return otac, remaining_domains, final_url + + +def validate_otac_format(otac: str) -> bool: + """ + Validate OTAC format. + + Args: + otac: OTAC string to validate + + Returns: + True if format is valid, False otherwise + """ + if not otac: + return False + + parts = otac.split(":", 1) + return len(parts) == 2 and parts[0] == "otac" and len(parts[1]) > 0 + + +def normalize_domain(domain: str) -> str: + """ + Normalize domain for comparison and URL building. + + Args: + domain: Domain string (may include protocol, port, path) + + Returns: + Normalized domain (hostname only) + """ + if not domain: + return "" + + # Remove protocol if present + if "://" in domain: + domain = domain.split("://", 1)[1] + + # Remove path if present + if "/" in domain: + domain = domain.split("/", 1)[0] + + # Remove port if present (keep it for localhost development) + if ":" in domain and not domain.startswith("localhost"): + domain = domain.split(":", 1)[0] + + return domain.lower() + + +def build_domain_list(primary_domain: str, sync_domains_str: str) -> List[str]: + """ + Build list of domains for cross-domain synchronization. + + Args: + primary_domain: Primary domain (where login was initiated) + sync_domains_str: Comma-separated string of additional domains + + Returns: + List of normalized domains (excluding primary domain) + """ + domains = [] + + if sync_domains_str: + # Parse comma-separated domains + for domain in sync_domains_str.split(","): + domain = domain.strip() + if domain: + normalized = normalize_domain(domain) + if normalized and normalized != normalize_domain(primary_domain): + domains.append(normalized) + + return domains + + +def should_initiate_sync(sync_domains: List[str]) -> bool: + """ + Determine if cross-domain synchronization should be initiated. + + Args: + sync_domains: List of domains to sync + + Returns: + True if sync should be initiated, False otherwise + """ + return len(sync_domains) > 0 + + +def extract_domain_from_url(url: str) -> str: + """ + Extract domain from a URL. + + Args: + url: Full URL + + Returns: + Domain portion of the URL + """ + if not url: + return "" + + try: + parsed = urlparse(url) + return parsed.netloc or "" + except Exception: + return "" diff --git a/backend/src/ml_space_lambda/auth/utils/rotation_handlers.py b/backend/src/ml_space_lambda/auth/utils/rotation_handlers.py new file mode 100644 index 00000000..fc65327c --- /dev/null +++ b/backend/src/ml_space_lambda/auth/utils/rotation_handlers.py @@ -0,0 +1,235 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +""" +AWS Secrets Manager rotation handlers for encryption keys. + +Provides handlers that work with Secrets Manager's rotation schedule +to automatically rotate state and token encryption keys following the +standard AWS rotation protocol with steps: createSecret, setSecret, testSecret, finishSecret. +""" + +import logging +from enum import StrEnum +from typing import Any, Dict, Optional + +from ml_space_lambda.auth.models.key_models import KeyType, SecretsManagerStage +from ml_space_lambda.auth.utils.key_rotation import ( + finalize_secrets_manager_rotation, + get_key_status, + initialize_state_encryption_key, + initialize_token_encryption_key, + rotate_state_encryption_key, + rotate_token_encryption_key, +) + +logger = logging.getLogger(__name__) +logger.setLevel(level=logging.INFO) + + +class RotationStep(StrEnum): + """AWS Secrets Manager rotation steps.""" + + CREATE_SECRET = "createSecret" + SET_SECRET = "setSecret" + TEST_SECRET = "testSecret" + FINISH_SECRET = "finishSecret" + + +def _validate_rotation_event(event: Dict[str, Any]) -> tuple[str, RotationStep, str]: + """ + Validate and extract rotation event parameters. + + Args: + event: Secrets Manager rotation event + + Returns: + Tuple of (secret_name, step) + + Raises: + ValueError: If required parameters are missing + """ + secret_name = event.get("SecretId") + step = event.get("Step") + version_token = event.get("ClientRequestToken", None) + + if not secret_name: + raise ValueError("SecretId is required in rotation event") + + if not step: + raise ValueError("Step is required in rotation event") + + try: + rotation_step = RotationStep(step) + except ValueError: + raise ValueError(f"Invalid rotation step: {step}") + + return secret_name, rotation_step, version_token + + +def _handle_create_secret_step(secret_name: str, key_type: str, version_token: Optional[str] = None) -> None: + """ + Handle the createSecret step of rotation. + + Args: + secret_name: Secret ARN + key_type: Type of key being rotated + """ + # Create new key version + if key_type == KeyType.STATE: + result = rotate_state_encryption_key(secret_name, version_token=version_token, keep_versions=3) + else: # token + result = rotate_token_encryption_key(secret_name, version_token=version_token, keep_versions=3) + + +def _handle_set_secret_step(secret_name: str) -> None: + """ + Handle the setSecret step of rotation. + + Args: + secret_name: Secret ARN + """ + # For our key rotation, the secret is already set during createSecret + logger.info(f"Secret already set during creation") + + +def _handle_test_secret_step(secret_name: str) -> None: + """ + Handle the testSecret step of rotation. + + Args: + secret_name: Secret ARN + """ + # Test the new key version by getting status + status = get_key_status(secret_name, SecretsManagerStage.PENDING) + if not status.success: + raise Exception(f"Failed to validate key: {status.error}") + + logger.info(f"Key validation successful, current version: {status.current_version}") + + +def _handle_finish_secret_step(secret_name: str) -> None: + """ + Handle the finishSecret step of rotation. + + Args: + secret_name: Secret ARN + """ + # Finalize the rotation by moving version labels + finalize_secrets_manager_rotation(secret_name) + logger.info(f"Key rotation completed successfully") + + +def state_key_secrets_manager_rotation_handler(event: Dict[str, Any], context: Any) -> None: + """ + AWS Secrets Manager rotation handler for state encryption keys. + + This handler is called by Secrets Manager during the rotation process. + It follows the standard rotation steps: createSecret, setSecret, testSecret, finishSecret. + + Args: + event: Secrets Manager rotation event with SecretId, Step, and Token + context: Lambda context + """ + try: + secret_name, step, version_token = _validate_rotation_event(event) + + logger.info(f"Starting state key rotation step: {step} for secret: {secret_name}") + + if step == RotationStep.CREATE_SECRET: + _handle_create_secret_step(secret_name, KeyType.STATE, version_token) + elif step == RotationStep.SET_SECRET: + _handle_set_secret_step(secret_name) + elif step == RotationStep.TEST_SECRET: + _handle_test_secret_step(secret_name) + elif step == RotationStep.FINISH_SECRET: + _handle_finish_secret_step(secret_name) + else: + raise ValueError(f"Unknown rotation step: {step}") + + logger.info(f"State key rotation step {step} completed successfully") + + except Exception as e: + logger.error(f"State key rotation failed at step {event.get('Step', 'unknown')}: {e}") + raise + + +def token_key_secrets_manager_rotation_handler(event: Dict[str, Any], context: Any) -> None: + """ + AWS Secrets Manager rotation handler for token encryption keys. + + This handler is called by Secrets Manager during the rotation process. + It follows the standard rotation steps: createSecret, setSecret, testSecret, finishSecret. + + Args: + event: Secrets Manager rotation event with SecretId, Step, and Token + context: Lambda context + """ + try: + secret_name, step, version_token = _validate_rotation_event(event) + + logger.info(f"Starting token key rotation step: {step} for secret: {secret_name}") + + if step == RotationStep.CREATE_SECRET: + _handle_create_secret_step(secret_name, KeyType.TOKEN, version_token) + elif step == RotationStep.SET_SECRET: + _handle_set_secret_step(secret_name) + elif step == RotationStep.TEST_SECRET: + _handle_test_secret_step(secret_name) + elif step == RotationStep.FINISH_SECRET: + _handle_finish_secret_step(secret_name) + else: + raise ValueError(f"Unknown rotation step: {step}") + + logger.info(f"Token key rotation step {step} completed successfully") + + except Exception as e: + logger.error(f"Token key rotation failed at step {event.get('Step', 'unknown')}: {e}") + raise + + +def initialize_secret_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]: + """ + Handler for initializing secrets with proper key structures. + + This can be called manually or during deployment to initialize secrets. + + Args: + event: Event containing secret_name and key_type + context: Lambda context + + Returns: + Initialization result + """ + try: + secret_name = event.get("secret_name") + key_type = event.get("key_type", KeyType.TOKEN) + + if not secret_name: + raise ValueError("secret_name is required") + + if key_type == KeyType.STATE: + result = initialize_state_encryption_key(secret_name) + elif key_type == KeyType.TOKEN: + result = initialize_token_encryption_key(secret_name) + else: + raise ValueError(f"Unknown key_type: {key_type}") + + logger.info(f"Successfully initialized {key_type} key secret: {secret_name}") + return result + + except Exception as e: + logger.error(f"Secret initialization failed: {e}") + raise diff --git a/backend/src/ml_space_lambda/auth/utils/state.py b/backend/src/ml_space_lambda/auth/utils/state.py new file mode 100644 index 00000000..eb406186 --- /dev/null +++ b/backend/src/ml_space_lambda/auth/utils/state.py @@ -0,0 +1,179 @@ +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +""" +State parameter management for CSRF protection in authentication flows. + +Handles creation and validation of encrypted state parameters used during +OIDC authentication to prevent CSRF attacks. +""" + +import json +import secrets +import time +from typing import Dict, Optional + +from cryptography.fernet import Fernet + + +class StateManager: + """ + Manages encrypted state parameters for CSRF protection during authentication. + + Uses Fernet symmetric encryption to create tamper-proof state parameters + that include nonce, timestamp, and redirect information. + """ + + def __init__(self, secret_key: bytes): + """ + Initialize state manager with encryption key. + + Args: + secret_key: 32-byte key for Fernet encryption + """ + self.cipher = Fernet(secret_key) + + def create_state( + self, redirect_url: str, domain: str, nonce: Optional[str] = None, protocol_data: Optional[Dict] = None + ) -> str: + """ + Create encrypted state parameter for auth flow. + + Args: + redirect_url: Where to redirect after authentication + domain: Domain initiating the authentication + nonce: Optional nonce (generated if not provided) + protocol_data: Optional protocol-specific data to store securely (e.g., PKCE code_verifier) + + Returns: + Encrypted state string + + Raises: + Exception: If state creation fails + """ + if nonce is None: + nonce = secrets.token_urlsafe(32) + + state_data = { + "redirect_url": redirect_url, + "nonce": nonce, + "timestamp": int(time.time()), + "domain": domain, + } + + # Add protocol-specific data if provided + if protocol_data: + state_data["protocol_data"] = protocol_data + + try: + state_json = json.dumps(state_data, separators=(",", ":")) + encrypted = self.cipher.encrypt(state_json.encode("utf-8")) + return encrypted.decode("utf-8") + except Exception as e: + raise Exception(f"Failed to create state parameter: {e}") + + def validate_state(self, encrypted_state: str, cookie_nonce: str, max_age_seconds: int = 600) -> Optional[Dict]: + """ + Validate and decrypt state parameter. + + Args: + encrypted_state: State from query parameter + cookie_nonce: Nonce from state cookie + max_age_seconds: Maximum age of state parameter (default 10 minutes) + + Returns: + State data if valid, None otherwise + """ + if not encrypted_state or not cookie_nonce: + return None + + try: + # Decrypt state + decrypted = self.cipher.decrypt(encrypted_state.encode("utf-8")) + state_data = json.loads(decrypted.decode("utf-8")) + + # Validate required fields + required_fields = ["redirect_url", "nonce", "timestamp", "domain"] + if not all(field in state_data for field in required_fields): + return None + + # Validate timestamp (check age) + state_age = int(time.time()) - state_data["timestamp"] + if state_age > max_age_seconds: + return None + + # Validate nonce matches cookie + if state_data["nonce"] != cookie_nonce: + return None + + return state_data + + except Exception: + return None + + def generate_nonce(self) -> str: + """ + Generate a cryptographically secure nonce. + + Returns: + URL-safe random string + """ + return secrets.token_urlsafe(32) + + +def create_state_encryption_key() -> bytes: + """ + Generate a new Fernet encryption key for state parameters. + + Returns: + 32-byte Fernet key + """ + return Fernet.generate_key() + + +def encode_state_key_for_storage(key: bytes) -> str: + """ + Encode Fernet key for storage in AWS Secrets Manager. + + Args: + key: Fernet encryption key + + Returns: + Base64 encoded key string (Fernet keys are already base64) + """ + return key.decode("utf-8") + + +def decode_state_key_from_storage(encoded_key: str) -> bytes: + """ + Decode Fernet key from AWS Secrets Manager. + + Args: + encoded_key: Base64 encoded Fernet key + + Returns: + Fernet encryption key + + Raises: + ValueError: If key is not valid Fernet key + """ + try: + key = encoded_key.encode("utf-8") + # Validate by creating a Fernet instance + Fernet(key) + return key + except Exception as e: + raise ValueError(f"Invalid Fernet key: {e}") diff --git a/backend/src/ml_space_lambda/authorizer/lambda_function.py b/backend/src/ml_space_lambda/authorizer/lambda_function.py index de838133..985a26cf 100644 --- a/backend/src/ml_space_lambda/authorizer/lambda_function.py +++ b/backend/src/ml_space_lambda/authorizer/lambda_function.py @@ -17,13 +17,12 @@ import json import logging import os -import time -import urllib -from typing import Any, Dict, Optional, Tuple - -import jwt -import urllib3 +import urllib.parse +from typing import Any, Dict, Optional +from ml_space_lambda.auth.session.manager import SessionManager +from ml_space_lambda.auth.session.validator import SessionValidator +from ml_space_lambda.auth.utils.cookies import get_cookie_value from ml_space_lambda.data_access_objects.dataset import DatasetDAO from ml_space_lambda.data_access_objects.group_dataset import GroupDatasetDAO from ml_space_lambda.data_access_objects.group_user import GroupUserDAO @@ -46,12 +45,108 @@ group_user_dao = GroupUserDAO() group_dataset_dao = GroupDatasetDAO() -oidc_keys: Dict[str, str] = {} -# If using self signed certs on the OIDC endpoint we need to skip ssl verification -http = urllib3.PoolManager( - num_pools=2, - cert_reqs="CERT_NONE" if os.getenv("OIDC_VERIFY_SSL", "True").lower() == "false" else "CERT_REQUIRED", -) +# Session manager for validating session cookies +_session_manager: Optional[SessionManager] = None + + +def _get_session_manager() -> SessionManager: + """ + Get or create session manager instance. + + Returns: + SessionManager instance + + Raises: + Exception: If session manager cannot be created + """ + global _session_manager + + if _session_manager is None: + # Get configuration from environment variables + session_table_name = os.environ.get("AUTH_SESSION_TABLE_NAME") + token_encryption_key_secret_name = os.environ.get("AUTH_TOKEN_ENCRYPTION_KEY_SECRET_NAME") + + if not session_table_name: + raise Exception("AUTH_SESSION_TABLE_NAME environment variable is required") + + if not token_encryption_key_secret_name: + raise Exception("AUTH_TOKEN_ENCRYPTION_KEY_SECRET_NAME environment variable is required") + + # Create token encryption instance + try: + # Always expect versioned format - use VersionedKeyManager + from ml_space_lambda.auth.session.key_manager import VersionedKeyManager, VersionedTokenEncryption + + key_manager = VersionedKeyManager(secret_arn=token_encryption_key_secret_name, key_type="token") + token_encryption = VersionedTokenEncryption(key_manager) + + except Exception as e: + logger.error(f"Failed to create token encryption: {e}") + raise Exception(f"Failed to initialize token encryption: {e}") + + # Create session manager + try: + _session_manager = SessionManager(table_name=session_table_name, encryption=token_encryption) + except Exception as e: + logger.error(f"Failed to create session manager: {e}") + raise Exception(f"Failed to initialize session manager: {e}") + + return _session_manager + + +def _validate_session_cookie(event: Dict[str, Any]) -> Optional[Dict]: + """ + Validate session cookie from request headers. + + Args: + event: Lambda event containing request headers + + Returns: + Session data if valid, None otherwise + """ + try: + # Extract session cookie from headers + cookie_header = None + if "cookie" in event.get("headers", {}): + cookie_header = event["headers"]["cookie"] + elif "Cookie" in event.get("headers", {}): + cookie_header = event["headers"]["Cookie"] + + if not cookie_header: + logger.info("No cookie header found in request") + return None + + # Extract session ID from cookie + session_id = get_cookie_value(cookie_header, "mlspace_session") + if not session_id: + logger.info("No mlspace_session cookie found") + return None + + # Validate session ID format + if not session_id.startswith("session:"): + logger.info(f"Invalid session ID format: {session_id}") + return None + + # Get session manager and validate session + session_manager = _get_session_manager() + session_data = session_manager.get_session(session_id) + + if not session_data: + logger.info(f"Session not found or expired: {session_id}") + return None + + # Validate session structure + is_valid, error_message = SessionValidator.validate_session_data(session_data) + if not is_valid: + logger.info(f"Invalid session data: {error_message}") + return None + + logger.info(f"Session validated successfully for user: {session_data['data']['user']['id']}") + return session_data + + except Exception as e: + logger.error(f"Error validating session cookie: {e}") + return None @authorization_wrapper @@ -73,339 +168,319 @@ def lambda_handler(event, context): f"- Method: {request_method}" ) - client_token = None - token_failure = False - auth_header = None - - if "authorization" in event["headers"]: - auth_header = event["headers"]["authorization"].split(" ") - if "Authorization" in event["headers"]: - auth_header = event["headers"]["Authorization"].split(" ") - - if auth_header and len(auth_header) == 2: - client_token = auth_header[1] - - if not client_token: - logging.info("Access Denied. No authentication token provided.") - token_failure = True - - if client_token and not token_failure: - # Decode token based on public key - verify_token = os.getenv("OIDC_VERIFY_SIGNATURE", "true").lower() - if verify_token != "false": - try: - # Grab public key id from token - token_headers = jwt.get_unverified_header(client_token) - [public_key, client_name] = _get_oidc_props(token_headers["kid"]) - token_info = jwt.decode(client_token, public_key, audience=client_name, algorithms=["RS256"]) - except Exception as e: - logging.exception(e) - logging.info("Access Denied. Encountered error validating supplied authentication token.") - token_failure = True - else: - try: - token_info = jwt.decode(client_token, options={"verify_signature": False}) - except Exception as e: - logging.exception(e) - logging.info("Access Denied. Encountered error decoding supplied authentication token.") - token_failure = True - - if token_failure: + # Validate session cookie + session_data = _validate_session_cookie(event) + + if not session_data: + logger.info("Access Denied. No valid session found.") return { "principalId": "Unknown", "policyDocument": {"Version": "2012-10-17", "Statement": [policy_statement]}, "context": response_context, } - username = urllib.parse.unquote(token_info["preferred_username"]).replace(",", "-").replace("=", "-").replace(" ", "-") - - # Only run through the auth logic if the token has not yet expired - if token_info["exp"] > time.time(): - # Look up user record - user = user_dao.get(username) - IS_ADMIN = Permission.ADMIN in user.permissions if user else False - - if requested_resource == "/user" and request_method == "POST": - logger.info("Attempting to create new user account...") - # Anyone can create a user account + # Extract user information from session + user_data = session_data["data"]["user"] + username = user_data["id"] + + # Normalize username for AWS IAM principal ID compatibility + # Replace special characters that aren't allowed in principal IDs + username = urllib.parse.unquote(username).replace(",", "-").replace("=", "-").replace(" ", "-") + + # Look up user record from database to get permissions and suspension status + user = user_dao.get(username) + + if not user: + # User doesn't exist in database - this shouldn't happen in normal flow + # but we'll create a minimal user object for authorization + logger.warning(f"User {username} found in session but not in database") + user = UserModel( + username=username, + email=user_data["email"], + display_name=user_data["displayName"], + permissions=[], # No permissions if not in database + suspended=False, # Session existence implies user is not suspended + ) + + IS_ADMIN = Permission.ADMIN in user.permissions if user else False + + if requested_resource.startswith("/auth"): + logger.info("Accessing auth API...") + # Anyone can create a user account + policy_statement["Effect"] = "Allow" + # users are now created as part of the login process + # elif requested_resource == "/user" and request_method == "POST": + # logger.info("Attempting to create new user account...") + # # Anyone can create a user account + # policy_statement["Effect"] = "Allow" + elif user.suspended: + if (requested_resource == "/login" and request_method == "PUT") or ( + requested_resource == "/current-user" and request_method == "GET" + ): + logger.info(f"User: '{username}' is currently suspended. Only login/current-user is allowed.") policy_statement["Effect"] = "Allow" - elif not user: - logger.info(f"Access Denied. Unknown user: '{username}'") - elif user.suspended: - if (requested_resource == "/login" and request_method == "PUT") or ( - requested_resource == "/current-user" and request_method == "GET" - ): - logger.info(f"User: '{username}' is currently suspended. Only login/current-user is allowed.") - policy_statement["Effect"] = "Allow" - else: - logger.info(f"Access Denied. User: '{username}' is currently suspended.") else: - # Check route access restrictions - response_context = {"user": json.dumps(user.to_dict())} + logger.info(f"Access Denied. User: '{username}' is currently suspended.") + else: + # Check route access restrictions + response_context = {"user": json.dumps(user.to_dict())} - # Create/Download/Delete/List Reports - if requested_resource.startswith("/report") and IS_ADMIN and request_method in ["GET", "DELETE", "POST"]: - policy_statement["Effect"] = "Allow" - # If the route has path params then we need to check project membership/resource ownership - elif path_params: - # Updating / deleting a user requires admin privileges or the user - # making the request must be the user getting updated - if ( - requested_resource.startswith("/user/") - and "username" in path_params - and request_method in ["GET", "PUT", "DELETE"] - ): - if IS_ADMIN: + # Create/Download/Delete/List Reports + if requested_resource.startswith("/report") and IS_ADMIN and request_method in ["GET", "DELETE", "POST"]: + policy_statement["Effect"] = "Allow" + # If the route has path params then we need to check project membership/resource ownership + elif path_params: + # Updating / deleting a user requires admin privileges or the user + # making the request must be the user getting updated + if ( + requested_resource.startswith("/user/") + and "username" in path_params + and request_method in ["GET", "PUT", "DELETE"] + ): + if IS_ADMIN: + policy_statement["Effect"] = "Allow" + elif path_params["username"] == user.username and request_method == "PUT": + # Users can update their own account preferences + policy_statement["Effect"] = "Allow" + else: + logger.info(f"Access Denied. User: '{username}' does not have permission to modify users.") + # Path params need to be checked individually + elif "projectName" in path_params: + project_name = path_params["projectName"] + # User must belong to the project for any project specific resources + if IS_ADMIN or is_member_of_project(user.username, project_name): + IS_OWNER = is_owner_of_project(user.username, project_name) + project_user = project_user_dao.get(project_name, username) + # User must be an owner or admin to add/remove users or update the project config + if ( + ( + request_method == "POST" + and ( + requested_resource.endswith("/users") + or requested_resource.endswith("/groups") + or requested_resource.endswith("/app-config") + ) + ) + or ( + request_method in ["PUT", "DELETE"] + and len(path_params) == 2 + and ("username" in path_params or "groupName" in path_params) + ) + ) and (project_user and not IS_OWNER and not IS_ADMIN): + logging.info(f"Access Denied. User: '{username}' does not have project user management permissions.") + # User must be a project owner to delete/update a project + elif ( + len(path_params) == 1 + and request_method in ["PUT", "DELETE"] + and (project_user and not IS_OWNER and not IS_ADMIN) + ): + logging.info(f"Access Denied. User: '{username}' does not have project management permission.") + # Check if there is a second param here and we're updating users... + else: policy_statement["Effect"] = "Allow" - elif path_params["username"] == user.username and request_method == "PUT": - # Users can update their own account preferences + elif "clusterId" in path_params: + try: + if _handle_emr_request(request_method, path_params, user, response_context): policy_statement["Effect"] = "Allow" - else: - logger.info(f"Access Denied. User: '{username}' does not have permission to modify users.") - # Path params need to be checked individually - elif "projectName" in path_params: - project_name = path_params["projectName"] - # User must belong to the project for any project specific resources - if IS_ADMIN or is_member_of_project(user.username, project_name): - IS_OWNER = is_owner_of_project(user.username, project_name) - project_user = project_user_dao.get(project_name, username) - # User must be an owner or admin to add/remove users or update the project config - if ( - ( - request_method == "POST" - and ( - requested_resource.endswith("/users") - or requested_resource.endswith("/groups") - or requested_resource.endswith("/app-config") - ) - ) - or ( - request_method in ["PUT", "DELETE"] - and len(path_params) == 2 - and ("username" in path_params or "groupName" in path_params) - ) - ) and (project_user and not IS_OWNER and not IS_ADMIN): - logging.info( - f"Access Denied. User: '{username}' does not have project user management permissions." - ) - # User must be a project owner to delete/update a project - elif ( - len(path_params) == 1 - and request_method in ["PUT", "DELETE"] - and (project_user and not IS_OWNER and not IS_ADMIN) - ): - logging.info(f"Access Denied. User: '{username}' does not have project management permission.") - # Check if there is a second param here and we're updating users... - else: - policy_statement["Effect"] = "Allow" - elif "clusterId" in path_params: - try: - if _handle_emr_request(request_method, path_params, user, response_context): - policy_statement["Effect"] = "Allow" - except Exception as e: - logging.exception(e) - logging.info("Access Denied. Encountered error while determining EMR access policy.") - elif "notebookName" in path_params: + except Exception as e: + logging.exception(e) + logging.info("Access Denied. Encountered error while determining EMR access policy.") + elif "notebookName" in path_params: + try: + if _handle_notebook_request( + requested_resource, + request_method, + path_params, + user, + response_context, + ): + policy_statement["Effect"] = "Allow" + except Exception as e: + logging.exception(e) + logging.info("Access Denied. Encountered error while determining notebook access policy.") + elif "scope" in path_params: + if "datasetName" in path_params: try: - if _handle_notebook_request( - requested_resource, + if _handle_dataset_request( request_method, path_params, user, - response_context, ): policy_statement["Effect"] = "Allow" except Exception as e: logging.exception(e) - logging.info("Access Denied. Encountered error while determining notebook access policy.") - elif "scope" in path_params: - if "datasetName" in path_params: - try: - if _handle_dataset_request( - request_method, - path_params, - user, - ): - policy_statement["Effect"] = "Allow" - except Exception as e: - logging.exception(e) - logging.info("Access Denied. Encountered error while determining dataset access policy.") - elif "jobId" in path_params: - if IS_ADMIN: + logging.info("Access Denied. Encountered error while determining dataset access policy.") + elif "jobId" in path_params: + if IS_ADMIN: + policy_statement["Effect"] = "Allow" + else: + job = resource_metadata_dao.get(path_params["jobId"], ResourceType.BATCH_TRANSLATE_JOB) + response_context["projectName"] = job.project + project_user = project_user_dao.get(job.project, user.username) + if project_user and Permission.PROJECT_OWNER in project_user.permissions: policy_statement["Effect"] = "Allow" else: - job = resource_metadata_dao.get(path_params["jobId"], ResourceType.BATCH_TRANSLATE_JOB) - response_context["projectName"] = job.project - project_user = project_user_dao.get(job.project, user.username) - if project_user and Permission.PROJECT_OWNER in project_user.permissions: + if job.user == user.username and project_user: policy_statement["Effect"] = "Allow" - else: - if job.user == user.username and project_user: + elif request_method == "POST": + logging.info(f"Access Denied. User: '{user.username}' does not have permission to stop this job.") + elif request_method == "GET": + # if user is part of the project, they can view this translate job + if project_user: policy_statement["Effect"] = "Allow" - elif request_method == "POST": - logging.info( - f"Access Denied. User: '{user.username}' does not have permission to stop this job." - ) - elif request_method == "GET": - # if user is part of the project, they can view this translate job - if project_user: - policy_statement["Effect"] = "Allow" - elif "groupName" in path_params: - is_group_member = _is_group_member(path_params["groupName"], username) - if IS_ADMIN: - policy_statement["Effect"] = "Allow" - elif request_method == "GET" and is_group_member: - policy_statement["Effect"] = "Allow" - else: - # All other sagemaker resources have the same general handling, GET calls - # typically require ADMIN or project membership, PUT/POST/DELETE typically - # require ADMIN or ownership of the resource. Additional comments for - # decisions can be found in the _allow_project_resources_read method. - job_type = "" - if ( - requested_resource.endswith("/logs") - and "/notebook" not in requested_resource - and "/endpoint" not in requested_resource + elif "groupName" in path_params: + is_group_member = _is_group_member(path_params["groupName"], username) + if IS_ADMIN: + policy_statement["Effect"] = "Allow" + elif request_method == "GET" and is_group_member: + policy_statement["Effect"] = "Allow" + else: + # All other sagemaker resources have the same general handling, GET calls + # typically require ADMIN or project membership, PUT/POST/DELETE typically + # require ADMIN or ownership of the resource. Additional comments for + # decisions can be found in the _allow_project_resources_read method. + job_type = "" + if ( + requested_resource.endswith("/logs") + and "/notebook" not in requested_resource + and "/endpoint" not in requested_resource + ): + job_type = path_params["jobType"] + + try: + if _allow_project_resource_action( + user, + request_method, + path_params, + requested_resource, + response_context, + job_type, ): - job_type = path_params["jobType"] - - try: - if _allow_project_resource_action( - user, - request_method, - path_params, - requested_resource, - response_context, - job_type, - ): - policy_statement["Effect"] = "Allow" - except Exception as e: - logging.exception(e) - logging.info("Access Denied. Encountered error while determining resource access policy.") - elif requested_resource == "/app-config" and request_method == "POST" and IS_ADMIN: - # Operations for app-wide configuration can only be performed by admins - policy_statement["Effect"] = "Allow" - elif requested_resource == "/login" and request_method == "PUT": + policy_statement["Effect"] = "Allow" + except Exception as e: + logging.exception(e) + logging.info("Access Denied. Encountered error while determining resource access policy.") + elif requested_resource == "/app-config" and request_method == "POST" and IS_ADMIN: + # Operations for app-wide configuration can only be performed by admins + policy_statement["Effect"] = "Allow" + elif requested_resource == "/login" and request_method == "PUT": + policy_statement["Effect"] = "Allow" + elif ( + (requested_resource == "/config" and request_method == "GET") or requested_resource.startswith("/admin/") + ) and IS_ADMIN: + policy_statement["Effect"] = "Allow" + elif requested_resource == "/project" and request_method == "POST": + if IS_ADMIN: policy_statement["Effect"] = "Allow" - elif ( - (requested_resource == "/config" and request_method == "GET") or requested_resource.startswith("/admin/") - ) and IS_ADMIN: + else: + # Get the latest app config + app_config = get_app_config() + # Check if project creation is admin only; if not, anyone can create a project + if not app_config.configuration.project_creation.admin_only: + policy_statement["Effect"] = "Allow" + elif requested_resource == "/group" and request_method == "POST": + if IS_ADMIN: policy_statement["Effect"] = "Allow" - elif requested_resource == "/project" and request_method == "POST": + elif requested_resource in ["/dataset/presigned-url", "/dataset/create"]: + # If this is a request for a dataset related presigned url or for + # creating a new dataset, we need to determine the underlying dataset + # and whether the user should have access to it + if "x-mlspace-dataset-type" in event["headers"] and "x-mlspace-dataset-scope" in event["headers"]: + target_type = event["headers"]["x-mlspace-dataset-type"] + target_scope = event["headers"]["x-mlspace-dataset-scope"] if IS_ADMIN: policy_statement["Effect"] = "Allow" - else: - # Get the latest app config - app_config = get_app_config() - # Check if project creation is admin only; if not, anyone can create a project - if not app_config.configuration.project_creation.admin_only: - policy_statement["Effect"] = "Allow" - elif requested_resource == "/group" and request_method == "POST": - if IS_ADMIN: + elif target_type == DatasetType.GLOBAL: policy_statement["Effect"] = "Allow" - elif requested_resource in ["/dataset/presigned-url", "/dataset/create"]: - # If this is a request for a dataset related presigned url or for - # creating a new dataset, we need to determine the underlying dataset - # and whether the user should have access to it - if "x-mlspace-dataset-type" in event["headers"] and "x-mlspace-dataset-scope" in event["headers"]: - target_type = event["headers"]["x-mlspace-dataset-type"] - target_scope = event["headers"]["x-mlspace-dataset-scope"] - if IS_ADMIN: - policy_statement["Effect"] = "Allow" - elif target_type == DatasetType.GLOBAL: + elif target_type == DatasetType.PROJECT: + project_user = project_user_dao.get(target_scope, username) + if project_user: policy_statement["Effect"] = "Allow" - elif target_type == DatasetType.PROJECT: + else: project_user = project_user_dao.get(target_scope, username) if project_user: policy_statement["Effect"] = "Allow" - else: - project_user = project_user_dao.get(target_scope, username) - if project_user: - policy_statement["Effect"] = "Allow" - elif target_type == DatasetType.PRIVATE and username == target_scope: - policy_statement["Effect"] = "Allow" - elif target_type == DatasetType.GROUP: - user_groups = group_user_dao.get_groups_for_user(username) - user_group_names = set() - for user_group in user_groups: - user_group_names.add(user_group.group) - - if requested_resource == "/dataset/create": - groups = target_scope.split(",") - # check that this user is a member of every group they're adding to the group dataset - is_valid_group_list = True - for group_names in user_group_names: - if group_names not in groups: - is_valid_group_list = False - if is_valid_group_list: + elif target_type == DatasetType.PRIVATE and username == target_scope: + policy_statement["Effect"] = "Allow" + elif target_type == DatasetType.GROUP: + user_groups = group_user_dao.get_groups_for_user(username) + user_group_names = set() + for user_group in user_groups: + user_group_names.add(user_group.group) + + if requested_resource == "/dataset/create": + groups = target_scope.split(",") + # check that this user is a member of every group they're adding to the group dataset + is_valid_group_list = True + for group_names in user_group_names: + if group_names not in groups: + is_valid_group_list = False + if is_valid_group_list: + policy_statement["Effect"] = "Allow" + elif requested_resource == "/dataset/presigned-url": + groups = group_dataset_dao.get_groups_for_dataset(target_scope) + for group in groups: + # validate the user is a member of at least one group associated with this dataset + if group.group in user_group_names: policy_statement["Effect"] = "Allow" - elif requested_resource == "/dataset/presigned-url": - groups = group_dataset_dao.get_groups_for_dataset(target_scope) - for group in groups: - # validate the user is a member of at least one group associated with this dataset - if group.group in user_group_names: - policy_statement["Effect"] = "Allow" - break + break - else: - logger.info( - "Missing one or more required headers 'x-mlspace-dataset-type', " - " 'x-mlspace-dataset-scope' for request." - ) - elif ( - requested_resource in ["/metadata/find-public-amis"] or requested_resource.startswith("/translate/realtime") - ) and request_method == "POST": - policy_statement["Effect"] = "Allow" - elif ( - requested_resource - in [ - "/notebook", - "/endpoint", - "/model", - "/endpoint-config", - "/emr", - "/batch-translate", - ] - or requested_resource.startswith("/job/") - ) and request_method == "POST": - # If a user is attempting to create a job, notebook, endpoint, - # endpoint-config, or model we need to inspect the request to - # determining what project they're - # creating the job within the scope of - if "x-mlspace-project" in event["headers"]: - project_name = event["headers"]["x-mlspace-project"] - project_user = project_user_dao.get(project_name, username) - if project_user: - policy_statement["Effect"] = "Allow" - else: - logger.info("Missing required header 'x-mlspace-project' for request.") - elif ( - requested_resource - in [ - "/notebook", - "/dataset", - "/current-user", - "/user", - "/model/images", - "/metadata/compute-types", - "/metadata/notebook-options", - "/metadata/subnets", - "/translate/list-languages", - "/project", - "/group", - "/emr", - "/emr/applications", - "/emr/release", - "/translate/custom-terminologies", - ] - ) and request_method == "GET": - # None of these paths require specific permissions, most will be scoped - # to the current user or don't care about the user at al (metadata related) - policy_statement["Effect"] = "Allow" else: - logger.info("Unhandled route. Access denied by default.") - else: - logger.info(f"Access Denied. Token is expired for user: '{username}'.") + logger.info( + "Missing one or more required headers 'x-mlspace-dataset-type', " " 'x-mlspace-dataset-scope' for request." + ) + elif ( + requested_resource in ["/metadata/find-public-amis"] or requested_resource.startswith("/translate/realtime") + ) and request_method == "POST": + policy_statement["Effect"] = "Allow" + elif ( + requested_resource + in [ + "/notebook", + "/endpoint", + "/model", + "/endpoint-config", + "/emr", + "/batch-translate", + ] + or requested_resource.startswith("/job/") + ) and request_method == "POST": + # If a user is attempting to create a job, notebook, endpoint, + # endpoint-config, or model we need to inspect the request to + # determining what project they're + # creating the job within the scope of + if "x-mlspace-project" in event["headers"]: + project_name = event["headers"]["x-mlspace-project"] + project_user = project_user_dao.get(project_name, username) + if project_user: + policy_statement["Effect"] = "Allow" + else: + logger.info("Missing required header 'x-mlspace-project' for request.") + elif ( + requested_resource + in [ + "/notebook", + "/dataset", + "/current-user", + "/user", + "/model/images", + "/metadata/compute-types", + "/metadata/notebook-options", + "/metadata/subnets", + "/translate/list-languages", + "/project", + "/group", + "/emr", + "/emr/applications", + "/emr/release", + "/translate/custom-terminologies", + ] + ) and request_method == "GET": + # None of these paths require specific permissions, most will be scoped + # to the current user or don't care about the user at al (metadata related) + policy_statement["Effect"] = "Allow" + else: + logger.info("Unhandled route. Access denied by default.") return { "principalId": username, @@ -634,36 +709,6 @@ def _allow_project_resource_action( return False -def _get_oidc_props(key_id: str) -> Tuple[Optional[str], Optional[str]]: - oidc_client_name = os.getenv("OIDC_CLIENT_NAME") - - global oidc_keys - if key_id not in oidc_keys: - oidc_endpoint = os.getenv("OIDC_URL") - if not oidc_client_name or not oidc_endpoint: - logging.error( - "Unable to retrieve OIDC configuration. Please ensure the environment " "variables are properly configured" - ) - raise ValueError("Missing OIDC environment variables.") - # Grab cert endpoint from well known config - response = http.request("GET", f"{oidc_endpoint}/.well-known/openid-configuration") - well_known_config = json.loads(response.data.decode("utf-8")) - if "jwks_uri" not in well_known_config: - logging.error("Unable to retrieve OIDC configuration. JWKS_URI not found in well known config.") - raise ValueError("Missing JWKS_URI.") - # Grab certs from jwks_uri endpoint - jwks_response = http.request("GET", f"{well_known_config['jwks_uri']}") - key_data = json.loads(jwks_response.data.decode("utf-8")) - for key in key_data["keys"]: - oidc_keys[key["kid"]] = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(key)) - - if key_id not in oidc_keys: - logging.info(f"Unable to finding matching OIDC public key for id '{key_id}'.") - raise ValueError("Missing OIDC configuration parameters.") - - return (oidc_keys[key_id], oidc_client_name) - - def _is_group_member(group_name: str, username: str) -> bool: group = group_user_dao.get(group_name, username) if group: diff --git a/backend/src/ml_space_lambda/data_access_objects/user.py b/backend/src/ml_space_lambda/data_access_objects/user.py index 66a6afc9..1a6dfd3c 100644 --- a/backend/src/ml_space_lambda/data_access_objects/user.py +++ b/backend/src/ml_space_lambda/data_access_objects/user.py @@ -41,6 +41,7 @@ def __init__( created_at: Optional[float] = None, last_login: Optional[float] = None, preferences: Optional[dict] = {}, + id: Optional[str] = None, ): now = int(time.time()) self.username = username @@ -51,9 +52,10 @@ def __init__( self.created_at = created_at if created_at else now self.last_login = last_login if last_login else now self.preferences = preferences + self.id = id # Durable IdP identifier (e.g., OIDC "sub" claim) def to_dict(self) -> dict: - return { + result = { "username": self.username, "email": self.email, "displayName": self.display_name, @@ -63,6 +65,10 @@ def to_dict(self) -> dict: "lastLogin": self.last_login, "preferences": self.preferences, } + # Only include id if it's set + if self.id is not None: + result["id"] = self.id + return result @staticmethod def from_dict(dict_object: dict) -> UserModel: @@ -75,6 +81,7 @@ def from_dict(dict_object: dict) -> UserModel: dict_object.get("createdAt", None), dict_object.get("lastLogin", None), dict_object.get("preferences", {}), + dict_object.get("id", None), # Gracefully handle missing id field ) @@ -91,23 +98,27 @@ def update(self, username: str, user: UserModel) -> UserModel: json_key = {"username": username} # Only a subset of fields can be modified update_exp = "SET #p = :permissions, suspended = :suspended, lastLogin = :lastLogin, preferences = :preferences" - exp_values = json.loads( - dynamodb_json.dumps( - { - ":permissions": serialize_permissions(user.permissions), - ":suspended": user.suspended, - ":lastLogin": user.last_login, - ":preferences": user.preferences, - ":username": username, - } - ) - ) + exp_values = { + ":permissions": serialize_permissions(user.permissions), + ":suspended": user.suspended, + ":lastLogin": user.last_login, + ":preferences": user.preferences, + ":username": username, + } + + # Add id to update if it's set (for backfilling existing users) + if user.id is not None: + update_exp += ", id = :id" + exp_values[":id"] = user.id + + exp_values_json = json.loads(dynamodb_json.dumps(exp_values)) exp_names = {"#p": "permissions"} + self._update( json_key=json_key, update_expression=update_exp, expression_names=exp_names, - expression_values=exp_values, + expression_values=exp_values_json, condition_expression="username = :username", ) return self._retrieve(json_key) diff --git a/backend/src/ml_space_lambda/dataset/lambda_functions.py b/backend/src/ml_space_lambda/dataset/lambda_functions.py index a87024bc..adf46cea 100644 --- a/backend/src/ml_space_lambda/dataset/lambda_functions.py +++ b/backend/src/ml_space_lambda/dataset/lambda_functions.py @@ -15,7 +15,6 @@ # import json -import re from urllib.parse import unquote import boto3 @@ -49,8 +48,6 @@ iam = boto3.client("iam", config=retry_config) iam_manager = IAMManager(iam) -dataset_description_regex = re.compile(r"[^ -~]") - def get_dataset_prefix(scope, dataset_name): dataset = dataset_dao.get(scope, dataset_name) @@ -106,8 +103,6 @@ def edit(event, context): if "description" in body: if len(body["description"]) > 254: raise Exception("Dataset description is over the max length of 254 characters.") - if dataset_description_regex.search(body["description"]): - raise Exception("Dataset description contains invalid character.") if dataset.type == DatasetType.GROUP: # get the new list of groups that have this dataset shared with them. # this list may be adding or removing existing groups from this dataset diff --git a/backend/src/ml_space_lambda/enums.py b/backend/src/ml_space_lambda/enums.py index 35d7a2f1..5492cbf0 100644 --- a/backend/src/ml_space_lambda/enums.py +++ b/backend/src/ml_space_lambda/enums.py @@ -112,7 +112,7 @@ def __str__(self): EMR_SECURITY_CONFIGURATION = "EMR_SECURITY_CONFIGURATION" EMR_EC2_SSH_KEY = "EMR_EC2_SSH_KEY" ENDPOINT_CONFIG_INSTANCE_CONSTRAINT_POLICY_ARN = "ENDPOINT_CONFIG_INSTANCE_CONSTRAINT_POLICY_ARN" - NEW_USER_SUSPENSION_DEFAULT = "NEW_USER_SUSPENSION_DEFAULT" + NEW_USERS_SUSPENDED = "NEW_USERS_SUSPENDED" TRANSLATE_DATE_ROLE_ARN = "TRANSLATE_DATE_ROLE_ARN" NOTEBOOK_ROLE_NAME = "NOTEBOOK_ROLE_NAME" APP_ROLE_NAME = "APP_ROLE_NAME" diff --git a/backend/src/ml_space_lambda/user/lambda_functions.py b/backend/src/ml_space_lambda/user/lambda_functions.py index c9194eba..c0b051a9 100644 --- a/backend/src/ml_space_lambda/user/lambda_functions.py +++ b/backend/src/ml_space_lambda/user/lambda_functions.py @@ -21,8 +21,8 @@ from ml_space_lambda.data_access_objects.group_user import GroupUserDAO from ml_space_lambda.data_access_objects.project_user import ProjectUserDAO -from ml_space_lambda.data_access_objects.user import TIMEZONE_PREFERENCE_KEY, UserDAO, UserModel -from ml_space_lambda.enums import EnvVariable, Permission, TimezonePreference +from ml_space_lambda.data_access_objects.user import UserDAO, UserModel +from ml_space_lambda.enums import EnvVariable, Permission from ml_space_lambda.utils.common_functions import api_wrapper, serialize_permissions, total_project_owners from ml_space_lambda.utils.exceptions import ResourceNotFound from ml_space_lambda.utils.iam_manager import IAMManager @@ -34,29 +34,6 @@ iam_manager = IAMManager() -@api_wrapper -def create(event, context): - entity = json.loads(event["body"]) - username = entity["username"] - suspended_state = get_environment_variables().get("NEW_USER_SUSPENSION_DEFAULT") == "True" - preferences = {TIMEZONE_PREFERENCE_KEY: TimezonePreference.LOCAL} - - existing_user = user_dao.get(username) - if existing_user: - raise ValueError("Username in use.") - - new_user = UserModel( - username=username, - email=entity["email"], - display_name=entity["name"], - suspended=suspended_state, - preferences=preferences, - ) - user_dao.create(new_user) - - return new_user.to_dict() - - @api_wrapper def delete(event, context): username = urllib.parse.unquote(event["pathParameters"]["username"]) diff --git a/backend/src/ml_space_lambda/utils/mlspace_config.py b/backend/src/ml_space_lambda/utils/mlspace_config.py index b6988520..600a3362 100644 --- a/backend/src/ml_space_lambda/utils/mlspace_config.py +++ b/backend/src/ml_space_lambda/utils/mlspace_config.py @@ -44,7 +44,7 @@ EnvVariable.KMS_INSTANCE_CONDITIONS_POLICY_ARN: "", EnvVariable.LOG_BUCKET: "mlspace-log-bucket", EnvVariable.MANAGE_IAM_ROLES: "", - EnvVariable.NEW_USER_SUSPENSION_DEFAULT: "True", + EnvVariable.NEW_USERS_SUSPENDED: "False", EnvVariable.NOTEBOOK_ROLE_NAME: "", EnvVariable.RESOURCE_METADATA_TABLE: "mlspace-resource-metadata", EnvVariable.RESOURCE_SCHEDULE_TABLE: "mlspace-resource-schedule", diff --git a/backend/test/auth/__init__.py b/backend/test/auth/__init__.py new file mode 100644 index 00000000..f9de63fb --- /dev/null +++ b/backend/test/auth/__init__.py @@ -0,0 +1,15 @@ +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# diff --git a/backend/test/auth/handlers/__init__.py b/backend/test/auth/handlers/__init__.py new file mode 100644 index 00000000..f9de63fb --- /dev/null +++ b/backend/test/auth/handlers/__init__.py @@ -0,0 +1,15 @@ +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# diff --git a/backend/test/auth/handlers/test_oidc_handler.py b/backend/test/auth/handlers/test_oidc_handler.py new file mode 100644 index 00000000..947bf582 --- /dev/null +++ b/backend/test/auth/handlers/test_oidc_handler.py @@ -0,0 +1,568 @@ +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from unittest.mock import Mock, patch + +import pytest + +from ml_space_lambda.auth.handlers.oidc_handler import OIDCConfig, OIDCHandler + + +class TestOIDCHandler: + """Test OIDC authentication handler.""" + + def setup_method(self): + """Set up test fixtures.""" + self.config = OIDCConfig( + issuer_url="https://example.com", + client_id="test-client-id", + client_secret="test-client-secret", + scopes=["openid", "profile", "email"], + ) + + # Mock discovery document + self.discovery_doc = { + "authorization_endpoint": "https://example.com/auth", + "token_endpoint": "https://example.com/token", + "userinfo_endpoint": "https://example.com/userinfo", + "jwks_uri": "https://example.com/jwks", + "end_session_endpoint": "https://example.com/logout", + } + + def test_init_missing_required_config(self): + """Test initialization with missing required configuration.""" + with pytest.raises(ValueError): + OIDCConfig() + + with pytest.raises(ValueError): + OIDCConfig(issuer_url="https://example.com") + + @patch("requests.get") + def test_init_successful_discovery(self, mock_get): + """Test successful OIDC discovery.""" + mock_response = Mock() + mock_response.ok = True + mock_response.json.return_value = self.discovery_doc + mock_get.return_value = mock_response + + handler = OIDCHandler(self.config) + + assert handler.config.issuer_url == "https://example.com" + assert handler.config.client_id == "test-client-id" + assert handler.config.client_secret == "test-client-secret" + assert handler.authorization_endpoint == "https://example.com/auth" + assert handler.token_endpoint == "https://example.com/token" + + @patch("requests.get") + def test_init_discovery_failure(self, mock_get): + """Test OIDC discovery failure.""" + mock_get.side_effect = Exception("Network error") + + with pytest.raises(Exception, match="Failed to discover OIDC endpoints"): + OIDCHandler(self.config) + + @patch("requests.get") + def test_get_authorization_url(self, mock_get): + """Test authorization URL generation.""" + mock_response = Mock() + mock_response.ok = True + mock_response.json.return_value = self.discovery_doc + mock_get.return_value = mock_response + + handler = OIDCHandler(self.config) + + # Generate code_verifier for PKCE + from authlib.common.security import generate_token + + code_verifier = generate_token(48) + + auth_url = handler.get_authorization_url("test-state", "https://app.example.com/callback", code_verifier=code_verifier) + + assert "https://example.com/auth" in auth_url + assert "client_id=test-client-id" in auth_url + assert "state=test-state" in auth_url + assert "redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback" in auth_url + + def test_normalize_user_data(self): + """Test user data normalization.""" + # Create handler without discovery for testing + handler = self._create_test_handler() + + raw_data = { + "sub": "user123", + "name": "John Doe", + "email": "john.doe@example.com", + "groups": ["admin", "users"], + "department": "Engineering", + "iss": "https://example.com", + "aud": "test-client", + "exp": 1234567890, + } + + normalized = handler.normalize_user_data(raw_data) + + # The ID should be derived from email prefix (fallback when preferred_username not present) + assert normalized.id == "john.doe" + assert normalized.displayName == "John Doe" + assert normalized.email == "john.doe@example.com" + assert set(normalized.groups) == {"admin", "users"} + assert normalized.attributes["department"] == "Engineering" + # sub should be stored in attributes for reference + assert normalized.attributes["sub"] == "user123" + # Standard OIDC claims should not be in attributes + assert "iss" not in normalized.attributes + assert "aud" not in normalized.attributes + + def test_normalize_user_data_fallbacks(self): + """Test user data normalization with fallback values.""" + handler = self._create_test_handler() + + # Test with minimal data + raw_data = {"preferred_username": "jdoe", "given_name": "John", "family_name": "Doe"} + + normalized = handler.normalize_user_data(raw_data) + + assert normalized.id == "jdoe" + assert normalized.displayName == "John Doe" + assert normalized.email == "" + assert normalized.groups == [] + + def test_extract_token_expiration(self): + """Test token expiration extraction.""" + handler = self._create_test_handler() + + from ml_space_lambda.auth.models.auth_models import IdPTokens + + # Test with standard expires_in + tokens = IdPTokens(access_token="token123", expires_in=3600, refresh_token="refresh123") + + access_exp, refresh_exp = handler.extract_token_expiration(tokens) + assert access_exp == 3600 + assert refresh_exp is None + + # Test with refresh expiration + tokens = IdPTokens(access_token="token123", expires_in=3600, refresh_token="refresh123", refresh_expires_in=7200) + access_exp, refresh_exp = handler.extract_token_expiration(tokens) + assert access_exp == 3600 + assert refresh_exp == 7200 + + # Test with missing expiration (should default to 1 hour) + tokens = IdPTokens(access_token="token123") + access_exp, refresh_exp = handler.extract_token_expiration(tokens) + assert access_exp == 3600 + assert refresh_exp is None + + def test_get_user_info(self): + """Test user info retrieval.""" + handler = self._create_test_handler() + + user_data = {"sub": "user123", "name": "John Doe", "email": "john.doe@example.com"} + + # Mock the normalize_user_data method to return expected result + with patch.object(handler, "normalize_user_data") as mock_normalize: + from ml_space_lambda.auth.models.auth_models import UserData + + expected_user = UserData(id="user123", displayName="John Doe", email="john.doe@example.com") + mock_normalize.return_value = expected_user + + # Mock OAuth2Session.get method + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = user_data + + # Patch OAuth2Session at the module level where it's used + with patch("ml_space_lambda.auth.handlers.oidc_handler.OAuth2Session") as mock_session_class: + mock_session = Mock() + mock_session.get.return_value = mock_response + mock_session_class.return_value = mock_session + + result = handler.get_user_info("test-access-token") + + assert result.id == "user123" + assert result.displayName == "John Doe" + assert result.email == "john.doe@example.com" + + # Verify normalize_user_data was called with the response + mock_normalize.assert_called_once_with(user_data) + + @patch("requests.get") + def test_get_user_info_failure(self, mock_get): + """Test user info retrieval failure.""" + handler = self._create_test_handler() + + mock_response = Mock() + mock_response.ok = False + mock_response.status_code = 401 + mock_get.return_value = mock_response + + result = handler.get_user_info("invalid-token") + + assert result.id == "" + + def test_validate_token(self): + """Test token validation.""" + handler = self._create_test_handler() + + from ml_space_lambda.auth.models.auth_models import UserData + + # Mock get_user_info to return user data for valid token + with patch.object(handler, "get_user_info") as mock_get_user_info: + mock_get_user_info.return_value = UserData(id="user123", displayName="Test", email="test@example.com") + assert handler.validate_token("valid-token") is True + + mock_get_user_info.return_value = UserData(id="", displayName="", email="") + assert handler.validate_token("invalid-token") is False + + def test_get_logout_url(self): + """Test logout URL generation.""" + handler = self._create_test_handler() + + # Test without post-logout redirect + logout_url = handler.get_logout_url() + assert logout_url == "https://example.com/logout" + + # Test with post-logout redirect + logout_url = handler.get_logout_url("https://app.example.com/") + assert "https://example.com/logout" in logout_url + assert "post_logout_redirect_uri=https%3A%2F%2Fapp.example.com%2F" in logout_url + + def test_get_logout_url_no_endpoint(self): + """Test logout URL when endpoint not available.""" + handler = self._create_test_handler() + handler.end_session_endpoint = None + + logout_url = handler.get_logout_url() + assert logout_url is None + + def test_refresh_tokens_success(self): + """Test successful token refresh.""" + from authlib.oauth2.rfc6749 import OAuth2Token + + from ml_space_lambda.auth.models.auth_models import UserData + + handler = self._create_test_handler() + + new_oauth_token = OAuth2Token( + { + "access_token": "new-access-token", + "refresh_token": "new-refresh-token", + "expires_in": 3600, + "token_type": "Bearer", + } + ) + + # Mock authlib's refresh_token method + with patch.object(handler.oauth_session, "refresh_token") as mock_refresh: + mock_refresh.return_value = new_oauth_token + + # Mock user info retrieval + with patch.object(handler, "_get_user_info_from_oauth_token") as mock_get_user_info: + mock_get_user_info.return_value = UserData(id="user123", displayName="John Doe", email="john@example.com") + + result = handler.refresh_tokens("old-refresh-token") + + assert result.success is True + assert result.tokens.access_token == "new-access-token" + assert result.user_data.id == "user123" + mock_refresh.assert_called_once_with(handler.token_endpoint, refresh_token="old-refresh-token") + + def test_refresh_tokens_failure(self): + """Test token refresh failure.""" + handler = self._create_test_handler() + + # Mock authlib's refresh_token method to raise an exception + with patch.object(handler.oauth_session, "refresh_token") as mock_refresh: + mock_refresh.side_effect = Exception("Invalid refresh token") + + result = handler.refresh_tokens("invalid-refresh-token") + + assert result.success is False + assert "Token refresh failed: Invalid refresh token" in result.error + + def _create_test_handler(self): + """Create OIDC handler for testing without discovery.""" + + class TestOIDCHandler(OIDCHandler): + def _discover_endpoints(self): + self.authorization_endpoint = "https://example.com/auth" + self.token_endpoint = "https://example.com/token" + self.userinfo_endpoint = "https://example.com/userinfo" + self.jwks_uri = "https://example.com/jwks" + self.end_session_endpoint = "https://example.com/logout" + + return TestOIDCHandler(self.config) + + def test_get_authorization_url_without_pkce(self): + """Test authorization URL generation without PKCE.""" + config = OIDCConfig( + issuer_url="https://example.com", client_id="test-client-id", client_secret="secret", use_pkce=False + ) + + with patch("requests.get") as mock_get: + mock_response = Mock() + mock_response.ok = True + mock_response.json.return_value = self.discovery_doc + mock_get.return_value = mock_response + + handler = OIDCHandler(config) + + auth_url = handler.get_authorization_url("test-state", "https://app.example.com/callback") + + assert "https://example.com/auth" in auth_url + assert "client_id=test-client-id" in auth_url + # Should not have code_challenge when PKCE is disabled + assert "code_challenge" not in auth_url + + def test_get_authorization_url_missing_code_verifier(self): + """Test authorization URL generation with PKCE but missing code_verifier.""" + handler = self._create_test_handler() + + with pytest.raises(Exception, match="Authorization URL generation failed"): + handler.get_authorization_url("test-state", "https://app.example.com/callback") + + def test_get_authorization_url_exception(self): + """Test authorization URL generation with exception.""" + handler = self._create_test_handler() + + # Mock OAuth2Session to raise an exception + with patch("ml_space_lambda.auth.handlers.oidc_handler.OAuth2Session") as mock_session_class: + mock_session = Mock() + mock_session.create_authorization_url.side_effect = Exception("Network error") + mock_session_class.return_value = mock_session + + from authlib.common.security import generate_token + + code_verifier = generate_token(48) + + with pytest.raises(Exception, match="Authorization URL generation failed"): + handler.get_authorization_url("test-state", "https://app.example.com/callback", code_verifier=code_verifier) + + def test_handle_callback_missing_code(self): + """Test callback handling with missing authorization code.""" + handler = self._create_test_handler() + + result = handler.handle_callback("", "https://app.example.com/callback") + + assert result.success is False + assert "Missing authorization code" in result.error + + def test_handle_callback_success(self): + """Test successful callback handling.""" + from authlib.oauth2.rfc6749 import OAuth2Token + + from ml_space_lambda.auth.models.auth_models import UserData + + handler = self._create_test_handler() + + oauth_token = OAuth2Token( + { + "access_token": "access123", + "refresh_token": "refresh123", + "id_token": "id123", + "expires_in": 3600, + "token_type": "Bearer", + } + ) + + # Mock the token exchange + with patch.object(handler, "_exchange_code_for_tokens") as mock_exchange: + mock_exchange.return_value = oauth_token + + # Mock user info extraction + with patch.object(handler, "_get_user_info_from_oauth_token") as mock_get_user: + mock_get_user.return_value = UserData(id="user123", displayName="John Doe", email="john@example.com") + + from authlib.common.security import generate_token + + code_verifier = generate_token(48) + + result = handler.handle_callback( + "auth-code-123", "https://app.example.com/callback", code_verifier=code_verifier + ) + + assert result.success is True + assert result.user_data.id == "user123" + assert result.tokens.access_token == "access123" + assert result.raw_response is not None + + def test_handle_callback_exception(self): + """Test callback handling with exception.""" + handler = self._create_test_handler() + + # Mock the token exchange to raise an exception + with patch.object(handler, "_exchange_code_for_tokens") as mock_exchange: + mock_exchange.side_effect = Exception("Token exchange failed") + + from authlib.common.security import generate_token + + code_verifier = generate_token(48) + + result = handler.handle_callback("auth-code-123", "https://app.example.com/callback", code_verifier=code_verifier) + + assert result.success is False + assert "Authentication failed" in result.error + + def test_normalize_user_data_with_preferred_username(self): + """Test user data normalization with preferred_username.""" + handler = self._create_test_handler() + + raw_data = { + "sub": "user123", + "preferred_username": "jdoe", + "name": "John Doe", + "email": "john.doe@example.com", + "groups": ["admin"], + } + + normalized = handler.normalize_user_data(raw_data) + + assert normalized.id == "jdoe" + assert normalized.displayName == "John Doe" + assert normalized.email == "john.doe@example.com" + + def test_normalize_user_data_with_sub_only(self): + """Test user data normalization with only sub claim.""" + handler = self._create_test_handler() + + raw_data = {"sub": "user123"} + + normalized = handler.normalize_user_data(raw_data) + + # When there's no preferred_username or email, user_id will be empty + # but displayName will fallback to user_id (which is empty) + assert normalized.id == "" + assert normalized.displayName == "" + assert normalized.email == "" + # sub should be in attributes + assert normalized.attributes["sub"] == "user123" + + def test_normalize_user_data_with_given_family_names(self): + """Test user data normalization with given_name and family_name.""" + handler = self._create_test_handler() + + raw_data = {"sub": "user123", "given_name": "John", "family_name": "Doe", "email": "john@example.com"} + + normalized = handler.normalize_user_data(raw_data) + + assert normalized.displayName == "John Doe" + + def test_normalize_user_data_with_only_given_name(self): + """Test user data normalization with only given_name.""" + handler = self._create_test_handler() + + raw_data = {"sub": "user123", "given_name": "John"} + + normalized = handler.normalize_user_data(raw_data) + + assert normalized.displayName == "John" + + def test_normalize_user_data_filters_standard_claims(self): + """Test that standard OIDC claims are filtered from attributes.""" + handler = self._create_test_handler() + + raw_data = { + "sub": "user123", + "name": "John Doe", + "email": "john@example.com", + "iss": "https://example.com", + "aud": "client-id", + "exp": 1234567890, + "iat": 1234567800, + "auth_time": 1234567800, + "custom_claim": "custom_value", + "department": "Engineering", + } + + normalized = handler.normalize_user_data(raw_data) + + # Standard claims that ARE filtered (in standard_claims set) + assert "iss" not in normalized.attributes + assert "aud" not in normalized.attributes + assert "exp" not in normalized.attributes + assert "iat" not in normalized.attributes + assert "auth_time" not in normalized.attributes + assert "name" not in normalized.attributes + assert "email" not in normalized.attributes + + # Custom claims should be in attributes + assert normalized.attributes["custom_claim"] == "custom_value" + assert normalized.attributes["department"] == "Engineering" + # sub should be preserved + assert normalized.attributes["sub"] == "user123" + + def test_extract_token_expiration_with_expires_at(self): + """Test token expiration extraction with expires_at timestamp.""" + handler = self._create_test_handler() + + from ml_space_lambda.auth.models.auth_models import IdPTokens + + # Test with expires_at (absolute timestamp) + tokens = IdPTokens(access_token="token123", expires_at=1234567890) + + access_exp, refresh_exp = handler.extract_token_expiration(tokens) + + # Should calculate relative expiration from expires_at + assert access_exp > 0 + + def test_get_user_info_http_error(self): + """Test user info retrieval with HTTP error.""" + handler = self._create_test_handler() + + # Mock OAuth2Session.get to raise an exception + with patch("ml_space_lambda.auth.handlers.oidc_handler.OAuth2Session") as mock_session_class: + mock_session = Mock() + mock_session.get.side_effect = Exception("Network error") + mock_session_class.return_value = mock_session + + result = handler.get_user_info("test-token") + + assert result.id == "" + assert result.displayName == "" + assert result.email == "" + + def test_validate_token_with_exception(self): + """Test token validation when get_user_info raises exception.""" + handler = self._create_test_handler() + + with patch.object(handler, "get_user_info") as mock_get_user_info: + mock_get_user_info.side_effect = Exception("Validation error") + + assert handler.validate_token("token") is False + + def test_refresh_tokens_with_user_info_failure(self): + """Test token refresh when user info retrieval fails.""" + from authlib.oauth2.rfc6749 import OAuth2Token + + from ml_space_lambda.auth.models.auth_models import UserData + + handler = self._create_test_handler() + + new_oauth_token = OAuth2Token( + {"access_token": "new-access-token", "refresh_token": "new-refresh-token", "expires_in": 3600} + ) + + with patch.object(handler.oauth_session, "refresh_token") as mock_refresh: + mock_refresh.return_value = new_oauth_token + + # Mock user info retrieval to return empty user + with patch.object(handler, "_get_user_info_from_oauth_token") as mock_get_user_info: + mock_get_user_info.return_value = UserData(id="", displayName="", email="") + + result = handler.refresh_tokens("old-refresh-token") + + # Should still succeed even if user info is empty + assert result.success is True + assert result.tokens.access_token == "new-access-token" diff --git a/backend/test/auth/models/test_key_models.py b/backend/test/auth/models/test_key_models.py new file mode 100644 index 00000000..26526870 --- /dev/null +++ b/backend/test/auth/models/test_key_models.py @@ -0,0 +1,200 @@ +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +""" +Tests for key management Pydantic models. +""" + +from datetime import datetime, timezone + +import pytest + +from ml_space_lambda.auth.models.key_models import ( + KeyCleanupResult, + KeyRotationResult, + KeyStatusResult, + KeyType, + VersionedKeyData, +) + + +class TestVersionedKeyData: + """Test cases for VersionedKeyData model.""" + + def test_create_initial(self): + """Test creating initial key data.""" + key_data = VersionedKeyData.create_initial(encoded_key="test-key-1", key_type=KeyType.TOKEN, created_by="test") + + assert key_data.current_version == 1 + assert key_data.keys == {"1": "test-key-1"} + assert key_data.key_type == KeyType.TOKEN + assert key_data.created_by == "test" + assert key_data.rotation_date is None + + def test_get_current_key(self): + """Test getting current key.""" + key_data = VersionedKeyData.create_initial("test-key", KeyType.STATE) + + current_key = key_data.get_current_key() + assert current_key == "test-key" + + def test_get_current_key_missing(self): + """Test getting current key when version is missing.""" + key_data = VersionedKeyData(current_version=2, keys={"1": "key-1"}, key_type=KeyType.TOKEN) + + with pytest.raises(ValueError, match="Current key version 2 not found"): + key_data.get_current_key() + + def test_get_key_by_version(self): + """Test getting key by specific version.""" + key_data = VersionedKeyData.create_initial("test-key", KeyType.TOKEN) + + key = key_data.get_key_by_version(1) + assert key == "test-key" + + key = key_data.get_key_by_version(999) + assert key is None + + def test_add_new_key_version(self): + """Test adding new key version.""" + key_data = VersionedKeyData.create_initial("key-1", KeyType.STATE) + + new_version = key_data.add_new_key_version("key-2", "rotator") + + assert new_version == 2 + assert key_data.current_version == 2 + assert key_data.keys["2"] == "key-2" + assert key_data.rotated_by == "rotator" + assert key_data.rotation_date is not None + + def test_cleanup_old_versions(self): + """Test cleaning up old key versions.""" + key_data = VersionedKeyData( + current_version=5, + keys={"1": "key-1", "2": "key-2", "3": "key-3", "4": "key-4", "5": "key-5"}, + key_type=KeyType.TOKEN, + ) + + removed = key_data.cleanup_old_versions(keep_versions=3) + + assert set(removed) == {"1", "2"} + assert set(key_data.keys.keys()) == {"3", "4", "5"} + assert key_data.last_cleanup is not None + + def test_cleanup_no_versions_to_remove(self): + """Test cleanup when no versions need to be removed.""" + key_data = VersionedKeyData.create_initial("key-1", KeyType.STATE) + + removed = key_data.cleanup_old_versions(keep_versions=3) + + assert removed == [] + assert len(key_data.keys) == 1 + + def test_cleanup_invalid_keep_versions(self): + """Test cleanup with invalid keep_versions parameter.""" + key_data = VersionedKeyData.create_initial("key-1", KeyType.TOKEN) + + with pytest.raises(ValueError, match="Must keep at least 1 version"): + key_data.cleanup_old_versions(keep_versions=0) + + def test_get_available_versions(self): + """Test getting available versions.""" + key_data = VersionedKeyData(current_version=3, keys={"1": "key-1", "3": "key-3", "2": "key-2"}, key_type=KeyType.STATE) + + versions = key_data.get_available_versions() + assert versions == [1, 2, 3] # Should be sorted + + def test_get_total_versions(self): + """Test getting total number of versions.""" + key_data = VersionedKeyData(current_version=2, keys={"1": "key-1", "2": "key-2"}, key_type=KeyType.TOKEN) + + total = key_data.get_total_versions() + assert total == 2 + + def test_serialization(self): + """Test JSON serialization and deserialization.""" + original = VersionedKeyData.create_initial("test-key", KeyType.STATE) + original.add_new_key_version("new-key", "test") + + # Serialize to JSON + json_str = original.to_secrets_manager_format() + + # Deserialize from JSON + restored = VersionedKeyData.from_secrets_manager_format(json_str) + + assert restored.current_version == original.current_version + assert restored.keys == original.keys + assert restored.key_type == original.key_type + assert restored.created_by == original.created_by + + def test_validation_empty_keys(self): + """Test validation fails with empty keys.""" + with pytest.raises(ValueError, match="Keys dictionary cannot be empty"): + VersionedKeyData(current_version=1, keys={}, key_type=KeyType.TOKEN) + + +class TestResultModels: + """Test cases for result models.""" + + def test_key_rotation_result(self): + """Test KeyRotationResult model.""" + result = KeyRotationResult( + success=True, + previous_version=1, + new_version=2, + rotation_date=datetime.now(timezone.utc), + total_versions=2, + message="Rotation successful", + ) + + assert result.success is True + assert result.previous_version == 1 + assert result.new_version == 2 + assert result.message == "Rotation successful" + + def test_key_cleanup_result(self): + """Test KeyCleanupResult model.""" + result = KeyCleanupResult( + success=True, removed_versions=["1", "2"], kept_versions=["3", "4"], message="Cleanup successful" + ) + + assert result.success is True + assert result.removed_versions == ["1", "2"] + assert result.kept_versions == ["3", "4"] + + def test_key_status_result_success(self): + """Test KeyStatusResult model for successful status.""" + result = KeyStatusResult( + success=True, + current_version=3, + total_versions=3, + available_versions=[1, 2, 3], + key_type=KeyType.TOKEN, + last_rotation=datetime.now(timezone.utc), + ) + + assert result.success is True + assert result.current_version == 3 + assert result.key_type == KeyType.TOKEN + assert result.error is None + + def test_key_status_result_error(self): + """Test KeyStatusResult model for error case.""" + result = KeyStatusResult(success=False, error="Failed to retrieve key status") + + assert result.success is False + assert result.error == "Failed to retrieve key status" + assert result.current_version is None diff --git a/backend/test/auth/session/__init__.py b/backend/test/auth/session/__init__.py new file mode 100644 index 00000000..f9de63fb --- /dev/null +++ b/backend/test/auth/session/__init__.py @@ -0,0 +1,15 @@ +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# diff --git a/backend/test/auth/session/test_encryption.py b/backend/test/auth/session/test_encryption.py new file mode 100644 index 00000000..63a398ab --- /dev/null +++ b/backend/test/auth/session/test_encryption.py @@ -0,0 +1,113 @@ +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import pytest + +from ml_space_lambda.auth.session.encryption import ( + TokenEncryption, + create_encryption_key, + decode_key_from_storage, + encode_key_for_storage, +) + + +class TestTokenEncryption: + """Test token encryption functionality.""" + + def test_encrypt_decrypt_token(self): + """Test basic encryption and decryption.""" + key = create_encryption_key() + encryption = TokenEncryption(key) + + original_token = "test_access_token_12345" + encrypted = encryption.encrypt_token(original_token) + decrypted = encryption.decrypt_token(encrypted) + + assert decrypted == original_token + assert encrypted != original_token + assert encrypted.startswith("v4.local.") + + def test_encrypt_token_format(self): + """Test encrypted token format.""" + key = create_encryption_key() + encryption = TokenEncryption(key) + + token = "test_token" + encrypted = encryption.encrypt_token(token) + + # PASETO v4.local tokens start with "v4.local." + assert encrypted.startswith("v4.local.") + + def test_encrypt_empty_token_raises_error(self): + """Test that encrypting empty token raises error.""" + key = create_encryption_key() + encryption = TokenEncryption(key) + + with pytest.raises(ValueError, match="Token cannot be empty"): + encryption.encrypt_token("") + + def test_decrypt_invalid_format_raises_error(self): + """Test that decrypting invalid format raises error.""" + key = create_encryption_key() + encryption = TokenEncryption(key) + + with pytest.raises(Exception, match="Token decryption failed"): + encryption.decrypt_token("invalid_format") + + def test_decrypt_with_wrong_key_raises_error(self): + """Test that decrypting with wrong key raises error.""" + key1 = create_encryption_key() + key2 = create_encryption_key() + + encryption1 = TokenEncryption(key1) + encryption2 = TokenEncryption(key2) + + token = "test_token" + encrypted = encryption1.encrypt_token(token) + + with pytest.raises(Exception, match="Token decryption failed"): + encryption2.decrypt_token(encrypted) + + def test_is_encrypted_token(self): + """Test checking if token is encrypted.""" + key = create_encryption_key() + encryption = TokenEncryption(key) + + plain_token = "plain_token" + encrypted_token = encryption.encrypt_token(plain_token) + + assert encryption.is_encrypted_token(encrypted_token) + assert not encryption.is_encrypted_token(plain_token) + assert not encryption.is_encrypted_token("") + + def test_invalid_key_length_raises_error(self): + """Test that invalid key length raises error.""" + with pytest.raises(ValueError, match="Encryption key must be exactly 32 bytes"): + TokenEncryption(b"short_key") + + def test_encode_decode_key(self): + """Test encoding and decoding encryption key.""" + key = create_encryption_key() + encoded = encode_key_for_storage(key) + decoded = decode_key_from_storage(encoded) + + assert decoded == key + assert len(decoded) == 32 + + def test_decode_invalid_key_raises_error(self): + """Test that decoding invalid key raises error.""" + with pytest.raises(ValueError, match="Invalid encoded key"): + decode_key_from_storage("not_valid_base64!!!") diff --git a/backend/test/auth/session/test_key_manager.py b/backend/test/auth/session/test_key_manager.py new file mode 100644 index 00000000..107d1fff --- /dev/null +++ b/backend/test/auth/session/test_key_manager.py @@ -0,0 +1,579 @@ +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +""" +Tests for versioned key manager. +""" + +import json +from unittest.mock import Mock, patch + +import pytest +from cryptography.fernet import Fernet + +from ml_space_lambda.auth.session.encryption import create_encryption_key, encode_key_for_storage +from ml_space_lambda.auth.session.key_manager import VersionedKeyManager, VersionedStateManager, VersionedTokenEncryption + + +class TestVersionedKeyManager: + """Tests for VersionedKeyManager class.""" + + def test_init(self): + """Test key manager initialization.""" + with patch("boto3.client"): + manager = VersionedKeyManager("arn:aws:secretsmanager:us-east-1:123456789012:secret:test", key_type="token") + + assert manager.secret_arn == "arn:aws:secretsmanager:us-east-1:123456789012:secret:test" + assert manager.key_type == "token" + assert manager._keys_cache is None + + @patch("boto3.client") + def test_load_keys_success(self, mock_boto_client): + """Test successful key loading from Secrets Manager.""" + # Create test keys + key1 = create_encryption_key() + key2 = create_encryption_key() + + secret_data = { + "current_version": 2, + "keys": {"1": encode_key_for_storage(key1), "2": encode_key_for_storage(key2)}, + "rotation_date": "2024-01-15T10:30:00Z", + } + + mock_secrets_client = Mock() + mock_secrets_client.get_secret_value.return_value = {"SecretString": json.dumps(secret_data)} + mock_boto_client.return_value = mock_secrets_client + + manager = VersionedKeyManager("arn:aws:secretsmanager:us-east-1:123456789012:secret:test") + + keys_data = manager._load_keys() + + assert keys_data["current_version"] == 2 + assert "1" in keys_data["keys"] + assert "2" in keys_data["keys"] + mock_secrets_client.get_secret_value.assert_called_once() + + @patch("boto3.client") + def test_load_keys_caching(self, mock_boto_client): + """Test that keys are cached after first load.""" + key1 = create_encryption_key() + + secret_data = {"current_version": 1, "keys": {"1": encode_key_for_storage(key1)}} + + mock_secrets_client = Mock() + mock_secrets_client.get_secret_value.return_value = {"SecretString": json.dumps(secret_data)} + mock_boto_client.return_value = mock_secrets_client + + manager = VersionedKeyManager("arn:aws:secretsmanager:us-east-1:123456789012:secret:test") + + # Load keys twice + keys_data1 = manager._load_keys() + keys_data2 = manager._load_keys() + + # Should only call Secrets Manager once due to caching + assert mock_secrets_client.get_secret_value.call_count == 1 + assert keys_data1 == keys_data2 + + @patch("boto3.client") + def test_load_keys_failure(self, mock_boto_client): + """Test key loading failure.""" + mock_secrets_client = Mock() + mock_secrets_client.get_secret_value.side_effect = Exception("Secrets Manager error") + mock_boto_client.return_value = mock_secrets_client + + manager = VersionedKeyManager("arn:aws:secretsmanager:us-east-1:123456789012:secret:test") + + with pytest.raises(Exception, match="Key loading failed"): + manager._load_keys() + + @patch("boto3.client") + def test_get_current_key_success(self, mock_boto_client): + """Test getting current encryption key.""" + key1 = create_encryption_key() + key2 = create_encryption_key() + + secret_data = {"current_version": 2, "keys": {"1": encode_key_for_storage(key1), "2": encode_key_for_storage(key2)}} + + mock_secrets_client = Mock() + mock_secrets_client.get_secret_value.return_value = {"SecretString": json.dumps(secret_data)} + mock_boto_client.return_value = mock_secrets_client + + manager = VersionedKeyManager("arn:aws:secretsmanager:us-east-1:123456789012:secret:test") + + version, key = manager.get_current_key() + + assert version == 2 + assert key == key2 + + @patch("boto3.client") + def test_get_current_key_missing_version(self, mock_boto_client): + """Test getting current key when version is missing.""" + key1 = create_encryption_key() + + secret_data = { + "current_version": 2, # Says version 2 is current + "keys": {"1": encode_key_for_storage(key1)}, # But only version 1 exists + } + + mock_secrets_client = Mock() + mock_secrets_client.get_secret_value.return_value = {"SecretString": json.dumps(secret_data)} + mock_boto_client.return_value = mock_secrets_client + + manager = VersionedKeyManager("arn:aws:secretsmanager:us-east-1:123456789012:secret:test") + + with pytest.raises(Exception, match="Current key version 2 not found"): + manager.get_current_key() + + @patch("boto3.client") + def test_get_key_by_version_success(self, mock_boto_client): + """Test getting specific key version.""" + key1 = create_encryption_key() + key2 = create_encryption_key() + + secret_data = {"current_version": 2, "keys": {"1": encode_key_for_storage(key1), "2": encode_key_for_storage(key2)}} + + mock_secrets_client = Mock() + mock_secrets_client.get_secret_value.return_value = {"SecretString": json.dumps(secret_data)} + mock_boto_client.return_value = mock_secrets_client + + manager = VersionedKeyManager("arn:aws:secretsmanager:us-east-1:123456789012:secret:test") + + # Get version 1 + key = manager.get_key_by_version(1) + assert key == key1 + + # Get version 2 + key = manager.get_key_by_version(2) + assert key == key2 + + @patch("boto3.client") + def test_get_key_by_version_not_found(self, mock_boto_client): + """Test getting non-existent key version returns None.""" + key1 = create_encryption_key() + + secret_data = {"current_version": 1, "keys": {"1": encode_key_for_storage(key1)}} + + mock_secrets_client = Mock() + mock_secrets_client.get_secret_value.return_value = {"SecretString": json.dumps(secret_data)} + mock_boto_client.return_value = mock_secrets_client + + manager = VersionedKeyManager("arn:aws:secretsmanager:us-east-1:123456789012:secret:test") + + result = manager.get_key_by_version(99) + assert result is None + + @patch("boto3.client") + def test_get_all_versions_success(self, mock_boto_client): + """Test getting all key versions.""" + key1 = create_encryption_key() + key2 = create_encryption_key() + key3 = create_encryption_key() + + secret_data = { + "current_version": 3, + "keys": {"1": encode_key_for_storage(key1), "2": encode_key_for_storage(key2), "3": encode_key_for_storage(key3)}, + } + + mock_secrets_client = Mock() + mock_secrets_client.get_secret_value.return_value = {"SecretString": json.dumps(secret_data)} + mock_boto_client.return_value = mock_secrets_client + + manager = VersionedKeyManager("arn:aws:secretsmanager:us-east-1:123456789012:secret:test") + + versions = manager.get_all_keys() + + assert len(versions) == 3 + assert 1 in versions + assert 2 in versions + assert 3 in versions + assert isinstance(versions[1], bytes) + assert isinstance(versions[2], bytes) + assert isinstance(versions[3], bytes) + + @patch("boto3.client") + def test_clear_cache(self, mock_boto_client): + """Test cache clearing.""" + key1 = create_encryption_key() + + secret_data = {"current_version": 1, "keys": {"1": encode_key_for_storage(key1)}} + + mock_secrets_client = Mock() + mock_secrets_client.get_secret_value.return_value = {"SecretString": json.dumps(secret_data)} + mock_boto_client.return_value = mock_secrets_client + + manager = VersionedKeyManager("arn:aws:secretsmanager:us-east-1:123456789012:secret:test") + + # Load keys to populate cache + manager._load_keys() + assert manager._keys_cache is not None + + # Clear cache + manager.invalidate_cache() + assert manager._keys_cache is None + + # Next load should call Secrets Manager again + manager._load_keys() + assert mock_secrets_client.get_secret_value.call_count == 2 + + +class TestVersionedTokenEncryption: + """Tests for VersionedTokenEncryption class.""" + + @patch("boto3.client") + def test_encrypt_decrypt_token(self, mock_boto_client): + """Test encrypting and decrypting tokens with versioned keys.""" + key1 = create_encryption_key() + + secret_data = {"current_version": 1, "keys": {"1": encode_key_for_storage(key1)}} + + mock_secrets_client = Mock() + mock_secrets_client.get_secret_value.return_value = {"SecretString": json.dumps(secret_data)} + mock_boto_client.return_value = mock_secrets_client + + key_manager = VersionedKeyManager("arn:aws:secretsmanager:us-east-1:123456789012:secret:test") + token_encryption = VersionedTokenEncryption(key_manager) + + # Encrypt a token + original_token = "test-access-token-12345" + encrypted = token_encryption.encrypt_token(original_token) + + # Verify format + assert encrypted.startswith("v1:") + + # Decrypt the token + decrypted = token_encryption.decrypt_token(encrypted) + assert decrypted == original_token + + @patch("boto3.client") + def test_decrypt_with_old_key_version(self, mock_boto_client): + """Test decrypting tokens encrypted with older key versions.""" + key1 = create_encryption_key() + key2 = create_encryption_key() + + secret_data = {"current_version": 2, "keys": {"1": encode_key_for_storage(key1), "2": encode_key_for_storage(key2)}} + + mock_secrets_client = Mock() + mock_secrets_client.get_secret_value.return_value = {"SecretString": json.dumps(secret_data)} + mock_boto_client.return_value = mock_secrets_client + + key_manager = VersionedKeyManager("arn:aws:secretsmanager:us-east-1:123456789012:secret:test") + token_encryption = VersionedTokenEncryption(key_manager) + + # Encrypt with current key (v2) + token = "test-token" + encrypted_v2 = token_encryption.encrypt_token(token) + assert encrypted_v2.startswith("v2:") + + # Manually create a token encrypted with v1 + from ml_space_lambda.auth.session.encryption import TokenEncryption + + encryptor_v1 = TokenEncryption(key1) + encrypted_v1_data = encryptor_v1.encrypt_token(token) + encrypted_v1 = f"v1:{encrypted_v1_data}" + + # Should be able to decrypt both versions + assert token_encryption.decrypt_token(encrypted_v1) == token + assert token_encryption.decrypt_token(encrypted_v2) == token + + @patch("boto3.client") + def test_decrypt_invalid_format(self, mock_boto_client): + """Test decrypting token with invalid format.""" + key1 = create_encryption_key() + + secret_data = {"current_version": 1, "keys": {"1": encode_key_for_storage(key1)}} + + mock_secrets_client = Mock() + mock_secrets_client.get_secret_value.return_value = {"SecretString": json.dumps(secret_data)} + mock_boto_client.return_value = mock_secrets_client + + key_manager = VersionedKeyManager("arn:aws:secretsmanager:us-east-1:123456789012:secret:test") + token_encryption = VersionedTokenEncryption(key_manager) + + # Invalid formats + with pytest.raises(Exception, match="Invalid versioned token format"): + token_encryption.decrypt_token("no-version-prefix") + + with pytest.raises(Exception, match="Invalid versioned token format"): + token_encryption.decrypt_token("v1-no-colon") + + @patch("boto3.client") + def test_decrypt_missing_key_version(self, mock_boto_client): + """Test decrypting token with unavailable key version.""" + key1 = create_encryption_key() + + secret_data = {"current_version": 1, "keys": {"1": encode_key_for_storage(key1)}} + + mock_secrets_client = Mock() + mock_secrets_client.get_secret_value.return_value = {"SecretString": json.dumps(secret_data)} + mock_boto_client.return_value = mock_secrets_client + + key_manager = VersionedKeyManager("arn:aws:secretsmanager:us-east-1:123456789012:secret:test") + token_encryption = VersionedTokenEncryption(key_manager) + + # Try to decrypt with non-existent version + with pytest.raises(Exception, match="Key version 99 not available"): + token_encryption.decrypt_token("v99:some-encrypted-data") + + @patch("boto3.client") + def test_is_encrypted_token(self, mock_boto_client): + """Test checking if token is encrypted.""" + key1 = create_encryption_key() + + secret_data = {"current_version": 1, "keys": {"1": encode_key_for_storage(key1)}} + + mock_secrets_client = Mock() + mock_secrets_client.get_secret_value.return_value = {"SecretString": json.dumps(secret_data)} + mock_boto_client.return_value = mock_secrets_client + + key_manager = VersionedKeyManager("arn:aws:secretsmanager:us-east-1:123456789012:secret:test") + token_encryption = VersionedTokenEncryption(key_manager) + + # Encrypted tokens + assert token_encryption.is_encrypted_token("v1:encrypted-data") + assert token_encryption.is_encrypted_token("v2:other-data") + + # Not encrypted + assert not token_encryption.is_encrypted_token("plain-token") + assert not token_encryption.is_encrypted_token("") + assert not token_encryption.is_encrypted_token("v1-no-colon") + + @patch("boto3.client") + def test_encryptor_caching(self, mock_boto_client): + """Test that encryptors are cached for performance.""" + key1 = create_encryption_key() + + secret_data = {"current_version": 1, "keys": {"1": encode_key_for_storage(key1)}} + + mock_secrets_client = Mock() + mock_secrets_client.get_secret_value.return_value = {"SecretString": json.dumps(secret_data)} + mock_boto_client.return_value = mock_secrets_client + + key_manager = VersionedKeyManager("arn:aws:secretsmanager:us-east-1:123456789012:secret:test") + token_encryption = VersionedTokenEncryption(key_manager) + + # Encrypt and decrypt multiple times + token = "test-token" + encrypted = token_encryption.encrypt_token(token) + + # First decrypt should create encryptor + token_encryption.decrypt_token(encrypted) + assert 1 in token_encryption._encryptors_cache + + # Second decrypt should use cached encryptor + token_encryption.decrypt_token(encrypted) + assert len(token_encryption._encryptors_cache) == 1 + + +class TestVersionedStateManager: + """Tests for VersionedStateManager class.""" + + @patch("boto3.client") + def test_create_and_validate_state(self, mock_boto_client): + """Test creating and validating state parameters.""" + # Create a Fernet key for state encryption + state_key = Fernet.generate_key() + + # Fernet keys are already base64-encoded, store them directly + secret_data = {"current_version": 1, "keys": {"1": state_key.decode("utf-8")}} + + mock_secrets_client = Mock() + mock_secrets_client.get_secret_value.return_value = {"SecretString": json.dumps(secret_data)} + mock_boto_client.return_value = mock_secrets_client + + key_manager = VersionedKeyManager("arn:aws:secretsmanager:us-east-1:123456789012:secret:test", key_type="state") + state_manager = VersionedStateManager(key_manager) + + # Create state + redirect_url = "https://example.com/callback" + domain = "example.com" + nonce = "test-nonce-12345" + + state = state_manager.create_state(redirect_url, domain, nonce) + + # Verify format + assert state.startswith("v1:") + + # Validate state + validated = state_manager.validate_state(state, nonce, max_age_seconds=600) + assert validated is not None + assert validated["redirect_url"] == redirect_url + assert validated["domain"] == domain + assert validated["nonce"] == nonce + assert "timestamp" in validated + + @patch("boto3.client") + def test_validate_state_with_old_version(self, mock_boto_client): + """Test validating state encrypted with older key version.""" + state_key1 = Fernet.generate_key() + state_key2 = Fernet.generate_key() + + secret_data = { + "current_version": 2, + "keys": {"1": state_key1.decode("utf-8"), "2": state_key2.decode("utf-8")}, + } + + mock_secrets_client = Mock() + mock_secrets_client.get_secret_value.return_value = {"SecretString": json.dumps(secret_data)} + mock_boto_client.return_value = mock_secrets_client + + key_manager = VersionedKeyManager("arn:aws:secretsmanager:us-east-1:123456789012:secret:test", key_type="state") + state_manager = VersionedStateManager(key_manager) + + # Create state with current key (v2) + nonce = "test-nonce" + state_v2 = state_manager.create_state("https://example.com", "example.com", nonce) + assert state_v2.startswith("v2:") + + # Manually create state with v1 + import time + + cipher_v1 = Fernet(state_key1) + state_data = { + "redirect_url": "https://example.com", + "nonce": nonce, + "timestamp": int(time.time()), + "domain": "example.com", + } + encrypted_v1 = cipher_v1.encrypt(json.dumps(state_data, separators=(",", ":")).encode("utf-8")) + state_v1 = f"v1:{encrypted_v1.decode('utf-8')}" + + # Should validate both versions + assert state_manager.validate_state(state_v1, nonce) is not None + assert state_manager.validate_state(state_v2, nonce) is not None + + @patch("boto3.client") + def test_validate_state_expired(self, mock_boto_client): + """Test validating expired state.""" + state_key = Fernet.generate_key() + + secret_data = {"current_version": 1, "keys": {"1": state_key.decode("utf-8")}} + + mock_secrets_client = Mock() + mock_secrets_client.get_secret_value.return_value = {"SecretString": json.dumps(secret_data)} + mock_boto_client.return_value = mock_secrets_client + + key_manager = VersionedKeyManager("arn:aws:secretsmanager:us-east-1:123456789012:secret:test", key_type="state") + state_manager = VersionedStateManager(key_manager) + + # Create state with old timestamp + import time + + cipher = Fernet(state_key) + nonce = "test-nonce" + old_timestamp = int(time.time()) - 700 # 700 seconds ago + state_data = { + "redirect_url": "https://example.com", + "nonce": nonce, + "timestamp": old_timestamp, + "domain": "example.com", + } + encrypted = cipher.encrypt(json.dumps(state_data, separators=(",", ":")).encode("utf-8")) + expired_state = f"v1:{encrypted.decode('utf-8')}" + + # Should fail validation due to age + result = state_manager.validate_state(expired_state, nonce, max_age_seconds=600) + assert result is None + + @patch("boto3.client") + def test_validate_state_wrong_nonce(self, mock_boto_client): + """Test validating state with wrong nonce.""" + state_key = Fernet.generate_key() + + secret_data = {"current_version": 1, "keys": {"1": state_key.decode("utf-8")}} + + mock_secrets_client = Mock() + mock_secrets_client.get_secret_value.return_value = {"SecretString": json.dumps(secret_data)} + mock_boto_client.return_value = mock_secrets_client + + key_manager = VersionedKeyManager("arn:aws:secretsmanager:us-east-1:123456789012:secret:test", key_type="state") + state_manager = VersionedStateManager(key_manager) + + # Create state + state = state_manager.create_state("https://example.com", "example.com", "correct-nonce") + + # Try to validate with wrong nonce + result = state_manager.validate_state(state, "wrong-nonce") + assert result is None + + @patch("boto3.client") + def test_validate_state_invalid_format(self, mock_boto_client): + """Test validating state with invalid format.""" + state_key = Fernet.generate_key() + + secret_data = {"current_version": 1, "keys": {"1": state_key.decode("utf-8")}} + + mock_secrets_client = Mock() + mock_secrets_client.get_secret_value.return_value = {"SecretString": json.dumps(secret_data)} + mock_boto_client.return_value = mock_secrets_client + + key_manager = VersionedKeyManager("arn:aws:secretsmanager:us-east-1:123456789012:secret:test", key_type="state") + state_manager = VersionedStateManager(key_manager) + + # Invalid formats + assert state_manager.validate_state("", "nonce") is None + assert state_manager.validate_state("no-version", "nonce") is None + assert state_manager.validate_state("v1-no-colon", "nonce") is None + assert state_manager.validate_state("v1:data", "") is None + + @patch("boto3.client") + def test_validate_state_missing_fields(self, mock_boto_client): + """Test validating state with missing required fields.""" + state_key = Fernet.generate_key() + + secret_data = {"current_version": 1, "keys": {"1": state_key.decode("utf-8")}} + + mock_secrets_client = Mock() + mock_secrets_client.get_secret_value.return_value = {"SecretString": json.dumps(secret_data)} + mock_boto_client.return_value = mock_secrets_client + + key_manager = VersionedKeyManager("arn:aws:secretsmanager:us-east-1:123456789012:secret:test", key_type="state") + state_manager = VersionedStateManager(key_manager) + + # Create state with missing fields + + cipher = Fernet(state_key) + incomplete_data = {"redirect_url": "https://example.com"} # Missing nonce, timestamp, domain + encrypted = cipher.encrypt(json.dumps(incomplete_data).encode("utf-8")) + invalid_state = f"v1:{encrypted.decode('utf-8')}" + + result = state_manager.validate_state(invalid_state, "nonce") + assert result is None + + @patch("boto3.client") + def test_cipher_caching(self, mock_boto_client): + """Test that ciphers are cached for performance.""" + state_key = Fernet.generate_key() + + secret_data = {"current_version": 1, "keys": {"1": state_key.decode("utf-8")}} + + mock_secrets_client = Mock() + mock_secrets_client.get_secret_value.return_value = {"SecretString": json.dumps(secret_data)} + mock_boto_client.return_value = mock_secrets_client + + key_manager = VersionedKeyManager("arn:aws:secretsmanager:us-east-1:123456789012:secret:test", key_type="state") + state_manager = VersionedStateManager(key_manager) + + # Create and validate state multiple times + nonce = "test-nonce" + state = state_manager.create_state("https://example.com", "example.com", nonce) + + # First validation should create cipher + state_manager.validate_state(state, nonce) + assert 1 in state_manager._ciphers_cache + + # Second validation should use cached cipher + state_manager.validate_state(state, nonce) + assert len(state_manager._ciphers_cache) == 1 diff --git a/backend/test/auth/session/test_manager.py b/backend/test/auth/session/test_manager.py new file mode 100644 index 00000000..34bd1635 --- /dev/null +++ b/backend/test/auth/session/test_manager.py @@ -0,0 +1,419 @@ +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from datetime import datetime, timedelta, timezone +from unittest.mock import Mock + +from ml_space_lambda.auth.session.encryption import TokenEncryption, create_encryption_key +from ml_space_lambda.auth.session.manager import SessionManager + + +class TestSessionManager: + """Test session manager functionality.""" + + def test_update_session_user_data_only(self): + """Test updating only user data without tokens.""" + # Setup + key = create_encryption_key() + encryption = TokenEncryption(key) + mock_store = Mock() + manager = SessionManager("test-table", encryption) + manager.store = mock_store # Replace the store with our mock + + session_id = "session:test123" + new_user_data = {"displayName": "Updated Name", "email": "new@example.com", "groups": ["admin", "users"]} + + # Test + result = manager.update_session(session_id=session_id, user_data=new_user_data) + + # Verify + assert result is True + mock_store._update.assert_called_once() + + # Check that update was called with the right parameters + call_args = mock_store._update.call_args + # Arguments are: (key, update_expression, condition_expression, expression_names, expression_values) + update_expression = call_args[0][1] # Second positional argument + + assert "displayName" in update_expression + assert "email" in update_expression + assert "groups" in update_expression + + def test_refresh_session_with_user_data(self): + """Test refreshing session with both tokens and user data.""" + # Setup + key = create_encryption_key() + encryption = TokenEncryption(key) + mock_store = Mock() + manager = SessionManager("test-table", encryption) + manager.store = mock_store # Replace the store with our mock + + session_id = "session:test123" + new_tokens = {"access_token": "new_access_token", "refresh_token": "new_refresh_token", "id_token": "new_id_token"} + new_user_data = {"displayName": "Updated Name", "email": "updated@example.com"} + expires_at = datetime.now(timezone.utc) + timedelta(hours=1) + refresh_at = datetime.now(timezone.utc) + timedelta(minutes=50) + + # Test + result = manager.refresh_session_with_user_data( + session_id=session_id, tokens=new_tokens, user_data=new_user_data, expires_at=expires_at, refresh_at=refresh_at + ) + + # Verify + assert result is True + mock_store._update.assert_called_once() + + # Check that update was called with the right parameters + call_args = mock_store._update.call_args + update_expression = call_args[0][1] # Second positional argument + + # Should have refresh_token (only token stored), user data, and timestamp updates + assert "refresh_token" in update_expression + assert "displayName" in update_expression + assert "expires_at" in update_expression + # access_token and id_token should NOT be in the update expression (not stored) + assert "access_token" not in update_expression + assert "id_token" not in update_expression + + def test_update_session_invalid_session_id(self): + """Test updating session with invalid session ID.""" + # Setup + key = create_encryption_key() + encryption = TokenEncryption(key) + mock_store = Mock() + manager = SessionManager("test-table", encryption) + manager.store = mock_store # Replace the store with our mock + + # Test with invalid session ID + result = manager.update_session(session_id="invalid_id", user_data={"displayName": "Test"}) + + # Verify + assert result is False + mock_store._update.assert_not_called() + + def test_create_session_success(self): + """Test successful session creation.""" + # Setup + key = create_encryption_key() + encryption = TokenEncryption(key) + mock_store = Mock() + manager = SessionManager("test-table", encryption) + manager.store = mock_store + + user_data = {"id": "user123", "displayName": "Test User", "email": "test@example.com", "groups": [], "attributes": {}} + tokens = {"access_token": "access123", "refresh_token": "refresh123", "id_token": "id123"} + expires_at = datetime.now(timezone.utc) + timedelta(hours=1) + refresh_at = datetime.now(timezone.utc) + timedelta(minutes=50) + + # Test + session_id = manager.create_session( + user_data=user_data, + tokens=tokens, + provider="oidc", + expires_at=expires_at, + refresh_at=refresh_at, + login_domain="example.com", + synced_domains=["example.com"], + raw_idp_response="base64_encoded_response", + ) + + # Verify + assert session_id.startswith("session:") + mock_store._create.assert_called_once() + + # Check the session record structure + call_args = mock_store._create.call_args + session_record = call_args[0][0] + assert session_record["pk"] == session_id + assert "ttl" in session_record + assert session_record["data"]["user"] == user_data + assert session_record["data"]["session"]["provider"] == "oidc" + assert "refresh_token" in session_record["data"]["session"] + assert session_record["raw_data"] == "base64_encoded_response" + + def test_create_session_without_optional_fields(self): + """Test session creation without optional fields.""" + # Setup + key = create_encryption_key() + encryption = TokenEncryption(key) + mock_store = Mock() + manager = SessionManager("test-table", encryption) + manager.store = mock_store + + user_data = {"id": "user123", "displayName": "Test User", "email": "test@example.com"} + tokens = {"refresh_token": "refresh123"} + expires_at = datetime.now(timezone.utc) + timedelta(hours=1) + refresh_at = datetime.now(timezone.utc) + timedelta(minutes=50) + + # Test + session_id = manager.create_session( + user_data=user_data, + tokens=tokens, + provider="oidc", + expires_at=expires_at, + refresh_at=refresh_at, + login_domain="example.com", + ) + + # Verify + assert session_id.startswith("session:") + call_args = mock_store._create.call_args + session_record = call_args[0][0] + assert "raw_data" not in session_record + assert session_record["data"]["metadata"]["syncedDomains"] == [] + + def test_create_session_failure(self): + """Test session creation failure.""" + # Setup + key = create_encryption_key() + encryption = TokenEncryption(key) + mock_store = Mock() + mock_store._create.side_effect = Exception("DynamoDB error") + manager = SessionManager("test-table", encryption) + manager.store = mock_store + + user_data = {"id": "user123", "displayName": "Test User", "email": "test@example.com"} + tokens = {"refresh_token": "refresh123"} + expires_at = datetime.now(timezone.utc) + timedelta(hours=1) + refresh_at = datetime.now(timezone.utc) + timedelta(minutes=50) + + # Test + try: + manager.create_session( + user_data=user_data, + tokens=tokens, + provider="oidc", + expires_at=expires_at, + refresh_at=refresh_at, + login_domain="example.com", + ) + assert False, "Should have raised exception" + except Exception as e: + assert "Failed to create session" in str(e) + + def test_get_session_success(self): + """Test successful session retrieval.""" + # Setup + key = create_encryption_key() + encryption = TokenEncryption(key) + mock_store = Mock() + manager = SessionManager("test-table", encryption) + manager.store = mock_store + + session_id = "session:test123" + expires_at = datetime.now(timezone.utc) + timedelta(hours=1) + encrypted_token = encryption.encrypt_token("refresh123") + + mock_session_record = { + "pk": session_id, + "ttl": int((datetime.now(timezone.utc) + timedelta(hours=2)).timestamp()), + "data": { + "user": {"id": "user123", "displayName": "Test User", "email": "test@example.com"}, + "session": { + "provider": "oidc", + "expiresAt": expires_at.isoformat(), + "refreshAt": expires_at.isoformat(), + "refresh_token": encrypted_token, + }, + }, + } + mock_store._retrieve.return_value = mock_session_record + + # Test + session_data = manager.get_session(session_id) + + # Verify + assert session_data is not None + assert session_data["data"]["user"]["id"] == "user123" + assert "refresh_token" in session_data["data"]["session"] + + def test_get_session_invalid_id(self): + """Test get_session with invalid session ID.""" + # Setup + key = create_encryption_key() + encryption = TokenEncryption(key) + manager = SessionManager("test-table", encryption) + + # Test + session_data = manager.get_session("invalid_id") + + # Verify + assert session_data is None + + def test_get_session_none_id(self): + """Test get_session with None session ID.""" + # Setup + key = create_encryption_key() + encryption = TokenEncryption(key) + manager = SessionManager("test-table", encryption) + + # Test + session_data = manager.get_session(None) + + # Verify + assert session_data is None + + def test_get_session_expired_ttl(self): + """Test get_session with expired TTL.""" + # Setup + key = create_encryption_key() + encryption = TokenEncryption(key) + mock_store = Mock() + manager = SessionManager("test-table", encryption) + manager.store = mock_store + + session_id = "session:test123" + expires_at = datetime.now(timezone.utc) + timedelta(hours=1) + + mock_session_record = { + "pk": session_id, + "ttl": int((datetime.now(timezone.utc) - timedelta(hours=1)).timestamp()), # Expired TTL + "data": { + "user": {"id": "user123"}, + "session": {"provider": "oidc", "expiresAt": expires_at.isoformat(), "refreshAt": expires_at.isoformat()}, + }, + } + mock_store._retrieve.return_value = mock_session_record + + # Test + session_data = manager.get_session(session_id) + + # Verify + assert session_data is None + + def test_get_session_expired_session(self): + """Test get_session with expired session timestamp.""" + # Setup + key = create_encryption_key() + encryption = TokenEncryption(key) + mock_store = Mock() + manager = SessionManager("test-table", encryption) + manager.store = mock_store + + session_id = "session:test123" + expires_at = datetime.now(timezone.utc) - timedelta(hours=1) # Expired + + mock_session_record = { + "pk": session_id, + "ttl": int((datetime.now(timezone.utc) + timedelta(hours=2)).timestamp()), + "data": { + "user": {"id": "user123"}, + "session": {"provider": "oidc", "expiresAt": expires_at.isoformat(), "refreshAt": expires_at.isoformat()}, + }, + } + mock_store._retrieve.return_value = mock_session_record + + # Test + session_data = manager.get_session(session_id) + + # Verify + assert session_data is None + + def test_get_session_not_found(self): + """Test get_session when session doesn't exist.""" + # Setup + key = create_encryption_key() + encryption = TokenEncryption(key) + mock_store = Mock() + mock_store._retrieve.side_effect = Exception("Not found") + manager = SessionManager("test-table", encryption) + manager.store = mock_store + + # Test + session_data = manager.get_session("session:test123") + + # Verify + assert session_data is None + + def test_update_session_tokens(self): + """Test updating session tokens.""" + # Setup + key = create_encryption_key() + encryption = TokenEncryption(key) + mock_store = Mock() + manager = SessionManager("test-table", encryption) + manager.store = mock_store + + session_id = "session:test123" + tokens = {"refresh_token": "new_refresh_token"} + expires_at = datetime.now(timezone.utc) + timedelta(hours=1) + refresh_at = datetime.now(timezone.utc) + timedelta(minutes=50) + + # Test + result = manager.update_session_tokens( + session_id=session_id, tokens=tokens, expires_at=expires_at, refresh_at=refresh_at + ) + + # Verify + assert result is True + mock_store._update.assert_called_once() + + def test_update_session_with_raw_idp_response(self): + """Test updating session with raw IdP response.""" + # Setup + key = create_encryption_key() + encryption = TokenEncryption(key) + mock_store = Mock() + manager = SessionManager("test-table", encryption) + manager.store = mock_store + + session_id = "session:test123" + raw_idp_response = "base64_encoded_response" + + # Test + result = manager.update_session(session_id=session_id, raw_idp_response=raw_idp_response) + + # Verify + assert result is True + call_args = mock_store._update.call_args + update_expression = call_args[0][1] + assert "raw_data" in update_expression + + def test_update_session_no_changes(self): + """Test updating session with no actual changes.""" + # Setup + key = create_encryption_key() + encryption = TokenEncryption(key) + mock_store = Mock() + manager = SessionManager("test-table", encryption) + manager.store = mock_store + + session_id = "session:test123" + + # Test - only timestamp will be updated + result = manager.update_session(session_id=session_id) + + # Verify + assert result is True + + def test_update_session_exception(self): + """Test update_session when DynamoDB update fails.""" + # Setup + key = create_encryption_key() + encryption = TokenEncryption(key) + mock_store = Mock() + mock_store._update.side_effect = Exception("Update failed") + manager = SessionManager("test-table", encryption) + manager.store = mock_store + + session_id = "session:test123" + user_data = {"displayName": "Updated Name"} + + # Test + result = manager.update_session(session_id=session_id, user_data=user_data) + + # Verify + assert result is False diff --git a/backend/test/auth/session/test_validator.py b/backend/test/auth/session/test_validator.py new file mode 100644 index 00000000..31805208 --- /dev/null +++ b/backend/test/auth/session/test_validator.py @@ -0,0 +1,376 @@ +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +""" +Tests for session validator module. +""" + +from datetime import datetime, timedelta, timezone + +from ml_space_lambda.auth.session.validator import SessionValidator + + +class TestSessionValidator: + """Tests for SessionValidator class.""" + + def test_validate_session_data_valid(self): + """Test validation of valid session data.""" + session_data = { + "data": { + "user": { + "id": "test-user", + "displayName": "Test User", + "email": "test@example.com", + }, + "session": { + "provider": "oidc", + "expiresAt": (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(), + "refreshAt": (datetime.now(timezone.utc) + timedelta(minutes=30)).isoformat(), + }, + } + } + + is_valid, error = SessionValidator.validate_session_data(session_data) + assert is_valid is True + assert error is None + + def test_validate_session_data_none(self): + """Test validation with None session data.""" + is_valid, error = SessionValidator.validate_session_data(None) + assert is_valid is False + assert error == "Session not found" + + def test_validate_session_data_missing_data_field(self): + """Test validation with missing data field.""" + session_data = {"other": "field"} + is_valid, error = SessionValidator.validate_session_data(session_data) + assert is_valid is False + assert error == "Invalid session structure" + + def test_validate_session_data_missing_user(self): + """Test validation with missing user field.""" + session_data = { + "data": { + "session": { + "provider": "oidc", + "expiresAt": datetime.now(timezone.utc).isoformat(), + "refreshAt": datetime.now(timezone.utc).isoformat(), + } + } + } + is_valid, error = SessionValidator.validate_session_data(session_data) + assert is_valid is False + assert error == "Missing required session fields" + + def test_validate_session_data_missing_session(self): + """Test validation with missing session field.""" + session_data = { + "data": { + "user": { + "id": "test-user", + "displayName": "Test User", + "email": "test@example.com", + } + } + } + is_valid, error = SessionValidator.validate_session_data(session_data) + assert is_valid is False + assert error == "Missing required session fields" + + def test_validate_session_data_missing_user_id(self): + """Test validation with missing user id.""" + session_data = { + "data": { + "user": { + "displayName": "Test User", + "email": "test@example.com", + }, + "session": { + "provider": "oidc", + "expiresAt": datetime.now(timezone.utc).isoformat(), + "refreshAt": datetime.now(timezone.utc).isoformat(), + }, + } + } + is_valid, error = SessionValidator.validate_session_data(session_data) + assert is_valid is False + assert "Missing required user field: id" in error + + def test_validate_session_data_missing_session_provider(self): + """Test validation with missing session provider.""" + session_data = { + "data": { + "user": { + "id": "test-user", + "displayName": "Test User", + "email": "test@example.com", + }, + "session": { + "expiresAt": datetime.now(timezone.utc).isoformat(), + "refreshAt": datetime.now(timezone.utc).isoformat(), + }, + } + } + is_valid, error = SessionValidator.validate_session_data(session_data) + assert is_valid is False + assert "Missing required session field: provider" in error + + def test_validate_session_data_expired(self): + """Test validation with expired session.""" + session_data = { + "data": { + "user": { + "id": "test-user", + "displayName": "Test User", + "email": "test@example.com", + }, + "session": { + "provider": "oidc", + "expiresAt": (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat(), + "refreshAt": datetime.now(timezone.utc).isoformat(), + }, + } + } + is_valid, error = SessionValidator.validate_session_data(session_data) + assert is_valid is False + assert error == "Session expired" + + def test_validate_session_data_invalid_expiration_format(self): + """Test validation with invalid expiration timestamp.""" + session_data = { + "data": { + "user": { + "id": "test-user", + "displayName": "Test User", + "email": "test@example.com", + }, + "session": { + "provider": "oidc", + "expiresAt": "invalid-timestamp", + "refreshAt": datetime.now(timezone.utc).isoformat(), + }, + } + } + is_valid, error = SessionValidator.validate_session_data(session_data) + assert is_valid is False + assert error == "Invalid expiration timestamp" + + def test_should_refresh_session_true(self): + """Test should_refresh_session returns True when within threshold.""" + session_data = { + "data": { + "session": { + "refreshAt": (datetime.now(timezone.utc) + timedelta(seconds=200)).isoformat(), + } + } + } + should_refresh = SessionValidator.should_refresh_session(session_data, threshold_seconds=300) + assert should_refresh is True + + def test_should_refresh_session_false(self): + """Test should_refresh_session returns False when outside threshold.""" + session_data = { + "data": { + "session": { + "refreshAt": (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(), + } + } + } + should_refresh = SessionValidator.should_refresh_session(session_data, threshold_seconds=300) + assert should_refresh is False + + def test_should_refresh_session_none_data(self): + """Test should_refresh_session with None data.""" + should_refresh = SessionValidator.should_refresh_session(None) + assert should_refresh is False + + def test_should_refresh_session_missing_refresh_at(self): + """Test should_refresh_session with missing refreshAt.""" + session_data = {"data": {"session": {}}} + should_refresh = SessionValidator.should_refresh_session(session_data) + assert should_refresh is False + + def test_should_refresh_session_invalid_timestamp(self): + """Test should_refresh_session with invalid timestamp.""" + session_data = { + "data": { + "session": { + "refreshAt": "invalid-timestamp", + } + } + } + should_refresh = SessionValidator.should_refresh_session(session_data) + assert should_refresh is False + + def test_is_session_expired_true(self): + """Test is_session_expired returns True for expired session.""" + session_data = { + "data": { + "session": { + "expiresAt": (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat(), + } + } + } + is_expired = SessionValidator.is_session_expired(session_data) + assert is_expired is True + + def test_is_session_expired_false(self): + """Test is_session_expired returns False for valid session.""" + session_data = { + "data": { + "session": { + "expiresAt": (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(), + } + } + } + is_expired = SessionValidator.is_session_expired(session_data) + assert is_expired is False + + def test_is_session_expired_none_data(self): + """Test is_session_expired with None data.""" + is_expired = SessionValidator.is_session_expired(None) + assert is_expired is True + + def test_is_session_expired_missing_expires_at(self): + """Test is_session_expired with missing expiresAt.""" + session_data = {"data": {"session": {}}} + is_expired = SessionValidator.is_session_expired(session_data) + assert is_expired is True + + def test_is_session_expired_invalid_timestamp(self): + """Test is_session_expired with invalid timestamp.""" + session_data = { + "data": { + "session": { + "expiresAt": "invalid-timestamp", + } + } + } + is_expired = SessionValidator.is_session_expired(session_data) + assert is_expired is True + + def test_get_session_info_valid(self): + """Test get_session_info with valid data.""" + session_data = { + "data": { + "user": { + "id": "test-user", + "displayName": "Test User", + "email": "test@example.com", + "groups": ["group1", "group2"], + "attributes": {"key": "value"}, + }, + "session": { + "provider": "oidc", + "expiresAt": "2024-01-01T00:00:00Z", + "refreshAt": "2024-01-01T00:00:00Z", + }, + } + } + info = SessionValidator.get_session_info(session_data) + assert info["user"]["id"] == "test-user" + assert info["user"]["displayName"] == "Test User" + assert info["user"]["email"] == "test@example.com" + assert info["user"]["groups"] == ["group1", "group2"] + assert info["user"]["attributes"] == {"key": "value"} + assert info["session"]["provider"] == "oidc" + + def test_get_session_info_none_data(self): + """Test get_session_info with None data.""" + info = SessionValidator.get_session_info(None) + assert info == {} + + def test_get_session_info_missing_fields(self): + """Test get_session_info with missing optional fields.""" + session_data = { + "data": { + "user": { + "id": "test-user", + "displayName": "Test User", + "email": "test@example.com", + }, + "session": { + "provider": "oidc", + }, + } + } + info = SessionValidator.get_session_info(session_data) + assert info["user"]["groups"] == [] + assert info["user"]["attributes"] == {} + + def test_extract_session_id_from_cookie_valid(self): + """Test extracting session ID from valid cookie.""" + cookie_header = "mlspace_session=session:abc123; other=value" + session_id = SessionValidator.extract_session_id_from_cookie(cookie_header) + assert session_id == "session:abc123" + + def test_extract_session_id_from_cookie_none_header(self): + """Test extracting session ID from None header.""" + session_id = SessionValidator.extract_session_id_from_cookie(None) + assert session_id is None + + def test_extract_session_id_from_cookie_missing(self): + """Test extracting session ID when cookie is missing.""" + cookie_header = "other=value; another=cookie" + session_id = SessionValidator.extract_session_id_from_cookie(cookie_header) + assert session_id is None + + def test_extract_session_id_from_cookie_invalid_format(self): + """Test extracting session ID with invalid format.""" + cookie_header = "mlspace_session=invalid-format" + session_id = SessionValidator.extract_session_id_from_cookie(cookie_header) + assert session_id is None + + def test_extract_session_id_from_cookie_custom_name(self): + """Test extracting session ID with custom cookie name.""" + cookie_header = "custom_session=session:xyz789" + session_id = SessionValidator.extract_session_id_from_cookie(cookie_header, cookie_name="custom_session") + assert session_id == "session:xyz789" + + def test_validate_domain_valid(self): + """Test domain validation with valid domain.""" + is_valid = SessionValidator.validate_domain("example.com", ["example.com", "test.com"]) + assert is_valid is True + + def test_validate_domain_invalid(self): + """Test domain validation with invalid domain.""" + is_valid = SessionValidator.validate_domain("invalid.com", ["example.com", "test.com"]) + assert is_valid is False + + def test_validate_domain_none_domain(self): + """Test domain validation with None domain.""" + is_valid = SessionValidator.validate_domain(None, ["example.com"]) + assert is_valid is False + + def test_validate_domain_none_allowed(self): + """Test domain validation with None allowed list.""" + is_valid = SessionValidator.validate_domain("example.com", None) + assert is_valid is False + + def test_validate_domain_with_port(self): + """Test domain validation with port in domain.""" + is_valid = SessionValidator.validate_domain("example.com:8080", ["example.com"]) + assert is_valid is True + + def test_validate_domain_with_path(self): + """Test domain validation with path in domain.""" + is_valid = SessionValidator.validate_domain("example.com/path", ["example.com"]) + assert is_valid is True + + def test_validate_domain_case_insensitive(self): + """Test domain validation is case insensitive.""" + is_valid = SessionValidator.validate_domain("Example.COM", ["example.com"]) + assert is_valid is True diff --git a/backend/test/auth/test_lambda_functions.py b/backend/test/auth/test_lambda_functions.py new file mode 100644 index 00000000..7d55d1ff --- /dev/null +++ b/backend/test/auth/test_lambda_functions.py @@ -0,0 +1,1456 @@ +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import json +import os +from unittest.mock import Mock, patch + +from ml_space_lambda.auth.lambda_functions import callback, callback_post, identity, login, logout + + +class TestAuthLambdaFunctions: + """Test authentication lambda functions.""" + + def setup_method(self): + """Set up test fixtures.""" + self.mock_context = Mock() + self.mock_context.aws_request_id = "test-request-id" + + # Mock environment variables + self.env_vars = { + "AUTH_IDP_TYPE": "oidc", + "AUTH_OIDC_URL": "https://example.com", + "AUTH_OIDC_CLIENT_ID": "test-client-id", + "AUTH_OIDC_CLIENT_SECRET_NAME": "test/client-secret", + "AUTH_OIDC_USE_PKCE": "true", + "AUTH_STATE_ENCRYPTION_KEY_SECRET_NAME": "test/state-key", + "AUTH_TOKEN_ENCRYPTION_KEY_SECRET_NAME": "test/token-key", + "AUTH_SESSION_TABLE_NAME": "test-session-table", + "AUTH_OIDC_VERIFY_SSL": "true", + "AUTH_OIDC_VERIFY_SIGNATURE": "true", + "AUTH_SYNC_DOMAINS": "", + "WEB_CUSTOM_DOMAIN_NAME": "", + "AWS_REGION": "us-east-1", + } + + # Mock SSM responses (legacy - keeping for backward compatibility tests) + # Generate a valid Fernet key for testing + import base64 + + from cryptography.fernet import Fernet + + test_fernet_key = Fernet.generate_key().decode("utf-8") + # Generate a 32-byte key for token encryption and encode as base64 + test_token_key = base64.b64encode(os.urandom(32)).decode("utf-8") + + self.mock_ssm_responses = { + "/test/client-secret": "test-client-secret", + "/test/state-key": test_fernet_key, + "/test/token-key": test_token_key, + } + + # Mock Secrets Manager responses + self.mock_secrets_responses = { + "test/client-secret": { + "current_version": 1, + "keys": {"1": "test-client-secret"}, + "key_type": "token", + "created_by": "test", + }, + "test/state-key": { + "current_version": 1, + "keys": {"1": test_fernet_key}, + "key_type": "state", + "created_by": "test", + }, + "test/token-key": {"current_version": 1, "keys": {"1": test_token_key}, "key_type": "token", "created_by": "test"}, + } + + @patch.dict("os.environ", {}) + def test_login_missing_config(self): + """Test login with missing configuration.""" + event = { + "headers": {"Host": "app.example.com"}, + "body": json.dumps({"redirectUrl": "/dashboard"}), + } + + response = login(event, self.mock_context) + + assert response["statusCode"] == 400 + body = json.loads(response["body"]) + assert body["error"] == "INVALID_CONFIGURATION" + assert "AUTH_OIDC_URL" in body["message"] + + @patch.dict("os.environ") + @patch("ml_space_lambda.auth.lambda_functions.secrets_client") + @patch("ml_space_lambda.auth.lambda_functions.ssm_client") + @patch("ml_space_lambda.auth.lambda_functions.OIDCHandler") + @patch("ml_space_lambda.auth.lambda_functions.StateManager") + def test_login_success(self, mock_state_manager_class, mock_oidc_handler_class, mock_ssm_client, mock_secrets_client): + """Test successful login flow.""" + # Set up environment + for key, value in self.env_vars.items(): + os.environ[key] = value + + # Mock SSM client + def mock_get_parameter(Name, WithDecryption=True): + return {"Parameter": {"Value": self.mock_ssm_responses[Name]}} + + mock_ssm_client.get_parameter.side_effect = mock_get_parameter + + # Mock Secrets Manager client + def mock_get_secret_value(SecretId): + secret_data = self.mock_secrets_responses[SecretId] + return {"SecretString": json.dumps(secret_data)} + + mock_secrets_client.get_secret_value.side_effect = mock_get_secret_value + + # Mock state manager + mock_state_manager = Mock() + mock_state_manager.generate_nonce.return_value = "test-nonce" + mock_state_manager.create_state.return_value = "encrypted-state" + mock_state_manager_class.return_value = mock_state_manager + + # Mock OIDC handler + mock_oidc_handler = Mock() + mock_oidc_handler.get_authorization_url.return_value = "https://example.com/auth?client_id=test&state=encrypted-state" + mock_oidc_handler_class.return_value = mock_oidc_handler + + event = { + "headers": {"Host": "app.example.com"}, + "body": json.dumps({"redirectUrl": "/dashboard"}), + } + + response = login(event, self.mock_context) + + # Verify response + assert response["statusCode"] == 302 + assert "Location" in response["headers"] + assert "https://example.com/auth" in response["headers"]["Location"] + assert "multiValueHeaders" in response + assert "Set-Cookie" in response["multiValueHeaders"] + + # Verify state cookie + cookies = response["multiValueHeaders"]["Set-Cookie"] + state_cookie = next((c for c in cookies if "mlspace_auth_state" in c), None) + assert state_cookie is not None + assert "test-nonce" in state_cookie + assert "HttpOnly" in state_cookie + assert "Secure" in state_cookie + + @patch.dict("os.environ") + @patch("ml_space_lambda.auth.lambda_functions.secrets_client") + @patch("ml_space_lambda.auth.lambda_functions.ssm_client") + @patch("ml_space_lambda.auth.lambda_functions.OIDCHandler") + @patch("ml_space_lambda.auth.lambda_functions.StateManager") + def test_login_with_custom_domain( + self, mock_state_manager_class, mock_oidc_handler_class, mock_ssm_client, mock_secrets_client + ): + """Test login flow uses WEB_CUSTOM_DOMAIN_NAME when configured.""" + # Set up environment with custom domain + env_vars = self.env_vars.copy() + env_vars["WEB_CUSTOM_DOMAIN_NAME"] = "https://mlspace.example.com" + + for key, value in env_vars.items(): + os.environ[key] = value + + # Mock SSM client + def mock_get_parameter(Name, WithDecryption=True): + return {"Parameter": {"Value": self.mock_ssm_responses[Name]}} + + mock_ssm_client.get_parameter.side_effect = mock_get_parameter + + # Mock Secrets Manager client + def mock_get_secret_value(SecretId): + secret_data = self.mock_secrets_responses[SecretId] + return {"SecretString": json.dumps(secret_data)} + + mock_secrets_client.get_secret_value.side_effect = mock_get_secret_value + + # Mock state manager + mock_state_manager = Mock() + mock_state_manager.generate_nonce.return_value = "test-nonce" + mock_state_manager.create_state.return_value = "encrypted-state" + mock_state_manager_class.return_value = mock_state_manager + + # Mock OIDC handler + mock_oidc_handler = Mock() + mock_oidc_handler.get_authorization_url.return_value = "https://idp.example.com/auth?state=encrypted-state" + mock_oidc_handler_class.return_value = mock_oidc_handler + + event = { + "headers": {"Host": "api-gateway-id.execute-api.us-east-1.amazonaws.com"}, + "queryStringParameters": {"redirectUrl": "/dashboard"}, + } + + response = login(event, self.mock_context) + + # Verify response + assert response["statusCode"] == 302 + + # Verify OIDC handler was called with custom domain in redirect_uri + mock_oidc_handler.get_authorization_url.assert_called_once() + call_kwargs = mock_oidc_handler.get_authorization_url.call_args[1] + assert call_kwargs["redirect_uri"] == "https://mlspace.example.com/auth/callback" + + @patch.dict("os.environ") + @patch("ml_space_lambda.auth.lambda_functions.secrets_client") + @patch("ml_space_lambda.auth.lambda_functions.ssm_client") + def test_login_invalid_redirect_url(self, mock_ssm_client, mock_secrets_client): + """Test login with invalid redirect URL.""" + # Set up environment + for key, value in self.env_vars.items(): + os.environ[key] = value + + # Mock SSM client + def mock_get_parameter(Name, WithDecryption=True): + return {"Parameter": {"Value": self.mock_ssm_responses[Name]}} + + mock_ssm_client.get_parameter.side_effect = mock_get_parameter + + # Mock Secrets Manager client + def mock_get_secret_value(SecretId): + secret_data = self.mock_secrets_responses[SecretId] + return {"SecretString": json.dumps(secret_data)} + + mock_secrets_client.get_secret_value.side_effect = mock_get_secret_value + + with patch("ml_space_lambda.auth.lambda_functions.OIDCHandler") as mock_oidc_handler_class, patch( + "ml_space_lambda.auth.lambda_functions.StateManager" + ) as mock_state_manager_class: + + # Mock state manager + mock_state_manager = Mock() + mock_state_manager.generate_nonce.return_value = "test-nonce" + mock_state_manager.create_state.return_value = "encrypted-state" + mock_state_manager_class.return_value = mock_state_manager + + # Mock OIDC handler + mock_oidc_handler = Mock() + mock_oidc_handler.get_authorization_url.return_value = "https://example.com/auth" + mock_oidc_handler_class.return_value = mock_oidc_handler + + event = { + "headers": {"Host": "app.example.com"}, + "body": json.dumps({"redirectUrl": "https://malicious.com/steal"}), + } + + response = login(event, self.mock_context) + + # Should still succeed but use safe redirect + assert response["statusCode"] == 302 + + # Verify state was created with safe redirect URL + mock_state_manager.create_state.assert_called_once() + call_args = mock_state_manager.create_state.call_args[1] + assert call_args["redirect_url"] == "/" # Should default to safe URL + + @patch.dict("os.environ") + @patch("ml_space_lambda.auth.lambda_functions.secrets_client") + @patch("ml_space_lambda.auth.lambda_functions.ssm_client") + def test_login_no_body(self, mock_ssm_client, mock_secrets_client): + """Test login with no request body.""" + # Set up environment + for key, value in self.env_vars.items(): + os.environ[key] = value + + # Mock SSM client + def mock_get_parameter(Name, WithDecryption=True): + return {"Parameter": {"Value": self.mock_ssm_responses[Name]}} + + mock_ssm_client.get_parameter.side_effect = mock_get_parameter + + # Mock Secrets Manager client + def mock_get_secret_value(SecretId): + secret_data = self.mock_secrets_responses[SecretId] + return {"SecretString": json.dumps(secret_data)} + + mock_secrets_client.get_secret_value.side_effect = mock_get_secret_value + + with patch("ml_space_lambda.auth.lambda_functions.OIDCHandler") as mock_oidc_handler_class, patch( + "ml_space_lambda.auth.lambda_functions.StateManager" + ) as mock_state_manager_class: + + # Mock state manager + mock_state_manager = Mock() + mock_state_manager.generate_nonce.return_value = "test-nonce" + mock_state_manager.create_state.return_value = "encrypted-state" + mock_state_manager_class.return_value = mock_state_manager + + # Mock OIDC handler + mock_oidc_handler = Mock() + mock_oidc_handler.get_authorization_url.return_value = "https://example.com/auth" + mock_oidc_handler_class.return_value = mock_oidc_handler + + event = { + "headers": {"Host": "app.example.com"}, + # No body + } + + response = login(event, self.mock_context) + + # Should succeed with default redirect + assert response["statusCode"] == 302 + + # Verify state was created with default redirect URL + mock_state_manager.create_state.assert_called_once() + call_args = mock_state_manager.create_state.call_args[1] + assert call_args["redirect_url"] == "/" + + @patch.dict("os.environ") + @patch("ml_space_lambda.auth.lambda_functions.ssm_client") + def test_login_ssm_error(self, mock_ssm_client): + """Test login with SSM parameter retrieval error.""" + # Set up environment - use empty state key param to trigger SSM error + env_vars = self.env_vars.copy() + env_vars["AUTH_STATE_ENCRYPTION_KEY_SSM_PARAM"] = "/test/missing-key" + + for key, value in env_vars.items(): + os.environ[key] = value + + # Mock SSM client to raise exception for missing key + def mock_get_parameter(Name, WithDecryption=True): + if Name == "/test/missing-key": + raise Exception("Parameter not found") + return {"Parameter": {"Value": self.mock_ssm_responses.get(Name, "")}} + + mock_ssm_client.get_parameter.side_effect = mock_get_parameter + + event = { + "headers": {"Host": "app.example.com"}, + "body": json.dumps({"redirectUrl": "/dashboard"}), + } + + response = login(event, self.mock_context) + + assert response["statusCode"] == 400 + body = json.loads(response["body"]) + assert body["error"] == "INVALID_CONFIGURATION" + assert "Failed to discover OIDC endpoints" in body["message"] + + @patch.dict("os.environ") + def test_login_unsupported_idp_type(self): + """Test login with unsupported IdP type.""" + # Set up environment with unsupported IdP type + env_vars = self.env_vars.copy() + env_vars["AUTH_IDP_TYPE"] = "saml" + + for key, value in env_vars.items(): + os.environ[key] = value + + event = { + "headers": {"Host": "app.example.com"}, + "body": json.dumps({"redirectUrl": "/dashboard"}), + } + + response = login(event, self.mock_context) + + assert response["statusCode"] == 400 + body = json.loads(response["body"]) + assert body["error"] == "INVALID_CONFIGURATION" + assert "Unsupported IdP type: saml" in body["message"] + assert "Only 'oidc' is currently supported" in body["message"] + + @patch.dict("os.environ") + @patch("ml_space_lambda.auth.lambda_functions.secrets_client") + @patch("ml_space_lambda.auth.lambda_functions.ssm_client") + @patch("ml_space_lambda.auth.lambda_functions.OIDCHandler") + @patch("ml_space_lambda.auth.lambda_functions.StateManager") + @patch("ml_space_lambda.auth.lambda_functions.SessionManager") + @patch("ml_space_lambda.auth.lambda_functions.OTACManager") + @patch("ml_space_lambda.data_access_objects.dynamo_data_store.boto3") + @patch("ml_space_lambda.auth.lambda_functions.get_environment_variables") + def test_callback_success( + self, + mock_get_env_vars, + mock_boto3, + mock_otac_manager_class, + mock_session_manager_class, + mock_state_manager_class, + mock_oidc_handler_class, + mock_ssm_client, + mock_secrets_client, + ): + """Test successful callback flow.""" + # Set up environment + for key, value in self.env_vars.items(): + os.environ[key] = value + + # Mock environment variables for user creation + mock_get_env_vars.return_value = { + "NEW_USERS_SUSPENDED": "False", + "USERS_TABLE": "test-users-table", + } + + # Mock DynamoDB client for UserDAO + mock_dynamodb_client = Mock() + mock_dynamodb_client.get_item.return_value = {} # User doesn't exist + mock_dynamodb_client.put_item.return_value = {} + mock_boto3.client.return_value = mock_dynamodb_client + + # Mock SSM client + def mock_get_parameter(Name, WithDecryption=True): + return {"Parameter": {"Value": self.mock_ssm_responses[Name]}} + + mock_ssm_client.get_parameter.side_effect = mock_get_parameter + + # Mock Secrets Manager client + def mock_get_secret_value(SecretId): + secret_data = self.mock_secrets_responses[SecretId] + return {"SecretString": json.dumps(secret_data)} + + mock_secrets_client.get_secret_value.side_effect = mock_get_secret_value + + # Mock state manager + mock_state_manager = Mock() + mock_state_manager.validate_state.return_value = { + "redirect_url": "/dashboard", + "nonce": "test-nonce", + "domain": "app.example.com", + } + mock_state_manager_class.return_value = mock_state_manager + + # Mock OIDC handler + from ml_space_lambda.auth.models.auth_models import AuthenticationResult, IdPTokens, UserData + + mock_user_data = UserData( + id="test-user", + displayName="Test User", + email="test@example.com", + groups=["users"], + attributes={"sub": "idp-sub-12345"}, # Include sub claim in attributes + ) + + mock_tokens = IdPTokens( + access_token="access-token", refresh_token="refresh-token", id_token="id-token", expires_in=3600 + ) + + mock_auth_result = AuthenticationResult( + success=True, user_data=mock_user_data, tokens=mock_tokens, raw_response="raw-response" + ) + + mock_oidc_handler = Mock() + mock_oidc_handler.handle_callback.return_value = mock_auth_result + mock_oidc_handler.extract_token_expiration.return_value = (3600, 86400) + mock_oidc_handler_class.return_value = mock_oidc_handler + + # Mock session manager + mock_session_manager = Mock() + mock_session_manager.create_session.return_value = "session:test-session-id" + mock_session_manager_class.return_value = mock_session_manager + + # Mock OTAC manager + mock_otac_manager = Mock() + mock_otac_manager_class.return_value = mock_otac_manager + + event = { + "headers": {"Host": "app.example.com", "Cookie": "mlspace_auth_state=test-nonce"}, + "queryStringParameters": {"code": "auth-code", "state": "encrypted-state"}, + } + + response = callback(event, self.mock_context) + + # Verify response + assert response["statusCode"] == 302 + assert "Location" in response["headers"] + assert response["headers"]["Location"] == "/dashboard" + assert "multiValueHeaders" in response + assert "Set-Cookie" in response["multiValueHeaders"] + + # Verify user was checked and created + mock_dynamodb_client.get_item.assert_called() + mock_dynamodb_client.put_item.assert_called() + + @patch.dict("os.environ") + @patch("ml_space_lambda.auth.lambda_functions.ssm_client") + def test_callback_missing_code(self, mock_ssm_client): + """Test callback with missing authorization code.""" + # Set up environment + for key, value in self.env_vars.items(): + os.environ[key] = value + + # Mock SSM client + def mock_get_parameter(Name, WithDecryption=True): + return {"Parameter": {"Value": self.mock_ssm_responses[Name]}} + + mock_ssm_client.get_parameter.side_effect = mock_get_parameter + + event = { + "headers": {"Host": "app.example.com"}, + "queryStringParameters": { + "state": "encrypted-state" + # Missing "code" + }, + } + + response = callback(event, self.mock_context) + + # Should redirect with error + assert response["statusCode"] == 302 + assert "error=invalid_request" in response["headers"]["Location"] + + @patch.dict("os.environ") + @patch("ml_space_lambda.auth.lambda_functions.ssm_client") + def test_callback_idp_error(self, mock_ssm_client): + """Test callback with IdP error response.""" + # Set up environment + for key, value in self.env_vars.items(): + os.environ[key] = value + + # Mock SSM client + def mock_get_parameter(Name, WithDecryption=True): + return {"Parameter": {"Value": self.mock_ssm_responses[Name]}} + + mock_ssm_client.get_parameter.side_effect = mock_get_parameter + + event = { + "headers": {"Host": "app.example.com"}, + "queryStringParameters": {"error": "access_denied", "error_description": "User denied access"}, + } + + response = callback(event, self.mock_context) + + # Should redirect with error + assert response["statusCode"] == 302 + assert "error=authentication_failed" in response["headers"]["Location"] + + @patch.dict("os.environ") + def test_callback_post_unsupported(self): + """Test POST callback for unsupported IdP type.""" + # Set up environment + for key, value in self.env_vars.items(): + os.environ[key] = value + + event = {"headers": {"Host": "app.example.com"}, "body": "saml-assertion-data"} + + response = callback_post(event, self.mock_context) + + # Should redirect with error (SAML not implemented) + assert response["statusCode"] == 302 + assert "error=unsupported_callback" in response["headers"]["Location"] + + @patch.dict("os.environ") + @patch("ml_space_lambda.auth.lambda_functions.ssm_client") + @patch("ml_space_lambda.auth.lambda_functions.SessionManager") + def test_logout_success(self, mock_session_manager_class, mock_ssm_client): + """Test successful logout flow.""" + # Set up environment + for key, value in self.env_vars.items(): + os.environ[key] = value + + # Mock SSM client + def mock_get_parameter(Name, WithDecryption=True): + return {"Parameter": {"Value": self.mock_ssm_responses[Name]}} + + mock_ssm_client.get_parameter.side_effect = mock_get_parameter + + # Mock session manager + mock_session_manager = Mock() + mock_session_manager.get_session.return_value = { + "data": { + "user": {"id": "test-user", "displayName": "Test User", "email": "test@example.com"}, + "session": {"provider": "oidc"}, + } + } + mock_session_manager.delete_session.return_value = True + mock_session_manager_class.return_value = mock_session_manager + + event = { + "headers": {"Host": "app.example.com", "Cookie": "mlspace_session=session:test-session-id"}, + "body": json.dumps({"logoutFromIdp": False}), + } + + response = logout(event, self.mock_context) + + # Verify response + assert response["statusCode"] == 200 + body = json.loads(response["body"]) + assert body["status"] == "LOGGED_OUT" + assert "idpLogoutUrl" not in body + + # Verify session cookie is cleared + assert "multiValueHeaders" in response + assert "Set-Cookie" in response["multiValueHeaders"] + cookies = response["multiValueHeaders"]["Set-Cookie"] + session_cookie = next((c for c in cookies if "mlspace_session" in c), None) + assert session_cookie is not None + assert "Max-Age=0" in session_cookie + + # Verify session deletion was called + mock_session_manager.delete_session.assert_called_once_with("session:test-session-id") + + @patch.dict("os.environ") + @patch("ml_space_lambda.auth.lambda_functions.ssm_client") + @patch("ml_space_lambda.auth.lambda_functions.SessionManager") + @patch("ml_space_lambda.auth.lambda_functions.OIDCHandler") + def test_logout_with_idp_logout(self, mock_oidc_handler_class, mock_session_manager_class, mock_ssm_client): + """Test logout with IdP logout URL generation.""" + # Set up environment + for key, value in self.env_vars.items(): + os.environ[key] = value + + # Mock SSM client + def mock_get_parameter(Name, WithDecryption=True): + return {"Parameter": {"Value": self.mock_ssm_responses[Name]}} + + mock_ssm_client.get_parameter.side_effect = mock_get_parameter + + # Mock session manager + mock_session_manager = Mock() + mock_session_manager.get_session.return_value = { + "data": { + "user": {"id": "test-user", "displayName": "Test User", "email": "test@example.com"}, + "session": {"provider": "oidc"}, + } + } + mock_session_manager.delete_session.return_value = True + mock_session_manager_class.return_value = mock_session_manager + + # Mock OIDC handler + mock_oidc_handler = Mock() + mock_oidc_handler.get_logout_url.return_value = ( + "https://example.com/logout?post_logout_redirect_uri=https://app.example.com/" + ) + mock_oidc_handler_class.return_value = mock_oidc_handler + + event = { + "headers": {"Host": "app.example.com", "Cookie": "mlspace_session=session:test-session-id"}, + "body": json.dumps({"logoutFromIdp": True}), + } + + response = logout(event, self.mock_context) + + # Verify response + assert response["statusCode"] == 200 + body = json.loads(response["body"]) + assert body["status"] == "LOGGED_OUT" + assert "idpLogoutUrl" in body + assert "https://example.com/logout" in body["idpLogoutUrl"] + + # Verify IdP logout URL was requested + mock_oidc_handler.get_logout_url.assert_called_once_with("https://app.example.com/") + + def test_logout_no_session_cookie(self): + """Test logout with no session cookie.""" + event = { + "headers": {"Host": "app.example.com"}, + "body": json.dumps({"logoutFromIdp": False}), + } + + response = logout(event, self.mock_context) + + # Should return error + assert response["statusCode"] == 400 + body = json.loads(response["body"]) + assert body["error"] == "INVALID_SESSION" + assert body["message"] == "No active session found" + + @patch.dict("os.environ") + @patch("ml_space_lambda.auth.lambda_functions.ssm_client") + @patch("ml_space_lambda.auth.lambda_functions.SessionManager") + def test_logout_invalid_session(self, mock_session_manager_class, mock_ssm_client): + """Test logout with invalid session.""" + # Set up environment + for key, value in self.env_vars.items(): + os.environ[key] = value + + # Mock SSM client + def mock_get_parameter(Name, WithDecryption=True): + return {"Parameter": {"Value": self.mock_ssm_responses[Name]}} + + mock_ssm_client.get_parameter.side_effect = mock_get_parameter + + # Mock session manager to return None (invalid session) + mock_session_manager = Mock() + mock_session_manager.get_session.return_value = None + mock_session_manager_class.return_value = mock_session_manager + + event = { + "headers": {"Host": "app.example.com", "Cookie": "mlspace_session=session:invalid-session-id"}, + "body": json.dumps({"logoutFromIdp": False}), + } + + response = logout(event, self.mock_context) + + # Should return error + assert response["statusCode"] == 400 + body = json.loads(response["body"]) + assert body["error"] == "INVALID_SESSION" + assert body["message"] == "No active session found" + + @patch.dict("os.environ") + @patch("ml_space_lambda.auth.lambda_functions.ssm_client") + @patch("ml_space_lambda.auth.lambda_functions.SessionManager") + def test_logout_no_body(self, mock_session_manager_class, mock_ssm_client): + """Test logout with no request body.""" + # Set up environment + for key, value in self.env_vars.items(): + os.environ[key] = value + + # Mock SSM client + def mock_get_parameter(Name, WithDecryption=True): + return {"Parameter": {"Value": self.mock_ssm_responses[Name]}} + + mock_ssm_client.get_parameter.side_effect = mock_get_parameter + + # Mock session manager + mock_session_manager = Mock() + mock_session_manager.get_session.return_value = { + "data": { + "user": {"id": "test-user", "displayName": "Test User", "email": "test@example.com"}, + "session": {"provider": "oidc"}, + } + } + mock_session_manager.delete_session.return_value = True + mock_session_manager_class.return_value = mock_session_manager + + event = { + "headers": {"Host": "app.example.com", "Cookie": "mlspace_session=session:test-session-id"}, + # No body + } + + response = logout(event, self.mock_context) + + # Should succeed with default behavior (no IdP logout) + assert response["statusCode"] == 200 + body = json.loads(response["body"]) + assert body["status"] == "LOGGED_OUT" + assert "idpLogoutUrl" not in body + + @patch.dict("os.environ") + @patch("ml_space_lambda.auth.lambda_functions.ssm_client") + @patch("ml_space_lambda.auth.lambda_functions.SessionManager") + def test_logout_session_deletion_failure(self, mock_session_manager_class, mock_ssm_client): + """Test logout when session deletion fails.""" + # Set up environment + for key, value in self.env_vars.items(): + os.environ[key] = value + + # Mock SSM client + def mock_get_parameter(Name, WithDecryption=True): + return {"Parameter": {"Value": self.mock_ssm_responses[Name]}} + + mock_ssm_client.get_parameter.side_effect = mock_get_parameter + + # Mock session manager + mock_session_manager = Mock() + mock_session_manager.get_session.return_value = { + "data": { + "user": {"id": "test-user", "displayName": "Test User", "email": "test@example.com"}, + "session": {"provider": "oidc"}, + } + } + mock_session_manager.delete_session.return_value = False # Deletion fails + mock_session_manager_class.return_value = mock_session_manager + + event = { + "headers": {"Host": "app.example.com", "Cookie": "mlspace_session=session:test-session-id"}, + "body": json.dumps({"logoutFromIdp": False}), + } + + response = logout(event, self.mock_context) + + # Should still succeed (logout continues even if deletion fails) + assert response["statusCode"] == 200 + body = json.loads(response["body"]) + assert body["status"] == "LOGGED_OUT" + + # Verify session cookie is still cleared + assert "multiValueHeaders" in response + assert "Set-Cookie" in response["multiValueHeaders"] + + @patch.dict("os.environ") + @patch("ml_space_lambda.auth.lambda_functions.ssm_client") + @patch("ml_space_lambda.auth.lambda_functions.SessionManager") + def test_identity_success(self, mock_session_manager_class, mock_ssm_client): + """Test successful identity request.""" + # Set up environment + for key, value in self.env_vars.items(): + os.environ[key] = value + + # Mock SSM client + def mock_get_parameter(Name, WithDecryption=True): + return {"Parameter": {"Value": self.mock_ssm_responses[Name]}} + + mock_ssm_client.get_parameter.side_effect = mock_get_parameter + + # Mock session manager + from datetime import datetime, timedelta, timezone + + future_time = datetime.now(timezone.utc) + timedelta(hours=1) + refresh_time = datetime.now(timezone.utc) + timedelta(minutes=30) + + mock_session_data = { + "data": { + "user": { + "id": "test-user", + "displayName": "Test User", + "email": "test@example.com", + "groups": ["users", "admin"], + "attributes": {"department": "Engineering"}, + }, + "session": {"provider": "oidc", "expiresAt": future_time.isoformat(), "refreshAt": refresh_time.isoformat()}, + } + } + + mock_session_manager = Mock() + mock_session_manager.get_session.return_value = mock_session_data + mock_session_manager_class.return_value = mock_session_manager + + event = {"headers": {"Host": "app.example.com", "Cookie": "mlspace_session=session:test-session-id"}} + + response = identity(event, self.mock_context) + + # Verify response + assert response["statusCode"] == 200 + body = json.loads(response["body"]) + assert body["status"] == "AUTHENTICATED" + assert body["user"]["id"] == "test-user" + assert body["user"]["displayName"] == "Test User" + assert body["user"]["email"] == "test@example.com" + assert body["user"]["groups"] == ["users", "admin"] + assert body["user"]["attributes"] == {"department": "Engineering"} + assert body["session"]["provider"] == "oidc" + assert body["session"]["expiresAt"] == future_time.isoformat() + assert body["session"]["refreshAt"] == refresh_time.isoformat() + assert "refreshed" not in body["session"] + + def test_identity_no_session_cookie(self): + """Test identity request with no session cookie.""" + event = {"headers": {"Host": "app.example.com"}} + + response = identity(event, self.mock_context) + + # Should return unauthenticated + assert response["statusCode"] == 401 + body = json.loads(response["body"]) + assert body["status"] == "UNAUTHENTICATED" + assert body["error"] == "NO_SESSION" + assert body["message"] == "No session cookie found" + + @patch.dict("os.environ") + @patch("ml_space_lambda.auth.lambda_functions.ssm_client") + @patch("ml_space_lambda.auth.lambda_functions.SessionManager") + def test_identity_invalid_session(self, mock_session_manager_class, mock_ssm_client): + """Test identity request with invalid session.""" + # Set up environment + for key, value in self.env_vars.items(): + os.environ[key] = value + + # Mock SSM client + def mock_get_parameter(Name, WithDecryption=True): + return {"Parameter": {"Value": self.mock_ssm_responses[Name]}} + + mock_ssm_client.get_parameter.side_effect = mock_get_parameter + + # Mock session manager to return None (invalid session) + mock_session_manager = Mock() + mock_session_manager.get_session.return_value = None + mock_session_manager_class.return_value = mock_session_manager + + event = {"headers": {"Host": "app.example.com", "Cookie": "mlspace_session=session:invalid-session-id"}} + + response = identity(event, self.mock_context) + + # Should return unauthenticated + assert response["statusCode"] == 401 + body = json.loads(response["body"]) + assert body["status"] == "UNAUTHENTICATED" + assert body["error"] == "SESSION_EXPIRED" + assert body["message"] == "Session has expired or is invalid" + + @patch.dict("os.environ") + @patch("ml_space_lambda.auth.lambda_functions.ssm_client") + @patch("ml_space_lambda.auth.lambda_functions.SessionManager") + @patch("ml_space_lambda.auth.lambda_functions.OIDCHandler") + def test_identity_with_token_refresh(self, mock_oidc_handler_class, mock_session_manager_class, mock_ssm_client): + """Test identity request that triggers token refresh.""" + # Set up environment + for key, value in self.env_vars.items(): + os.environ[key] = value + + # Mock SSM client + def mock_get_parameter(Name, WithDecryption=True): + return {"Parameter": {"Value": self.mock_ssm_responses[Name]}} + + mock_ssm_client.get_parameter.side_effect = mock_get_parameter + + # Mock session manager + from datetime import datetime, timedelta, timezone + + future_time = datetime.now(timezone.utc) + timedelta(hours=1) + # Set refresh time in the past to trigger refresh + past_refresh_time = datetime.now(timezone.utc) - timedelta(minutes=5) + + mock_session_data = { + "data": { + "user": { + "id": "test-user", + "displayName": "Test User", + "email": "test@example.com", + "groups": ["users"], + "attributes": {}, + }, + "session": { + "provider": "oidc", + "expiresAt": future_time.isoformat(), + "refreshAt": past_refresh_time.isoformat(), + "refresh_token": "refresh-token-value", + }, + } + } + + # Updated session data after refresh + updated_session_data = { + "data": { + "user": { + "id": "test-user", + "displayName": "Test User Updated", + "email": "test@example.com", + "groups": ["users", "premium"], + "attributes": {}, + }, + "session": { + "provider": "oidc", + "expiresAt": (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(), + "refreshAt": (datetime.now(timezone.utc) + timedelta(minutes=55)).isoformat(), + "refresh_token": "new-refresh-token-value", + }, + } + } + + mock_session_manager = Mock() + # First call returns original data, second call returns updated data + mock_session_manager.get_session.side_effect = [mock_session_data, updated_session_data] + mock_session_manager.refresh_session_with_user_data.return_value = True + mock_session_manager_class.return_value = mock_session_manager + + # Mock OIDC handler for token refresh + from ml_space_lambda.auth.models.auth_models import AuthenticationResult, IdPTokens, UserData + + mock_user_data = UserData( + id="test-user", + displayName="Test User Updated", + email="test@example.com", + groups=["users", "premium"], + attributes={}, + ) + + mock_tokens = IdPTokens( + access_token="new-access-token", refresh_token="new-refresh-token", id_token="new-id-token", expires_in=3600 + ) + + mock_refresh_result = AuthenticationResult( + success=True, user_data=mock_user_data, tokens=mock_tokens, raw_response="new-raw-response" + ) + + mock_oidc_handler = Mock() + mock_oidc_handler.refresh_tokens.return_value = mock_refresh_result + mock_oidc_handler.extract_token_expiration.return_value = (3600, 86400) + mock_oidc_handler_class.return_value = mock_oidc_handler + + event = {"headers": {"Host": "app.example.com", "Cookie": "mlspace_session=session:test-session-id"}} + + response = identity(event, self.mock_context) + + # Verify response + assert response["statusCode"] == 200 + body = json.loads(response["body"]) + assert body["status"] == "AUTHENTICATED" + assert body["user"]["displayName"] == "Test User Updated" + assert body["user"]["groups"] == ["users", "premium"] + assert body["session"]["provider"] == "oidc" + assert body["session"]["refreshed"] is True + + # Verify token refresh was called + mock_oidc_handler.refresh_tokens.assert_called_once_with("refresh-token-value") + + # Verify session was updated + mock_session_manager.refresh_session_with_user_data.assert_called_once() + + # Verify new session cookie is set + assert "multiValueHeaders" in response + assert "Set-Cookie" in response["multiValueHeaders"] + + @patch.dict("os.environ") + @patch("ml_space_lambda.auth.lambda_functions.ssm_client") + @patch("ml_space_lambda.auth.lambda_functions.SessionManager") + @patch("ml_space_lambda.auth.lambda_functions.OIDCHandler") + def test_identity_refresh_failure(self, mock_oidc_handler_class, mock_session_manager_class, mock_ssm_client): + """Test identity request when token refresh fails.""" + # Set up environment + for key, value in self.env_vars.items(): + os.environ[key] = value + + # Mock SSM client + def mock_get_parameter(Name, WithDecryption=True): + return {"Parameter": {"Value": self.mock_ssm_responses[Name]}} + + mock_ssm_client.get_parameter.side_effect = mock_get_parameter + + # Mock session manager + from datetime import datetime, timedelta, timezone + + future_time = datetime.now(timezone.utc) + timedelta(hours=1) + # Set refresh time in the past to trigger refresh + past_refresh_time = datetime.now(timezone.utc) - timedelta(minutes=5) + + mock_session_data = { + "data": { + "user": { + "id": "test-user", + "displayName": "Test User", + "email": "test@example.com", + "groups": ["users"], + "attributes": {}, + }, + "session": { + "provider": "oidc", + "expiresAt": future_time.isoformat(), + "refreshAt": past_refresh_time.isoformat(), + "refresh_token": "refresh-token-value", + }, + } + } + + mock_session_manager = Mock() + mock_session_manager.get_session.return_value = mock_session_data + mock_session_manager_class.return_value = mock_session_manager + + # Mock OIDC handler for failed token refresh + from ml_space_lambda.auth.models.auth_models import AuthenticationResult + + mock_refresh_result = AuthenticationResult(success=False, error="Token refresh failed") + + mock_oidc_handler = Mock() + mock_oidc_handler.refresh_tokens.return_value = mock_refresh_result + mock_oidc_handler_class.return_value = mock_oidc_handler + + event = {"headers": {"Host": "app.example.com", "Cookie": "mlspace_session=session:test-session-id"}} + + response = identity(event, self.mock_context) + + # Should still return authenticated with original session data + assert response["statusCode"] == 200 + body = json.loads(response["body"]) + assert body["status"] == "AUTHENTICATED" + assert body["user"]["id"] == "test-user" + assert body["user"]["displayName"] == "Test User" + assert "refreshed" not in body["session"] + + # Verify token refresh was attempted + mock_oidc_handler.refresh_tokens.assert_called_once_with("refresh-token-value") + + @patch.dict("os.environ") + @patch("ml_space_lambda.auth.lambda_functions.ssm_client") + @patch("ml_space_lambda.auth.lambda_functions.SessionManager") + def test_identity_no_refresh_token(self, mock_session_manager_class, mock_ssm_client): + """Test identity request when refresh is needed but no refresh token available.""" + # Set up environment + for key, value in self.env_vars.items(): + os.environ[key] = value + + # Mock SSM client + def mock_get_parameter(Name, WithDecryption=True): + return {"Parameter": {"Value": self.mock_ssm_responses[Name]}} + + mock_ssm_client.get_parameter.side_effect = mock_get_parameter + + # Mock session manager + from datetime import datetime, timedelta, timezone + + future_time = datetime.now(timezone.utc) + timedelta(hours=1) + # Set refresh time in the past to trigger refresh + past_refresh_time = datetime.now(timezone.utc) - timedelta(minutes=5) + + mock_session_data = { + "data": { + "user": { + "id": "test-user", + "displayName": "Test User", + "email": "test@example.com", + "groups": ["users"], + "attributes": {}, + }, + "session": { + "provider": "oidc", + "expiresAt": future_time.isoformat(), + "refreshAt": past_refresh_time.isoformat(), + # No refresh_token + }, + } + } + + mock_session_manager = Mock() + mock_session_manager.get_session.return_value = mock_session_data + mock_session_manager_class.return_value = mock_session_manager + + event = {"headers": {"Host": "app.example.com", "Cookie": "mlspace_session=session:test-session-id"}} + + response = identity(event, self.mock_context) + + # Should still return authenticated with original session data + assert response["statusCode"] == 200 + body = json.loads(response["body"]) + assert body["status"] == "AUTHENTICATED" + assert body["user"]["id"] == "test-user" + assert "refreshed" not in body["session"] + + @patch.dict("os.environ") + @patch("ml_space_lambda.auth.lambda_functions.ssm_client") + @patch("ml_space_lambda.auth.lambda_functions.OTACManager") + def test_sync_success_end_of_chain(self, mock_otac_manager_class, mock_ssm_client): + """Test successful sync request at end of chain.""" + # Set up environment + for key, value in self.env_vars.items(): + os.environ[key] = value + + # Mock SSM client + def mock_get_parameter(Name, WithDecryption=True): + return {"Parameter": {"Value": self.mock_ssm_responses[Name]}} + + mock_ssm_client.get_parameter.side_effect = mock_get_parameter + + # Mock OTAC manager + mock_otac_data = { + "sessionId": "session:test-session-id", + "remainingDomains": [], # End of chain + "finalRedirectUrl": "/dashboard", + "usedAt": None, + } + + mock_otac_manager = Mock() + mock_otac_manager.validate_and_consume_otac.return_value = mock_otac_data + mock_otac_manager_class.return_value = mock_otac_manager + + event = { + "headers": {"Host": "api.example.com"}, + "queryStringParameters": { + "otac": "otac:valid-otac-code", + "final": "/dashboard", + }, + } + + from ml_space_lambda.auth.lambda_functions import sync + + response = sync(event, self.mock_context) + + # Verify response + assert response["statusCode"] == 302 + assert response["headers"]["Location"] == "/dashboard" + assert "multiValueHeaders" in response + assert "Set-Cookie" in response["multiValueHeaders"] + + # Verify session cookie is set + cookies = response["multiValueHeaders"]["Set-Cookie"] + session_cookie = next((c for c in cookies if "mlspace_session" in c), None) + assert session_cookie is not None + assert "session:test-session-id" in session_cookie + assert "HttpOnly" in session_cookie + assert "Secure" in session_cookie + + # Verify OTAC validation was called + mock_otac_manager.validate_and_consume_otac.assert_called_once_with("otac:valid-otac-code") + + @patch.dict("os.environ") + @patch("ml_space_lambda.auth.lambda_functions.ssm_client") + @patch("ml_space_lambda.auth.lambda_functions.OTACManager") + def test_sync_success_continue_chain(self, mock_otac_manager_class, mock_ssm_client): + """Test successful sync request that continues the chain.""" + # Set up environment + for key, value in self.env_vars.items(): + os.environ[key] = value + + # Mock SSM client + def mock_get_parameter(Name, WithDecryption=True): + return {"Parameter": {"Value": self.mock_ssm_responses[Name]}} + + mock_ssm_client.get_parameter.side_effect = mock_get_parameter + + # Mock OTAC manager + mock_otac_data = { + "sessionId": "session:test-session-id", + "remainingDomains": ["notebooks.example.com"], # More domains to sync + "finalRedirectUrl": "/dashboard", + "usedAt": None, + } + + mock_otac_manager = Mock() + mock_otac_manager.validate_and_consume_otac.return_value = mock_otac_data + mock_otac_manager.create_otac.return_value = "otac:next-otac-code" + mock_otac_manager_class.return_value = mock_otac_manager + + event = { + "headers": {"Host": "api.example.com"}, + "queryStringParameters": { + "otac": "otac:valid-otac-code", + "next": "notebooks.example.com", + "final": "/dashboard", + }, + } + + from ml_space_lambda.auth.lambda_functions import sync + + response = sync(event, self.mock_context) + + # Verify response + assert response["statusCode"] == 302 + assert "notebooks.example.com" in response["headers"]["Location"] + assert "otac=otac%3Anext-otac-code" in response["headers"]["Location"] # URL encoded + assert "final=%2Fdashboard" in response["headers"]["Location"] + + # Verify session cookie is set + assert "multiValueHeaders" in response + assert "Set-Cookie" in response["multiValueHeaders"] + cookies = response["multiValueHeaders"]["Set-Cookie"] + session_cookie = next((c for c in cookies if "mlspace_session" in c), None) + assert session_cookie is not None + + # Verify OTAC operations + mock_otac_manager.validate_and_consume_otac.assert_called_once_with("otac:valid-otac-code") + mock_otac_manager.create_otac.assert_called_once_with( + session_id="session:test-session-id", + remaining_domains=[], # notebooks.example.com removed from list + final_redirect_url="/dashboard", + ) + + def test_sync_missing_otac(self): + """Test sync request with missing OTAC.""" + event = { + "headers": {"Host": "api.example.com"}, + "queryStringParameters": { + "final": "/dashboard", + # Missing "otac" + }, + } + + from ml_space_lambda.auth.lambda_functions import sync + + response = sync(event, self.mock_context) + + # Should redirect with error + assert response["statusCode"] == 302 + assert "error=invalid_otac" in response["headers"]["Location"] + + def test_sync_invalid_otac_format(self): + """Test sync request with invalid OTAC format.""" + event = { + "headers": {"Host": "api.example.com"}, + "queryStringParameters": { + "otac": "invalid-otac-format", + "final": "/dashboard", + }, + } + + from ml_space_lambda.auth.lambda_functions import sync + + response = sync(event, self.mock_context) + + # Should redirect with error + assert response["statusCode"] == 302 + assert "error=invalid_otac" in response["headers"]["Location"] + + def test_sync_missing_final_url(self): + """Test sync request with missing final redirect URL.""" + event = { + "headers": {"Host": "api.example.com"}, + "queryStringParameters": { + "otac": "otac:valid-otac-code", + # Missing "final" + }, + } + + from ml_space_lambda.auth.lambda_functions import sync + + response = sync(event, self.mock_context) + + # Should redirect with error + assert response["statusCode"] == 302 + assert "error=invalid_request" in response["headers"]["Location"] + + @patch.dict("os.environ") + @patch("ml_space_lambda.auth.lambda_functions.ssm_client") + def test_sync_missing_host_header(self, mock_ssm_client): + """Test sync request with missing Host header.""" + # Set up environment + for key, value in self.env_vars.items(): + os.environ[key] = value + + # Mock SSM client + def mock_get_parameter(Name, WithDecryption=True): + return {"Parameter": {"Value": self.mock_ssm_responses[Name]}} + + mock_ssm_client.get_parameter.side_effect = mock_get_parameter + + event = { + "headers": {}, # Missing Host header + "queryStringParameters": { + "otac": "otac:valid-otac-code", + "final": "/dashboard", + }, + } + + from ml_space_lambda.auth.lambda_functions import sync + + response = sync(event, self.mock_context) + + # Should redirect with error + assert response["statusCode"] == 302 + assert "error=invalid_request" in response["headers"]["Location"] + + @patch.dict("os.environ") + @patch("ml_space_lambda.auth.lambda_functions.ssm_client") + @patch("ml_space_lambda.auth.lambda_functions.OTACManager") + def test_sync_invalid_otac(self, mock_otac_manager_class, mock_ssm_client): + """Test sync request with invalid/expired OTAC.""" + # Set up environment + for key, value in self.env_vars.items(): + os.environ[key] = value + + # Mock SSM client + def mock_get_parameter(Name, WithDecryption=True): + return {"Parameter": {"Value": self.mock_ssm_responses[Name]}} + + mock_ssm_client.get_parameter.side_effect = mock_get_parameter + + # Mock OTAC manager to return None (invalid OTAC) + mock_otac_manager = Mock() + mock_otac_manager.validate_and_consume_otac.return_value = None + mock_otac_manager_class.return_value = mock_otac_manager + + event = { + "headers": {"Host": "api.example.com"}, + "queryStringParameters": { + "otac": "otac:invalid-otac-code", + "final": "/dashboard", + }, + } + + from ml_space_lambda.auth.lambda_functions import sync + + response = sync(event, self.mock_context) + + # Should redirect with error + assert response["statusCode"] == 302 + assert "error=invalid_otac" in response["headers"]["Location"] + assert "Authentication code is invalid or expired" in response["headers"]["Location"] + + # Verify OTAC validation was attempted + mock_otac_manager.validate_and_consume_otac.assert_called_once_with("otac:invalid-otac-code") + + @patch.dict("os.environ") + @patch("ml_space_lambda.auth.lambda_functions.ssm_client") + @patch("ml_space_lambda.auth.lambda_functions.OTACManager") + def test_sync_otac_creation_failure(self, mock_otac_manager_class, mock_ssm_client): + """Test sync request when OTAC creation for next domain fails.""" + # Set up environment + for key, value in self.env_vars.items(): + os.environ[key] = value + + # Mock SSM client + def mock_get_parameter(Name, WithDecryption=True): + return {"Parameter": {"Value": self.mock_ssm_responses[Name]}} + + mock_ssm_client.get_parameter.side_effect = mock_get_parameter + + # Mock OTAC manager + mock_otac_data = { + "sessionId": "session:test-session-id", + "remainingDomains": ["notebooks.example.com"], + "finalRedirectUrl": "/dashboard", + "usedAt": None, + } + + mock_otac_manager = Mock() + mock_otac_manager.validate_and_consume_otac.return_value = mock_otac_data + mock_otac_manager.create_otac.side_effect = Exception("OTAC creation failed") + mock_otac_manager_class.return_value = mock_otac_manager + + event = { + "headers": {"Host": "api.example.com"}, + "queryStringParameters": { + "otac": "otac:valid-otac-code", + "next": "notebooks.example.com", + "final": "/dashboard", + }, + } + + from ml_space_lambda.auth.lambda_functions import sync + + response = sync(event, self.mock_context) + + # Should redirect with error + assert response["statusCode"] == 302 + assert "error=sync_failed" in response["headers"]["Location"] + assert "Failed to continue synchronization" in response["headers"]["Location"] + + @patch.dict("os.environ") + def test_sync_unauthorized_domain(self): + """Test sync request from unauthorized domain. + + With the removal of AUTH_PRIMARY_DOMAIN, the requesting domain (from Host header) + is always considered authorized. This test now verifies that the sync endpoint + validates OTAC properly when called from any domain. + """ + # Set up environment with specific sync domains + env_vars = self.env_vars.copy() + env_vars["AUTH_SYNC_DOMAINS"] = "api.example.com,notebooks.example.com" + + for key, value in env_vars.items(): + os.environ[key] = value + + event = { + "headers": {"Host": "app.example.com"}, # Any domain can receive sync requests + "queryStringParameters": { + "otac": "otac:invalid-otac-code", # Invalid OTAC + "final": "/dashboard", + }, + } + + from ml_space_lambda.auth.lambda_functions import sync + + response = sync(event, self.mock_context) + + # Should redirect with error due to invalid OTAC + assert response["statusCode"] == 302 + assert "error=invalid_otac" in response["headers"]["Location"] + + @patch.dict("os.environ") + @patch("ml_space_lambda.auth.lambda_functions.ssm_client") + def test_sync_configuration_error(self, mock_ssm_client): + """Test sync request with configuration error.""" + # Set up environment with missing required config + env_vars = self.env_vars.copy() + env_vars["AUTH_SESSION_TABLE_NAME"] = "" # Missing required config + + for key, value in env_vars.items(): + os.environ[key] = value + + event = { + "headers": {"Host": "api.example.com"}, + "queryStringParameters": { + "otac": "otac:valid-otac-code", + "final": "/dashboard", + }, + } + + from ml_space_lambda.auth.lambda_functions import sync + + response = sync(event, self.mock_context) + + # Should redirect with error + assert response["statusCode"] == 302 + assert "error=sync_failed" in response["headers"]["Location"] diff --git a/backend/test/auth/utils/__init__.py b/backend/test/auth/utils/__init__.py new file mode 100644 index 00000000..f9de63fb --- /dev/null +++ b/backend/test/auth/utils/__init__.py @@ -0,0 +1,15 @@ +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# diff --git a/backend/test/auth/utils/test_cookies.py b/backend/test/auth/utils/test_cookies.py new file mode 100644 index 00000000..24ea3845 --- /dev/null +++ b/backend/test/auth/utils/test_cookies.py @@ -0,0 +1,121 @@ +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from ml_space_lambda.auth.utils.cookies import ( + clear_session_cookie, + clear_state_cookie, + create_session_cookie, + create_state_cookie, + extract_domain_from_host, + get_cookie_value, + parse_cookies, + should_set_secure_flag, +) + + +class TestCookieUtilities: + """Test cookie utility functions.""" + + def test_create_session_cookie(self): + """Test creating session cookie.""" + session_id = "session:12345" + cookie = create_session_cookie(session_id) + + assert "mlspace_session=session:12345" in cookie + assert "HttpOnly" in cookie + assert "Max-Age=86400" in cookie + assert "Path=/" in cookie + assert "SameSite=Strict" in cookie + assert "Secure" in cookie + + def test_create_session_cookie_with_domain(self): + """Test creating session cookie with domain.""" + session_id = "session:12345" + cookie = create_session_cookie(session_id, domain="example.com") + + assert "Domain=example.com" in cookie + + def test_create_state_cookie(self): + """Test creating state cookie.""" + nonce = "test_nonce_12345" + cookie = create_state_cookie(nonce) + + assert "mlspace_auth_state=test_nonce_12345" in cookie + assert "HttpOnly" in cookie + assert "Max-Age=600" in cookie + assert "Path=/auth" in cookie + assert "SameSite=Strict" in cookie + assert "Secure" in cookie + + def test_clear_session_cookie(self): + """Test clearing session cookie.""" + cookie = clear_session_cookie() + + assert "mlspace_session=" in cookie + assert "Max-Age=0" in cookie + + def test_clear_state_cookie(self): + """Test clearing state cookie.""" + cookie = clear_state_cookie() + + assert "mlspace_auth_state=" in cookie + assert "Max-Age=0" in cookie + + def test_parse_cookies(self): + """Test parsing cookie header.""" + cookie_header = "mlspace_session=session:123; other_cookie=value; third=test" + cookies = parse_cookies(cookie_header) + + assert cookies["mlspace_session"] == "session:123" + assert cookies["other_cookie"] == "value" + assert cookies["third"] == "test" + + def test_parse_cookies_empty_header(self): + """Test parsing empty cookie header.""" + cookies = parse_cookies(None) + assert cookies == {} + + cookies = parse_cookies("") + assert cookies == {} + + def test_get_cookie_value(self): + """Test extracting specific cookie value.""" + cookie_header = "mlspace_session=session:123; other_cookie=value" + + session_id = get_cookie_value(cookie_header, "mlspace_session") + assert session_id == "session:123" + + other_value = get_cookie_value(cookie_header, "other_cookie") + assert other_value == "value" + + missing_value = get_cookie_value(cookie_header, "missing") + assert missing_value is None + + def test_extract_domain_from_host(self): + """Test extracting domain from host header.""" + assert extract_domain_from_host("example.com") == "example.com" + assert extract_domain_from_host("example.com:8080") == "example.com" + assert extract_domain_from_host("localhost:3000") == "localhost:3000" + assert extract_domain_from_host("sub.example.com") == "sub.example.com" + assert extract_domain_from_host("invalid") is None + assert extract_domain_from_host(None) is None + + def test_should_set_secure_flag(self): + """Test determining if Secure flag should be set.""" + assert should_set_secure_flag("example.com") is True + assert should_set_secure_flag("localhost:3000") is False + assert should_set_secure_flag("LOCALHOST:8080") is False + assert should_set_secure_flag(None) is True diff --git a/backend/test/auth/utils/test_key_rotation.py b/backend/test/auth/utils/test_key_rotation.py new file mode 100644 index 00000000..955f237f --- /dev/null +++ b/backend/test/auth/utils/test_key_rotation.py @@ -0,0 +1,477 @@ +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +""" +Tests for key rotation utilities. +""" + +import json +from datetime import datetime +from unittest import mock + +import pytest + +from ml_space_lambda.auth.models.key_models import ( + KeyRotationResult, + KeyStatusResult, + KeyType, + SecretsManagerStage, + VersionedKeyData, +) +from ml_space_lambda.auth.utils.key_rotation import ( + finalize_secrets_manager_rotation, + get_key_status, + initialize_state_encryption_key, + initialize_token_encryption_key, + rotate_state_encryption_key, + rotate_token_encryption_key, + state_key_rotation_handler, + token_key_rotation_handler, +) + + +@pytest.fixture +def mock_secrets_client(): + """Mock boto3 secrets manager client.""" + with mock.patch("boto3.client") as mock_client: + yield mock_client.return_value + + +@pytest.fixture +def sample_versioned_key_data(): + """Sample versioned key data for testing.""" + return VersionedKeyData.create_initial(encoded_key="test-encoded-key-123", key_type=KeyType.TOKEN, created_by="test_user") + + +class TestInitializeStateEncryptionKey: + """Tests for initialize_state_encryption_key function.""" + + def test_initialize_state_key_success(self, mock_secrets_client): + """Test successful state key initialization.""" + secret_arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test-state-key" + + result = initialize_state_encryption_key(secret_arn) + + assert result["success"] is True + assert result["key_type"] == KeyType.STATE + assert result["initial_version"] == 1 + assert "created_date" in result + + mock_secrets_client.update_secret.assert_called_once() + call_args = mock_secrets_client.update_secret.call_args + assert call_args[1]["SecretId"] == secret_arn + assert "SecretString" in call_args[1] + + def test_initialize_state_key_failure(self, mock_secrets_client): + """Test state key initialization failure.""" + secret_arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test-state-key" + mock_secrets_client.update_secret.side_effect = Exception("AWS error") + + with pytest.raises(Exception, match="State key initialization failed"): + initialize_state_encryption_key(secret_arn) + + +class TestInitializeTokenEncryptionKey: + """Tests for initialize_token_encryption_key function.""" + + def test_initialize_token_key_success(self, mock_secrets_client): + """Test successful token key initialization.""" + secret_arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test-token-key" + + result = initialize_token_encryption_key(secret_arn) + + assert result["success"] is True + assert result["key_type"] == KeyType.TOKEN + assert result["initial_version"] == 1 + assert "created_date" in result + + mock_secrets_client.update_secret.assert_called_once() + + def test_initialize_token_key_failure(self, mock_secrets_client): + """Test token key initialization failure.""" + secret_arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test-token-key" + mock_secrets_client.update_secret.side_effect = Exception("AWS error") + + with pytest.raises(Exception, match="Token key initialization failed"): + initialize_token_encryption_key(secret_arn) + + +class TestRotateStateEncryptionKey: + """Tests for rotate_state_encryption_key function.""" + + def test_rotate_state_key_success(self, mock_secrets_client, sample_versioned_key_data): + """Test successful state key rotation.""" + secret_arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test-state-key" + + mock_secrets_client.get_secret_value.return_value = { + "SecretString": sample_versioned_key_data.to_secrets_manager_format() + } + + result = rotate_state_encryption_key(secret_arn) + + assert isinstance(result, KeyRotationResult) + assert result.success is True + assert result.previous_version == 1 + assert result.new_version == 2 + assert result.total_versions == 2 + + mock_secrets_client.put_secret_value.assert_called_once() + call_args = mock_secrets_client.put_secret_value.call_args + assert call_args[1]["SecretId"] == secret_arn + assert SecretsManagerStage.PENDING in call_args[1]["VersionStages"] + + def test_rotate_state_key_with_version_token(self, mock_secrets_client, sample_versioned_key_data): + """Test state key rotation with version token.""" + secret_arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test-state-key" + version_token = "test-version-token-123" + + mock_secrets_client.get_secret_value.return_value = { + "SecretString": sample_versioned_key_data.to_secrets_manager_format() + } + + result = rotate_state_encryption_key(secret_arn, version_token=version_token) + + assert result.success is True + call_args = mock_secrets_client.put_secret_value.call_args + assert call_args[1]["ClientRequestToken"] == version_token + + def test_rotate_state_key_with_cleanup(self, mock_secrets_client): + """Test state key rotation with old version cleanup.""" + secret_arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test-state-key" + + # Create key data with multiple versions + key_data = VersionedKeyData.create_initial("key1", KeyType.STATE) + key_data.add_new_key_version("key2") + key_data.add_new_key_version("key3") + key_data.add_new_key_version("key4") + + mock_secrets_client.get_secret_value.return_value = {"SecretString": key_data.to_secrets_manager_format()} + + result = rotate_state_encryption_key(secret_arn, keep_versions=3) + + assert result.success is True + assert result.new_version == 5 + # Should keep only 3 most recent versions + assert result.total_versions == 3 + + def test_rotate_state_key_first_rotation(self, mock_secrets_client): + """Test state key rotation when no existing key data.""" + secret_arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test-state-key" + + mock_secrets_client.get_secret_value.side_effect = Exception("Secret not found") + + result = rotate_state_encryption_key(secret_arn) + + assert result.success is True + assert result.previous_version == 0 + assert result.new_version == 1 + # rotation_date should be None for initial creation + assert result.rotation_date is None or isinstance(result.rotation_date, datetime) + + def test_rotate_state_key_failure(self, mock_secrets_client): + """Test state key rotation failure.""" + secret_arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test-state-key" + mock_secrets_client.put_secret_value.side_effect = Exception("AWS error") + + with pytest.raises(Exception, match="State key rotation failed"): + rotate_state_encryption_key(secret_arn) + + +class TestRotateTokenEncryptionKey: + """Tests for rotate_token_encryption_key function.""" + + def test_rotate_token_key_success(self, mock_secrets_client, sample_versioned_key_data): + """Test successful token key rotation.""" + secret_arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test-token-key" + + mock_secrets_client.get_secret_value.return_value = { + "SecretString": sample_versioned_key_data.to_secrets_manager_format() + } + + result = rotate_token_encryption_key(secret_arn) + + assert isinstance(result, KeyRotationResult) + assert result.success is True + assert result.previous_version == 1 + assert result.new_version == 2 + + mock_secrets_client.put_secret_value.assert_called_once() + + def test_rotate_token_key_with_custom_stage(self, mock_secrets_client, sample_versioned_key_data): + """Test token key rotation with custom version stage.""" + secret_arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test-token-key" + custom_stage = "CUSTOM_STAGE" + + mock_secrets_client.get_secret_value.return_value = { + "SecretString": sample_versioned_key_data.to_secrets_manager_format() + } + + result = rotate_token_encryption_key(secret_arn, version_stage=custom_stage) + + assert result.success is True + call_args = mock_secrets_client.put_secret_value.call_args + assert custom_stage in call_args[1]["VersionStages"] + + def test_rotate_token_key_failure(self, mock_secrets_client): + """Test token key rotation failure.""" + secret_arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test-token-key" + mock_secrets_client.put_secret_value.side_effect = Exception("AWS error") + + with pytest.raises(Exception, match="Token key rotation failed"): + rotate_token_encryption_key(secret_arn) + + +class TestFinalizeSecretsManagerRotation: + """Tests for finalize_secrets_manager_rotation function.""" + + def test_finalize_rotation_success(self, mock_secrets_client): + """Test successful rotation finalization.""" + secret_arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test-key" + + mock_secrets_client.describe_secret.return_value = { + "VersionIdsToStages": { + "version-1": [SecretsManagerStage.CURRENT], + "version-2": [SecretsManagerStage.PENDING], + } + } + + finalize_secrets_manager_rotation(secret_arn) + + # Should call update_secret_version_stage twice + assert mock_secrets_client.update_secret_version_stage.call_count == 2 + + def test_finalize_rotation_no_pending_version(self, mock_secrets_client): + """Test finalization when no pending version exists.""" + secret_arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test-key" + + mock_secrets_client.describe_secret.return_value = {"VersionIdsToStages": {"version-1": [SecretsManagerStage.CURRENT]}} + + with pytest.raises(Exception, match="No version found with stage"): + finalize_secrets_manager_rotation(secret_arn) + + def test_finalize_rotation_no_current_version(self, mock_secrets_client): + """Test finalization when no current version exists.""" + secret_arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test-key" + + mock_secrets_client.describe_secret.return_value = {"VersionIdsToStages": {"version-1": [SecretsManagerStage.PENDING]}} + + finalize_secrets_manager_rotation(secret_arn) + + # Should still succeed, just without removing from current + assert mock_secrets_client.update_secret_version_stage.call_count == 2 + + def test_finalize_rotation_failure(self, mock_secrets_client): + """Test rotation finalization failure.""" + secret_arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test-key" + mock_secrets_client.describe_secret.side_effect = Exception("AWS error") + + with pytest.raises(Exception, match="AWS error"): + finalize_secrets_manager_rotation(secret_arn) + + +class TestGetKeyStatus: + """Tests for get_key_status function.""" + + def test_get_key_status_success(self, mock_secrets_client, sample_versioned_key_data): + """Test successful key status retrieval.""" + secret_arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test-key" + + mock_secrets_client.get_secret_value.return_value = { + "SecretString": sample_versioned_key_data.to_secrets_manager_format() + } + + result = get_key_status(secret_arn, SecretsManagerStage.CURRENT) + + assert isinstance(result, KeyStatusResult) + assert result.success is True + assert result.current_version == 1 + assert result.total_versions == 1 + assert result.key_type == KeyType.TOKEN + + def test_get_key_status_with_multiple_versions(self, mock_secrets_client): + """Test key status with multiple versions.""" + secret_arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test-key" + + key_data = VersionedKeyData.create_initial("key1", KeyType.STATE) + key_data.add_new_key_version("key2") + key_data.add_new_key_version("key3") + + mock_secrets_client.get_secret_value.return_value = {"SecretString": key_data.to_secrets_manager_format()} + + result = get_key_status(secret_arn, SecretsManagerStage.CURRENT) + + assert result.success is True + assert result.current_version == 3 + assert result.total_versions == 3 + assert result.available_versions == [1, 2, 3] + + def test_get_key_status_failure(self, mock_secrets_client): + """Test key status retrieval failure.""" + secret_arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test-key" + mock_secrets_client.get_secret_value.side_effect = Exception("AWS error") + + result = get_key_status(secret_arn, SecretsManagerStage.CURRENT) + + assert result.success is False + assert result.error is not None + + +class TestStateKeyRotationHandler: + """Tests for state_key_rotation_handler Lambda function.""" + + def test_handler_initialize_action(self, mock_secrets_client): + """Test handler with initialize action.""" + event = {"action": "initialize", "secret_arn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:test"} + + response = state_key_rotation_handler(event, None) + + assert response["statusCode"] == 200 + body = json.loads(response["body"]) + assert body["success"] is True + assert body["key_type"] == KeyType.STATE + + def test_handler_rotate_action(self, mock_secrets_client, sample_versioned_key_data): + """Test handler with rotate action.""" + event = { + "action": "rotate", + "secret_arn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:test", + "keep_versions": 3, + } + + mock_secrets_client.get_secret_value.return_value = { + "SecretString": sample_versioned_key_data.to_secrets_manager_format() + } + + response = state_key_rotation_handler(event, None) + + assert response["statusCode"] == 200 + body = json.loads(response["body"]) + assert body["success"] is True + + def test_handler_status_action(self, mock_secrets_client, sample_versioned_key_data): + """Test handler with status action.""" + event = {"action": "status", "secret_arn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:test"} + + mock_secrets_client.get_secret_value.return_value = { + "SecretString": sample_versioned_key_data.to_secrets_manager_format() + } + + response = state_key_rotation_handler(event, None) + + assert response["statusCode"] == 200 + body = json.loads(response["body"]) + assert body["success"] is True + + def test_handler_missing_parameters(self): + """Test handler with missing parameters.""" + event = {"action": "initialize"} + + response = state_key_rotation_handler(event, None) + + assert response["statusCode"] == 400 + body = json.loads(response["body"]) + assert "error" in body + + def test_handler_unknown_action(self): + """Test handler with unknown action.""" + event = {"action": "unknown", "secret_arn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:test"} + + response = state_key_rotation_handler(event, None) + + assert response["statusCode"] == 400 + body = json.loads(response["body"]) + assert "Unknown action" in body["error"] + + def test_handler_exception(self, mock_secrets_client): + """Test handler with exception.""" + event = {"action": "initialize", "secret_arn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:test"} + + mock_secrets_client.update_secret.side_effect = Exception("AWS error") + + response = state_key_rotation_handler(event, None) + + assert response["statusCode"] == 500 + body = json.loads(response["body"]) + assert "error" in body + + +class TestTokenKeyRotationHandler: + """Tests for token_key_rotation_handler Lambda function.""" + + def test_handler_initialize_action(self, mock_secrets_client): + """Test handler with initialize action.""" + event = {"action": "initialize", "secret_arn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:test"} + + response = token_key_rotation_handler(event, None) + + assert response["statusCode"] == 200 + body = json.loads(response["body"]) + assert body["success"] is True + assert body["key_type"] == KeyType.TOKEN + + def test_handler_rotate_action(self, mock_secrets_client, sample_versioned_key_data): + """Test handler with rotate action.""" + event = { + "action": "rotate", + "secret_arn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:test", + "keep_versions": 3, + } + + mock_secrets_client.get_secret_value.return_value = { + "SecretString": sample_versioned_key_data.to_secrets_manager_format() + } + + response = token_key_rotation_handler(event, None) + + assert response["statusCode"] == 200 + body = json.loads(response["body"]) + assert body["success"] is True + + def test_handler_status_action(self, mock_secrets_client, sample_versioned_key_data): + """Test handler with status action.""" + event = {"action": "status", "secret_arn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:test"} + + mock_secrets_client.get_secret_value.return_value = { + "SecretString": sample_versioned_key_data.to_secrets_manager_format() + } + + response = token_key_rotation_handler(event, None) + + assert response["statusCode"] == 200 + body = json.loads(response["body"]) + assert body["success"] is True + + def test_handler_missing_parameters(self): + """Test handler with missing parameters.""" + event = {"action": "rotate"} + + response = token_key_rotation_handler(event, None) + + assert response["statusCode"] == 400 + body = json.loads(response["body"]) + assert "error" in body + + def test_handler_exception(self, mock_secrets_client): + """Test handler with exception.""" + event = {"action": "initialize", "secret_arn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:test"} + + mock_secrets_client.update_secret.side_effect = Exception("AWS error") + + response = token_key_rotation_handler(event, None) + + assert response["statusCode"] == 500 + body = json.loads(response["body"]) + assert "error" in body diff --git a/backend/test/auth/utils/test_otac.py b/backend/test/auth/utils/test_otac.py new file mode 100644 index 00000000..6f29b534 --- /dev/null +++ b/backend/test/auth/utils/test_otac.py @@ -0,0 +1,154 @@ +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from ml_space_lambda.auth.utils.otac import ( + build_domain_list, + build_sync_chain_url, + extract_domain_from_url, + generate_otac, + normalize_domain, + parse_sync_request, + should_initiate_sync, + validate_otac_format, +) + + +class TestOTACUtilities: + """Test OTAC utility functions.""" + + def test_generate_otac(self): + """Test OTAC generation.""" + otac = generate_otac() + + assert otac.startswith("otac:") + assert len(otac) > 5 # Should have content after prefix + + # Generate multiple OTACs to ensure uniqueness + otac2 = generate_otac() + assert otac != otac2 + + def test_validate_otac_format(self): + """Test OTAC format validation.""" + valid_otac = "otac:abc123xyz" + invalid_otacs = ["", "invalid", "otac:", "session:123", "otac", None] + + assert validate_otac_format(valid_otac) is True + + for invalid in invalid_otacs: + assert validate_otac_format(invalid) is False + + def test_build_sync_chain_url(self): + """Test building sync chain URL.""" + current_domain = "app.example.com" + otac = "otac:test123" + remaining_domains = ["api.example.com", "notebooks.example.com"] + final_url = "https://app.example.com/dashboard" + + url = build_sync_chain_url(current_domain, otac, remaining_domains, final_url) + + assert url.startswith("https://api.example.com/auth/sync") + assert "otac=otac%3Atest123" in url # URL encoded colon + assert "next=notebooks.example.com" in url + assert "final=https%3A%2F%2Fapp.example.com%2Fdashboard" in url # URL encoded + + def test_build_sync_chain_url_end_of_chain(self): + """Test building sync chain URL at end of chain.""" + current_domain = "notebooks.example.com" + otac = "otac:test123" + remaining_domains = [] + final_url = "https://app.example.com/dashboard" + + url = build_sync_chain_url(current_domain, otac, remaining_domains, final_url) + + assert url == final_url + + def test_parse_sync_request(self): + """Test parsing sync request parameters.""" + query_params = { + "otac": "otac:test123", + "next": "api.example.com,notebooks.example.com", + "final": "https://app.example.com/dashboard", + } + + otac, remaining_domains, final_url = parse_sync_request(query_params) + + assert otac == "otac:test123" + assert remaining_domains == ["api.example.com", "notebooks.example.com"] + assert final_url == "https://app.example.com/dashboard" + + def test_parse_sync_request_no_next(self): + """Test parsing sync request without next domains.""" + query_params = {"otac": "otac:test123", "final": "https://app.example.com/dashboard"} + + otac, remaining_domains, final_url = parse_sync_request(query_params) + + assert otac == "otac:test123" + assert remaining_domains == [] + assert final_url == "https://app.example.com/dashboard" + + def test_normalize_domain(self): + """Test domain normalization.""" + test_cases = [ + ("example.com", "example.com"), + ("https://example.com", "example.com"), + ("http://example.com", "example.com"), + ("example.com:8080", "example.com"), + ("localhost:3000", "localhost:3000"), # Keep port for localhost + ("example.com/path", "example.com"), + ("EXAMPLE.COM", "example.com"), + ("", ""), + ] + + for input_domain, expected in test_cases: + assert normalize_domain(input_domain) == expected + + def test_build_domain_list(self): + """Test building domain list for sync.""" + primary_domain = "app.example.com" + sync_domains_str = "api.example.com, notebooks.example.com, app.example.com" + + domains = build_domain_list(primary_domain, sync_domains_str) + + # Should exclude primary domain + assert "app.example.com" not in domains + assert "api.example.com" in domains + assert "notebooks.example.com" in domains + + def test_build_domain_list_empty(self): + """Test building domain list with empty input.""" + domains = build_domain_list("app.example.com", "") + assert domains == [] + + domains = build_domain_list("app.example.com", None) + assert domains == [] + + def test_should_initiate_sync(self): + """Test determining if sync should be initiated.""" + assert should_initiate_sync(["api.example.com"]) is True + assert should_initiate_sync([]) is False + + def test_extract_domain_from_url(self): + """Test extracting domain from URL.""" + test_cases = [ + ("https://example.com/path", "example.com"), + ("http://api.example.com:8080/auth", "api.example.com:8080"), + ("https://sub.example.com", "sub.example.com"), + ("invalid-url", ""), + ("", ""), + ] + + for url, expected in test_cases: + assert extract_domain_from_url(url) == expected diff --git a/backend/test/auth/utils/test_rotation_handlers.py b/backend/test/auth/utils/test_rotation_handlers.py new file mode 100644 index 00000000..255d4664 --- /dev/null +++ b/backend/test/auth/utils/test_rotation_handlers.py @@ -0,0 +1,508 @@ +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +""" +Tests for AWS Secrets Manager rotation handlers. +""" + +from unittest import mock + +import pytest + +from ml_space_lambda.auth.models.key_models import KeyStatusResult, KeyType, VersionedKeyData +from ml_space_lambda.auth.utils.rotation_handlers import ( + RotationStep, + _handle_create_secret_step, + _handle_finish_secret_step, + _handle_set_secret_step, + _handle_test_secret_step, + _validate_rotation_event, + initialize_secret_handler, + state_key_secrets_manager_rotation_handler, + token_key_secrets_manager_rotation_handler, +) + + +@pytest.fixture +def mock_rotation_functions(): + """Mock all rotation-related functions.""" + with mock.patch( + "ml_space_lambda.auth.utils.rotation_handlers.rotate_state_encryption_key" + ) as mock_rotate_state, mock.patch( + "ml_space_lambda.auth.utils.rotation_handlers.rotate_token_encryption_key" + ) as mock_rotate_token, mock.patch( + "ml_space_lambda.auth.utils.rotation_handlers.get_key_status" + ) as mock_get_status, mock.patch( + "ml_space_lambda.auth.utils.rotation_handlers.finalize_secrets_manager_rotation" + ) as mock_finalize, mock.patch( + "ml_space_lambda.auth.utils.rotation_handlers.initialize_state_encryption_key" + ) as mock_init_state, mock.patch( + "ml_space_lambda.auth.utils.rotation_handlers.initialize_token_encryption_key" + ) as mock_init_token: + + yield { + "rotate_state": mock_rotate_state, + "rotate_token": mock_rotate_token, + "get_status": mock_get_status, + "finalize": mock_finalize, + "init_state": mock_init_state, + "init_token": mock_init_token, + } + + +@pytest.fixture +def sample_versioned_key_data(): + """Sample versioned key data for testing.""" + return VersionedKeyData.create_initial(encoded_key="test-encoded-key-123", key_type=KeyType.TOKEN, created_by="test_user") + + +class TestValidateRotationEvent: + """Tests for _validate_rotation_event function.""" + + def test_validate_event_success(self): + """Test successful event validation.""" + event = { + "SecretId": "arn:aws:secretsmanager:us-east-1:123456789012:secret:test", + "Step": "createSecret", + "ClientRequestToken": "test-token-123", + } + + secret_name, step, version_token = _validate_rotation_event(event) + + assert secret_name == "arn:aws:secretsmanager:us-east-1:123456789012:secret:test" + assert step == RotationStep.CREATE_SECRET + assert version_token == "test-token-123" + + def test_validate_event_without_token(self): + """Test event validation without version token.""" + event = { + "SecretId": "arn:aws:secretsmanager:us-east-1:123456789012:secret:test", + "Step": "createSecret", + } + + secret_name, step, version_token = _validate_rotation_event(event) + + assert secret_name == "arn:aws:secretsmanager:us-east-1:123456789012:secret:test" + assert step == RotationStep.CREATE_SECRET + assert version_token is None + + def test_validate_event_missing_secret_id(self): + """Test validation with missing SecretId.""" + event = {"Step": "createSecret"} + + with pytest.raises(ValueError, match="SecretId is required"): + _validate_rotation_event(event) + + def test_validate_event_missing_step(self): + """Test validation with missing Step.""" + event = {"SecretId": "arn:aws:secretsmanager:us-east-1:123456789012:secret:test"} + + with pytest.raises(ValueError, match="Step is required"): + _validate_rotation_event(event) + + def test_validate_event_invalid_step(self): + """Test validation with invalid step.""" + event = { + "SecretId": "arn:aws:secretsmanager:us-east-1:123456789012:secret:test", + "Step": "invalidStep", + } + + with pytest.raises(ValueError, match="Invalid rotation step"): + _validate_rotation_event(event) + + def test_validate_all_rotation_steps(self): + """Test validation for all valid rotation steps.""" + secret_id = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test" + steps = ["createSecret", "setSecret", "testSecret", "finishSecret"] + + for step_name in steps: + event = {"SecretId": secret_id, "Step": step_name} + secret_name, step, version_token = _validate_rotation_event(event) + assert secret_name == secret_id + assert isinstance(step, RotationStep) + + +class TestHandleCreateSecretStep: + """Tests for _handle_create_secret_step function.""" + + def test_create_secret_state_key(self, mock_rotation_functions): + """Test creating secret for state key.""" + secret_name = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test" + + _handle_create_secret_step(secret_name, KeyType.STATE) + + mock_rotation_functions["rotate_state"].assert_called_once_with(secret_name, version_token=None, keep_versions=3) + + def test_create_secret_token_key(self, mock_rotation_functions): + """Test creating secret for token key.""" + secret_name = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test" + + _handle_create_secret_step(secret_name, KeyType.TOKEN) + + mock_rotation_functions["rotate_token"].assert_called_once_with(secret_name, version_token=None, keep_versions=3) + + def test_create_secret_with_version_token(self, mock_rotation_functions): + """Test creating secret with version token.""" + secret_name = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test" + version_token = "test-token-123" + + _handle_create_secret_step(secret_name, KeyType.STATE, version_token) + + mock_rotation_functions["rotate_state"].assert_called_once_with( + secret_name, version_token=version_token, keep_versions=3 + ) + + +class TestHandleSetSecretStep: + """Tests for _handle_set_secret_step function.""" + + def test_set_secret_step(self): + """Test set secret step (no-op).""" + secret_name = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test" + + # Should not raise any exceptions + _handle_set_secret_step(secret_name) + + +class TestHandleTestSecretStep: + """Tests for _handle_test_secret_step function.""" + + def test_test_secret_success(self, mock_rotation_functions): + """Test successful secret validation.""" + secret_name = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test" + + mock_rotation_functions["get_status"].return_value = KeyStatusResult( + success=True, current_version=2, total_versions=2, key_type=KeyType.TOKEN + ) + + _handle_test_secret_step(secret_name) + + mock_rotation_functions["get_status"].assert_called_once() + + def test_test_secret_failure(self, mock_rotation_functions): + """Test secret validation failure.""" + secret_name = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test" + + mock_rotation_functions["get_status"].return_value = KeyStatusResult(success=False, error="Validation failed") + + with pytest.raises(Exception, match="Failed to validate key"): + _handle_test_secret_step(secret_name) + + +class TestHandleFinishSecretStep: + """Tests for _handle_finish_secret_step function.""" + + def test_finish_secret_success(self, mock_rotation_functions): + """Test successful secret finalization.""" + secret_name = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test" + + _handle_finish_secret_step(secret_name) + + mock_rotation_functions["finalize"].assert_called_once_with(secret_name) + + def test_finish_secret_failure(self, mock_rotation_functions): + """Test secret finalization failure.""" + secret_name = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test" + + mock_rotation_functions["finalize"].side_effect = Exception("Finalization failed") + + with pytest.raises(Exception, match="Finalization failed"): + _handle_finish_secret_step(secret_name) + + +class TestStateKeySecretsManagerRotationHandler: + """Tests for state_key_secrets_manager_rotation_handler function.""" + + def test_handler_create_secret_step(self, mock_rotation_functions): + """Test handler with createSecret step.""" + event = { + "SecretId": "arn:aws:secretsmanager:us-east-1:123456789012:secret:test", + "Step": "createSecret", + "ClientRequestToken": "test-token", + } + + state_key_secrets_manager_rotation_handler(event, None) + + mock_rotation_functions["rotate_state"].assert_called_once() + + def test_handler_set_secret_step(self, mock_rotation_functions): + """Test handler with setSecret step.""" + event = { + "SecretId": "arn:aws:secretsmanager:us-east-1:123456789012:secret:test", + "Step": "setSecret", + } + + state_key_secrets_manager_rotation_handler(event, None) + + # setSecret is a no-op, so no functions should be called + mock_rotation_functions["rotate_state"].assert_not_called() + + def test_handler_test_secret_step(self, mock_rotation_functions): + """Test handler with testSecret step.""" + event = { + "SecretId": "arn:aws:secretsmanager:us-east-1:123456789012:secret:test", + "Step": "testSecret", + } + + mock_rotation_functions["get_status"].return_value = KeyStatusResult( + success=True, current_version=2, total_versions=2, key_type=KeyType.STATE + ) + + state_key_secrets_manager_rotation_handler(event, None) + + mock_rotation_functions["get_status"].assert_called_once() + + def test_handler_finish_secret_step(self, mock_rotation_functions): + """Test handler with finishSecret step.""" + event = { + "SecretId": "arn:aws:secretsmanager:us-east-1:123456789012:secret:test", + "Step": "finishSecret", + } + + state_key_secrets_manager_rotation_handler(event, None) + + mock_rotation_functions["finalize"].assert_called_once() + + def test_handler_invalid_event(self): + """Test handler with invalid event.""" + event = {"Step": "createSecret"} # Missing SecretId + + with pytest.raises(ValueError, match="SecretId is required"): + state_key_secrets_manager_rotation_handler(event, None) + + def test_handler_unknown_step(self): + """Test handler with unknown step.""" + event = { + "SecretId": "arn:aws:secretsmanager:us-east-1:123456789012:secret:test", + "Step": "unknownStep", + } + + with pytest.raises(ValueError, match="Invalid rotation step"): + state_key_secrets_manager_rotation_handler(event, None) + + def test_handler_exception_during_rotation(self, mock_rotation_functions): + """Test handler exception during rotation.""" + event = { + "SecretId": "arn:aws:secretsmanager:us-east-1:123456789012:secret:test", + "Step": "createSecret", + } + + mock_rotation_functions["rotate_state"].side_effect = Exception("Rotation failed") + + with pytest.raises(Exception, match="Rotation failed"): + state_key_secrets_manager_rotation_handler(event, None) + + +class TestTokenKeySecretsManagerRotationHandler: + """Tests for token_key_secrets_manager_rotation_handler function.""" + + def test_handler_create_secret_step(self, mock_rotation_functions): + """Test handler with createSecret step.""" + event = { + "SecretId": "arn:aws:secretsmanager:us-east-1:123456789012:secret:test", + "Step": "createSecret", + "ClientRequestToken": "test-token", + } + + token_key_secrets_manager_rotation_handler(event, None) + + mock_rotation_functions["rotate_token"].assert_called_once() + + def test_handler_set_secret_step(self, mock_rotation_functions): + """Test handler with setSecret step.""" + event = { + "SecretId": "arn:aws:secretsmanager:us-east-1:123456789012:secret:test", + "Step": "setSecret", + } + + token_key_secrets_manager_rotation_handler(event, None) + + # setSecret is a no-op + mock_rotation_functions["rotate_token"].assert_not_called() + + def test_handler_test_secret_step(self, mock_rotation_functions): + """Test handler with testSecret step.""" + event = { + "SecretId": "arn:aws:secretsmanager:us-east-1:123456789012:secret:test", + "Step": "testSecret", + } + + mock_rotation_functions["get_status"].return_value = KeyStatusResult( + success=True, current_version=2, total_versions=2, key_type=KeyType.TOKEN + ) + + token_key_secrets_manager_rotation_handler(event, None) + + mock_rotation_functions["get_status"].assert_called_once() + + def test_handler_finish_secret_step(self, mock_rotation_functions): + """Test handler with finishSecret step.""" + event = { + "SecretId": "arn:aws:secretsmanager:us-east-1:123456789012:secret:test", + "Step": "finishSecret", + } + + token_key_secrets_manager_rotation_handler(event, None) + + mock_rotation_functions["finalize"].assert_called_once() + + def test_handler_all_steps_sequence(self, mock_rotation_functions): + """Test handler with complete rotation sequence.""" + secret_id = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test" + + mock_rotation_functions["get_status"].return_value = KeyStatusResult( + success=True, current_version=2, total_versions=2, key_type=KeyType.TOKEN + ) + + # Step 1: createSecret + event = {"SecretId": secret_id, "Step": "createSecret", "ClientRequestToken": "token-1"} + token_key_secrets_manager_rotation_handler(event, None) + mock_rotation_functions["rotate_token"].assert_called_once() + + # Step 2: setSecret + event = {"SecretId": secret_id, "Step": "setSecret"} + token_key_secrets_manager_rotation_handler(event, None) + + # Step 3: testSecret + event = {"SecretId": secret_id, "Step": "testSecret"} + token_key_secrets_manager_rotation_handler(event, None) + mock_rotation_functions["get_status"].assert_called_once() + + # Step 4: finishSecret + event = {"SecretId": secret_id, "Step": "finishSecret"} + token_key_secrets_manager_rotation_handler(event, None) + mock_rotation_functions["finalize"].assert_called_once() + + def test_handler_exception_during_rotation(self, mock_rotation_functions): + """Test handler exception during rotation.""" + event = { + "SecretId": "arn:aws:secretsmanager:us-east-1:123456789012:secret:test", + "Step": "createSecret", + } + + mock_rotation_functions["rotate_token"].side_effect = Exception("Rotation failed") + + with pytest.raises(Exception, match="Rotation failed"): + token_key_secrets_manager_rotation_handler(event, None) + + +class TestInitializeSecretHandler: + """Tests for initialize_secret_handler function.""" + + def test_initialize_state_key(self, mock_rotation_functions): + """Test initializing state key.""" + event = { + "secret_name": "arn:aws:secretsmanager:us-east-1:123456789012:secret:test", + "key_type": KeyType.STATE, + } + + mock_rotation_functions["init_state"].return_value = { + "success": True, + "key_type": KeyType.STATE, + "initial_version": 1, + } + + result = initialize_secret_handler(event, None) + + assert result["success"] is True + assert result["key_type"] == KeyType.STATE + mock_rotation_functions["init_state"].assert_called_once() + + def test_initialize_token_key(self, mock_rotation_functions): + """Test initializing token key.""" + event = { + "secret_name": "arn:aws:secretsmanager:us-east-1:123456789012:secret:test", + "key_type": KeyType.TOKEN, + } + + mock_rotation_functions["init_token"].return_value = { + "success": True, + "key_type": KeyType.TOKEN, + "initial_version": 1, + } + + result = initialize_secret_handler(event, None) + + assert result["success"] is True + assert result["key_type"] == KeyType.TOKEN + mock_rotation_functions["init_token"].assert_called_once() + + def test_initialize_default_key_type(self, mock_rotation_functions): + """Test initializing with default key type (token).""" + event = {"secret_name": "arn:aws:secretsmanager:us-east-1:123456789012:secret:test"} + + mock_rotation_functions["init_token"].return_value = { + "success": True, + "key_type": KeyType.TOKEN, + "initial_version": 1, + } + + result = initialize_secret_handler(event, None) + + assert result["success"] is True + mock_rotation_functions["init_token"].assert_called_once() + + def test_initialize_missing_secret_name(self): + """Test initialization with missing secret name.""" + event = {"key_type": KeyType.STATE} + + with pytest.raises(ValueError, match="secret_name is required"): + initialize_secret_handler(event, None) + + def test_initialize_unknown_key_type(self): + """Test initialization with unknown key type.""" + event = { + "secret_name": "arn:aws:secretsmanager:us-east-1:123456789012:secret:test", + "key_type": "unknown", + } + + with pytest.raises(ValueError, match="Unknown key_type"): + initialize_secret_handler(event, None) + + def test_initialize_exception(self, mock_rotation_functions): + """Test initialization with exception.""" + event = { + "secret_name": "arn:aws:secretsmanager:us-east-1:123456789012:secret:test", + "key_type": KeyType.STATE, + } + + mock_rotation_functions["init_state"].side_effect = Exception("Initialization failed") + + with pytest.raises(Exception, match="Initialization failed"): + initialize_secret_handler(event, None) + + +class TestRotationStepEnum: + """Tests for RotationStep enum.""" + + def test_rotation_step_values(self): + """Test rotation step enum values.""" + assert RotationStep.CREATE_SECRET == "createSecret" + assert RotationStep.SET_SECRET == "setSecret" + assert RotationStep.TEST_SECRET == "testSecret" + assert RotationStep.FINISH_SECRET == "finishSecret" + + def test_rotation_step_from_string(self): + """Test creating rotation step from string.""" + assert RotationStep("createSecret") == RotationStep.CREATE_SECRET + assert RotationStep("setSecret") == RotationStep.SET_SECRET + assert RotationStep("testSecret") == RotationStep.TEST_SECRET + assert RotationStep("finishSecret") == RotationStep.FINISH_SECRET + + def test_rotation_step_invalid_value(self): + """Test invalid rotation step value.""" + with pytest.raises(ValueError): + RotationStep("invalidStep") diff --git a/backend/test/auth/utils/test_state.py b/backend/test/auth/utils/test_state.py new file mode 100644 index 00000000..eebdc3d9 --- /dev/null +++ b/backend/test/auth/utils/test_state.py @@ -0,0 +1,316 @@ +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +""" +Tests for state parameter management. +""" + +import json +import time + +import pytest +from cryptography.fernet import Fernet + +from ml_space_lambda.auth.utils.state import ( + StateManager, + create_state_encryption_key, + decode_state_key_from_storage, + encode_state_key_for_storage, +) + + +class TestStateManager: + """Tests for StateManager class.""" + + def test_create_state_with_nonce(self): + """Test creating state with provided nonce.""" + key = Fernet.generate_key() + manager = StateManager(key) + + state = manager.create_state(redirect_url="https://example.com/callback", domain="example.com", nonce="test-nonce-123") + + assert state is not None + assert isinstance(state, str) + assert len(state) > 0 + + def test_create_state_without_nonce(self): + """Test creating state with auto-generated nonce.""" + key = Fernet.generate_key() + manager = StateManager(key) + + state = manager.create_state(redirect_url="https://example.com/callback", domain="example.com") + + assert state is not None + assert isinstance(state, str) + + def test_create_state_with_protocol_data(self): + """Test creating state with protocol-specific data.""" + key = Fernet.generate_key() + manager = StateManager(key) + + protocol_data = {"code_verifier": "test-verifier-123", "custom_field": "value"} + + state = manager.create_state( + redirect_url="https://example.com/callback", domain="example.com", nonce="test-nonce", protocol_data=protocol_data + ) + + assert state is not None + + # Validate we can decrypt and retrieve protocol data + validated = manager.validate_state(state, "test-nonce") + assert validated is not None + assert validated["protocol_data"] == protocol_data + + def test_validate_state_success(self): + """Test successful state validation.""" + key = Fernet.generate_key() + manager = StateManager(key) + + nonce = "test-nonce-123" + state = manager.create_state(redirect_url="https://example.com/callback", domain="example.com", nonce=nonce) + + validated = manager.validate_state(state, nonce) + + assert validated is not None + assert validated["redirect_url"] == "https://example.com/callback" + assert validated["domain"] == "example.com" + assert validated["nonce"] == nonce + assert "timestamp" in validated + + def test_validate_state_with_protocol_data(self): + """Test state validation with protocol data.""" + key = Fernet.generate_key() + manager = StateManager(key) + + nonce = "test-nonce" + protocol_data = {"code_verifier": "verifier-123"} + + state = manager.create_state( + redirect_url="https://example.com/callback", domain="example.com", nonce=nonce, protocol_data=protocol_data + ) + + validated = manager.validate_state(state, nonce) + + assert validated is not None + assert validated["protocol_data"]["code_verifier"] == "verifier-123" + + def test_validate_state_empty_state(self): + """Test validation with empty state.""" + key = Fernet.generate_key() + manager = StateManager(key) + + validated = manager.validate_state("", "nonce") + assert validated is None + + def test_validate_state_empty_nonce(self): + """Test validation with empty nonce.""" + key = Fernet.generate_key() + manager = StateManager(key) + + state = manager.create_state(redirect_url="https://example.com/callback", domain="example.com", nonce="test-nonce") + + validated = manager.validate_state(state, "") + assert validated is None + + def test_validate_state_nonce_mismatch(self): + """Test validation with mismatched nonce.""" + key = Fernet.generate_key() + manager = StateManager(key) + + state = manager.create_state(redirect_url="https://example.com/callback", domain="example.com", nonce="correct-nonce") + + validated = manager.validate_state(state, "wrong-nonce") + assert validated is None + + def test_validate_state_expired(self): + """Test validation with expired state.""" + key = Fernet.generate_key() + manager = StateManager(key) + + nonce = "test-nonce" + + # Create state with old timestamp by manually crafting it + old_timestamp = int(time.time()) - 700 # 700 seconds ago + state_data = { + "redirect_url": "https://example.com/callback", + "nonce": nonce, + "timestamp": old_timestamp, + "domain": "example.com", + } + + state_json = json.dumps(state_data, separators=(",", ":")) + encrypted = manager.cipher.encrypt(state_json.encode("utf-8")) + state = encrypted.decode("utf-8") + + # Validate with max_age of 600 seconds (10 minutes) + validated = manager.validate_state(state, nonce, max_age_seconds=600) + + assert validated is None + + def test_validate_state_invalid_encrypted_data(self): + """Test validation with invalid encrypted data.""" + key = Fernet.generate_key() + manager = StateManager(key) + + validated = manager.validate_state("invalid-encrypted-data", "nonce") + assert validated is None + + def test_validate_state_tampered_data(self): + """Test validation with tampered state.""" + key = Fernet.generate_key() + manager = StateManager(key) + + nonce = "test-nonce" + state = manager.create_state(redirect_url="https://example.com/callback", domain="example.com", nonce=nonce) + + # Tamper with the state + tampered_state = state[:-5] + "XXXXX" + + validated = manager.validate_state(tampered_state, nonce) + assert validated is None + + def test_validate_state_different_key(self): + """Test validation with different encryption key.""" + key1 = Fernet.generate_key() + key2 = Fernet.generate_key() + + manager1 = StateManager(key1) + manager2 = StateManager(key2) + + nonce = "test-nonce" + state = manager1.create_state(redirect_url="https://example.com/callback", domain="example.com", nonce=nonce) + + # Try to validate with different key + validated = manager2.validate_state(state, nonce) + assert validated is None + + def test_validate_state_missing_required_fields(self): + """Test validation with state missing required fields.""" + key = Fernet.generate_key() + manager = StateManager(key) + + # Create state with missing fields by manually encrypting + incomplete_data = {"redirect_url": "https://example.com/callback"} # Missing nonce, timestamp, domain + state_json = json.dumps(incomplete_data) + encrypted = manager.cipher.encrypt(state_json.encode("utf-8")) + state = encrypted.decode("utf-8") + + validated = manager.validate_state(state, "nonce") + assert validated is None + + def test_generate_nonce(self): + """Test nonce generation.""" + key = Fernet.generate_key() + manager = StateManager(key) + + nonce1 = manager.generate_nonce() + nonce2 = manager.generate_nonce() + + assert nonce1 is not None + assert nonce2 is not None + assert nonce1 != nonce2 # Should be unique + assert len(nonce1) > 20 # Should be reasonably long + + def test_create_state_encryption_key(self): + """Test encryption key generation.""" + key = create_state_encryption_key() + + assert key is not None + assert isinstance(key, bytes) + # Fernet keys are 44 bytes when base64 encoded + assert len(key) == 44 + + # Should be able to create a Fernet instance with it + cipher = Fernet(key) + assert cipher is not None + + def test_encode_decode_state_key(self): + """Test encoding and decoding state key for storage.""" + key = create_state_encryption_key() + + # Encode for storage + encoded = encode_state_key_for_storage(key) + assert isinstance(encoded, str) + + # Decode from storage + decoded = decode_state_key_from_storage(encoded) + assert decoded == key + + # Should be able to use decoded key + manager = StateManager(decoded) + state = manager.create_state(redirect_url="https://example.com", domain="example.com", nonce="test") + assert state is not None + + def test_decode_invalid_state_key(self): + """Test decoding invalid state key.""" + with pytest.raises(ValueError, match="Invalid Fernet key"): + decode_state_key_from_storage("invalid-key-data") + + def test_decode_empty_state_key(self): + """Test decoding empty state key.""" + with pytest.raises(ValueError, match="Invalid Fernet key"): + decode_state_key_from_storage("") + + def test_create_state_exception_handling(self): + """Test state creation with invalid data that causes exception.""" + key = Fernet.generate_key() + manager = StateManager(key) + + # Create a scenario that would cause JSON serialization to fail + # by mocking the cipher to raise an exception + original_encrypt = manager.cipher.encrypt + + def mock_encrypt(data): + raise Exception("Encryption failed") + + manager.cipher.encrypt = mock_encrypt + + with pytest.raises(Exception, match="Failed to create state parameter"): + manager.create_state(redirect_url="https://example.com", domain="example.com") + + # Restore original + manager.cipher.encrypt = original_encrypt + + def test_state_roundtrip_with_special_characters(self): + """Test state creation and validation with special characters in URLs.""" + key = Fernet.generate_key() + manager = StateManager(key) + + nonce = "test-nonce" + redirect_url = "https://example.com/callback?param=value&other=test#fragment" + + state = manager.create_state(redirect_url=redirect_url, domain="example.com", nonce=nonce) + + validated = manager.validate_state(state, nonce) + + assert validated is not None + assert validated["redirect_url"] == redirect_url + + def test_state_max_age_boundary(self): + """Test state validation at max age boundary.""" + key = Fernet.generate_key() + manager = StateManager(key) + + nonce = "test-nonce" + state = manager.create_state(redirect_url="https://example.com/callback", domain="example.com", nonce=nonce) + + # Should be valid with large max_age + validated = manager.validate_state(state, nonce, max_age_seconds=3600) + assert validated is not None + + # Should be valid with exact age (within 1 second) + validated = manager.validate_state(state, nonce, max_age_seconds=1) + assert validated is not None diff --git a/backend/test/authorizer/test_authorizer.py b/backend/test/authorizer/test_authorizer.py index 29bbf7a1..b572d7af 100644 --- a/backend/test/authorizer/test_authorizer.py +++ b/backend/test/authorizer/test_authorizer.py @@ -21,7 +21,6 @@ from typing import Dict from unittest import mock -import jwt import pytest from botocore.exceptions import ClientError @@ -35,13 +34,10 @@ from ml_space_lambda.data_access_objects.user import UserModel from ml_space_lambda.enums import DatasetType, Permission, ResourceType, ServiceType -TEST_ENV_CONFIG = {"AWS_DEFAULT_REGION": "us-east-1", "OIDC_VERIFY_SIGNATURE": "False"} -MOCK_OIDC_ENV = { +TEST_ENV_CONFIG = { "AWS_DEFAULT_REGION": "us-east-1", - "OIDC_URL": "https://example-oidc.com/realms/mlspace", - "OIDC_CLIENT_NAME": "web-client", - # We're using a self signed cert for dev - "OIDC_VERIFY_SSL": "False", + "AUTH_SESSION_TABLE_NAME": "test-sessions", + "AUTH_TOKEN_ENCRYPTION_KEY_SECRET_NAME": "test-secret-arn", } @@ -76,6 +72,67 @@ MOCK_GROUP = GroupModel(MOCK_GROUP_NAME, "Group used for unit tests", MOCK_USER.username) +@pytest.fixture(autouse=True) +def mock_session_manager(request): + """ + Automatically mock the session manager for all tests. + By default, sets up valid sessions for common test users. + Tests can override by calling setup_mock_session or configuring the mock directly. + """ + with mock.patch("ml_space_lambda.authorizer.lambda_function._get_session_manager") as mock_get_session_manager: + mock_session_manager_instance = mock.Mock() + + # Create a mapping of users to their session data + user_sessions = { + MOCK_ADMIN_USER.username: mock_session_data(MOCK_ADMIN_USER), + MOCK_SUSPENDED_USER.username: mock_session_data(MOCK_SUSPENDED_USER), + MOCK_OWNER_USER.username: mock_session_data(MOCK_OWNER_USER), + MOCK_USER.username: mock_session_data(MOCK_USER), + } + + # Default behavior: return session based on common test users or test parameters + def default_get_session(session_id): + # If session_id is None or empty, no session cookie was provided + if not session_id: + return None + + # Try to infer which user from test parameters + if hasattr(request, "node") and hasattr(request.node, "callspec"): + params = request.node.callspec.params + if "user" in params: + user = params["user"] + if hasattr(user, "username"): + # For custom users not in our mapping, create session data on the fly + if user.username not in user_sessions: + return mock_session_data(user) + return user_sessions.get(user.username) + # Return None if we can't determine the user + return None + + mock_session_manager_instance.get_session.side_effect = default_get_session + mock_get_session_manager.return_value = mock_session_manager_instance + yield mock_session_manager_instance + + +def setup_mock_session(mock_session_manager, user: UserModel = None, username: str = MOCK_USERNAME, expired: bool = False): + """ + Helper to configure the mock session manager with session data. + + Args: + mock_session_manager: The mock session manager fixture + user: User model to create session for + username: Username if no user model provided + expired: Whether session should be expired + """ + # Clear any side_effect to allow return_value to work + mock_session_manager.get_session.side_effect = None + + if expired: + mock_session_manager.get_session.return_value = None + else: + mock_session_manager.get_session.return_value = mock_session_data(user, username, expired) + + def policy_response( allow: bool = True, user: UserModel = None, @@ -109,6 +166,50 @@ def policy_response( } +def mock_session_data(user: UserModel = None, username: str = MOCK_USERNAME, expired: bool = False) -> Dict: + """ + Create mock session data for testing. + + Args: + user: User model to create session for + username: Username if no user model provided + expired: Whether session should be expired + + Returns: + Mock session data structure + """ + now = datetime.now(timezone.utc) + expires_at = now - timedelta(hours=1) if expired else now + timedelta(hours=1) + refresh_at = now - timedelta(minutes=30) if expired else now + timedelta(minutes=30) + + session_user = user if user else UserModel(username, f"{username}@example.com", username, False, []) + + return { + "pk": "session:test-session-id", + "ttl": int(expires_at.timestamp()) + 3600, + "created_at": now.isoformat(), + "updated_at": now.isoformat(), + "data": { + "user": { + "id": session_user.username, + "displayName": session_user.display_name, + "email": session_user.email, + "groups": [], + "attributes": {}, + }, + "session": { + "provider": "oidc", + "expiresAt": expires_at.isoformat(), + "refreshAt": refresh_at.isoformat(), + "accessToken": "encrypted_access_token", + "refreshToken": "encrypted_refresh_token", + "idToken": "encrypted_id_token", + }, + "metadata": {"loginDomain": "app.example.com", "syncedDomains": []}, + }, + } + + def mock_event( method: str = "GET", path_params: Dict[str, str] = {}, @@ -118,23 +219,33 @@ def mock_event( headers: Dict[str, str] = {}, username: str = MOCK_USERNAME, kid: str = "GLptrSDjXhtLZfjbgEjpmZy4r6CtwWnNg6k-Oyfd864", + valid_session: bool = True, ): - now = datetime.now(tz=timezone.utc) - exp_time = now if expired_token else now + timedelta(minutes=60) - with open("test/authorizer/jwtRS256.key") as rsa_key: - encoded_jwt = jwt.encode( - { - "aud": "web-client", - "exp": exp_time, - "preferred_username": user.username if user else username, - "email": user.email if user else username, - }, - rsa_key.read(), - algorithm="RS256", - headers={"kid": kid}, - ) + """ + Create mock Lambda event for testing with session cookie. + + Args: + method: HTTP method + path_params: Path parameters + resource: Resource path + expired_token: Whether session should be expired (legacy param name) + user: User model for session + headers: Additional headers + username: Username if no user model + kid: Legacy parameter (unused) + valid_session: Whether to include valid session cookie + + Returns: + Mock Lambda event + """ + if valid_session: + # Add session cookie to headers + session_id = "session:test-session-id" + if "cookie" in headers: + headers["cookie"] += f"; mlspace_session={session_id}" + else: + headers["cookie"] = f"mlspace_session={session_id}" - headers["authorization"] = f"Bearer {encoded_jwt}" return { "resource": resource, "pathParameters": path_params, @@ -220,42 +331,50 @@ def test_missing_auth_header(): @mock.patch.dict("os.environ", TEST_ENV_CONFIG, clear=True) def test_invalid_auth_header(): + # Test with invalid session cookie assert lambda_handler( { "resource": "/fake-resource", "pathParameters": {}, "httpMethod": "GET", "methodArn": "fakeArn", - "headers": {"authorization": "Bearer "}, + "headers": {"cookie": "mlspace_session=invalid"}, }, {}, ) == policy_response(allow=False, valid_token=False) @mock.patch.dict("os.environ", TEST_ENV_CONFIG, clear=True) -@mock.patch("ml_space_lambda.authorizer.lambda_function.jwt") -def test_malformed_token(mock_jwt): - mock_jwt.decode.side_effect = Exception("DecodeError") +def test_malformed_token(mock_session_manager): + # Mock session manager to raise exception + mock_session_manager.get_session.side_effect = Exception("Session error") + assert lambda_handler( { "resource": "/fake-resource", "pathParameters": {}, "httpMethod": "GET", "methodArn": "fakeArn", - "headers": {"authorization": "Bearer asdf"}, + "headers": {"cookie": "mlspace_session=session:test"}, }, {}, ) == policy_response(allow=False, valid_token=False) @mock.patch.dict("os.environ", TEST_ENV_CONFIG, clear=True) -def test_expired_token(): - assert lambda_handler(mock_event(expired_token=True), {}) == policy_response(allow=False) +def test_expired_token(mock_session_manager): + # Mock session manager to return expired session + mock_session_manager.get_session.return_value = None # Expired sessions return None + + assert lambda_handler(mock_event(expired_token=True), {}) == policy_response(allow=False, valid_token=False) @mock.patch.dict("os.environ", TEST_ENV_CONFIG, clear=True) @mock.patch("ml_space_lambda.authorizer.lambda_function.user_dao") -def test_initcap_header(mock_user_dao): +def test_initcap_header(mock_user_dao, mock_session_manager): + # Mock session manager to return valid session + setup_mock_session(mock_session_manager, MOCK_ADMIN_USER) + mock_user_dao.get.return_value = MOCK_ADMIN_USER mock_event_body = copy.deepcopy( mock_event( @@ -264,8 +383,7 @@ def test_initcap_header(mock_user_dao): method="GET", ) ) - mock_event_body["headers"]["Authorization"] = mock_event_body["headers"]["authorization"] - del mock_event_body["headers"]["authorization"] + # Remove the old authorization header test - we're using cookies now assert lambda_handler( mock_event_body, @@ -276,14 +394,30 @@ def test_initcap_header(mock_user_dao): @mock.patch.dict("os.environ", TEST_ENV_CONFIG, clear=True) @mock.patch("ml_space_lambda.authorizer.lambda_function.user_dao") -def test_nonexistent_user(mock_user_dao): +def test_nonexistent_user(mock_user_dao, mock_session_manager): + # Mock session manager to return valid session but user doesn't exist in DB + setup_mock_session(mock_session_manager, username="nonexistent") + mock_user_dao.get.return_value = None - assert lambda_handler(mock_event(), {}) == policy_response(allow=False) + + # When user is in session but not in DB, authorizer creates a minimal user object + # So we expect the user in the context, not an empty context + result = lambda_handler(mock_event(username="nonexistent"), {}) + + # Check that access is denied + assert result["policyDocument"]["Statement"][0]["Effect"] == "Deny" + # Check that principalId is set to the username + assert result["principalId"] == "nonexistent" + # Check that user is in context (minimal user object created by authorizer) + assert "user" in result["context"] @mock.patch.dict("os.environ", TEST_ENV_CONFIG, clear=True) @mock.patch("ml_space_lambda.authorizer.lambda_function.user_dao") -def test_suspended_user(mock_user_dao): +def test_suspended_user(mock_user_dao, mock_session_manager): + # Mock session manager to return valid session + setup_mock_session(mock_session_manager, MOCK_SUSPENDED_USER) + mock_user_dao.get.return_value = MOCK_SUSPENDED_USER assert lambda_handler(mock_event(user=MOCK_SUSPENDED_USER), {}) == policy_response(allow=False, user=MOCK_SUSPENDED_USER) mock_user_dao.get.assert_called_with(MOCK_SUSPENDED_USER.username) @@ -291,18 +425,27 @@ def test_suspended_user(mock_user_dao): @mock.patch.dict("os.environ", TEST_ENV_CONFIG, clear=True) @mock.patch("ml_space_lambda.authorizer.lambda_function.user_dao") -def test_create_user(mock_user_dao): +def test_create_user(mock_user_dao, mock_session_manager): + # User creation is now part of the login process and requires a session + # Without a session, access should be denied mock_user_dao.get.return_value = None new_username = "new-user" - assert lambda_handler( + + result = lambda_handler( mock_event( resource="/user", method="POST", username=new_username, + valid_session=False, # No session ), {}, - ) == policy_response(username=new_username) - mock_user_dao.get.assert_called_with(new_username) + ) + + # Should deny access without session + assert result["principalId"] == "Unknown" + assert result["policyDocument"]["Statement"][0]["Effect"] == "Deny" + assert result["context"] == {} + # User DAO should not be called for user creation endpoint @pytest.mark.parametrize( @@ -326,7 +469,10 @@ def test_create_user(mock_user_dao): ) @mock.patch.dict("os.environ", TEST_ENV_CONFIG, clear=True) @mock.patch("ml_space_lambda.authorizer.lambda_function.user_dao") -def test_user_management(mock_user_dao, user: UserModel, method: str, allow: bool): +def test_user_management(mock_user_dao, mock_session_manager, user: UserModel, method: str, allow: bool): + # Configure session manager to return valid session + setup_mock_session(mock_session_manager, user) + mock_user_dao.get.return_value = user assert lambda_handler( mock_event( @@ -1439,7 +1585,8 @@ def test_unauthenticated_endpoint_post(mock_user_dao, resource: str, path_params @mock.patch.dict("os.environ", TEST_ENV_CONFIG, clear=True) @mock.patch("ml_space_lambda.authorizer.lambda_function.user_dao") -def test_unhandled_route(mock_user_dao): +def test_unhandled_route(mock_user_dao, mock_session_manager): + setup_mock_session(mock_session_manager, MOCK_USER) mock_user_dao.get.return_value = MOCK_USER assert lambda_handler(mock_event(user=MOCK_USER, resource="/secret/super-backdoor"), {}) == policy_response( user=MOCK_USER, allow=False @@ -1729,7 +1876,10 @@ def test_dataset_routes( @mock.patch("ml_space_lambda.authorizer.lambda_function.project_user_dao") @mock.patch("ml_space_lambda.authorizer.lambda_function.user_dao") @mock.patch("ml_space_lambda.authorizer.lambda_function.dataset_dao") -def test_dataset_adversarial(mock_dataset_dao, mock_user_dao, mock_project_user_dao, mock_group_user_dao): +def test_dataset_adversarial( + mock_dataset_dao, mock_user_dao, mock_project_user_dao, mock_group_user_dao, mock_session_manager +): + setup_mock_session(mock_session_manager, MOCK_USER) method = "GET" allow = False @@ -1776,7 +1926,9 @@ def test_get_nonexistent_dataset( mock_dataset_dao, mock_user_dao, mock_project_user_dao, + mock_session_manager, ): + setup_mock_session(mock_session_manager, MOCK_USER) mock_scope = "fakeScope" mock_name = "fakeName" mock_dataset_dao.get.return_value = None @@ -1898,7 +2050,16 @@ def test_app_config_routes( method: str, path_params: dict, allow: bool, + mock_session_manager, ): + # Setup session only if user is provided + if user: + setup_mock_session(mock_session_manager, user) + else: + # No session for non-user test case + mock_session_manager.get_session.side_effect = None + mock_session_manager.get_session.return_value = None + mock_user_dao.get.return_value = user mock_project_user_dao.get.return_value = project_user @@ -1912,10 +2073,15 @@ def test_app_config_routes( resource="/app-config", method=method, path_params=path_params, + valid_session=user is not None, # Only add session cookie if user is provided ), {}, ) == policy_response( - allow=allow, user=user, username="Unknown" if method == "GET" else MOCK_USERNAME, default_to_username=method == "GET" + allow=allow, + user=user, + username="Unknown" if (method == "GET" or user is None) else MOCK_USERNAME, + default_to_username=method == "GET", + valid_token=user is not None, # No valid token when user is None ) if user and method != "GET": mock_user_dao.get.assert_called_with(user.username) @@ -2196,7 +2362,8 @@ def test_create_project(mock_user_dao, mock_app_configuration_dao, user: UserMod ) @mock.patch.dict("os.environ", TEST_ENV_CONFIG, clear=True) @mock.patch("ml_space_lambda.authorizer.lambda_function.user_dao") -def test_presigned_url_missing_headers(mock_user_dao, resource: str): +def test_presigned_url_missing_headers(mock_user_dao, mock_session_manager, resource: str): + setup_mock_session(mock_session_manager, MOCK_USER) mock_user_dao.get.return_value = MOCK_USER assert lambda_handler( mock_event( @@ -2212,7 +2379,8 @@ def test_presigned_url_missing_headers(mock_user_dao, resource: str): @mock.patch.dict("os.environ", TEST_ENV_CONFIG, clear=True) @mock.patch("ml_space_lambda.authorizer.lambda_function.project_user_dao") @mock.patch("ml_space_lambda.authorizer.lambda_function.user_dao") -def test_stop_job_missing_header(mock_user_dao, mock_project_user_dao): +def test_stop_job_missing_header(mock_user_dao, mock_project_user_dao, mock_session_manager): + setup_mock_session(mock_session_manager, MOCK_USER) mock_user_dao.get.return_value = MOCK_USER assert lambda_handler( mock_event( @@ -2282,99 +2450,6 @@ def test_stop_batch_translate_job( mock_user_dao.get.assert_called_with(user.username) -@mock.patch.dict("os.environ", MOCK_OIDC_ENV, clear=True) -@mock.patch("ml_space_lambda.utils.app_config_utils.app_configuration_dao") -@mock.patch("ml_space_lambda.authorizer.lambda_function.user_dao") -@mock.patch("ml_space_lambda.authorizer.lambda_function.http") -def test_verified_token(mock_http, mock_user_dao, mock_app_config, mock_well_known_config, mock_oidc_jwks_keys): - mock_http.request.side_effect = [ - mock_well_known_config, - mock_oidc_jwks_keys, - ] - mock_user_dao.get.return_value = MOCK_USER - mock_app_config.get.return_value = [generate_test_config()] - get_app_config.cache_clear() - assert lambda_handler(mock_event(user=MOCK_USER, resource="/project", method="POST"), {}) == policy_response( - allow=True, user=MOCK_USER - ) - mock_user_dao.get.assert_called_with(MOCK_USER.username) - mock_http.request.assert_has_calls( - [ - mock.call("GET", "https://example-oidc.com/realms/mlspace/.well-known/openid-configuration"), - mock.call("GET", "https://example-oidc.com/realms/mlspace/protocol/openid-connect/certs"), - ] - ) - - -@mock.patch.dict("os.environ", MOCK_OIDC_ENV, clear=True) -@mock.patch("ml_space_lambda.authorizer.lambda_function.user_dao") -@mock.patch("ml_space_lambda.authorizer.lambda_function.http") -def test_verified_token_bad_well_known_config(mock_http, mock_user_dao, mock_oidc_jwks_keys): - mock_http.request.side_effect = [ - mock_oidc_jwks_keys, - ] - mock_user_dao.get.return_value = MOCK_USER - assert lambda_handler( - mock_event(user=MOCK_USER, resource="/project", method="POST", kid="fake-cert-kid"), {} - ) == policy_response(allow=False, valid_token=False) - mock_user_dao.get.assert_not_called() - mock_http.request.assert_called_once() - mock_http.request.assert_called_with("GET", "https://example-oidc.com/realms/mlspace/.well-known/openid-configuration") - - -@mock.patch.dict("os.environ", MOCK_OIDC_ENV, clear=True) -@mock.patch("ml_space_lambda.authorizer.lambda_function.user_dao") -@mock.patch("ml_space_lambda.authorizer.lambda_function.http") -def test_verified_token_bad_unrecognized_key(mock_http, mock_user_dao, mock_well_known_config, mock_oidc_jwks_keys): - mock_http.request.side_effect = [ - mock_well_known_config, - mock_oidc_jwks_keys, - ] - mock_user_dao.get.return_value = MOCK_USER - assert lambda_handler( - mock_event(user=MOCK_USER, resource="/project", method="POST", kid="fake-cert-kid"), {} - ) == policy_response(allow=False, valid_token=False) - mock_user_dao.get.assert_not_called() - mock_http.request.assert_has_calls( - [ - mock.call("GET", "https://example-oidc.com/realms/mlspace/.well-known/openid-configuration"), - mock.call("GET", "https://example-oidc.com/realms/mlspace/protocol/openid-connect/certs"), - ] - ) - - -@mock.patch.dict( - "os.environ", - {"AWS_DEFAULT_REGION": "us-east-1", "OIDC_URL": "https://example-oidc.com/realms/mlspace"}, - clear=True, -) -@mock.patch("ml_space_lambda.authorizer.lambda_function.user_dao") -@mock.patch("ml_space_lambda.authorizer.lambda_function.http") -def test_verified_token_missing_client_name(mock_http, mock_user_dao): - mock_user_dao.get.return_value = MOCK_USER - assert lambda_handler( - mock_event(user=MOCK_USER, resource="/project", method="POST", kid="fake-cert-kid"), {} - ) == policy_response(allow=False, valid_token=False) - mock_user_dao.get.assert_not_called() - mock_http.request.assert_not_called() - - -@mock.patch.dict( - "os.environ", - {"AWS_DEFAULT_REGION": "us-east-1", "OIDC_CLIENT_NAME": "web-client"}, - clear=True, -) -@mock.patch("ml_space_lambda.authorizer.lambda_function.user_dao") -@mock.patch("ml_space_lambda.authorizer.lambda_function.http") -def test_verified_token_missing_oidc_endpoint(mock_http, mock_user_dao): - mock_user_dao.get.return_value = MOCK_USER - assert lambda_handler( - mock_event(user=MOCK_USER, resource="/project", method="POST", kid="fake-cert-kid"), {} - ) == policy_response(allow=False, valid_token=False) - mock_user_dao.get.assert_not_called() - mock_http.request.assert_not_called() - - def is_owner_of_project(username: str, project_name: str) -> bool: return MOCK_OWNER_PROJECT_USER.user == username and MOCK_OWNER_PROJECT_USER.project == project_name @@ -2491,10 +2566,12 @@ def test_manage_project_sagemaker_resource_boto_error( mock_project_dao, mock_user_dao, mock_resource_metadata_dao, + mock_session_manager, method: str, resource: str, path_param_key: str, ): + setup_mock_session(mock_session_manager, MOCK_OWNER_USER) path_params = {} path_params[path_param_key] = "fakeResourceName" @@ -2533,7 +2610,9 @@ def test_manage_project_sagemaker_resource_boto_error( @mock.patch.dict("os.environ", TEST_ENV_CONFIG, clear=True) @mock.patch("ml_space_lambda.authorizer.lambda_function.resource_metadata_dao") @mock.patch("ml_space_lambda.authorizer.lambda_function.user_dao") -def test_emr_cluster_missing_metadata_entry(mock_user_dao, mock_resource_metadata_dao): +def test_emr_cluster_missing_metadata_entry(mock_user_dao, mock_resource_metadata_dao, mock_session_manager): + setup_mock_session(mock_session_manager, MOCK_OWNER_USER) + mock_cluster_id = "clusterId" mock_resource_metadata_dao.get.return_value = None diff --git a/backend/test/config/test_describe_config.py b/backend/test/config/test_describe_config.py index d441ebcf..faa33f9f 100644 --- a/backend/test/config/test_describe_config.py +++ b/backend/test/config/test_describe_config.py @@ -60,7 +60,7 @@ def test_describe_config_success(mock_sagemaker, mock_pull_config, mock_s3_param EnvVariable.LOG_BUCKET: "mlspace-log-bucket", EnvVariable.KMS_INSTANCE_CONDITIONS_POLICY_ARN: "", EnvVariable.MANAGE_IAM_ROLES: "", - EnvVariable.NEW_USER_SUSPENSION_DEFAULT: "True", + EnvVariable.NEW_USERS_SUSPENDED: "False", EnvVariable.PROJECT_USERS_TABLE: "mlspace-project-users", EnvVariable.PROJECT_GROUPS_TABLE: "mlspace-project-groups", EnvVariable.PROJECTS_TABLE: "mlspace-projects", diff --git a/backend/test/conftest.py b/backend/test/conftest.py index 581e64e5..fb570f99 100644 --- a/backend/test/conftest.py +++ b/backend/test/conftest.py @@ -28,6 +28,9 @@ def pytest_generate_tests(metafunc): # Provides the location of the LAMBDA_TASK_ROOT which is used by the 'retrieve' function to identify built-in training algorithms os.environ["LAMBDA_TASK_ROOT"] = "./src/" + # Set AWS region for boto3 clients initialized at module level + if "AWS_DEFAULT_REGION" not in os.environ: + os.environ["AWS_DEFAULT_REGION"] = "us-east-1" @pytest.fixture diff --git a/backend/test/dataset/test_edit_dataset.py b/backend/test/dataset/test_edit_dataset.py index f5b62591..c60fc7cc 100644 --- a/backend/test/dataset/test_edit_dataset.py +++ b/backend/test/dataset/test_edit_dataset.py @@ -154,19 +154,6 @@ def test_edit_dataset_missing_parameters(mock_dataset_dao): mock_dataset_dao.update.assert_not_called() -@mock.patch("ml_space_lambda.dataset.lambda_functions.dataset_dao") -def test_edit_dataset_invalid_description(mock_dataset_dao, mock_global_dataset): - expected_response = generate_html_response(400, "Bad Request: Dataset description contains invalid character.") - update_event = { - "body": json.dumps({"description": "!!! $$$ ####"}), - "pathParameters": {"scope": mock_ds_scope, "datasetName": mock_ds_name}, - } - mock_dataset_dao.get.return_value = mock_global_dataset - - assert lambda_handler(update_event, mock_context) == expected_response - mock_dataset_dao.update.assert_not_called() - - @mock.patch("ml_space_lambda.dataset.lambda_functions.dataset_dao") def test_edit_dataset_long_description(mock_dataset_dao, mock_global_dataset): expected_response = generate_html_response( diff --git a/backend/test/user/test_create_user.py b/backend/test/user/test_create_user.py index 01e9cf51..c5a89a2d 100644 --- a/backend/test/user/test_create_user.py +++ b/backend/test/user/test_create_user.py @@ -1,4 +1,3 @@ -# # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"). @@ -12,105 +11,105 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -# -import json -from unittest import mock - -import pytest -from botocore.exceptions import ClientError - -from ml_space_lambda.data_access_objects.user import TIMEZONE_PREFERENCE_KEY, UserModel -from ml_space_lambda.enums import TimezonePreference -from ml_space_lambda.utils.common_functions import generate_html_response, serialize_permissions - -TEST_ENV_CONFIG = { - "AWS_DEFAULT_REGION": "us-east-1", -} - -mock_context = mock.Mock() - - -@pytest.fixture -def mock_user(): - return UserModel( - username="testUser101", - display_name="Test User 101st", - email="testUser101@amazon.com", - suspended=True, - ) - - -@pytest.fixture -def mock_event(mock_user): - return { - "body": json.dumps( - { - "username": mock_user.username, - "name": mock_user.display_name, - "email": mock_user.email, - } - ), - } - - -with mock.patch.dict("os.environ", TEST_ENV_CONFIG, clear=True): - from ml_space_lambda.user.lambda_functions import create - - -@mock.patch("ml_space_lambda.user.lambda_functions.user_dao") -def test_create_new_user_success(mock_user_dao, mock_user, mock_event): - mock_user_dao.get.return_value = None - actual_response = create(mock_event, mock_context) - response_body = json.loads(actual_response["body"]) - - mock_user_dao.get.assert_called_with(mock_user.username) - mock_user_dao.create.assert_called_once() - create_args = mock_user_dao.create.call_args.args[0].to_dict() - - assert create_args["username"] == response_body["username"] == mock_user.username - assert create_args["email"] == response_body["email"] == mock_user.email - assert create_args["displayName"] == response_body["displayName"] == mock_user.display_name - assert create_args["permissions"] == response_body["permissions"] == serialize_permissions(mock_user.permissions) - assert create_args["suspended"] == response_body["suspended"] == mock_user.suspended - assert create_args["preferences"] == response_body["preferences"] == {TIMEZONE_PREFERENCE_KEY: TimezonePreference.LOCAL} - # Ocassionally the tests and mocks may wind up off by 1 second so handle that instead of looking - # for exact equality - assert abs(response_body["lastLogin"] >= mock_user.last_login) <= 1 - assert abs(response_body["lastLogin"] - mock_user.created_at) <= 1 - - -@mock.patch("ml_space_lambda.user.lambda_functions.user_dao") -def test_create_new_user_already_present(mock_user_dao, mock_user, mock_event): - mock_user_dao.get.return_value = mock_user - expected_response = generate_html_response( - 400, - "Bad Request: Username in use.", - ) - assert create(mock_event, mock_context) == expected_response - mock_user_dao.get.assert_called_with(mock_user.username) - mock_user_dao.create.assert_not_called() - - -@mock.patch("ml_space_lambda.user.lambda_functions.user_dao") -def test_create_new_user_client_error(mock_user_dao, mock_user, mock_event): - mock_exception = ClientError( - { - "Error": {"Code": "MissingParameter", "Message": "Dummy error message."}, - "ResponseMetadata": {"HTTPStatusCode": 400}, - }, - "GetItem", - ) - expected_response = generate_html_response( - 400, - "An error occurred (MissingParameter) when calling the " "GetItem operation: Dummy error message.", - ) - mock_user_dao.get.side_effect = mock_exception - assert create(mock_event, mock_context) == expected_response - mock_user_dao.get.assert_called_with(mock_user.username) - mock_user_dao.create.assert_not_called() - - -def test_create_new_user_missing_parameters(): - expected_response = generate_html_response(400, "Missing event parameter: 'body'") - assert create({}, mock_context) == expected_response + +# import json +# from unittest import mock + +# import pytest +# from botocore.exceptions import ClientError + +# from ml_space_lambda.data_access_objects.user import TIMEZONE_PREFERENCE_KEY, UserModel +# from ml_space_lambda.enums import TimezonePreference +# from ml_space_lambda.utils.common_functions import generate_html_response, serialize_permissions + +# TEST_ENV_CONFIG = { +# "AWS_DEFAULT_REGION": "us-east-1", +# } + +# mock_context = mock.Mock() + + +# @pytest.fixture +# def mock_user(): +# return UserModel( +# username="testUser101", +# display_name="Test User 101st", +# email="testUser101@amazon.com", +# suspended=False, +# ) + + +# @pytest.fixture +# def mock_event(mock_user): +# return { +# "body": json.dumps( +# { +# "username": mock_user.username, +# "name": mock_user.display_name, +# "email": mock_user.email, +# } +# ), +# } + + +# with mock.patch.dict("os.environ", TEST_ENV_CONFIG, clear=True): +# from ml_space_lambda.user.lambda_functions import create + + +# @mock.patch("ml_space_lambda.user.lambda_functions.user_dao") +# def test_create_new_user_success(mock_user_dao, mock_user, mock_event): +# mock_user_dao.get.return_value = None +# actual_response = create(mock_event, mock_context) +# response_body = json.loads(actual_response["body"]) + +# mock_user_dao.get.assert_called_with(mock_user.username) +# mock_user_dao.create.assert_called_once() +# create_args = mock_user_dao.create.call_args.args[0].to_dict() + +# assert create_args["username"] == response_body["username"] == mock_user.username +# assert create_args["email"] == response_body["email"] == mock_user.email +# assert create_args["displayName"] == response_body["displayName"] == mock_user.display_name +# assert create_args["permissions"] == response_body["permissions"] == serialize_permissions(mock_user.permissions) +# assert create_args["suspended"] == response_body["suspended"] == mock_user.suspended +# assert create_args["preferences"] == response_body["preferences"] == {TIMEZONE_PREFERENCE_KEY: TimezonePreference.LOCAL} +# # Ocassionally the tests and mocks may wind up off by 1 second so handle that instead of looking +# # for exact equality +# assert abs(response_body["lastLogin"] >= mock_user.last_login) <= 1 +# assert abs(response_body["lastLogin"] - mock_user.created_at) <= 1 + + +# @mock.patch("ml_space_lambda.user.lambda_functions.user_dao") +# def test_create_new_user_already_present(mock_user_dao, mock_user, mock_event): +# mock_user_dao.get.return_value = mock_user +# expected_response = generate_html_response( +# 400, +# "Bad Request: Username in use.", +# ) +# assert create(mock_event, mock_context) == expected_response +# mock_user_dao.get.assert_called_with(mock_user.username) +# mock_user_dao.create.assert_not_called() + + +# @mock.patch("ml_space_lambda.user.lambda_functions.user_dao") +# def test_create_new_user_client_error(mock_user_dao, mock_user, mock_event): +# mock_exception = ClientError( +# { +# "Error": {"Code": "MissingParameter", "Message": "Dummy error message."}, +# "ResponseMetadata": {"HTTPStatusCode": 400}, +# }, +# "GetItem", +# ) +# expected_response = generate_html_response( +# 400, +# "An error occurred (MissingParameter) when calling the " "GetItem operation: Dummy error message.", +# ) +# mock_user_dao.get.side_effect = mock_exception +# assert create(mock_event, mock_context) == expected_response +# mock_user_dao.get.assert_called_with(mock_user.username) +# mock_user_dao.create.assert_not_called() + + +# def test_create_new_user_missing_parameters(): +# expected_response = generate_html_response(400, "Missing event parameter: 'body'") +# assert create({}, mock_context) == expected_response diff --git a/backend/test/utils/test_mlspace_config.py b/backend/test/utils/test_mlspace_config.py index 99b1192f..d928b628 100644 --- a/backend/test/utils/test_mlspace_config.py +++ b/backend/test/utils/test_mlspace_config.py @@ -66,7 +66,7 @@ def test_environment_variables(): EnvVariable.LOG_BUCKET: "mlspace-log-bucket", EnvVariable.KMS_INSTANCE_CONDITIONS_POLICY_ARN: "", EnvVariable.MANAGE_IAM_ROLES: "", - EnvVariable.NEW_USER_SUSPENSION_DEFAULT: "True", + EnvVariable.NEW_USERS_SUSPENDED: "False", EnvVariable.NOTEBOOK_ROLE_NAME: "", EnvVariable.PERMISSIONS_BOUNDARY_ARN: "", EnvVariable.PROJECTS_TABLE: "mlspace-projects", diff --git a/backend/test_session_authorizer.py b/backend/test_session_authorizer.py new file mode 100644 index 00000000..5c31262c --- /dev/null +++ b/backend/test_session_authorizer.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Simple test to verify session cookie validation in the authorizer. +This is a basic functional test to ensure the session-based authorizer works. +""" + +from datetime import datetime, timedelta, timezone +from unittest import mock + +# Mock environment variables for session-based auth +TEST_SESSION_ENV = { + "AWS_DEFAULT_REGION": "us-east-1", + "AUTH_SESSION_TABLE_NAME": "test-session-table", + "AUTH_TOKEN_ENCRYPTION_KEY_SECRET_NAME": "test/encryption/key", +} + + +def test_session_cookie_validation(): + """Test that the authorizer correctly validates session cookies.""" + + with mock.patch.dict("os.environ", TEST_SESSION_ENV, clear=True): + # Import after setting environment variables + from ml_space_lambda.authorizer.lambda_function import lambda_handler + from ml_space_lambda.data_access_objects.user import UserModel + from ml_space_lambda.enums import Permission + + # Mock user data + mock_user = UserModel( + username="test@example.com", + email="test@example.com", + display_name="Test User", + suspended=False, + permissions=[Permission.ADMIN], + ) + + # Mock session data + mock_session_data = { + "data": { + "user": { + "id": "test@example.com", + "displayName": "Test User", + "email": "test@example.com", + "groups": ["admin"], + "attributes": {}, + }, + "session": { + "provider": "oidc", + "expiresAt": (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(), + "refreshAt": (datetime.now(timezone.utc) + timedelta(minutes=30)).isoformat(), + }, + } + } + + # Test 1: No cookie header - should deny access + event_no_cookie = { + "resource": "/test-resource", + "pathParameters": {}, + "httpMethod": "GET", + "methodArn": "test-arn", + "headers": {}, + } + + with mock.patch("ml_space_lambda.authorizer.lambda_function.user_dao") as mock_user_dao: + mock_user_dao.get.return_value = mock_user + + result = lambda_handler(event_no_cookie, {}) + + assert result["principalId"] == "Unknown" + assert result["policyDocument"]["Statement"][0]["Effect"] == "Deny" + print("✓ Test 1 passed: No cookie header correctly denied") + + # Test 2: Valid session cookie - should allow access + event_with_cookie = { + "resource": "/current-user", + "pathParameters": {}, + "httpMethod": "GET", + "methodArn": "test-arn", + "headers": {"cookie": "mlspace_session=session:test-session-id"}, + } + + with mock.patch("ml_space_lambda.authorizer.lambda_function.user_dao") as mock_user_dao, mock.patch( + "ml_space_lambda.authorizer.lambda_function._get_session_manager" + ) as mock_get_session_manager: + + # Mock session manager + mock_session_manager = mock.Mock() + mock_session_manager.get_session.return_value = mock_session_data + mock_get_session_manager.return_value = mock_session_manager + + # Mock user DAO + mock_user_dao.get.return_value = mock_user + + result = lambda_handler(event_with_cookie, {}) + + assert result["principalId"] == "test@example.com" + assert result["policyDocument"]["Statement"][0]["Effect"] == "Allow" + assert "user" in result["context"] + print("✓ Test 2 passed: Valid session cookie correctly allowed") + + # Test 3: Invalid session cookie - should deny access + with mock.patch("ml_space_lambda.authorizer.lambda_function.user_dao") as mock_user_dao, mock.patch( + "ml_space_lambda.authorizer.lambda_function._get_session_manager" + ) as mock_get_session_manager: + + # Mock session manager to return None (invalid session) + mock_session_manager = mock.Mock() + mock_session_manager.get_session.return_value = None + mock_get_session_manager.return_value = mock_session_manager + + # Mock user DAO + mock_user_dao.get.return_value = mock_user + + result = lambda_handler(event_with_cookie, {}) + + assert result["principalId"] == "Unknown" + assert result["policyDocument"]["Statement"][0]["Effect"] == "Deny" + print("✓ Test 3 passed: Invalid session cookie correctly denied") + + print("\n🎉 All session cookie validation tests passed!") + + +if __name__ == "__main__": + test_session_cookie_validation() diff --git a/bin/mlspace-cdk.ts b/bin/mlspace-cdk.ts index bcddd6e4..70a0e03f 100644 --- a/bin/mlspace-cdk.ts +++ b/bin/mlspace-cdk.ts @@ -20,6 +20,7 @@ import { App, Aspects, Tags } from 'aws-cdk-lib'; import { LogGroup } from 'aws-cdk-lib/aws-logs'; import 'source-map-support/register'; import { AdminApiStack } from '../lib/stacks/api/admin'; +import { AuthApiStack } from '../lib/stacks/api/auth'; import { DatasetsApiStack } from '../lib/stacks/api/datasets'; import { EmrApiStack } from '../lib/stacks/api/emr'; import { InferenceApiStack } from '../lib/stacks/api/inference'; @@ -150,7 +151,6 @@ const restStack = new RestApiStack(app, 'mlspace-web-tier', { mlSpaceAppRole, lambdaSourcePath, frontEndAssetsPath, - verifyOIDCTokenSignature: config.OIDC_VERIFY_SIGNATURE, mlSpaceVPC, lambdaSecurityGroups: [vpcStack.vpcSecurityGroup], isIso, @@ -191,6 +191,7 @@ const apiStackProperties: ApiStackProperties = { const apiStacks = [ new AdminApiStack(app, 'mlspace-admin-apis', apiStackProperties), + new AuthApiStack(app, 'mlspace-auth-apis', apiStackProperties), new DatasetsApiStack(app, 'mlspace-dataset-apis', apiStackProperties), new InferenceApiStack(app, 'mlspace-inference-apis', apiStackProperties), new JobsApiStack(app, 'mlspace-jobs-apis', apiStackProperties), diff --git a/design/auth-enhancement.md b/design/auth-enhancement.md new file mode 100644 index 00000000..1ad8dafa --- /dev/null +++ b/design/auth-enhancement.md @@ -0,0 +1,130 @@ +MLSpace Auth Enhancement + +Executive summary/Purpose + +Since MLSpace's launch, a recurring topic in conversations with current and prospective integrators has been the ability to integrate with Identity Providers (IdPs) using authentication schemes that are not currently supported. These integrators have identified significant pain points stemming from their inability to connect MLSpace to IdPs that exclusively support the SAML protocol or OIDC authentication flows requiring secret keys not designed for Single Page Applications (SPAs). + +While MLSpace currently supports the most common OIDC authentication flow for SPAs using PKCE (Proof Key Code Exchange), the original decision to support a single authentication scheme—which enabled rapid deployment with broad compatibility—has become one of the primary sources of integration friction for GeoAxis customers. Geoaxis is a popular IdP for DoD customers. Three, soon to be four, MLSpace customers use GeoAxis today. + +Proposal + +This solution overhauls the current authentication architecture by introducing a Backend for Frontend (BFF) pattern that abstracts authentication details from the frontend SPA and manages IdP integration directly on the backend. This new abstraction layer will significantly enhance MLSpace's flexibility regarding IdP selection and integration methods by establishing a frontend facade supported by an extensible backend architecture. This approach will enable compatibility with previously unsupported IdPs like GeoAxis and future compatibilty with SAML only IdPs like AWS Identity Center, addressing the integration limitations that have been a source of friction for some organizations. The MLSpace development team will create a simplified authentication API for frontend consumption that eliminates all IdP and authentication protocol-specific dependencies, providing a clean, abstracted interface for authentication and credential retrieval. + +The enhancement encompasses three primary areas of responsibility: the backend API, frontend modifications, and the new authentication flow that connects these components seamlessly. + +Backend API + +Endpoint Description Payload + +/auth/login +Browser endpoint that initiates the authentication process by redirecting to the configured Identity Provider. +- + +/auth/callback +Return endpoint where the browser is redirected following authentication attempts (successful or failed) from the Identity Provider. +- + +/auth/logout +Logout endpoint that terminates the user session by deleting the session cookie, removing the DynamoDB record, and optionally logging out from the Identity Provider. +- + +/auth/identity +Endpoint for retrieving the current user's identity information and authentication status. +HTTP 401 + +{ + "status": "UNAUTHENTICATED" +} + +HTTP 200 + +{ + "status": "AUTHENTICATED", + "displayName": "kermit", + "email": "kermit@sesamest.org" + ... +} + +Frontend Changes + +The current frontend implementation utilizes a React component that serves as an OIDC identity token context provider, managing the complete authentication lifecycle including automatic redirects to the Identity Provider and seamless access token refresh operations. This component currently handles all OIDC-specific protocol details directly within the browser environment. + +The proposed authentication enhancement will replace this OIDC-specific context provider with a new MLSpace Authentication context provider that abstracts away all Identity Provider and protocol-specific complexity. Instead of managing OIDC tokens and flows directly, this new provider will communicate exclusively with the backend authentication API endpoints to determine user authentication status and retrieve identity information. + +This architectural shift moves the authentication complexity from the client-side JavaScript environment to the backend infrastructure, where sensitive operations like token storage and IdP communication can be handled more securely. + +The IdP integration code can pick an appropriate period refresh that is provided to the browser as part of the response to /auth/identity. The browser can then periodically refresh this data and allow the backend to optionally synchronize with the IdP to capture any user updates or credential revocations. This enables IdP synchornizations without the complexity of the frontend handling multiple protocols and without the difficulties of backend task scheduling in a serverless environment. + +The new context provider will maintain the same developer experience for other React components that consume authentication state, but will rely on session-based authentication supported by the new backend APIs rather than managing JWT tokens directly in the browser. + +Authentication Flow + +The new authentication workflow implements a Backend for Frontend pattern that abstracts Identity Provider complexity from the Single Page Application. The flow operates as follows: + +1. Authentication Initiation: When the SPA requires authentication, it directs the user to a unified /auth/login endpoint on the MLSpace backend. +2. Identity Provider Redirect: The MLSpace backend redirects the browser to the configured Identity Provider based on the deployment configuration, supporting multiple authentication protocols (SAML, OIDC with client secrets, etc.). +3. Identity Provider Authentication: The user completes authentication with the Identity Provider, which then redirects back to the MLSpace backend at /auth/callback with the appropriate tokens, assertions, or credentials. +4. Credential Storage and Session Management: The MLSpace backend securely stores the IdP-provided tokens and credentials in a DynamoDB table, then issues a session cookie to the SPA containing an identifier that references the stored credential data. +5. Cross-Domain Synchronization (Optional): For multi-domain deployments, the API Gateway backend may redirect to the SPA with an additional One Time Authentication Code (OTAC) to facilitate cookie synchronization across different domains. +6. Secondary Domain Authentication (Optional): The secondary backend validates the OTAC from the query parameters, retrieves the corresponding credential information from DynamoDB, and issues a domain-specific session cookie. + + + + +Design details + +CDK + +A new architectural structure must be implemented to route the appropriate API Gateway resources of /auth/login and /auth/callback to appropriate Lambda functions for handling IdP specific integration logic. These handlers will manage Identity Provider redirections and extract relevant authentication data from IdP responses and will be wired up based on the updated application configuration that specifies IdP type and protocol specific options. + +lambda/ +├── auth/ +│ ├── oidc-handler.ts +│ ├── saml-handler.ts (future improvement) +│ └── custom-handler.ts (customer integration point) + +Additionally, a new DynamoDB table is required to store session information, as Identity Provider payloads frequently exceed the 4,096-byte browser cookie limitation and can’t be used for this purpose. This table will serve dual purposes: session management and cookie synchronization. The primary key structure will utilize prefixes to distinguish record types: session: for MLSpace session records and sync: for ephemeral cookie synchronization entries. + + +PK TTL data raw_data + +session: + +MLSpace Session Object +IdP response + +sync: + +session: + +This design enables efficient session lookup while maintaining separation between active user sessions and temporary synchronization tokens used during the authentication flow. + +Backend + +Initial Impementation + +The initial implementation will only provide additional OIDC providers requiring authentication flows not currently unsupported in MLSpace. The Python OAuth/OIDC library authlib has been identified as a low effort way to implement the needed support in the backend for these integrations. + +Additional Work + +Several components require development or modification to support the new authentication architecture. The existing authorizer must be updated to validate session cookies rather than OIDC tokens and retrieve user information from DynamoDB to populate the authContext passed to AWS Lambda functions. A new data model representing the MLSpace Session Object will be required, along with a basic repository pattern for persistence operations against the DynamoDB session table. Although not relevant to MLSpace, cookie synchronization functionality may be implemented on a separate domain to accept one-time authentication codes from the browser to generate appropriate session cookies as described in authentication flow steps 5 and 6 above. + +Frontend + +Implementation Requirements + +A new frontend context provider must be developed to retrieve authentication status from the /auth/identity endpoint and provide this information to the application. This provider will handle session expiration notifications and manage authentication redirects as needed. + +Compatibility Verification + +All existing authentication context usage throughout the application should be audited to ensure continued compatibility with the new authentication model and identify any necessary modifications to maintain functionality. + +Alternative Considersations + +Additional Browser-based Authentication Protocol Support + +One alternative considered was expanding the Single Page Application to support additional authentication protocols directly within the frontend. This approach was ultimately rejected for two primary reasons. First, it would not address the fundamental requirement for integrators who need OIDC flows utilizing client secrets, which are inherently incompatible with browser-based authentication due to security constraints. Second, this approach would substantially increase code complexity and maintenance burden, as each new authentication protocol or Identity Provider implementation would necessitate corresponding changes across both frontend and backend codebases, leading to significant technical debt and reduced maintainability over time. + +Store Client Secret in Frontend + +While many integrators have worked around client secret limitations by embedding them directly in the frontend code and accepting the security implications of shared client secrets across all users, this solution creates additional challenges. These modifications require maintaining custom patches that complicate keeping MLSpace synchronized with upstream project updates, and this approach only addresses a single unsupported OIDC flow rather than providing a comprehensive authentication framework that can accommodate diverse Identity Provider requirements and security models. diff --git a/docs/BFF_AUTHENTICATION_KEY_ROTATION.md b/docs/BFF_AUTHENTICATION_KEY_ROTATION.md new file mode 100644 index 00000000..797593f6 --- /dev/null +++ b/docs/BFF_AUTHENTICATION_KEY_ROTATION.md @@ -0,0 +1,289 @@ +# Enhanced Authentication Key Rotation Guide + +This document provides comprehensive guidance for managing encryption key rotation in the MLSpace enhanced authentication system. + +## Overview + +The enhanced authentication system uses two types of encryption keys: + +1. **Token Encryption Keys** - Encrypt IdP tokens stored in sessions (high impact) +2. **State Encryption Keys** - Encrypt CSRF state parameters during login flow (low impact) + +## Key Rotation Strategy + +### Hybrid Approach + +We implement a **hybrid key rotation strategy** that balances security with operational simplicity: + +- **Token Keys**: Versioned with automated rotation (zero downtime) +- **State Keys**: Simple deploy-time generation (minimal impact) + +### Impact Assessment + +| Key Type | Rotation Impact | Affected Users | Duration | +|----------|----------------|----------------|----------| +| Token Keys | Zero downtime | None | N/A | +| State Keys | Minimal | Users actively logging in | 1-2 minutes | + +## Token Key Rotation (Automated) + +### Architecture + +Token encryption uses a **versioned key system** that supports graceful rotation: + +- **Encryption**: Always uses the latest key version +- **Decryption**: Can decrypt with any available key version +- **Storage**: AWS Secrets Manager with versioned JSON structure +- **Rotation**: Automated via EventBridge + Lambda (90-day schedule) + +### Key Structure + +```json +{ + "current_version": 2, + "keys": { + "1": "base64-encoded-key-v1", + "2": "base64-encoded-key-v2" + }, + "key_type": "token", + "rotation_date": "2024-01-15T10:30:00Z", + "rotated_by": "key_rotation_manager" +} +``` + +### Automated Rotation + +**Schedule**: Every 90 days via EventBridge rule + +**Process**: +1. Generate new 32-byte encryption key +2. Increment version number +3. Add new key to versioned structure +4. Update Secrets Manager +5. New sessions use latest key +6. Existing sessions continue with their original key + +**Zero Impact**: Users experience no disruption during rotation. + +### Key Cleanup + +Old key versions are automatically retained for backward compatibility and cleaned up during rotation based on the configured retention policy (default: 3 versions). + +### Monitoring Token Key Rotation + +Monitor rotation through CloudWatch Logs and AWS Secrets Manager console: + +- **CloudWatch Logs**: Check `/aws/lambda/mlspace-key-rotation` log group +- **Secrets Manager Console**: View rotation configuration and history +- **CloudWatch Metrics**: Monitor rotation success/failure rates + +## State Key Rotation (Deploy-Time) + +### Architecture + +State encryption uses a **simple Fernet key** that can be regenerated as needed: + +- **Generation**: Initialization script creates key using `create_state_encryption_key()` +- **Storage**: AWS Secrets Manager (simple string value) +- **Rotation**: Regenerate via initialization script +- **Impact**: Only affects users actively logging in (1-2 minutes) +- **Rotation**: Automatic via configured rotation schedule + +## Deployment Configuration + +### CDK Integration + +The `AuthSecretsConstruct` creates the secrets with placeholder values: + +```typescript +// In your CDK stack +const authSecrets = new AuthSecretsConstruct(this, 'AuthSecrets', { + encryptionKey: kmsKey, // Optional: Use customer-managed KMS key + enableTokenKeyRotation: true, // Enable automated rotation + tokenKeyRotationSchedule: Schedule.rate(Duration.days(90)), // Optional: Custom schedule +}); +``` + +### Key Initialization + +After CDK deployment, run the initialization script to populate secrets with proper keys: + +```bash +# Initialize both state and token encryption keys +python3 scripts/initialize-auth-keys.py +``` + +**What the script does:** +- Generates proper Fernet key for state encryption using `create_state_encryption_key()` +- Generates versioned token encryption key using `create_encryption_key()` +- Stores keys in the correct format expected by the authentication system + +### Environment Variables + +Lambda functions receive these environment variables: + +```bash +# Token encryption (versioned) +AUTH_TOKEN_ENCRYPTION_KEY_SECRET_NAME=mlspace/auth/token-encryption-keys + +# State encryption (simple) +AUTH_STATE_ENCRYPTION_KEY_SECRET_NAME=mlspace/auth/state-encryption-key +``` + +### Configuration Files + +**lib/config.json**: +```json +{ + "AUTH_TOKEN_ENCRYPTION_KEY_SECRET_NAME": "mlspace/auth/token-encryption-keys", + "AUTH_STATE_ENCRYPTION_KEY_SECRET_NAME": "mlspace/auth/state-encryption-key" +} +``` + +## Security Considerations + +### Key Generation + +- **Token Keys**: 32-byte cryptographically secure random keys +- **State Keys**: Fernet-compatible keys (32-byte base64-encoded) +- **Entropy**: All keys use `os.urandom()` for cryptographic randomness + +### Access Control + +**IAM Permissions**: +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "secretsmanager:GetSecretValue", + "secretsmanager:UpdateSecret" + ], + "Resource": [ + "arn:aws:secretsmanager:*:*:secret:mlspace/auth/*" + ] + } + ] +} +``` + +### Encryption at Rest + +- **Secrets Manager**: Encrypted with AWS managed keys or customer-managed KMS keys +- **DynamoDB**: Session data encrypted at rest +- **Application-Level**: Tokens encrypted before storage in DynamoDB + +## Troubleshooting + +### Token Key Rotation Issues + +**Problem**: Rotation Lambda fails +- Check CloudWatch Logs for the `/aws/lambda/mlspace-key-rotation` log group +- Review CloudWatch metrics for rotation failures +- Verify IAM permissions for the rotation Lambda function + +**Problem**: Sessions become invalid after rotation +- This should not happen with versioned keys +- Check that `VersionedTokenEncryption` is being used +- Verify key versions are properly stored + +### State Key Rotation Issues + +**Problem**: Users can't log in after deployment +- Expected behavior during state key rotation +- Users should retry login after 1-2 minutes +- Check that new state key was generated properly + +**Problem**: State key generation fails +- Check CloudWatch logs for the rotation Lambda function +- Verify the rotation schedule is configured correctly in AWS Secrets Manager + +### Key Validation + +Verify key structure using AWS Secrets Manager console or by checking CloudWatch logs for the rotation Lambda function. + +## Migration from Legacy Keys + +The system now uses versioned keys by default. If migrating from a legacy deployment: + +1. **Backup existing keys** using AWS Secrets Manager console or CLI +2. **Deploy updated infrastructure** with versioned key support +3. **Initialize secrets** using the initialization script +4. **Verify rotation schedule** is configured correctly + +### Rollback Plan + +If issues occur during migration: + +1. **Check CloudWatch logs** for rotation Lambda errors +2. **Verify secret structure** matches expected versioned format +3. **Contact support** if issues persist + +## Best Practices + +### Rotation Schedule + +- **Token Keys**: Every 90 days (automated via AWS Secrets Manager) +- **State Keys**: Every 90 days (automated via AWS Secrets Manager) + +### Monitoring + +- **CloudWatch Alarms**: Monitor rotation Lambda failures +- **Session Metrics**: Track session creation/expiration rates +- **Error Rates**: Monitor authentication error rates during rotation + +### Testing + +- **Pre-Production**: Test rotation in staging environment +- **Load Testing**: Verify rotation works under load +- **Rollback Testing**: Practice emergency rollback procedures + +### Documentation + +- **Runbooks**: Document rotation procedures for operations team +- **Incident Response**: Include key rotation in security incident procedures +- **Change Management**: Include rotation schedule in change calendar + +## Emergency Procedures + +### Key Compromise Response + +If keys are compromised: + +1. **Trigger immediate rotation**: Use AWS Secrets Manager console to trigger rotation immediately +2. **Monitor rotation completion**: Check CloudWatch logs for rotation Lambda execution +3. **Invalidate sessions if necessary**: Consider clearing the session table for critical security incidents + +### Key Recovery + +If rotation fails and keys are lost: + +1. **Check CloudTrail** for key modification events +2. **Restore from backup** if available +3. **Generate new keys** and accept that existing sessions will be invalidated +4. **Notify users** of required re-authentication + +## Compliance and Auditing + +### Audit Trail + +All key operations are logged: +- **CloudTrail**: Secrets Manager API calls +- **Lambda Logs**: Rotation operation details +- **EventBridge**: Scheduled rotation events + +### Compliance Requirements + +- **Key Rotation**: Automated 90-day rotation meets most compliance requirements +- **Access Logging**: All key access is logged and auditable +- **Encryption**: Keys encrypted at rest and in transit +- **Separation of Duties**: Rotation is automated, reducing human access + +### Reporting + +Monitor rotation history through: +- **CloudWatch Logs Insights**: Query rotation events in the Lambda log group +- **AWS Secrets Manager Console**: View rotation history for each secret +- **CloudTrail**: Audit all Secrets Manager API calls \ No newline at end of file diff --git a/docs/KEY_MANAGEMENT_REFACTOR_USAGE.md b/docs/KEY_MANAGEMENT_REFACTOR_USAGE.md new file mode 100644 index 00000000..b6a72706 --- /dev/null +++ b/docs/KEY_MANAGEMENT_REFACTOR_USAGE.md @@ -0,0 +1,233 @@ +# Key Management Refactor Usage Guide + +This document explains how to use the refactored key management system with separate functions, Pydantic models, and rotation schedules. + +## Overview + +The refactored system provides: + +- **Separate functions** for state and token key initialization and rotation +- **Pydantic models** for type-safe key data management with domain methods +- **Rotation schedules** using AWS Secrets Manager instead of EventBridge +- **Automatic cleanup** of old key versions during rotation +- **No custom resources** - relies on rotation schedules directly + +## CDK Usage + +### Basic Setup + +```typescript +import { AuthSecretsConstruct } from './constructs/auth/authSecretsConstruct'; + +const authSecrets = new AuthSecretsConstruct(this, 'AuthSecrets', { + lambdaSourcePath: 'backend/src', + mlSpaceAppRole: appRole, + encryptionKey: kmsKey, + + // Enable automatic rotation + enableStateKeyRotation: true, + enableTokenKeyRotation: true, + + // Rotation intervals (optional, defaults to 90 days) + stateKeyRotationDays: 60, + tokenKeyRotationDays: 90, + + // Optional OIDC client secret + oidcClientSecret: 'your-oidc-client-secret' +}); +``` + +### Manual Initialization (if needed) + +```typescript +// Create manual initialization functions +const { stateInit, tokenInit } = authSecrets.createManualInitializationFunctions(); + +// These can be invoked manually to initialize secrets +// Event payload: { "secret_arn": "arn:...", "key_type": "state" } +``` + +## Python Usage + +### Using the Pydantic Models + +```python +from ml_space_lambda.auth.models.key_models import VersionedKeyData, KeyType + +# Load key data from Secrets Manager +secrets_client = boto3.client("secretsmanager") +response = secrets_client.get_secret_value(SecretId=secret_arn) +key_data = VersionedKeyData.from_secrets_manager_format(response["SecretString"]) + +# Use domain methods +current_key = key_data.get_current_key() +available_versions = key_data.get_available_versions() + +# Add new version +new_version = key_data.add_new_key_version(encoded_new_key, "manual_rotator") + +# Cleanup old versions +removed_versions = key_data.cleanup_old_versions(keep_versions=3) + +# Save back to Secrets Manager +secrets_client.update_secret( + SecretId=secret_arn, + SecretString=key_data.to_secrets_manager_format() +) +``` + +### Using the Rotation Functions + +```python +from ml_space_lambda.auth.utils.key_rotation import ( + initialize_state_encryption_key, + initialize_token_encryption_key, + rotate_state_encryption_key, + rotate_token_encryption_key, + get_key_status +) + +# Initialize secrets +state_result = initialize_state_encryption_key(state_secret_arn) +token_result = initialize_token_encryption_key(token_secret_arn) + +# Rotate keys (includes automatic cleanup) +rotation_result = rotate_token_encryption_key( + secret_arn=token_secret_arn, + keep_versions=3 # Keep 3 most recent versions +) + +print(f"Rotated from v{rotation_result.previous_version} to v{rotation_result.new_version}") +print(f"Total versions: {rotation_result.total_versions}") + +# Get key status +status = get_key_status(secret_arn) +if status.success: + print(f"Current version: {status.current_version}") + print(f"Available versions: {status.available_versions}") +``` + +## Key Features + +### 1. AWS Secrets Manager Rotation Protocol + +The system now properly implements the AWS Secrets Manager rotation protocol with four steps: + +```python +# Rotation steps handled automatically by AWS Secrets Manager: +# 1. createSecret - Creates new key version with AWSPENDING label +# 2. setSecret - Sets the new secret (already done in step 1) +# 3. testSecret - Validates the new key version +# 4. finishSecret - Moves AWSPENDING to AWSCURRENT label + +# The rotation handlers follow this protocol: +def state_key_secrets_manager_rotation_handler(event, context): + secret_arn = event['SecretId'] # Provided by Secrets Manager + step = event['Step'] # createSecret, setSecret, testSecret, finishSecret + token = event.get('Token', 'AWSCURRENT') # AWSPENDING for new versions +``` + +### 2. Domain-Driven Design + +The `VersionedKeyData` class encapsulates all key management logic: + +```python +# Instead of manually manipulating dictionaries +key_data.add_new_key_version(new_key, "rotator") +key_data.cleanup_old_versions(3) + +# Instead of manual JSON handling +json_str = key_data.to_secrets_manager_format() +key_data = VersionedKeyData.from_secrets_manager_format(json_str) +``` + +### 2. Automatic Cleanup + +Rotation functions automatically clean up old versions: + +```python +# This will rotate AND cleanup in one operation +result = rotate_token_encryption_key(secret_arn, keep_versions=3) +print(result.message) # "Rotated to version 5, removed 2 old versions" +``` + +### 3. Type Safety with Enums + +```python +from ml_space_lambda.auth.models.key_models import KeyType + +# Use enums instead of strings +key_data = VersionedKeyData.create_initial( + encoded_key=key, + key_type=KeyType.TOKEN, # Instead of "token" + created_by="initializer" +) +``` + +### 4. Rotation Schedules + +The system uses AWS Secrets Manager rotation schedules instead of EventBridge: + +- Automatic rotation every N days +- Built-in retry logic +- Integration with AWS monitoring +- No custom EventBridge rules needed + +## Migration from Old System + +### 1. Update CDK Props + +```typescript +// Old +enableTokenKeyRotation: true, +tokenKeyRotationSchedule: Schedule.rate(Duration.days(90)) + +// New +enableStateKeyRotation: true, +enableTokenKeyRotation: true, +stateKeyRotationDays: 90, +tokenKeyRotationDays: 90 +``` + +### 2. Remove Custom Resources + +The new system doesn't need custom resources for initialization. Secrets are initialized through the rotation schedule or manual functions. + +### 3. Update Lambda Handlers + +```python +# Old +rotation_manager = KeyRotationManager() +result = rotation_manager.rotate_token_encryption_key(secret_arn) + +# New +result = rotate_token_encryption_key(secret_arn, keep_versions=3) +``` + +## Benefits + +1. **Cleaner Architecture**: Separate concerns with dedicated functions +2. **Type Safety**: Pydantic models prevent runtime errors +3. **Domain Logic**: Business logic encapsulated in domain objects +4. **Automatic Cleanup**: No need for separate cleanup operations +5. **Better Integration**: Uses AWS Secrets Manager rotation natively +6. **Simplified Deployment**: No custom resources needed + +## Testing + +```python +# Test key data manipulation +key_data = VersionedKeyData.create_initial("test-key", KeyType.STATE) +assert key_data.current_version == 1 +assert key_data.get_current_key() == "test-key" + +# Test rotation +new_version = key_data.add_new_key_version("new-key", "test") +assert new_version == 2 +assert key_data.current_version == 2 + +# Test cleanup +removed = key_data.cleanup_old_versions(1) +assert len(removed) == 1 +assert "1" in removed +``` \ No newline at end of file diff --git a/docs/VERSIONED_KEY_MANAGEMENT_GUIDE.md b/docs/VERSIONED_KEY_MANAGEMENT_GUIDE.md new file mode 100644 index 00000000..b49a4dbc --- /dev/null +++ b/docs/VERSIONED_KEY_MANAGEMENT_GUIDE.md @@ -0,0 +1,195 @@ +# Versioned Key Management Guide + +This document explains how the refactored key management system handles multiple versions and cleanup using only the new versioned format. + +## ✅ **Version Handling - Versioned Format Only** + +### **Simplified Architecture** + +All consumers of the secrets now expect only the versioned format: + +```python +# _get_secret_value() function - simplified +def _get_secret_value(secret_arn: str, key: str = "key") -> str: + response = secrets_client.get_secret_value(SecretId=secret_arn) + # Always expect versioned format + key_data = VersionedKeyData.from_secrets_manager_format(response["SecretString"]) + return key_data.get_current_key() +``` + +### **Places Using Versioned Keys** + +1. **`_get_secret_value()` in `lambda_functions.py`** - Always expects versioned format +2. **`VersionedKeyManager` in `key_manager.py`** - Designed for versioned keys +3. **`authorizer/lambda_function.py`** - Uses `VersionedTokenEncryption` directly +4. **All rotation functions** - Work with versioned format using AWS Secrets Manager protocol + +### **Versioned Token Encryption** + +The `VersionedTokenEncryption` class handles multiple key versions seamlessly: + +```python +# Encryption always uses current key +encrypted = encryption.encrypt_token("my-token") +# Result: "v2:v4.local.encrypted_data" + +# Decryption works with any available version +decrypted = encryption.decrypt_token("v1:v4.local.old_encrypted_data") # Works! +decrypted = encryption.decrypt_token("v2:v4.local.new_encrypted_data") # Works! +``` + +### **Versioned State Management** + +Similarly, state parameters support multiple versions: + +```python +# Creation uses current key +state = state_manager.create_state(redirect_url, domain, nonce) +# Result: "v2:encrypted_state_data" + +# Validation works with any version +state_data = state_manager.validate_state("v1:old_encrypted_state", nonce) # Works! +``` + +## ✅ **Old Version Cleanup** + +### **Two-Level Cleanup System** + +The system implements cleanup at two levels: + +#### **1. Internal Key Cleanup (Within Secret Content)** + +```python +# During rotation, old internal key versions are cleaned up +removed_versions = key_data.cleanup_old_versions(keep_versions=3) +# Keeps only the 3 most recent key versions within the secret +``` + +#### **2. AWS Secrets Manager Version Cleanup** + +```python +def cleanup_old_secret_versions(secret_arn: str, keep_versions: int = 3): + """Clean up old AWS Secrets Manager versions.""" + # Gets all secret versions from AWS + # Protects AWSCURRENT and AWSPENDING versions + # Deletes older versions beyond keep_versions limit +``` + +### **Automatic Cleanup During Rotation** + +Cleanup happens automatically during the `finishSecret` step: + +```python +def finalize_secrets_manager_rotation(secret_arn: str, token: str = "AWSPENDING"): + # 1. Move AWSPENDING → AWSCURRENT + secrets_client.update_secret_version_stage(...) + + # 2. Clean up old secret versions automatically + cleanup_result = cleanup_old_secret_versions(secret_arn, keep_versions=3) +``` + +### **What Gets Cleaned Up** + +1. **Internal Key Versions**: Old key versions within the secret content (keeps 3 most recent) +2. **AWS Secret Versions**: Old AWS Secrets Manager versions (keeps 3 most recent + protected versions) +3. **Protected Versions**: AWSCURRENT and AWSPENDING are never deleted + +## **AWS Secrets Manager Rotation Protocol** + +### **Rotation Steps** + +The system follows the standard AWS Secrets Manager rotation protocol: + +```python +# Event structure from AWS Secrets Manager: +{ + "SecretId": "arn:aws:secretsmanager:...", # Secret ARN + "Step": "createSecret", # Rotation step + "Token": "AWSPENDING" # Version stage +} + +# Rotation steps: +# 1. createSecret - Creates new key version with AWSPENDING label +# 2. setSecret - Sets the new secret (no-op for our use case) +# 3. testSecret - Validates the new key version +# 4. finishSecret - Moves AWSPENDING to AWSCURRENT and cleans up +``` + +### **Simplified Functions** + +All rotation functions now use the AWS Secrets Manager protocol: + +```python +# Single function for each key type +def rotate_state_encryption_key(secret_arn: str, token: str = "AWSPENDING", keep_versions: int = 3) +def rotate_token_encryption_key(secret_arn: str, token: str = "AWSPENDING", keep_versions: int = 3) +``` + +## **Configuration** + +### **CDK Setup** + +```typescript +const authSecrets = new AuthSecretsConstruct(this, 'AuthSecrets', { + enableStateKeyRotation: true, + enableTokenKeyRotation: true, + stateKeyRotationDays: 90, + tokenKeyRotationDays: 90, +}); +``` + +### **Retention Policy** + +```python +# Keep 3 versions (default) +rotate_token_encryption_key(secret_arn, keep_versions=3) + +# Keep 5 versions for longer grace period +rotate_token_encryption_key(secret_arn, keep_versions=5) +``` + +## **Key Benefits** + +### **1. Simplified Architecture** + +- No legacy format handling +- Single code path for all operations +- Cleaner, more maintainable code + +### **2. Zero Downtime Rotation** + +- Old tokens remain valid during rotation +- New tokens use new keys immediately +- Gradual transition as old tokens expire + +### **3. Automatic Cleanup** + +- No manual intervention required +- Prevents accumulation of old versions +- Configurable retention policy + +### **4. AWS Native Integration** + +- Uses proper Secrets Manager rotation protocol +- Built-in retry logic and monitoring +- Integration with CloudWatch + +## **Monitoring & Observability** + +```python +# Get status of key versions +status = get_key_status(secret_arn) +print(f"Current version: {status.current_version}") +print(f"Available versions: {status.available_versions}") +print(f"Last rotation: {status.last_rotation}") +``` + +## **Best Practices** + +1. **Monitor Rotation**: Set up CloudWatch alarms for rotation failures +2. **Test Rotation**: Test rotation in non-production environments first +3. **Gradual Rollout**: Enable rotation on less critical environments first +4. **Version Monitoring**: Monitor the number of active versions +5. **Retention Policy**: Choose appropriate `keep_versions` based on your token TTL + +This simplified versioned system ensures seamless key rotation with automatic cleanup while maintaining a clean, maintainable codebase focused solely on the versioned format. \ No newline at end of file diff --git a/flake.lock b/flake.lock index 5aec80c8..67c4096a 100644 --- a/flake.lock +++ b/flake.lock @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1761468971, - "narHash": "sha256-vY2OLVg5ZTobdroQKQQSipSIkHlxOTrIF1fsMzPh8w8=", + "lastModified": 1767051569, + "narHash": "sha256-0MnuWoN+n1UYaGBIpqpPs9I9ZHW4kynits4mrnh1Pk4=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "78e34d1667d32d8a0ffc3eba4591ff256e80576e", + "rev": "40ee5e1944bebdd128f9fbada44faefddfde29bd", "type": "github" }, "original": { diff --git a/frontend/docs/.vitepress/config.mts b/frontend/docs/.vitepress/config.mts index 09279142..fcae5cde 100644 --- a/frontend/docs/.vitepress/config.mts +++ b/frontend/docs/.vitepress/config.mts @@ -9,6 +9,8 @@ const docItems = [ { text: 'Install Guide', link: '/admin-guide/install' }, { text: 'Getting Started', link: '/admin-guide/getting-started' }, { text: 'Setting Initial Admin', link: '/admin-guide/initial-admin' }, + { text: 'Enhanced Authentication Configuration', link: '/admin-guide/bff-authentication' }, + { text: 'Enhanced Authentication Migration', link: '/admin-guide/bff-authentication-migration' }, { text: `Configure AWS Cognito for ${APPLICATION_NAME}`, link: '/admin-guide/configure-cognito' }, { text: `Create a Ground Truth Workforce using Keycloak`, link: '/admin-guide/gt-workforce-keycloak' }, { text: `Security`, link: '/admin-guide/security/intro', items: [ @@ -22,6 +24,8 @@ const docItems = [ { text: 'Advanced Configuration', items: [ + { text: 'AUTH_* Configuration Reference', link: '/admin-guide/auth-configuration-reference' }, + { text: 'Custom Domain Configuration', link: '/admin-guide/custom-domain' }, { text: `Enabling Access To S3 Buckets In ${APPLICATION_NAME}`, link: '/admin-guide/manual-s3-permissions' }, { text: `Custom Algorithm Containers In ${APPLICATION_NAME}`, link: '/admin-guide/byom-permissions' }, { text: 'Branding', link: '/admin-guide/branding' }, diff --git a/frontend/docs/admin-guide/auth-configuration-reference.md b/frontend/docs/admin-guide/auth-configuration-reference.md new file mode 100644 index 00000000..95e8e2a9 --- /dev/null +++ b/frontend/docs/admin-guide/auth-configuration-reference.md @@ -0,0 +1,396 @@ +--- +outline: deep +--- + +# AUTH_* Configuration Reference + +## Quick Reference + +This page provides a quick reference for all AUTH_* configuration parameters used in the enhanced authentication system. + +::: danger OIDC_* PARAMETERS NOT SUPPORTED +The legacy `OIDC_*` configuration parameters (such as `OIDC_URL`, `OIDC_CLIENT_NAME`, `OIDC_VERIFY_SSL`, etc.) are **deprecated and no longer supported**. You must use the `AUTH_*` parameters documented on this page. See the [Migration Mapping](#migration-mapping) section below for the complete mapping from legacy to new parameters. +::: + +## Required Parameters + +### AUTH_IDP_TYPE +- **Type**: String +- **Required**: Yes +- **Default**: None +- **Valid Values**: `"oidc"` (SAML support planned) +- **Description**: Specifies the Identity Provider type +- **Example**: `"oidc"` + +### AUTH_OIDC_URL +- **Type**: String +- **Required**: Yes (when `AUTH_IDP_TYPE` is `"oidc"`) +- **Default**: None +- **Description**: OIDC issuer URL for authentication +- **Example**: `"https://auth.example.com"` +- **Notes**: Replaces legacy `OIDC_URL` parameter + +### AUTH_OIDC_CLIENT_ID +- **Type**: String +- **Required**: Yes (when `AUTH_IDP_TYPE` is `"oidc"`) +- **Default**: None +- **Description**: OIDC client identifier +- **Example**: `"mlspace-client"` +- **Notes**: Replaces legacy `OIDC_CLIENT_NAME` parameter + +## Optional Parameters + +### AUTH_SESSION_TTL_HOURS +- **Type**: Number +- **Required**: No +- **Default**: `24` +- **Range**: `1` to `168` (1 week) +- **Description**: Session duration in hours +- **Example**: `8` (for 8-hour sessions) +- **Notes**: Affects both session cookies and DynamoDB TTL + +### AUTH_SYNC_DOMAINS +- **Type**: String (comma-separated) +- **Required**: No +- **Default**: None +- **Description**: Additional domains for cross-domain cookie sync +- **Example**: `"notebooks.mlspace.com,admin.mlspace.com"` +- **Notes**: Enables seamless authentication across multiple domains. The primary domain is automatically detected from the Host header. + +### AUTH_OIDC_CLIENT_SECRET_NAME +- **Type**: String +- **Required**: No +- **Default**: `"mlspace/auth/oidc-client-secret"` +- **Description**: AWS Secrets Manager secret name for OIDC client secret +- **Example**: `"mlspace/auth/oidc-client-secret"` +- **Notes**: Used for confidential OIDC client flow; secret is stored in Secrets Manager + +### AUTH_OIDC_CLIENT_SECRET_VALUE +- **Type**: String +- **Required**: No +- **Default**: None +- **Description**: Optional OIDC client secret value for deployment-time configuration +- **Example**: `"your-client-secret-here"` +- **Notes**: If provided in config.json, the secret will be created/updated during deployment + +### AUTH_OIDC_USE_PKCE +- **Type**: Boolean +- **Required**: No +- **Default**: `true` +- **Description**: Whether to use PKCE (Proof Key for Code Exchange) flow +- **Example**: `true` +- **Notes**: Recommended to keep enabled even when using client_secret for enhanced security + +### AUTH_OIDC_VERIFY_SSL +- **Type**: Boolean +- **Required**: No +- **Default**: `true` +- **Description**: Whether to verify SSL certificates for OIDC requests +- **Example**: `true` +- **Notes**: Should only be set to false for development/testing with self-signed certificates + +### AUTH_OIDC_VERIFY_SIGNATURE +- **Type**: Boolean +- **Required**: No +- **Default**: `true` +- **Description**: Whether to verify OIDC token signatures +- **Example**: `true` +- **Notes**: Should always be true in production for security + +### AUTH_SESSION_TABLE_NAME +- **Type**: String +- **Required**: No +- **Default**: `"mlspace-auth-sessions"` +- **Description**: DynamoDB table name for storing authentication sessions +- **Example**: `"mlspace-auth-sessions"` +- **Notes**: Automatically created during deployment + +### AUTH_TOKEN_ENCRYPTION_KEY_SECRET_NAME +- **Type**: String +- **Required**: No +- **Default**: `"mlspace/auth/token-encryption-keys"` +- **Description**: AWS Secrets Manager secret name for versioned token encryption keys +- **Example**: `"mlspace/auth/token-encryption-keys"` +- **Notes**: Supports key rotation; automatically created during deployment + +### AUTH_STATE_ENCRYPTION_KEY_SECRET_NAME +- **Type**: String +- **Required**: No +- **Default**: `"mlspace/auth/state-encryption-key"` +- **Description**: AWS Secrets Manager secret name for state encryption key +- **Example**: `"mlspace/auth/state-encryption-key"` +- **Notes**: Used for encrypting OAuth state parameter; automatically created during deployment + +## Secrets Manager Configuration + +### mlspace/auth/oidc-client-secret +- **Type**: SecureString +- **Required**: No (only for confidential OIDC clients) +- **Description**: OIDC client secret for confidential client flow +- **Creation**: + ```bash + aws ssm put-parameter \ + --name "mlspace/auth/oidc-client-secret" \ + --value "your-client-secret" \ + --type "SecureString" + ``` + +### mlspace/auth/encryption-key +- **Type**: SecureString +- **Required**: No (auto-generated if not provided) +- **Description**: AES-256 key for token encryption +- **Notes**: Automatically generated during deployment if not specified + +## Environment-Specific Examples + +### Development Environment (Minimal) +```json +{ + "AUTH_IDP_TYPE": "oidc", + "AUTH_OIDC_URL": "https://auth.dev.example.com", + "AUTH_OIDC_CLIENT_ID": "mlspace-dev-client", + "AUTH_SESSION_TTL_HOURS": 8 +} +``` + +### Development Environment (With Client Secret) +```json +{ + "AUTH_IDP_TYPE": "oidc", + "AUTH_OIDC_URL": "https://auth.dev.example.com", + "AUTH_OIDC_CLIENT_ID": "mlspace-dev-client", + "AUTH_OIDC_CLIENT_SECRET_VALUE": "dev-client-secret-here", + "AUTH_SESSION_TTL_HOURS": 8, + "AUTH_OIDC_USE_PKCE": true, + "AUTH_OIDC_VERIFY_SSL": true, + "AUTH_OIDC_VERIFY_SIGNATURE": true +} +``` + +### Production Environment +```json +{ + "AUTH_IDP_TYPE": "oidc", + "AUTH_OIDC_URL": "https://auth.example.com", + "AUTH_OIDC_CLIENT_ID": "mlspace-prod-client", + "AUTH_OIDC_CLIENT_SECRET_VALUE": "prod-client-secret-here", + "AUTH_SESSION_TTL_HOURS": 24, + "AUTH_SYNC_DOMAINS": "notebooks.mlspace.com,admin.mlspace.com", + "AUTH_OIDC_USE_PKCE": true, + "AUTH_OIDC_VERIFY_SSL": true, + "AUTH_OIDC_VERIFY_SIGNATURE": true +} +``` + +### Multi-Domain Production Environment +```json +{ + "AUTH_IDP_TYPE": "oidc", + "AUTH_OIDC_URL": "https://sso.company.com", + "AUTH_OIDC_CLIENT_ID": "mlspace-enterprise", + "AUTH_OIDC_CLIENT_SECRET_VALUE": "enterprise-client-secret-here", + "AUTH_SESSION_TTL_HOURS": 12, + "AUTH_SYNC_DOMAINS": "mlspace-notebooks.company.com,mlspace-admin.company.com", + "AUTH_OIDC_USE_PKCE": true, + "AUTH_OIDC_VERIFY_SSL": true, + "AUTH_OIDC_VERIFY_SIGNATURE": true +} +``` + +## Configuration Validation + +### Required Validation Rules + +1. **AUTH_IDP_TYPE**: Must be `"oidc"` (case-sensitive) +2. **AUTH_OIDC_URL**: Must be valid HTTPS URL when `AUTH_IDP_TYPE` is `"oidc"` +3. **AUTH_OIDC_CLIENT_ID**: Must be non-empty string when `AUTH_IDP_TYPE` is `"oidc"` +4. **AUTH_SESSION_TTL_HOURS**: Must be positive integer between 1 and 168 + +### Optional Validation Rules + +1. **AUTH_SYNC_DOMAINS**: Must be comma-separated list of valid domain names if specified +2. **AUTH_OIDC_USE_PKCE**: Must be boolean (true/false) +4. **AUTH_OIDC_VERIFY_SSL**: Must be boolean (true/false); should be true in production +5. **AUTH_OIDC_VERIFY_SIGNATURE**: Must be boolean (true/false); should be true in production +6. **AUTH_OIDC_CLIENT_SECRET_NAME**: Must be valid Secrets Manager secret name if specified +7. **AUTH_TOKEN_ENCRYPTION_KEY_SECRET_NAME**: Must be valid Secrets Manager secret name if specified +8. **AUTH_STATE_ENCRYPTION_KEY_SECRET_NAME**: Must be valid Secrets Manager secret name if specified + +### Validation Script + +```bash +#!/bin/bash +# validate-auth-config.sh + +CONFIG_FILE="lib/config.json" +ENV=${1:-"dev"} + +# Extract configuration for environment +AUTH_IDP_TYPE=$(jq -r ".${ENV}.AUTH_IDP_TYPE // empty" "$CONFIG_FILE") +AUTH_OIDC_URL=$(jq -r ".${ENV}.AUTH_OIDC_URL // empty" "$CONFIG_FILE") +AUTH_OIDC_CLIENT_ID=$(jq -r ".${ENV}.AUTH_OIDC_CLIENT_ID // empty" "$CONFIG_FILE") +AUTH_SESSION_TTL_HOURS=$(jq -r ".${ENV}.AUTH_SESSION_TTL_HOURS // 24" "$CONFIG_FILE") + +# Validate required parameters +if [ "$AUTH_IDP_TYPE" != "oidc" ]; then + echo "ERROR: AUTH_IDP_TYPE must be 'oidc'" + exit 1 +fi + +if [ -z "$AUTH_OIDC_URL" ]; then + echo "ERROR: AUTH_OIDC_URL is required when AUTH_IDP_TYPE is 'oidc'" + exit 1 +fi + +if [ -z "$AUTH_OIDC_CLIENT_ID" ]; then + echo "ERROR: AUTH_OIDC_CLIENT_ID is required when AUTH_IDP_TYPE is 'oidc'" + exit 1 +fi + +if [ "$AUTH_SESSION_TTL_HOURS" -lt 1 ] || [ "$AUTH_SESSION_TTL_HOURS" -gt 168 ]; then + echo "ERROR: AUTH_SESSION_TTL_HOURS must be between 1 and 168" + exit 1 +fi + +echo "✅ Configuration validation passed for environment: $ENV" +``` + +## Migration Mapping + +::: danger LEGACY PARAMETERS NOT SUPPORTED +All `OIDC_*` parameters listed below are **deprecated and no longer supported**. You must migrate to the corresponding `AUTH_*` parameters. Attempting to use legacy parameters will result in configuration errors. +::: + +### Legacy to New Parameter Mapping + +| Legacy Parameter | New Parameter | Migration Notes | +|------------------|---------------|-----------------| +| `OIDC_URL` | `AUTH_OIDC_URL` | Direct replacement - use the same OIDC issuer URL | +| `OIDC_CLIENT_NAME` | `AUTH_OIDC_CLIENT_ID` | Direct replacement - use the same client identifier | +| `OIDC_REDIRECT_URL` | _(automatic)_ | No longer needed - redirect is automatically `/auth/callback` | +| `OIDC_VERIFY_SSL` | `AUTH_OIDC_VERIFY_SSL` | Now configurable (default: true); should be true in production | +| `OIDC_VERIFY_SIGNATURE` | `AUTH_OIDC_VERIFY_SIGNATURE` | Now configurable (default: true); should be true in production | +| `IDP_ENDPOINT_SSM_PARAM` | _(removed)_ | No longer needed - use `AUTH_OIDC_URL` directly | +| `INTERNAL_OIDC_URL` | _(removed)_ | No longer needed with server-side authentication | +| _(none)_ | `AUTH_OIDC_CLIENT_SECRET_NAME` | **New** - Secrets Manager name for client secret | +| _(none)_ | `AUTH_OIDC_CLIENT_SECRET_VALUE` | **New** - Optional deployment-time secret value | +| _(none)_ | `AUTH_OIDC_USE_PKCE` | **New** - Enable PKCE flow (default: true) | +| _(none)_ | `AUTH_SESSION_TTL_HOURS` | **New** - Session duration configuration | +| _(none)_ | `AUTH_SYNC_DOMAINS` | **New** - Multi-domain cookie sync | +| _(none)_ | `AUTH_SESSION_TABLE_NAME` | **New** - DynamoDB session table name | +| _(none)_ | `AUTH_TOKEN_ENCRYPTION_KEY_SECRET_NAME` | **New** - Token encryption keys (rotatable) | +| _(none)_ | `AUTH_STATE_ENCRYPTION_KEY_SECRET_NAME` | **New** - State encryption key | + +### Configuration File Migration + +**Before (legacy):** +```typescript +// lib/constants.ts +export const OIDC_URL = 'https://auth.example.com'; +export const OIDC_CLIENT_NAME = 'mlspace-client'; +export const OIDC_VERIFY_SSL = true; +export const OIDC_VERIFY_SIGNATURE = true; +``` + +**After (Enhanced Authentication):** +```typescript +// lib/constants.ts +export const AUTH_IDP_TYPE = 'oidc'; +export const AUTH_OIDC_URL = 'https://auth.example.com'; +export const AUTH_OIDC_CLIENT_ID = 'mlspace-client'; +export const AUTH_SESSION_TTL_HOURS = 24; +``` + +## Troubleshooting Configuration Issues + +### Common Configuration Errors + +#### Error: "Invalid AUTH_IDP_TYPE" +``` +Cause: AUTH_IDP_TYPE is not set to "oidc" +Solution: Set AUTH_IDP_TYPE to "oidc" (case-sensitive) +``` + +#### Error: "OIDC URL not configured" +``` +Cause: AUTH_OIDC_URL is missing or empty +Solution: Set AUTH_OIDC_URL to your OIDC issuer URL +``` + +#### Error: "OIDC Client ID not configured" +``` +Cause: AUTH_OIDC_CLIENT_ID is missing or empty +Solution: Set AUTH_OIDC_CLIENT_ID to your OIDC client identifier +``` + +#### Error: "Invalid session TTL" +``` +Cause: AUTH_SESSION_TTL_HOURS is outside valid range (1-168) +Solution: Set AUTH_SESSION_TTL_HOURS to a value between 1 and 168 +``` + +### Configuration Testing + +#### Test OIDC Connectivity +```bash +# Test OIDC discovery endpoint +curl -s "https://your-oidc-url/.well-known/openid-configuration" | jq . + +# Verify required endpoints are available +curl -s "https://your-oidc-url/.well-known/openid-configuration" | \ + jq -r '.authorization_endpoint, .token_endpoint, .userinfo_endpoint' +``` + +#### Test Secrets Manager Access +```bash +# Verify client secret exists +aws secretsmanager describe-secret \ + --secret-id "mlspace/auth/oidc-client-secret" + +# Test secret access (requires appropriate IAM permissions) +aws secretsmanager get-secret-value \ + --secret-id "mlspace/auth/oidc-client-secret" \ + --query 'SecretString' \ + --output text + +# Verify token encryption keys secret +aws secretsmanager describe-secret \ + --secret-id "mlspace/auth/token-encryption-keys" + +# Verify state encryption key secret +aws secretsmanager describe-secret \ + --secret-id "mlspace/auth/state-encryption-key" +``` + +## Security Considerations + +### Parameter Security + +1. **Client Secrets**: Always store in Secrets Manager (not SSM Parameter Store) +2. **URLs**: Use HTTPS for all AUTH_OIDC_URL values +3. **Domains**: Ensure AUTH_SYNC_DOMAINS use HTTPS +4. **TTL**: Set appropriate AUTH_SESSION_TTL_HOURS based on security requirements +5. **SSL Verification**: Keep AUTH_OIDC_VERIFY_SSL=true in production +6. **Signature Verification**: Keep AUTH_OIDC_VERIFY_SIGNATURE=true in production +7. **PKCE**: Keep AUTH_OIDC_USE_PKCE=true for enhanced security + +### Access Control + +1. **Secrets Manager Permissions**: Limit secret access to MLSpace Lambda execution role only +2. **KMS Keys**: Use appropriate KMS keys for Secrets Manager encryption +3. **Domain Validation**: Ensure sync domains are under your control +4. **Secret Rotation**: Use versioned secrets (token encryption keys) for rotation support + +### Monitoring + +1. **Configuration Changes**: Monitor changes to AUTH_* parameters in constants.ts and config.json +2. **Secrets Access**: Monitor access to `mlspace/auth/*` secrets in CloudTrail +3. **Failed Authentication**: Monitor authentication failures for configuration issues +4. **Secret Rotation**: Monitor secret rotation events and ensure smooth transitions + +## Related Documentation + +- [Enhanced Authentication Configuration Guide](./bff-authentication.md) +- [Enhanced Authentication Migration Guide](./bff-authentication-migration.md) +- [Install Guide](./install.md) +- [Security Documentation](./security/intro.md) \ No newline at end of file diff --git a/frontend/docs/admin-guide/bff-authentication-migration.md b/frontend/docs/admin-guide/bff-authentication-migration.md new file mode 100644 index 00000000..a90dc65e --- /dev/null +++ b/frontend/docs/admin-guide/bff-authentication-migration.md @@ -0,0 +1,607 @@ +--- +outline: deep +--- + +# Enhanced Authentication Migration Guide + +## Overview + +This guide provides detailed step-by-step instructions for migrating from the legacy OIDC authentication system to the enhanced authentication system. The new system provides improved security, better enterprise IdP support, and simplified application code. + +## Migration Benefits + +- **Enhanced Security**: Session-based authentication with HttpOnly cookies prevents token exposure in browser +- **Enterprise IdP Support**: Support for OIDC with client secrets and SAML protocol +- **Simplified Application**: No token management in browser JavaScript +- **Cross-Domain Support**: Seamless authentication across multiple domains +- **Automatic Token Refresh**: Server-side token management and automatic refresh + +## Pre-Migration Assessment + +### Current Configuration Review + +Before starting the migration, document your current configuration: + +1. **Current OIDC Settings:** + ```bash + # Review current constants + grep -E "OIDC_|IDP_" lib/constants.ts + + # Review current config + cat lib/config.json | jq '.OIDC_URL, .OIDC_CLIENT_NAME' + ``` + +2. **OIDC Provider Configuration:** + - Current redirect URI + - Client type (public vs confidential) + - Available client secret (if confidential client) + - Supported authentication flows + +3. **Current Deployment:** + - Environment (dev/staging/prod) + - Custom domains in use + - Multi-domain setup requirements + +### Compatibility Check + +Verify your OIDC provider supports the enhanced authentication requirements: + +- ✅ Authorization Code flow +- ✅ Client secret support (for confidential clients) +- ✅ Token refresh capability +- ✅ Configurable redirect URIs + +## Migration Planning + +### Maintenance Window + +Plan for a maintenance window during migration: + +- **Estimated Downtime**: 15-30 minutes +- **User Impact**: All users will need to re-authenticate +- **Rollback Time**: 10-15 minutes if needed + +### Backup Strategy + +Create backups before migration: + +```bash +# Backup configuration files +cp lib/constants.ts lib/constants.ts.backup +cp lib/config.json lib/config.json.backup +cp lib/utils/configTypes.ts lib/utils/configTypes.ts.backup + +# Backup current deployment +git tag pre-bff-migration +git push origin pre-bff-migration +``` + +## Step-by-Step Migration + +### Step 1: Update Configuration Files + +#### 1.1 Update lib/constants.ts + +```typescript +// REMOVE these legacy constants: +export const OIDC_URL = 'https://auth.example.com'; +export const OIDC_CLIENT_NAME = 'mlspace-client'; +export const OIDC_REDIRECT_URL = undefined; +export const OIDC_VERIFY_SSL = true; +export const OIDC_VERIFY_SIGNATURE = true; + +// ADD these new AUTH constants: +export const AUTH_IDP_TYPE = 'oidc'; +export const AUTH_OIDC_URL = 'https://auth.example.com'; +export const AUTH_OIDC_CLIENT_ID = 'mlspace-client'; +export const AUTH_SESSION_TTL_HOURS = 24; +export const AUTH_SYNC_DOMAINS = ''; +``` + +#### 1.2 Update lib/config.json + +```json +{ + "dev": { + // REMOVE legacy OIDC config: + // "OIDC_URL": "https://auth.dev.example.com", + // "OIDC_CLIENT_NAME": "mlspace-dev-client", + + // ADD new AUTH config: + "AUTH_IDP_TYPE": "oidc", + "AUTH_OIDC_URL": "https://auth.dev.example.com", + "AUTH_OIDC_CLIENT_ID": "mlspace-dev-client", + "AUTH_SESSION_TTL_HOURS": 8 + }, + "prod": { + // REMOVE legacy OIDC config: + // "OIDC_URL": "https://auth.example.com", + // "OIDC_CLIENT_NAME": "mlspace-prod-client", + + // ADD new AUTH config: + "AUTH_IDP_TYPE": "oidc", + "AUTH_OIDC_URL": "https://auth.example.com", + "AUTH_OIDC_CLIENT_ID": "mlspace-prod-client", + "AUTH_SESSION_TTL_HOURS": 24, + "AUTH_SYNC_DOMAINS": "notebooks.mlspace.com" + } +} +``` + +#### 1.3 Update lib/utils/configTypes.ts + +```typescript +export interface MLSpaceConfig { + // ... existing properties ... + + // REMOVE legacy OIDC properties: + // OIDC_URL?: string; + // OIDC_CLIENT_NAME?: string; + // OIDC_REDIRECT_URL?: string; + // OIDC_VERIFY_SSL?: boolean; + // OIDC_VERIFY_SIGNATURE?: boolean; + + // ADD new AUTH properties: + AUTH_IDP_TYPE: string; + AUTH_OIDC_URL?: string; + AUTH_OIDC_CLIENT_ID?: string; + AUTH_SESSION_TTL_HOURS: number; + AUTH_SYNC_DOMAINS?: string; +} +``` + +### Step 2: Configure Client Secret (If Required) + +If your OIDC provider requires a client secret: + +#### 2.1 Store Client Secret in SSM + +```bash +# For development environment +aws ssm put-parameter \ + --name "mlspace/auth/oidc-client-secret" \ + --value "your-dev-client-secret" \ + --type "SecureString" \ + --description "OIDC client secret for MLSpace development" + +# For production environment +aws ssm put-parameter \ + --name "mlspace/auth/oidc-client-secret" \ + --value "your-prod-client-secret" \ + --type "SecureString" \ + --description "OIDC client secret for MLSpace production" +``` + +#### 2.2 Verify SSM Parameter + +```bash +# Verify parameter exists +aws ssm describe-parameters \ + --parameter-filters "Key=Name,Values=mlspace/auth/oidc-client-secret" + +# Test parameter access (will show encrypted value) +aws ssm get-parameter \ + --name "mlspace/auth/oidc-client-secret" \ + --with-decryption +``` + +### Step 3: Update OIDC Provider Configuration + +Update your OIDC provider's redirect URI configuration: + +#### 3.1 Current Redirect URI +``` +https://your-api-gateway.execute-api.region.amazonaws.com/Prod/ +``` + +#### 3.2 New Redirect URI +``` +https://your-api-gateway.execute-api.region.amazonaws.com/Prod/auth/callback +``` + +#### 3.3 Provider-Specific Instructions + +**AWS Cognito:** +1. Go to AWS Cognito Console +2. Select your User Pool +3. Go to "App integration" → "App clients" +4. Edit your MLSpace app client +5. Update "Allowed callback URLs" to include `/auth/callback` + +**Azure AD:** +1. Go to Azure Portal → Azure Active Directory +2. Select "App registrations" → Your MLSpace app +3. Go to "Authentication" +4. Update redirect URI to include `/auth/callback` + +**Keycloak:** +1. Go to Keycloak Admin Console +2. Select your realm → Clients → Your MLSpace client +3. Update "Valid Redirect URIs" to include `/auth/callback` + +### Step 4: Build and Deploy + +#### 4.1 Build Frontend + +```bash +cd frontend/ +npm run clean +npm install +npm run build +``` + +#### 4.2 Deploy CDK Changes + +```bash +cd ../ +npm install +cdk deploy --all --require-approval never +``` + +#### 4.3 Monitor Deployment + +```bash +# Monitor CloudFormation stack +aws cloudformation describe-stacks \ + --stack-name MLSpaceStack \ + --query 'Stacks[0].StackStatus' + +# Check Lambda function updates +aws lambda list-functions \ + --query 'Functions[?contains(FunctionName, `mls-lambda-auth`)].FunctionName' +``` + +### Step 5: Verification and Testing + +#### 5.1 Basic Authentication Test + +1. **Clear Browser Data:** + - Clear cookies for your MLSpace domain + - Clear localStorage and sessionStorage + +2. **Test Authentication Flow:** + ```bash + # Visit your MLSpace application + curl -I https://your-mlspace-domain.com/ + + # Should redirect to /auth/login + # Follow redirect chain to OIDC provider + # Complete authentication + # Verify redirect back to /auth/callback + # Verify final redirect to application + ``` + +3. **Verify Session Cookies:** + - Open browser developer tools + - Check Application → Cookies + - Verify `mlspace_session` cookie exists + - Verify cookie has `HttpOnly` and `Secure` flags + +#### 5.2 API Authentication Test + +```bash +# Test API call with session cookie +curl -b "mlspace_session=your-session-id" \ + https://your-api-gateway.execute-api.region.amazonaws.com/Prod/user/current + +# Should return user information without Authorization header +``` + +#### 5.3 Cross-Domain Test (If Configured) + +If you configured `AUTH_SYNC_DOMAINS`: + +1. Authenticate on primary domain +2. Visit each sync domain +3. Verify automatic authentication without re-login +4. Check that each domain has its own session cookie + +#### 5.4 Token Refresh Test + +```bash +# Wait for token refresh threshold (default: 5 minutes before expiry) +# Make API call to trigger refresh +curl -b "mlspace_session=your-session-id" \ + https://your-api-gateway.execute-api.region.amazonaws.com/Prod/auth/identity + +# Verify response includes "refreshed": true +``` + +### Step 6: Post-Migration Cleanup + +#### 6.1 Remove Legacy Code References + +Search for and remove any remaining legacy OIDC references: + +```bash +# Search for legacy OIDC usage +grep -r "OIDC_URL\|OIDC_CLIENT_NAME" --exclude-dir=node_modules . +grep -r "oidc.user:" frontend/src/ +grep -r "sessionStorage.*oidc" frontend/src/ +``` + +#### 6.2 Update Documentation + +Update any internal documentation that references: +- Legacy OIDC configuration +- Frontend token management +- Authentication troubleshooting procedures + +#### 6.3 Monitor CloudWatch Logs + +Monitor authentication-related log groups: + +```bash +# Check authentication logs +aws logs filter-log-events \ + --log-group-name "/aws/lambda/mls-lambda-auth-login" \ + --start-time $(date -d "1 hour ago" +%s)000 + +aws logs filter-log-events \ + --log-group-name "/aws/lambda/mls-lambda-auth-callback" \ + --start-time $(date -d "1 hour ago" +%s)000 +``` + +## Rollback Procedure + +If issues occur during migration, follow this rollback procedure: + +### Step 1: Revert Configuration Files + +```bash +# Restore backup files +cp lib/constants.ts.backup lib/constants.ts +cp lib/config.json.backup lib/config.json +cp lib/utils/configTypes.ts.backup lib/utils/configTypes.ts +``` + +### Step 2: Revert OIDC Provider Configuration + +Restore the original redirect URI in your OIDC provider: +``` +https://your-api-gateway.execute-api.region.amazonaws.com/Prod/ +``` + +### Step 3: Rebuild and Redeploy + +```bash +# Rebuild frontend with legacy configuration +cd frontend/ +npm run clean && npm run build + +# Redeploy CDK +cd ../ +cdk deploy --all --require-approval never +``` + +### Step 4: Verify Rollback + +Test the legacy authentication flow to ensure it's working correctly. + +## Troubleshooting Common Issues + +### Issue: Authentication Redirect Loop + +**Symptoms:** User gets stuck redirecting between IdP and MLSpace + +**Diagnosis:** +```bash +# Check redirect URI configuration +curl -I https://your-mlspace-domain.com/auth/login +# Should redirect to OIDC provider with correct callback URI +``` + +**Solution:** +1. Verify OIDC provider redirect URI includes `/auth/callback` +2. Check `AUTH_OIDC_CLIENT_ID` matches IdP configuration +3. Verify IdP is accessible from Lambda functions + +### Issue: Session Cookies Not Set + +**Symptoms:** Authentication succeeds but API calls return 401 + +**Diagnosis:** +```bash +# Check session creation in logs +aws logs filter-log-events \ + --log-group-name "/aws/lambda/mls-lambda-auth-callback" \ + --filter-pattern "Session created" +``` + +**Solution:** +1. Check cookie domain settings in browser +2. Ensure HTTPS is used (cookies won't set over HTTP) + +### Issue: Client Secret Authentication Fails + +**Symptoms:** "invalid_client" error during token exchange + +**Diagnosis:** +```bash +# Check SSM parameter access +aws ssm get-parameter \ + --name "mlspace/auth/oidc-client-secret" \ + --with-decryption + +# Check Lambda execution role permissions +aws iam simulate-principal-policy \ + --policy-source-arn "arn:aws:iam::ACCOUNT:role/MLSpaceAppRole" \ + --action-names "ssm:GetParameter" \ + --resource-arns "arn:aws:ssm:REGION:ACCOUNT:parameter/mlspace/auth/oidc-client-secret" +``` + +**Solution:** +1. Verify SSM parameter exists and has correct value +2. Check Lambda execution role has SSM read permissions +3. Verify KMS key permissions if using custom encryption + +### Issue: Cross-Domain Sync Failures + +**Symptoms:** Authentication works on primary domain but fails on sync domains + +**Diagnosis:** +```bash +# Check OTAC generation and validation +aws logs filter-log-events \ + --log-group-name "/aws/lambda/mls-lambda-auth-sync" \ + --filter-pattern "OTAC" +``` + +**Solution:** +1. Verify `AUTH_SYNC_DOMAINS` configuration +2. Check DNS resolution for all sync domains +3. Ensure all domains point to the same API Gateway +4. Verify OTAC TTL and usage patterns + +## Performance Optimization + +### Session Table Scaling + +Monitor DynamoDB session table performance: + +```bash +# Check table metrics +aws cloudwatch get-metric-statistics \ + --namespace "AWS/DynamoDB" \ + --metric-name "ConsumedReadCapacityUnits" \ + --dimensions Name=TableName,Value=mlspace-auth-sessions \ + --start-time $(date -d "1 hour ago" --iso-8601) \ + --end-time $(date --iso-8601) \ + --period 300 \ + --statistics Sum +``` + +### Lambda Cold Start Optimization + +Monitor authentication endpoint performance: + +```bash +# Check Lambda duration metrics +aws cloudwatch get-metric-statistics \ + --namespace "AWS/Lambda" \ + --metric-name "Duration" \ + --dimensions Name=FunctionName,Value=mls-lambda-auth-login \ + --start-time $(date -d "1 hour ago" --iso-8601) \ + --end-time $(date --iso-8601) \ + --period 300 \ + --statistics Average,Maximum +``` + +## Security Validation + +### Session Security Audit + +Verify session security configuration: + +```bash +# Check session cookie attributes +curl -I https://your-mlspace-domain.com/auth/callback +# Look for: HttpOnly; Secure; SameSite=Strict + +# Verify session encryption +aws dynamodb scan \ + --table-name mlspace-auth-sessions \ + --limit 1 \ + --query 'Items[0].data.session.accessToken.S' +# Should show encrypted token, not plain text +``` + +### Token Storage Audit + +Verify tokens are not exposed in browser: + +1. Open browser developer tools +2. Check Application → Local Storage (should be empty of tokens) +3. Check Application → Session Storage (should be empty of tokens) +4. Check Network → Response bodies (should not contain tokens) + +## Monitoring and Alerting + +Set up CloudWatch alarms for authentication health: + +```bash +# Authentication failure rate alarm +aws cloudwatch put-metric-alarm \ + --alarm-name "MLSpace-Auth-Failure-Rate" \ + --alarm-description "High authentication failure rate" \ + --metric-name "Errors" \ + --namespace "AWS/Lambda" \ + --statistic "Sum" \ + --period 300 \ + --threshold 10 \ + --comparison-operator "GreaterThanThreshold" \ + --dimensions Name=FunctionName,Value=mls-lambda-auth-callback + +# Session creation rate alarm +aws cloudwatch put-metric-alarm \ + --alarm-name "MLSpace-Session-Creation-Rate" \ + --alarm-description "Unusual session creation pattern" \ + --metric-name "Invocations" \ + --namespace "AWS/Lambda" \ + --statistic "Sum" \ + --period 300 \ + --threshold 100 \ + --comparison-operator "GreaterThanThreshold" \ + --dimensions Name=FunctionName,Value=mls-lambda-auth-callback +``` + +## Migration Checklist + +Use this checklist to track migration progress: + +### Pre-Migration +- [ ] Document current OIDC configuration +- [ ] Verify OIDC provider compatibility +- [ ] Create configuration backups +- [ ] Plan maintenance window +- [ ] Notify users of upcoming changes + +### Configuration Updates +- [ ] Update `lib/constants.ts` +- [ ] Update `lib/config.json` +- [ ] Update `lib/utils/configTypes.ts` +- [ ] Store client secret in SSM (if required) +- [ ] Update OIDC provider redirect URI + +### Deployment +- [ ] Build frontend with new configuration +- [ ] Deploy CDK changes +- [ ] Monitor deployment progress +- [ ] Verify Lambda function updates + +### Testing +- [ ] Test basic authentication flow +- [ ] Verify session cookie creation +- [ ] Test API authentication +- [ ] Test cross-domain sync (if configured) +- [ ] Test token refresh functionality +- [ ] Test logout functionality + +### Post-Migration +- [ ] Remove legacy code references +- [ ] Update internal documentation +- [ ] Monitor CloudWatch logs +- [ ] Set up performance monitoring +- [ ] Configure security alerts +- [ ] Validate security configuration + +### Rollback (If Needed) +- [ ] Revert configuration files +- [ ] Revert OIDC provider settings +- [ ] Rebuild and redeploy +- [ ] Verify legacy functionality + +## Support and Next Steps + +After successful migration: + +1. **Monitor Performance**: Watch authentication metrics for the first week +2. **User Training**: Update user documentation if authentication flow changes +3. **Security Review**: Conduct security audit of new authentication system +4. **Optimization**: Tune session TTL and refresh thresholds based on usage patterns +5. **Future Enhancements**: Consider SAML integration or additional IdP support + +For additional support, refer to: +- [Enhanced Authentication Configuration Guide](./bff-authentication.md) +- [MLSpace Security Documentation](./security/intro.md) +- CloudWatch logs for detailed troubleshooting \ No newline at end of file diff --git a/frontend/docs/admin-guide/bff-authentication.md b/frontend/docs/admin-guide/bff-authentication.md new file mode 100644 index 00000000..1f5f2bcf --- /dev/null +++ b/frontend/docs/admin-guide/bff-authentication.md @@ -0,0 +1,540 @@ +--- +outline: deep +--- + +# Enhanced Authentication Configuration + +## Overview + +The enhanced authentication system provides improved security and enterprise Identity Provider (IdP) integration. Authentication is handled server-side, enabling support for enterprise IdPs that require client secrets, while providing better security through secure cookies (HttpOnly cookies that can't be accessed by JavaScript and are only sent to the server with requests) and simplified application code. + +::: danger LEGACY OIDC_* PARAMETERS NOT SUPPORTED +The legacy `OIDC_*` configuration parameters (such as `OIDC_URL`, `OIDC_CLIENT_NAME`, `OIDC_VERIFY_SSL`, `OIDC_REDIRECT_URL`, etc.) are **deprecated and no longer supported**. You must use the `AUTH_*` parameters documented below. See the [Migration from Legacy OIDC Configuration](#migration-from-legacy-oidc-configuration) section for migration instructions. +::: + +## Configuration Parameters + +### Required AUTH_* Parameters + +The enhanced authentication system uses `AUTH_*` configuration parameters that replace the legacy `OIDC_*` parameters: + +| Parameter | Description | Example | Required | +|-----------|-------------|---------|----------| +| `AUTH_IDP_TYPE` | Identity Provider type | `"oidc"` | Yes | +| `AUTH_OIDC_URL` | OIDC issuer URL (replaces `OIDC_URL`) | `"https://auth.example.com"` | Yes (for OIDC) | +| `AUTH_OIDC_CLIENT_ID` | OIDC client identifier (replaces `OIDC_CLIENT_NAME`) | `"mlspace-client"` | Yes (for OIDC) | +| `AUTH_SESSION_TTL_HOURS` | Session duration in hours | `24` | No (default: 24) | + +### Optional AUTH_* Parameters + +| Parameter | Description | Example | Default | +|-----------|-------------|---------|---------| +| `AUTH_SYNC_DOMAINS` | Comma-separated list of additional domains for cookie sync | `"notebooks.mlspace.com,admin.mlspace.com"` | None | +| `AUTH_OIDC_CLIENT_SECRET_NAME` | Secrets Manager secret name for OIDC client secret | `"mlspace/auth/oidc-client-secret"` | `"mlspace/auth/oidc-client-secret"` | +| `AUTH_OIDC_CLIENT_SECRET_VALUE` | Optional OIDC client secret value for deployment-time configuration | `"your-secret-here"` | None | +| `AUTH_OIDC_USE_PKCE` | Whether to use PKCE flow (recommended) | `true` | `true` | +| `AUTH_OIDC_VERIFY_SSL` | Whether to verify SSL certificates for OIDC requests | `true` | `true` | +| `AUTH_OIDC_VERIFY_SIGNATURE` | Whether to verify OIDC token signatures | `true` | `true` | +| `AUTH_SESSION_TABLE_NAME` | DynamoDB table name for authentication sessions | `"mlspace-auth-sessions"` | `"mlspace-auth-sessions"` | +| `AUTH_TOKEN_ENCRYPTION_KEY_SECRET_NAME` | Secrets Manager secret name for token encryption keys (versioned) | `"mlspace/auth/token-encryption-keys"` | `"mlspace/auth/token-encryption-keys"` | +| `AUTH_STATE_ENCRYPTION_KEY_SECRET_NAME` | Secrets Manager secret name for state encryption key | `"mlspace/auth/state-encryption-key"` | `"mlspace/auth/state-encryption-key"` | + +## Configuration Setup + +### 1. Update lib/constants.ts + +Replace the legacy OIDC constants with new AUTH constants: + +```typescript +// Remove these legacy constants: +// export const OIDC_URL = ''; +// export const OIDC_CLIENT_NAME = ''; + +// Add these new AUTH constants: +export const AUTH_IDP_TYPE = 'oidc'; +export const AUTH_OIDC_URL = ''; +export const AUTH_OIDC_CLIENT_ID = ''; +export const AUTH_OIDC_CLIENT_SECRET_NAME = 'mlspace/auth/oidc-client-secret'; +export const AUTH_OIDC_CLIENT_SECRET_VALUE = ''; // Optional: set during deployment +export const AUTH_OIDC_USE_PKCE = true; +export const AUTH_OIDC_VERIFY_SSL = true; +export const AUTH_OIDC_VERIFY_SIGNATURE = true; +export const AUTH_SESSION_TTL_HOURS = 24; +export const AUTH_SYNC_DOMAINS = ''; +export const AUTH_SESSION_TABLE_NAME = 'mlspace-auth-sessions'; +export const AUTH_TOKEN_ENCRYPTION_KEY_SECRET_NAME = 'mlspace/auth/token-encryption-keys'; +export const AUTH_STATE_ENCRYPTION_KEY_SECRET_NAME = 'mlspace/auth/state-encryption-key'; +``` + +### 2. Update lib/config.json + +Update your environment-specific configuration file: + +```json +{ + "dev": { + "AUTH_IDP_TYPE": "oidc", + "AUTH_OIDC_URL": "https://auth.dev.example.com", + "AUTH_OIDC_CLIENT_ID": "mlspace-dev-client", + "AUTH_OIDC_CLIENT_SECRET_VALUE": "dev-client-secret-here", + "AUTH_OIDC_USE_PKCE": true, + "AUTH_OIDC_VERIFY_SSL": true, + "AUTH_OIDC_VERIFY_SIGNATURE": true, + "AUTH_SESSION_TTL_HOURS": 8, + "AUTH_SYNC_DOMAINS": "" + }, + "prod": { + "AUTH_IDP_TYPE": "oidc", + "AUTH_OIDC_URL": "https://auth.example.com", + "AUTH_OIDC_CLIENT_ID": "mlspace-prod-client", + "AUTH_OIDC_CLIENT_SECRET_VALUE": "prod-client-secret-here", + "AUTH_OIDC_USE_PKCE": true, + "AUTH_OIDC_VERIFY_SSL": true, + "AUTH_OIDC_VERIFY_SIGNATURE": true, + "AUTH_SESSION_TTL_HOURS": 24, + "AUTH_SYNC_DOMAINS": "notebooks.mlspace.com,admin.mlspace.com" + } +} +``` + +### 3. Update lib/utils/configTypes.ts + +Add the new AUTH properties to the MLSpaceConfig interface: + +```typescript +export interface MLSpaceConfig { + // ... existing properties ... + + // Remove legacy OIDC properties: + // OIDC_URL?: string; + // OIDC_CLIENT_NAME?: string; + + // Add new AUTH properties: + AUTH_IDP_TYPE: string; + AUTH_OIDC_URL?: string; + AUTH_OIDC_CLIENT_ID?: string; + AUTH_OIDC_CLIENT_SECRET_NAME?: string; + AUTH_OIDC_CLIENT_SECRET_VALUE?: string; + AUTH_OIDC_USE_PKCE?: boolean; + AUTH_OIDC_VERIFY_SSL?: boolean; + AUTH_OIDC_VERIFY_SIGNATURE?: boolean; + AUTH_SESSION_TTL_HOURS?: number; + AUTH_SYNC_DOMAINS?: string; + AUTH_SESSION_TABLE_NAME?: string; + AUTH_TOKEN_ENCRYPTION_KEY_SECRET_NAME?: string; + AUTH_STATE_ENCRYPTION_KEY_SECRET_NAME?: string; +} +``` + +## Secrets Manager Setup for Client Secret + +For OIDC deployments that require client secrets (confidential client flow), you can configure the client secret in two ways: + +### Option 1: Deployment-Time Configuration (Recommended) + +Add the client secret to your `lib/config.json` file: + +```json +{ + "AUTH_OIDC_CLIENT_SECRET_VALUE": "your-client-secret-here" +} +``` + +The secret will be automatically created in AWS Secrets Manager during deployment. + +### Option 2: Manual Secrets Manager Configuration + +If you prefer to manage the secret manually: + +Using AWS CLI: + +```bash +# Create new secret +aws secretsmanager create-secret \ + --name "mlspace/auth/oidc-client-secret" \ + --secret-string '{"client_secret":"your-client-secret-here","configured":true}' \ + --description "OIDC client secret for MLSpace authentication" + +# Or update existing secret +aws secretsmanager update-secret \ + --secret-id "mlspace/auth/oidc-client-secret" \ + --secret-string '{"client_secret":"your-new-secret-here","configured":true}' +``` + +Using AWS Console: +1. Navigate to AWS Secrets Manager +2. Click "Store a new secret" +3. Select "Other type of secret" +4. Add key-value pairs: + - Key: `client_secret`, Value: Your OIDC client secret + - Key: `configured`, Value: `true` +5. Set Secret name: `mlspace/auth/oidc-client-secret` +6. Click "Store" + +::: info SECRETS MANAGER VS SSM PARAMETER STORE +MLSpace uses AWS Secrets Manager (not SSM Parameter Store) for authentication secrets. Secrets Manager provides better support for secret rotation, versioning, and automatic generation. +::: + +### Lambda Access Permissions + +The MLSpace Lambda execution role needs permission to read secrets: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "secretsmanager:GetSecretValue", + "secretsmanager:DescribeSecret" + ], + "Resource": [ + "arn:aws:secretsmanager:{AWS_REGION}:{AWS_ACCOUNT}:secret:mlspace/auth/*" + ] + } + ] +} +``` + +### Encryption Key Access + +If using a custom KMS key for Secrets Manager encryption, ensure the Lambda execution role has decrypt permissions: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "kms:Decrypt", + "kms:DescribeKey" + ], + "Resource": [ + "arn:aws:kms:{AWS_REGION}:{AWS_ACCOUNT}:key/{KMS_KEY_ID}" + ] + } + ] +} +``` + +## Multi-Domain Cookie Synchronization + +For deployments spanning multiple domains (e.g., separate domains for API, notebooks, and admin interfaces), configure cross-domain cookie synchronization. + +### Configuration + +Set the `AUTH_SYNC_DOMAINS` parameter with a comma-separated list of additional domains: + +```json +{ + "AUTH_SYNC_DOMAINS": "notebooks.mlspace.com,admin.mlspace.com" +} +``` + +::: tip Domain Detection +The system automatically uses the Host header to determine the primary domain where authentication is initiated. +::: + +### How It Works + +1. **Primary Authentication**: User authenticates on the current domain (detected from Host header) +2. **OTAC Generation**: System generates One-Time Authentication Code (OTAC) +3. **Domain Chain**: Browser is redirected through each sync domain in sequence +4. **Cookie Setting**: Each domain validates the OTAC and sets its own session cookie +5. **Final Redirect**: User is redirected to their original destination + +### Security Considerations + +- OTACs expire after 5 minutes +- OTACs are single-use only +- Each domain validates OTAC independently +- Session cookies are domain-specific with `HttpOnly` and `Secure` flags + +### Example Flow + +``` +1. User visits: https://app.mlspace.com/dashboard +2. Redirected to: https://api.mlspace.com/auth/login +3. OIDC authentication completes +4. Redirected to: https://notebooks.mlspace.com/auth/sync?otac=xyz&next=admin.mlspace.com&final=https://app.mlspace.com/dashboard +5. Redirected to: https://admin.mlspace.com/auth/sync?otac=abc&final=https://app.mlspace.com/dashboard +6. Final redirect: https://app.mlspace.com/dashboard +``` + +## Migration from Legacy OIDC Configuration + +### Pre-Migration Checklist + +Before migrating to the enhanced authentication system, ensure you have: + +- [ ] Backup of current `lib/constants.ts` and `lib/config.json` +- [ ] OIDC client secret (if using confidential client flow) +- [ ] Access to AWS Systems Manager Parameter Store +- [ ] Understanding of your current OIDC configuration +- [ ] Planned maintenance window for deployment + +### Migration Steps + +#### 1. Update Configuration Files + +**lib/constants.ts changes:** +```typescript +// BEFORE (Legacy OIDC) +export const OIDC_URL = 'https://auth.example.com'; +export const OIDC_CLIENT_NAME = 'mlspace-client'; +export const OIDC_REDIRECT_URL = undefined; +export const OIDC_VERIFY_SSL = true; +export const OIDC_VERIFY_SIGNATURE = true; + +// AFTER (Enhanced Authentication) +export const AUTH_IDP_TYPE = 'oidc'; +export const AUTH_OIDC_URL = 'https://auth.example.com'; +export const AUTH_OIDC_CLIENT_ID = 'mlspace-client'; +export const AUTH_OIDC_CLIENT_SECRET_NAME = 'mlspace/auth/oidc-client-secret'; +export const AUTH_OIDC_CLIENT_SECRET_VALUE = ''; // Optional: set in config.json +export const AUTH_OIDC_USE_PKCE = true; +export const AUTH_OIDC_VERIFY_SSL = true; +export const AUTH_OIDC_VERIFY_SIGNATURE = true; +export const AUTH_SESSION_TTL_HOURS = 24; +export const AUTH_SYNC_DOMAINS = ''; +``` + +**lib/config.json changes:** +```json +// BEFORE +{ + "OIDC_URL": "https://auth.example.com", + "OIDC_CLIENT_NAME": "mlspace-client" +} + +// AFTER +{ + "AUTH_IDP_TYPE": "oidc", + "AUTH_OIDC_URL": "https://auth.example.com", + "AUTH_OIDC_CLIENT_ID": "mlspace-client", + "AUTH_OIDC_CLIENT_SECRET_VALUE": "your-client-secret-here", + "AUTH_OIDC_USE_PKCE": true, + "AUTH_OIDC_VERIFY_SSL": true, + "AUTH_OIDC_VERIFY_SIGNATURE": true, + "AUTH_SESSION_TTL_HOURS": 24 +} +``` + +#### 2. Set Up Client Secret (If Required) + +If your OIDC provider requires a client secret, add it to your `lib/config.json`: + +```json +{ + "AUTH_OIDC_CLIENT_SECRET_VALUE": "your-client-secret" +} +``` + +Or create it manually in Secrets Manager: + +```bash +# Store client secret in Secrets Manager +aws secretsmanager create-secret \ + --name "mlspace/auth/oidc-client-secret" \ + --secret-string '{"client_secret":"your-client-secret","configured":true}' +``` + +#### 3. Update OIDC Provider Configuration + +Update your OIDC provider's redirect URI configuration: + +**Before (Legacy):** +- Redirect URI: `https://your-api-gateway.execute-api.region.amazonaws.com/Prod/` + +**After (Enhanced Authentication):** +- Redirect URI: `https://your-api-gateway.execute-api.region.amazonaws.com/Prod/auth/callback` + +#### 4. Deploy Updated Configuration + +```bash +# Build frontend with new configuration +cd frontend/ +npm run clean && npm install && npm run build + +# Deploy CDK changes +cd ../ +cdk deploy --all +``` + +#### 5. Verify Migration + +After deployment, verify the migration: + +1. **Test Authentication Flow:** + - Visit your MLSpace application + - Verify redirect to `/auth/login` + - Complete OIDC authentication + - Verify successful redirect back to application + +2. **Check Session Management:** + - Verify session cookies are set with `HttpOnly` flag + - Test automatic token refresh + - Test logout functionality + +3. **Validate API Calls:** + - Verify API calls work without Authorization header + - Check that session cookies are included in requests + +### Rollback Plan + +If issues occur during migration, you can rollback: + +1. **Revert Configuration Files:** + ```bash + git checkout HEAD~1 -- lib/constants.ts lib/config.json lib/utils/configTypes.ts + ``` + +2. **Rebuild and Redeploy:** + ```bash + cd frontend/ + npm run build + cd ../ + cdk deploy --all + ``` + +3. **Update OIDC Provider:** + - Revert redirect URI to original value + +### Common Migration Issues + +#### Issue: Authentication Loops + +**Symptoms:** User gets stuck in redirect loop between IdP and MLSpace + +**Solution:** +- Verify OIDC redirect URI is correctly set to `/auth/callback` +- Check that `AUTH_OIDC_CLIENT_ID` matches IdP configuration + +#### Issue: Session Cookies Not Set + +**Symptoms:** User can authenticate but gets 401 errors on API calls + +**Solution:** +- Verify cookies are being set with correct domain +- Check browser developer tools for cookie presence +- Ensure HTTPS is being used (cookies won't set over HTTP in production) + +#### Issue: Client Secret Errors + +**Symptoms:** Authentication fails with "invalid_client" error + +**Solution:** +- Verify SSM parameter `mlspace/auth/oidc-client-secret` exists +- Check Lambda execution role has SSM read permissions +- Ensure client secret value is correct + +#### Issue: Cross-Domain Sync Failures + +**Symptoms:** Authentication works on primary domain but fails on sync domains + +**Solution:** +- Verify `AUTH_SYNC_DOMAINS` configuration +- Check that all domains resolve correctly +- Ensure OTAC generation and validation is working + +## Troubleshooting + +### Enable Debug Logging + +Add debug environment variables to Lambda functions: + +```typescript +const authCommonEnv = { + // ... existing environment variables ... + LOG_LEVEL: 'DEBUG', + DEBUG_AUTH: 'true' +}; +``` + +### Check CloudWatch Logs + +Monitor these log groups for authentication issues: + +- `/aws/lambda/mls-lambda-auth-login` +- `/aws/lambda/mls-lambda-auth-callback` +- `/aws/lambda/mls-lambda-auth-identity` +- `/aws/lambda/mls-lambda-auth-logout` +- `/aws/lambda/mls-lambda-authorizer` + +### Common Log Messages + +**Successful Authentication:** +``` +[INFO] User authenticated successfully: user@example.com +[INFO] Session created: session:550e8400-e29b-41d4-a716-446655440000 +[INFO] Session cookie set for domain: api.mlspace.com +``` + +**Authentication Failures:** +``` +[ERROR] OIDC token exchange failed: invalid_grant +[ERROR] Session validation failed: session not found +[ERROR] OTAC validation failed: code expired +``` + +### Performance Monitoring + +Monitor these CloudWatch metrics: + +- **Authentication Success Rate:** Custom metric tracking successful logins +- **Session Duration:** Average time between login and logout +- **Token Refresh Rate:** Frequency of automatic token refreshes +- **OTAC Usage:** Cross-domain sync success rate + +## Security Considerations + +### Session Security + +- **HttpOnly Cookies:** Prevents JavaScript access to session tokens +- **Secure Flag:** Ensures cookies only sent over HTTPS +- **SameSite=Strict:** Prevents CSRF attacks +- **Session Encryption:** All IdP tokens encrypted before storage + +### Token Management + +- **Automatic Refresh:** Tokens refreshed transparently before expiration +- **Secure Storage:** Tokens stored encrypted in DynamoDB +- **TTL Cleanup:** Expired sessions automatically deleted + +### Cross-Domain Security + +- **OTAC Expiration:** One-time codes expire after 5 minutes +- **Single Use:** OTACs can only be used once +- **Domain Validation:** Each domain validates OTACs independently + +### Monitoring and Alerting + +Set up CloudWatch alarms for: + +- High authentication failure rates +- Unusual session creation patterns +- Failed OTAC validations +- SSM parameter access failures + +## Support and Maintenance + +### Regular Maintenance Tasks + +1. **Monitor SSM Parameters:** Ensure client secrets remain valid +2. **Review Session Metrics:** Check for unusual authentication patterns +3. **Update Dependencies:** Keep authentication libraries current +4. **Rotate Secrets:** Periodically rotate client secrets and encryption keys + +### Backup and Recovery + +1. **Configuration Backup:** Regularly backup configuration files +2. **SSM Parameter Backup:** Export SSM parameters for disaster recovery +3. **Session Data:** DynamoDB sessions are automatically backed up with TTL + +### Scaling Considerations + +- **DynamoDB Capacity:** Monitor read/write capacity for session table +- **Lambda Concurrency:** Ensure sufficient concurrency for auth endpoints +- **API Gateway Limits:** Monitor request rates for authentication endpoints \ No newline at end of file diff --git a/frontend/docs/admin-guide/configure-cognito.md b/frontend/docs/admin-guide/configure-cognito.md index 5adcfdc7..fa1e1d2c 100644 --- a/frontend/docs/admin-guide/configure-cognito.md +++ b/frontend/docs/admin-guide/configure-cognito.md @@ -57,19 +57,23 @@ In order to connect {{ $params.APPLICATION_NAME }} to an existing Cognito user p ![Cognito App Integration properties](../img/cognito/app-integration.png) -Once you have these two values, you can update the `constants.ts` file in the `lib/` application source directory. The Cognito Client ID value will need to be used for `OIDC_CLIENT_NAME`, i.e.: +Once you have these two values, you can update the `constants.ts` file in the `lib/` application source directory. The Cognito Client ID value will need to be used for `AUTH_OIDC_CLIENT_ID`, i.e.: ```javascript -export const OIDC_CLIENT_NAME = '7sm0a9nvvurn0guite1f2jgqi9'; +export const AUTH_OIDC_CLIENT_ID = '7sm0a9nvvurn0guite1f2jgqi9'; ``` -The value for User Pool ID should be combined with the correct region endpoint from [AWS Cognito Identity Documentation](https://docs.aws.amazon.com/general/latest/gr/cognito_identity.html) to form the `OIDC_URL`. In the example above, the User Pool exists in the `us-east-2` region, so the full endpoint would be `https://cognito-idp.us-east-2.amazonaws.com/us-east-2_oUmWoN1YP`. In the `constants.ts` file, this endpoint should be assigned to the `OIDC_URL` variable: +The value for User Pool ID should be combined with the correct region endpoint from [AWS Cognito Identity Documentation](https://docs.aws.amazon.com/general/latest/gr/cognito_identity.html) to form the `AUTH_OIDC_URL`. In the example above, the User Pool exists in the `us-east-2` region, so the full endpoint would be `https://cognito-idp.us-east-2.amazonaws.com/us-east-2_oUmWoN1YP`. In the `constants.ts` file, this endpoint should be assigned to the `AUTH_OIDC_URL` variable: ```javascript -export const OIDC_URL = 'https://cognito-idp.us-east-2.amazonaws.com/us-east-2_oUmWoN1YP'; +export const AUTH_OIDC_URL = 'https://cognito-idp.us-east-2.amazonaws.com/us-east-2_oUmWoN1YP'; ``` -Once both values have been updated, you can build and deploy {{ $params.APPLICATION_NAME }}, and it will use Cognito as the IdP. Once {{ $params.APPLICATION_NAME }} is deployed, you will have to update your Cognito app client to add the {{ $params.APPLICATION_NAME }} API Gateway endpoint to the list of "Allowed callback URLs". You can do this by navigating to the App Client details page, scrolling down to the hosted UI, and clicking the edit button. From there, you will need to add your custom domain or the {{ $params.APPLICATION_NAME }} API Gateway endpoint to the URL list. If you aren't using a custom domain, that value should be something similar to `https://.execute-api..amazonaws.com/Prod/`. +::: warning LEGACY OIDC_* PARAMETERS NOT SUPPORTED +The legacy `OIDC_URL` and `OIDC_CLIENT_NAME` parameters are deprecated and no longer supported. You must use the new `AUTH_OIDC_URL` and `AUTH_OIDC_CLIENT_ID` parameters instead. See the [Enhanced Authentication Configuration Guide](./bff-authentication.md) for complete details on all AUTH_* parameters. +::: + +Once both values have been updated, you can build and deploy {{ $params.APPLICATION_NAME }}, and it will use Cognito as the IdP. Once {{ $params.APPLICATION_NAME }} is deployed, you will have to update your Cognito app client to add the {{ $params.APPLICATION_NAME }} API Gateway endpoint to the list of "Allowed callback URLs". You can do this by navigating to the App Client details page, scrolling down to the hosted UI, and clicking the edit button. From there, you will need to add your custom domain or the {{ $params.APPLICATION_NAME }} API Gateway endpoint with the `/auth/callback` path to the URL list. If you aren't using a custom domain, that value should be something similar to `https://.execute-api..amazonaws.com/Prod/auth/callback`. ## Troubleshooting diff --git a/frontend/docs/admin-guide/custom-domain.md b/frontend/docs/admin-guide/custom-domain.md new file mode 100644 index 00000000..8c43f0eb --- /dev/null +++ b/frontend/docs/admin-guide/custom-domain.md @@ -0,0 +1,290 @@ +--- +outline: deep +--- + +# Custom Domain Configuration + +This guide explains how to configure {{ $params.APPLICATION_NAME }} to use a custom domain name instead of the default API Gateway URL. + +## Overview + +By default, {{ $params.APPLICATION_NAME }} uses the auto-generated API Gateway URL (e.g., `https://abc123xyz.execute-api.us-east-1.amazonaws.com/Prod/`). You can configure a custom domain to provide a more user-friendly URL like `https://mlspace.mycompany.com`. + +## Prerequisites + +Before configuring a custom domain, ensure you have: + +- A registered domain name +- An SSL/TLS certificate in AWS Certificate Manager (ACM) for your domain + - For CloudFront distributions or edge-optimized API Gateway endpoints, the certificate must be in the `us-east-1` region + - For regional API Gateway endpoints, the certificate must be in the same region as your API +- Appropriate DNS access to create CNAME or A records +- Admin access to your AWS account + +## Configuration Steps + +### Step 1: Update CDK Configuration + +1. Open `lib/constants.ts` in your {{ $params.APPLICATION_NAME }} deployment directory. + +2. Set the `WEB_CUSTOM_DOMAIN_NAME` constant to your custom domain URL: + +```typescript +// An optional custom domain name to use in place of the default API Gateway URL +// eg: 'https://mlspace.mycompany.com' +export const WEB_CUSTOM_DOMAIN_NAME = 'https://mlspace.mycompany.com'; +``` + +::: warning +Include the full URL with `https://` protocol. Do not include a trailing slash or path segments. +::: + +### Step 2: Configure Frontend Homepage (Optional) + +If you're **not** using a stage name in your custom domain path (e.g., using `https://mlspace.mycompany.com` instead of `https://mlspace.mycompany.com/Prod`), update the frontend package.json: + +1. Open `frontend/package.json` + +2. Update the `homepage` attribute: + +```json +{ + "name": "@amzn/mlspace", + "homepage": "/", + "version": "1.6.11", + ... +} +``` + +If you're keeping the stage name in your path (e.g., `https://mlspace.mycompany.com/Prod`), set: + +```json +{ + "homepage": "/Prod/", + ... +} +``` + +::: tip +The `homepage` value should match the base path where your application will be served. This affects how static assets are referenced. +::: + +### Step 3: Deploy CDK Changes + +Deploy your updated {{ $params.APPLICATION_NAME }} stack: + +```bash +cdk deploy --all +``` + +This will update the application configuration to use your custom domain for: +- Authentication redirects +- API endpoint references in the frontend +- Session management + +### Step 4: Create API Gateway Custom Domain + +After deploying the CDK changes, you must manually configure the API Gateway custom domain and API mapping. + +#### Using AWS Console + +1. Navigate to **API Gateway** in the AWS Console +2. Select **Custom domain names** from the left navigation +3. Click **Create** +4. Configure the custom domain: + - **Domain name**: Enter your domain (e.g., `mlspace.mycompany.com`) + - **Endpoint type**: Choose **Regional** (recommended) or **Edge optimized** + - **ACM certificate**: Select your SSL/TLS certificate from the dropdown + - **Security policy**: Choose **TLS 1.2** (recommended) +5. Click **Create domain name** +6. Note the **API Gateway domain name** (e.g., `d-abc123xyz.execute-api.us-east-1.amazonaws.com`) - you'll need this for DNS configuration + +#### Create API Mapping + +1. After creating the custom domain, select it from the list +2. Navigate to the **API mappings** tab +3. Click **Configure API mappings** +4. Click **Add new mapping** +5. Configure the mapping: + - **API**: Select your {{ $params.APPLICATION_NAME }} API (typically named with your stack name) + - **Stage**: Select your deployment stage (e.g., `Prod`) + - **Path**: Leave empty if not using a stage path, or enter `Prod` if keeping the stage in the URL +6. Click **Save** + +#### Using AWS CLI + +```bash +# Create custom domain +aws apigateway create-domain-name \ + --domain-name mlspace.mycompany.com \ + --regional-certificate-arn arn:aws:acm:us-east-1:123456789012:certificate/abc-123 \ + --endpoint-configuration types=REGIONAL \ + --security-policy TLS_1_2 + +# Create base path mapping +aws apigateway create-base-path-mapping \ + --domain-name mlspace.mycompany.com \ + --rest-api-id abc123xyz \ + --stage Prod \ + --base-path '' # Leave empty for root path, or use 'Prod' for /Prod path +``` + +### Step 5: Configure DNS + +Create a DNS record pointing your custom domain to the API Gateway domain name: + +#### For Regional Endpoints + +Create a **CNAME** record: + +``` +Type: CNAME +Name: mlspace.mycompany.com +Value: d-abc123xyz.execute-api.us-east-1.amazonaws.com +TTL: 300 +``` + +#### For Edge-Optimized Endpoints (with CloudFront) + +Create an **A** record with an alias to the CloudFront distribution: + +``` +Type: A (Alias) +Name: mlspace.mycompany.com +Value: d-abc123xyz.cloudfront.net +TTL: 300 +``` + +#### Using Route 53 + +If using Route 53, you can create an A record with an alias: + +1. Navigate to **Route 53** in the AWS Console +2. Select your hosted zone +3. Click **Create record** +4. Configure: + - **Record name**: `mlspace` (or leave empty for apex domain) + - **Record type**: A + - **Alias**: Yes + - **Route traffic to**: Alias to API Gateway API + - **Region**: Select your API region + - **API Gateway endpoint**: Select your custom domain +5. Click **Create records** + +### Step 6: Verify Configuration + +1. Wait for DNS propagation (typically 5-15 minutes, but can take up to 48 hours) + +2. Test DNS resolution: +```bash +nslookup mlspace.mycompany.com +# or +dig mlspace.mycompany.com +``` + +3. Access your custom domain in a browser: +``` +https://mlspace.mycompany.com +``` + +4. Verify that: + - The application loads correctly + - Authentication redirects work properly + - API calls are successful + - No mixed content warnings appear in the browser console + +## Troubleshooting + +### Certificate Validation Errors + +**Problem**: API Gateway cannot validate your ACM certificate. + +**Solution**: +- Ensure the certificate is in the correct region (us-east-1 for edge-optimized, same region as API for regional) +- Verify the certificate status is "Issued" in ACM +- Check that the certificate covers your domain (wildcard certificates like `*.mycompany.com` work for subdomains) + +### DNS Not Resolving + +**Problem**: Custom domain doesn't resolve to API Gateway. + +**Solution**: +- Verify the CNAME/A record is correctly configured +- Check DNS propagation status using online tools +- Ensure there are no conflicting DNS records +- Wait longer for DNS propagation (can take up to 48 hours) + +### Authentication Redirect Errors + +**Problem**: After login, users are redirected to the wrong URL. + +**Solution**: +- Verify `WEB_CUSTOM_DOMAIN_NAME` is set correctly in `lib/constants.ts` +- Ensure the value includes `https://` and has no trailing slash +- Redeploy the CDK stack after making changes +- Update your identity provider's allowed redirect URIs to include the custom domain + +### Mixed Content Warnings + +**Problem**: Browser shows mixed content warnings or blocks resources. + +**Solution**: +- Ensure your custom domain uses HTTPS +- Verify the SSL/TLS certificate is valid and trusted +- Check that all API calls use HTTPS +- Update any hardcoded HTTP URLs in your configuration + +### API Mapping Not Working + +**Problem**: Requests to custom domain return 403 or 404 errors. + +**Solution**: +- Verify the API mapping is correctly configured in API Gateway +- Check that the stage name matches your deployment +- Ensure the base path matches your `homepage` setting in package.json +- Verify the API Gateway has been deployed to the stage + +## Path Configuration Examples + +### Example 1: Root Path (No Stage) + +**Configuration**: +- `WEB_CUSTOM_DOMAIN_NAME`: `https://mlspace.mycompany.com` +- `frontend/package.json` homepage: `/` +- API Gateway base path: `` (empty) + +**Result**: Application accessible at `https://mlspace.mycompany.com/` + +### Example 2: With Stage Path + +**Configuration**: +- `WEB_CUSTOM_DOMAIN_NAME`: `https://mlspace.mycompany.com` +- `frontend/package.json` homepage: `/Prod/` +- API Gateway base path: `Prod` + +**Result**: Application accessible at `https://mlspace.mycompany.com/Prod/` + +### Example 3: Subdomain with Root Path + +**Configuration**: +- `WEB_CUSTOM_DOMAIN_NAME`: `https://ml.mycompany.com` +- `frontend/package.json` homepage: `/` +- API Gateway base path: `` (empty) + +**Result**: Application accessible at `https://ml.mycompany.com/` + +## Security Considerations + +- Always use HTTPS for custom domains +- Use TLS 1.2 or higher for API Gateway security policy +- Regularly rotate SSL/TLS certificates before expiration +- Update your identity provider configuration to only allow redirects to your custom domain +- Consider using AWS WAF with your API Gateway for additional protection +- Enable API Gateway access logging to monitor traffic to your custom domain + +## Additional Resources + +- [AWS API Gateway Custom Domain Names](https://docs.aws.amazon.com/apigateway/latest/developerguide/how-to-custom-domains.html) +- [AWS Certificate Manager User Guide](https://docs.aws.amazon.com/acm/latest/userguide/acm-overview.html) +- [Route 53 Developer Guide](https://docs.aws.amazon.com/route53/index.html) +- [{{ $params.APPLICATION_NAME }} Enhanced Authentication Configuration](./bff-authentication.md) diff --git a/frontend/docs/admin-guide/install.md b/frontend/docs/admin-guide/install.md index af4c1443..9adcbd56 100644 --- a/frontend/docs/admin-guide/install.md +++ b/frontend/docs/admin-guide/install.md @@ -1609,12 +1609,16 @@ Use the MLSpace Config Wizard by running `npm run config` and select "Advanced C #### Required Parameters +::: warning Authentication Configuration +{{ $params.APPLICATION_NAME }} now supports enhanced authentication for improved security and enterprise IdP integration. For new deployments, see the [Enhanced Authentication Configuration Guide](./bff-authentication.md) for the recommended AUTH_* parameters. +::: + | Variable | Description | Default | |----------|:-------------|------:| | `AWS_ACCOUNT` | The account number that {{ $params.APPLICATION_NAME }} is being deployed into. Used to disambiguate S3 buckets within a region | - | | `AWS_REGION` | The region that {{ $params.APPLICATION_NAME }} is being deployed into. This is only needed when you are using an existing VPC or KMS key and `EXISTING_KMS_MASTER_KEY_ARN` or `EXISTING_VPC_ID` is set | - | -| `OIDC_URL` | The OIDC endpoint that will be used for {{ $params.APPLICATION_NAME }} authentication | - | -| `OIDC_CLIENT_NAME` | The OIDC client name that should be used by {{ $params.APPLICATION_NAME }} for authentication | - | +| `OIDC_URL` | **Legacy:** The OIDC endpoint for authentication. For new deployments, use `AUTH_OIDC_URL` instead. See [Enhanced Authentication Guide](./bff-authentication.md) | - | +| `OIDC_CLIENT_NAME` | **Legacy:** The OIDC client name for authentication. For new deployments, use `AUTH_OIDC_CLIENT_ID` instead. See [Enhanced Authentication Guide](./bff-authentication.md) | - | | `KEY_MANAGER_ROLE_NAME` | Name of the IAM role with permissions to manage the KMS Key. If this property is set, you _do not_ need to set `EXISTING_KMS_MASTER_KEY_ARN`. | - |
@@ -1626,10 +1630,10 @@ Use the MLSpace Config Wizard by running `npm run config` and select "Advanced C | Variable | Description | Default | |--------------------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------:| -| `IDP_ENDPOINT_SSM_PARAM` | If set, {{ $params.APPLICATION_NAME }} will use the value of this parameter as the `OIDC_URL`. During deployment, the value of this parameter will be read from SSM. This value takes precedence over `OIDC_URL` if both are set | - | -| `OIDC_REDIRECT_URL` | The redirect URL that should be used after successfully authenticating with the OIDC provider. This will default to the API gateway URL generated by the CDK deployment but can be manually set if you're using custom DNS | `undefined` | -| `OIDC_VERIFY_SSL` | Whether or not calls to the OIDC endpoint specified in the `OIDC_URL` environment variable should validate the server certificate | `True` | -| `OIDC_VERIFY_SIGNATURE` | Whether or not the lambda authorizer should verify the JWT token signature | `True` | +| `IDP_ENDPOINT_SSM_PARAM` | **Legacy:** If set, {{ $params.APPLICATION_NAME }} will use the value of this parameter as the `OIDC_URL`. For enhanced authentication, client secrets are stored in `mlspace/auth/oidc-client-secret`. See [Enhanced Authentication Guide](./bff-authentication.md) | - | +| `OIDC_REDIRECT_URL` | **Legacy:** The redirect URL after OIDC authentication. Enhanced authentication uses `/auth/callback` automatically. See [Enhanced Authentication Guide](./bff-authentication.md) | `undefined` | +| `OIDC_VERIFY_SSL` | **Legacy:** Whether to validate OIDC server certificates. Enhanced authentication handles SSL validation internally | `True` | +| `OIDC_VERIFY_SIGNATURE` | **Legacy:** Whether to verify JWT token signatures. Enhanced authentication handles token validation server-side | `True` | | `ADDITIONAL_LAMBDA_ENVIRONMENT_VARS` | A map of key-value pairs which will be set as environment variables on every {{ $params.APPLICATION_NAME }} lambda | `{}` | | `RESOURCE_TERMINATION_INTERVAL` | Interval (in minutes) to run the resource termination cleanup lambda | `60` | | `DATASETS_TABLE_NAME` | DynamoDB table to hold dataset-related metadata | `mlspace-datasets` | @@ -1669,7 +1673,7 @@ Use the MLSpace Config Wizard by running `npm run config` and select "Advanced C | `ENABLE_ACCESS_LOGGING` | Whether or not to enable access logging for S3 and APIGW in {{ $params.APPLICATION_NAME }} | `true` | | `APIGATEWAY_CLOUDWATCH_ROLE_ARN` | If API Gateway access logging is enabled (`ENABLE_ACCESS_LOGGING` is true) then this is the ARN of the role that will be used to push those access logs | - | | `CREATE_MLSPACE_CLOUDTRAIL_TRAIL` | Whether or not to create an {{ $params.APPLICATION_NAME }} trail within the account | `true` | -| `NEW_USERS_SUSPENDED` | Whether or not new user accounts will be created in a suspended state by default | `true` | +| `NEW_USERS_SUSPENDED` | Whether or not new user accounts will be created in a suspended state by default | `false` | | `LAMBDA_RUNTIME` | The lambda runtime to use for {{ $params.APPLICATION_NAME }} lambda functions and layers. This needs to be a python runtime available in the region in which {{ $params.APPLICATION_NAME }} is being deployed | Python 3.11 | | `LAMBDA_ARCHITECTURE` | The architecture on which to deploy the {{ $params.APPLICATION_NAME }} lambda functions. All lambda layers will also need to be built for the selected architecture. You can do this by ensuring you run the `cdk deploy` command from a machine with the same architecture you're targeting | x86 |
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c2cb3d79..ec7b65f8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@amzn/mlspace", - "version": "1.6.10", + "version": "1.6.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@amzn/mlspace", - "version": "1.6.10", + "version": "1.6.11", "dependencies": { "@cloudscape-design/components": "^3.0.886", "@cloudscape-design/components-themeable": "^3.0.898", @@ -71,16 +71,16 @@ "license": "MIT" }, "node_modules/@algolia/abtesting": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.3.0.tgz", - "integrity": "sha512-KqPVLdVNfoJzX5BKNGM9bsW8saHeyax8kmPFXul5gejrSPN3qss7PgsFH5mMem7oR8tvjvNkia97ljEYPYCN8Q==", + "version": "1.12.4", + "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.12.4.tgz", + "integrity": "sha512-lnxyHf4/EGgJHf+/BhmQD+jvNQIn8DhDX32f5TwHelT+hvldOBN9ytRiI3LNk27G2dymUiX3Q76w9IOM+coIEw==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.37.0", - "@algolia/requester-browser-xhr": "5.37.0", - "@algolia/requester-fetch": "5.37.0", - "@algolia/requester-node-http": "5.37.0" + "@algolia/client-common": "5.46.4", + "@algolia/requester-browser-xhr": "5.46.4", + "@algolia/requester-fetch": "5.46.4", + "@algolia/requester-node-http": "5.46.4" }, "engines": { "node": ">= 14.0.0" @@ -136,41 +136,41 @@ } }, "node_modules/@algolia/client-abtesting": { - "version": "5.37.0", - "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.37.0.tgz", - "integrity": "sha512-Dp2Zq+x9qQFnuiQhVe91EeaaPxWBhzwQ6QnznZQnH9C1/ei3dvtmAFfFeaTxM6FzfJXDLvVnaQagTYFTQz3R5g==", + "version": "5.46.4", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.46.4.tgz", + "integrity": "sha512-UnonM7xiPWPatk76XdVHXcA4mocqwKWGXRtwca2OUH6m1r/CVnvTKfJ1KhhF79+I3Ro32+6BN6xeXhbfTNxH5w==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.37.0", - "@algolia/requester-browser-xhr": "5.37.0", - "@algolia/requester-fetch": "5.37.0", - "@algolia/requester-node-http": "5.37.0" + "@algolia/client-common": "5.46.4", + "@algolia/requester-browser-xhr": "5.46.4", + "@algolia/requester-fetch": "5.46.4", + "@algolia/requester-node-http": "5.46.4" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-analytics": { - "version": "5.37.0", - "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.37.0.tgz", - "integrity": "sha512-wyXODDOluKogTuZxRII6mtqhAq4+qUR3zIUJEKTiHLe8HMZFxfUEI4NO2qSu04noXZHbv/sRVdQQqzKh12SZuQ==", + "version": "5.46.4", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.46.4.tgz", + "integrity": "sha512-yXBj+Fiqt0EVBAP8dvZIec/H5sgDOqggU/Yctza0T3gONW/4LKK9MLRs4IBUG+zr+CvGKGgBvw/R0ihKfM4pOw==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.37.0", - "@algolia/requester-browser-xhr": "5.37.0", - "@algolia/requester-fetch": "5.37.0", - "@algolia/requester-node-http": "5.37.0" + "@algolia/client-common": "5.46.4", + "@algolia/requester-browser-xhr": "5.46.4", + "@algolia/requester-fetch": "5.46.4", + "@algolia/requester-node-http": "5.46.4" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-common": { - "version": "5.37.0", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.37.0.tgz", - "integrity": "sha512-GylIFlPvLy9OMgFG8JkonIagv3zF+Dx3H401Uo2KpmfMVBBJiGfAb9oYfXtplpRMZnZPxF5FnkWaI/NpVJMC+g==", + "version": "5.46.4", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.46.4.tgz", + "integrity": "sha512-6yhVfyj6Gw6aBKIq87bZL+CJnYcr8Gvt+a5UfAKmXuJQlvYLZ1OfIOkpZ/jiRKJHhwDjUnl8GybWsyaM9pl5mA==", "dev": true, "license": "MIT", "engines": { @@ -178,151 +178,151 @@ } }, "node_modules/@algolia/client-insights": { - "version": "5.37.0", - "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.37.0.tgz", - "integrity": "sha512-T63afO2O69XHKw2+F7mfRoIbmXWGzgpZxgOFAdP3fR4laid7pWBt20P4eJ+Zn23wXS5kC9P2K7Bo3+rVjqnYiw==", + "version": "5.46.4", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.46.4.tgz", + "integrity": "sha512-4nccpwc9SaMxZB5UsRyjLnHgrZ0KeE92Z0jx0X7xFhLzZ2Va6dSkvgKA4BfREE7mil/cqS2SgaEn8T1mSgJKFA==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.37.0", - "@algolia/requester-browser-xhr": "5.37.0", - "@algolia/requester-fetch": "5.37.0", - "@algolia/requester-node-http": "5.37.0" + "@algolia/client-common": "5.46.4", + "@algolia/requester-browser-xhr": "5.46.4", + "@algolia/requester-fetch": "5.46.4", + "@algolia/requester-node-http": "5.46.4" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-personalization": { - "version": "5.37.0", - "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.37.0.tgz", - "integrity": "sha512-1zOIXM98O9zD8bYDCJiUJRC/qNUydGHK/zRK+WbLXrW1SqLFRXECsKZa5KoG166+o5q5upk96qguOtE8FTXDWQ==", + "version": "5.46.4", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.46.4.tgz", + "integrity": "sha512-ultQNOVFoPFKpNtCrGk80tPWzf2e7VEsAJOUXFm2hiLvF6GWLS5QI2GRJ/EhYMeaC/rihptC+/lMjWSRp68FXQ==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.37.0", - "@algolia/requester-browser-xhr": "5.37.0", - "@algolia/requester-fetch": "5.37.0", - "@algolia/requester-node-http": "5.37.0" + "@algolia/client-common": "5.46.4", + "@algolia/requester-browser-xhr": "5.46.4", + "@algolia/requester-fetch": "5.46.4", + "@algolia/requester-node-http": "5.46.4" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-query-suggestions": { - "version": "5.37.0", - "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.37.0.tgz", - "integrity": "sha512-31Nr2xOLBCYVal+OMZn1rp1H4lPs1914Tfr3a34wU/nsWJ+TB3vWjfkUUuuYhWoWBEArwuRzt3YNLn0F/KRVkg==", + "version": "5.46.4", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.46.4.tgz", + "integrity": "sha512-xNzsTWU3pd0AopYOsPIT3Y1fC9xTn25hORkHLeWSXWkmFyNnGjV69qgPfuJbukLO+WJ3qZQw7fEDOV4sc+slZw==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.37.0", - "@algolia/requester-browser-xhr": "5.37.0", - "@algolia/requester-fetch": "5.37.0", - "@algolia/requester-node-http": "5.37.0" + "@algolia/client-common": "5.46.4", + "@algolia/requester-browser-xhr": "5.46.4", + "@algolia/requester-fetch": "5.46.4", + "@algolia/requester-node-http": "5.46.4" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-search": { - "version": "5.37.0", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.37.0.tgz", - "integrity": "sha512-DAFVUvEg+u7jUs6BZiVz9zdaUebYULPiQ4LM2R4n8Nujzyj7BZzGr2DCd85ip4p/cx7nAZWKM8pLcGtkTRTdsg==", + "version": "5.46.4", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.46.4.tgz", + "integrity": "sha512-Aylv/PzU95lSWTNTsjJYGsIk/cTmX0wzmFHhkw1CQwPWuRscvSDEaUgaoWZ4oQ6Y7vaCQCzAK3kkUJ+8WPSKGg==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.37.0", - "@algolia/requester-browser-xhr": "5.37.0", - "@algolia/requester-fetch": "5.37.0", - "@algolia/requester-node-http": "5.37.0" + "@algolia/client-common": "5.46.4", + "@algolia/requester-browser-xhr": "5.46.4", + "@algolia/requester-fetch": "5.46.4", + "@algolia/requester-node-http": "5.46.4" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/ingestion": { - "version": "1.37.0", - "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.37.0.tgz", - "integrity": "sha512-pkCepBRRdcdd7dTLbFddnu886NyyxmhgqiRcHHaDunvX03Ij4WzvouWrQq7B7iYBjkMQrLS8wQqSP0REfA4W8g==", + "version": "1.46.4", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.46.4.tgz", + "integrity": "sha512-Nto+a0B/vPGlGhZtaDlOwi6jacdZOlsUMBHG1X3JgYtrFl4ugJEVKmpx73R9N8+EZWjJQBJrPS7jZEAoB0r8mA==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.37.0", - "@algolia/requester-browser-xhr": "5.37.0", - "@algolia/requester-fetch": "5.37.0", - "@algolia/requester-node-http": "5.37.0" + "@algolia/client-common": "5.46.4", + "@algolia/requester-browser-xhr": "5.46.4", + "@algolia/requester-fetch": "5.46.4", + "@algolia/requester-node-http": "5.46.4" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/monitoring": { - "version": "1.37.0", - "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.37.0.tgz", - "integrity": "sha512-fNw7pVdyZAAQQCJf1cc/ih4fwrRdQSgKwgor4gchsI/Q/ss9inmC6bl/69jvoRSzgZS9BX4elwHKdo0EfTli3w==", + "version": "1.46.4", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.46.4.tgz", + "integrity": "sha512-bDqyfurjiQorhdrtmHIrJ8V2LOvcqgUTP7lMpisMayObBW/V0meWn0VDkPTEFAJkjf2K7SnPHa8hbJhEuSSpqA==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.37.0", - "@algolia/requester-browser-xhr": "5.37.0", - "@algolia/requester-fetch": "5.37.0", - "@algolia/requester-node-http": "5.37.0" + "@algolia/client-common": "5.46.4", + "@algolia/requester-browser-xhr": "5.46.4", + "@algolia/requester-fetch": "5.46.4", + "@algolia/requester-node-http": "5.46.4" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/recommend": { - "version": "5.37.0", - "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.37.0.tgz", - "integrity": "sha512-U+FL5gzN2ldx3TYfQO5OAta2TBuIdabEdFwD5UVfWPsZE5nvOKkc/6BBqP54Z/adW/34c5ZrvvZhlhNTZujJXQ==", + "version": "5.46.4", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.46.4.tgz", + "integrity": "sha512-Epg2b/Vpj4LPtk+dXamrwiAId9/5mlILayjjdSg16kOZ/YtN9u9yuGl9sbgb6lksYUJhsph5HkC+rzW1daibsg==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.37.0", - "@algolia/requester-browser-xhr": "5.37.0", - "@algolia/requester-fetch": "5.37.0", - "@algolia/requester-node-http": "5.37.0" + "@algolia/client-common": "5.46.4", + "@algolia/requester-browser-xhr": "5.46.4", + "@algolia/requester-fetch": "5.46.4", + "@algolia/requester-node-http": "5.46.4" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-browser-xhr": { - "version": "5.37.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.37.0.tgz", - "integrity": "sha512-Ao8GZo8WgWFABrU7iq+JAftXV0t+UcOtCDL4mzHHZ+rQeTTf1TZssr4d0vIuoqkVNnKt9iyZ7T4lQff4ydcTrw==", + "version": "5.46.4", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.46.4.tgz", + "integrity": "sha512-+tlgrqZMs4A6hYyayWzNpgt62khMowpeUZaPSeJVNRS3Dnsgb3VEB0WqVNqmmhnfNwltAwBWJKr53DJTjIkH4g==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.37.0" + "@algolia/client-common": "5.46.4" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-fetch": { - "version": "5.37.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.37.0.tgz", - "integrity": "sha512-H7OJOXrFg5dLcGJ22uxx8eiFId0aB9b0UBhoOi4SMSuDBe6vjJJ/LeZyY25zPaSvkXNBN3vAM+ad6M0h6ha3AA==", + "version": "5.46.4", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.46.4.tgz", + "integrity": "sha512-FEnC6701rIGBzc+IroHUnvU9/jwvwNv6FFRKK/eqTrIDgKR3lmDaR56zTr/0pL9KFpkNhV7MCLKAVLymkAoL+A==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.37.0" + "@algolia/client-common": "5.46.4" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-node-http": { - "version": "5.37.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.37.0.tgz", - "integrity": "sha512-npZ9aeag4SGTx677eqPL3rkSPlQrnzx/8wNrl1P7GpWq9w/eTmRbOq+wKrJ2r78idlY0MMgmY/mld2tq6dc44g==", + "version": "5.46.4", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.46.4.tgz", + "integrity": "sha512-hkFLUPA3vyxlLUdfAtuuvozbpT55lhlHj8epfKkhiv0VqxSaqUp46UOJ4VSMjEv/3EmmBQJZluvTS1DPtVDx7Q==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.37.0" + "@algolia/client-common": "5.46.4" }, "engines": { "node": ">= 14.0.0" @@ -342,9 +342,9 @@ } }, "node_modules/@aws-cdk/asset-awscli-v1": { - "version": "2.2.242", - "resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.242.tgz", - "integrity": "sha512-4c1bAy2ISzcdKXYS1k4HYZsNrgiwbiDzj36ybwFVxEWZXVAP0dimQTCaB9fxu7sWzEjw3d+eaw6Fon+QTfTIpQ==", + "version": "2.2.261", + "resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.261.tgz", + "integrity": "sha512-XkKvgG/OxrEYtVTt2Q9gaD58Qn90fnX/dNczL4k6myeii7UgAGPmqh+I+1uKohCWGKIKd+4Gz2xWBL/tPi4YIA==", "dev": true, "license": "Apache-2.0" }, @@ -356,9 +356,9 @@ "license": "Apache-2.0" }, "node_modules/@aws-cdk/cloud-assembly-schema": { - "version": "48.9.0", - "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-48.9.0.tgz", - "integrity": "sha512-wZsFnrLLzowYA26yfV6Xe7h6ZAYXZwsAQLLBowxhVdR5NcJIeCWI0dAavNdrUdZgiEbYU4rkUX3AXYI+nC6FTA==", + "version": "48.20.0", + "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-48.20.0.tgz", + "integrity": "sha512-+eeiav9LY4wbF/EFuCt/vfvi/Zoxo8bf94PW5clbMraChEliq83w4TbRVy0jB9jE0v1ooFTtIjSQkowSPkfISg==", "bundleDependencies": [ "jsonschema", "semver" @@ -395,12 +395,12 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", + "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -409,29 +409,29 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", - "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", + "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", - "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.4", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.4", - "@babel/types": "^7.28.4", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", + "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -448,9 +448,9 @@ } }, "node_modules/@babel/eslint-parser": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.28.4.tgz", - "integrity": "sha512-Aa+yDiH87980jR6zvRfFuCR1+dLb00vBydhTL+zI992Rz/wQhSvuxjmOOuJOgO3XmakO6RykRGD2S1mq1AtgHA==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.28.6.tgz", + "integrity": "sha512-QGmsKi2PBO/MHSQk+AAgA9R6OHQr+VqnniFE0eMWZcVcfBZoA2dKn2hUsl3Csg/Plt9opRUWdY7//VXsrIlEiA==", "dev": true, "license": "MIT", "dependencies": { @@ -477,13 +477,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", - "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", + "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.3", - "@babel/types": "^7.28.2", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -506,12 +506,12 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.2", + "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", @@ -522,18 +522,18 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.3.tgz", - "integrity": "sha512-V9f6ZFIYSLNEbuGA/92uOvYsGCJNsuA8ESZ4ldc09bWk/j8H8TKiPw8Mk1eG6olpnO0ALHJmYfZvF4MEE4gajg==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", + "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1", + "@babel/helper-replace-supers": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.28.3", + "@babel/traverse": "^7.28.6", "semver": "^6.3.1" }, "engines": { @@ -544,14 +544,14 @@ } }, "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.27.1.tgz", - "integrity": "sha512-uVDC72XVf8UbrH5qQTc18Agb8emwjTiZrQE11Nv3CuBEZmVvTwwE9CBUEvHku06gQCAyYf8Nv6ja1IN+6LMbxQ==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", + "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "regexpu-core": "^6.2.0", + "@babel/helper-annotate-as-pure": "^7.27.3", + "regexpu-core": "^6.3.1", "semver": "^6.3.1" }, "engines": { @@ -588,41 +588,41 @@ } }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz", - "integrity": "sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -645,9 +645,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -672,15 +672,15 @@ } }, "node_modules/@babel/helper-replace-supers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz", - "integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", + "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/traverse": "^7.27.1" + "@babel/traverse": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -713,9 +713,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -731,40 +731,40 @@ } }, "node_modules/@babel/helper-wrap-function": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.3.tgz", - "integrity": "sha512-zdf983tNfLZFletc0RRXYrHrucBEg95NIFMkn6K9dbeMYnsgHaSBGcQqdsCSStG2PYwRre0Qc2NNSCXbG+xc6g==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz", + "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.3", - "@babel/types": "^7.28.2" + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", - "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", + "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.4" + "@babel/types": "^7.28.6" }, "bin": { "parser": "bin/babel-parser.js" @@ -774,14 +774,14 @@ } }, "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.27.1.tgz", - "integrity": "sha512-QPG3C9cCVRQLxAVwmefEmwdTanECuUBMQZ/ym5kiw3XKCGA7qkuQLcjWWHcrD/GKbn/WmJwaezfuuAOcyKlRPA==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", + "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.27.1" + "@babel/traverse": "^7.28.5" }, "engines": { "node": ">=6.9.0" @@ -841,14 +841,14 @@ } }, "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.3.tgz", - "integrity": "sha512-b6YTX108evsvE4YgWyQ921ZAFFQm3Bn+CA3+ZXlNVnPhx+UfsVURoPjfGAPCjBgrqo30yX/C2nZGX96DxvR9Iw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz", + "integrity": "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.28.3" + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/traverse": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -876,15 +876,15 @@ } }, "node_modules/@babel/plugin-proposal-decorators": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.28.0.tgz", - "integrity": "sha512-zOiZqvANjWDUaUS9xMxbMcK/Zccztbe/6ikvUXaG9nsPH3w6qh5UaPGAnirI/WhIbZ8m3OHU0ReyPrknG+ZKeg==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.28.6.tgz", + "integrity": "sha512-RVdFPPyY9fCRAX68haPmOk2iyKW8PKJFthmm8NeSI3paNxKWGZIn99+VbIf0FrtCpFnPgnpF/L48tadi617ULg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/plugin-syntax-decorators": "^7.27.1" + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-syntax-decorators": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1038,13 +1038,13 @@ } }, "node_modules/@babel/plugin-syntax-decorators": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.27.1.tgz", - "integrity": "sha512-YMq8Z87Lhl8EGkmb0MwYkt36QnxC+fzCgrl66ereamPlYToRpIk5nUjKUY3QKLWq8mwUB1BgbeXcTJhZOCDg5A==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.28.6.tgz", + "integrity": "sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1054,13 +1054,13 @@ } }, "node_modules/@babel/plugin-syntax-flow": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.27.1.tgz", - "integrity": "sha512-p9OkPbZ5G7UT1MofwYFigGebnrzGJacoBSQM0/6bi/PUMVE+qlWDD/OalvQKbwgQzU6dl0xAv6r4X7Jme0RYxA==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.28.6.tgz", + "integrity": "sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1070,13 +1070,13 @@ } }, "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.27.1.tgz", - "integrity": "sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz", + "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1086,12 +1086,12 @@ } }, "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", - "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1125,12 +1125,12 @@ } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", - "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1242,12 +1242,12 @@ } }, "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", - "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1290,15 +1290,15 @@ } }, "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.28.0.tgz", - "integrity": "sha512-BEOdvX4+M765icNPZeidyADIvQ1m1gmunXufXxvRESy/jNNyfovIqUyE7MVgGBjWktCoJlzvFA1To2O4ymIO3Q==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.28.6.tgz", + "integrity": "sha512-9knsChgsMzBV5Yh3kkhrZNxH3oCYAfMBkNNaVN4cP2RVlFPe8wYdwwcnOsAbkdDoV9UjFtOXWrWB52M8W4jNeA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-remap-async-to-generator": "^7.27.1", - "@babel/traverse": "^7.28.0" + "@babel/traverse": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1308,14 +1308,14 @@ } }, "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.27.1.tgz", - "integrity": "sha512-NREkZsZVJS4xmTr8qzE5y8AfIPqsdQfRuUiLRTEzb7Qii8iFWCyDKaUV2c0rCuh4ljDZ98ALHP/PetiBV2nddA==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz", + "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-remap-async-to-generator": "^7.27.1" }, "engines": { @@ -1342,13 +1342,13 @@ } }, "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.4.tgz", - "integrity": "sha512-1yxmvN0MJHOhPVmAsmoW5liWwoILobu/d/ShymZmj867bAdxGbehIrew1DuLpw2Ukv+qDSSPQdYW1dLNE7t11A==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", + "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1358,14 +1358,14 @@ } }, "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz", - "integrity": "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz", + "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1375,14 +1375,14 @@ } }, "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.3.tgz", - "integrity": "sha512-LtPXlBbRoc4Njl/oh1CeD/3jC+atytbnf/UqLoqTDcEYGUPj022+rvfkbDYieUrSj3CaV4yHDByPE+T2HwfsJg==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz", + "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.3", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1392,18 +1392,18 @@ } }, "node_modules/@babel/plugin-transform-classes": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.4.tgz", - "integrity": "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", + "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-globals": "^7.28.0", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1", - "@babel/traverse": "^7.28.4" + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/traverse": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1413,14 +1413,14 @@ } }, "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.27.1.tgz", - "integrity": "sha512-lj9PGWvMTVksbWiDT2tW68zGS/cyo4AkZ/QTp0sQT0mjPopCmrSkzxeXkznjqBxzDI6TclZhOJbBmbBLjuOZUw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz", + "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/template": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/template": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1430,14 +1430,14 @@ } }, "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.0.tgz", - "integrity": "sha512-v1nrSMBiKcodhsyJ4Gf+Z0U/yawmJDBOTpEB3mcQY52r9RIyPneGyAS/yM6seP/8I+mWI3elOMtT5dB8GJVs+A==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", + "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.28.0" + "@babel/traverse": "^7.28.5" }, "engines": { "node": ">=6.9.0" @@ -1447,14 +1447,14 @@ } }, "node_modules/@babel/plugin-transform-dotall-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.27.1.tgz", - "integrity": "sha512-gEbkDVGRvjj7+T1ivxrfgygpT7GUd4vmODtYpbs0gZATdkX8/iSnOtZSxiZnsgm1YjTgjI6VKBGSJJevkrclzw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz", + "integrity": "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1480,14 +1480,14 @@ } }, "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.27.1.tgz", - "integrity": "sha512-hkGcueTEzuhB30B3eJCbCYeCaaEQOmQR0AdvzpD4LoN0GXMWzzGSuRrxR2xTnCrvNbVwK9N6/jQ92GSLfiZWoQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.28.6.tgz", + "integrity": "sha512-5suVoXjC14lUN6ZL9OLKIHCNVWCrqGqlmEp/ixdXjvgnEl/kauLvvMO/Xw9NyMc95Joj1AeLVPVMvibBgSoFlA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1513,14 +1513,14 @@ } }, "node_modules/@babel/plugin-transform-explicit-resource-management": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.0.tgz", - "integrity": "sha512-K8nhUcn3f6iB+P3gwCv/no7OdzOZQcKchW6N389V6PD8NUWKZHzndOd9sPDVbMoBsbmjMqlB4L9fm+fEFNVlwQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz", + "integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/plugin-transform-destructuring": "^7.28.0" + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5" }, "engines": { "node": ">=6.9.0" @@ -1530,13 +1530,13 @@ } }, "node_modules/@babel/plugin-transform-exponentiation-operator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.27.1.tgz", - "integrity": "sha512-uspvXnhHvGKf2r4VVtBpeFnuDWsJLQ6MF6lGJLC89jBR1uoVeqM416AZtTuhTezOfgHicpJQmoD5YUakO/YmXQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz", + "integrity": "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1614,13 +1614,13 @@ } }, "node_modules/@babel/plugin-transform-json-strings": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.27.1.tgz", - "integrity": "sha512-6WVLVJiTjqcQauBhn1LkICsR2H+zm62I3h9faTDKt1qP4jn2o72tSvqMwtGFKGTpojce0gJs+76eZ2uCHRZh0Q==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz", + "integrity": "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1646,13 +1646,13 @@ } }, "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.27.1.tgz", - "integrity": "sha512-SJvDs5dXxiae4FbSL1aBJlG4wvl594N6YEVVn9e3JGulwioy6z3oPjx/sQBO3Y4NwUu5HNix6KJ3wBZoewcdbw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz", + "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1695,14 +1695,14 @@ } }, "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.27.1.tgz", - "integrity": "sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", + "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1712,16 +1712,16 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.27.1.tgz", - "integrity": "sha512-w5N1XzsRbc0PQStASMksmUeqECuzKuTJer7kFagK8AXgpCMkeDMO5S+aaFb7A51ZYDF7XI34qsTX+fkHiIm5yA==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.28.5.tgz", + "integrity": "sha512-vn5Jma98LCOeBy/KpeQhXcV2WZgaRUtjwQmjoBuLNlOmkg0fB5pdvYVeWRYI69wWKwK2cD1QbMiUQnoujWvrew==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-module-transforms": "^7.28.3", "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.27.1" + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.5" }, "engines": { "node": ">=6.9.0" @@ -1781,13 +1781,13 @@ } }, "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz", - "integrity": "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", + "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1797,13 +1797,13 @@ } }, "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.27.1.tgz", - "integrity": "sha512-fdPKAcujuvEChxDBJ5c+0BTaS6revLV7CJL08e4m3de8qJfNIuCc2nc7XJYOjBoTMJeqSmwXJ0ypE14RCjLwaw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz", + "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1813,17 +1813,17 @@ } }, "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.4.tgz", - "integrity": "sha512-373KA2HQzKhQCYiRVIRr+3MjpCObqzDlyrM6u4I201wL8Mp2wHf7uB8GhDwis03k2ti8Zr65Zyyqs1xOxUF/Ew==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz", + "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/plugin-transform-destructuring": "^7.28.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5", "@babel/plugin-transform-parameters": "^7.27.7", - "@babel/traverse": "^7.28.4" + "@babel/traverse": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1850,13 +1850,13 @@ } }, "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.27.1.tgz", - "integrity": "sha512-txEAEKzYrHEX4xSZN4kJ+OfKXFVSWKB2ZxM9dpcE3wT7smwkNmXo5ORRlVzMVdJbD+Q8ILTgSD7959uj+3Dm3Q==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz", + "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1866,13 +1866,13 @@ } }, "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz", - "integrity": "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz", + "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" }, "engines": { @@ -1899,14 +1899,14 @@ } }, "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.27.1.tgz", - "integrity": "sha512-10FVt+X55AjRAYI9BrdISN9/AQWHqldOeZDUoLyif1Kn05a56xVBXb8ZouL8pZ9jem8QpXaOt8TS7RHUIS+GPA==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", + "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1916,15 +1916,15 @@ } }, "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.27.1.tgz", - "integrity": "sha512-5J+IhqTi1XPa0DXF83jYOaARrX+41gOewWbkPyjMNRDqgOCqdffGh8L3f/Ek5utaEBZExjSAzcyjmV9SSAWObQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz", + "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-create-class-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1982,17 +1982,17 @@ } }, "node_modules/@babel/plugin-transform-react-jsx": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.27.1.tgz", - "integrity": "sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz", + "integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/plugin-syntax-jsx": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-syntax-jsx": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -2035,13 +2035,13 @@ } }, "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.4.tgz", - "integrity": "sha512-+ZEdQlBoRg9m2NnzvEeLgtvBMO4tkFBw5SQIUgLICgTrumLoU7lr+Oghi6km2PFj+dbUt2u1oby2w3BDO9YQnA==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.6.tgz", + "integrity": "sha512-eZhoEZHYQLL5uc1gS5e9/oTknS0sSSAtd5TkKMUp3J+S/CaUjagc0kOUPsEbDmMeva0nC3WWl4SxVY6+OBuxfw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -2051,14 +2051,14 @@ } }, "node_modules/@babel/plugin-transform-regexp-modifiers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.27.1.tgz", - "integrity": "sha512-TtEciroaiODtXvLZv4rmfMhkCv8jx3wgKpL68PuiPh2M4fvz5jhsA7697N1gMvkvr/JTF13DrFYyEbY9U7cVPA==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz", + "integrity": "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -2084,9 +2084,9 @@ } }, "node_modules/@babel/plugin-transform-runtime": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.28.3.tgz", - "integrity": "sha512-Y6ab1kGqZ0u42Zv/4a7l0l72n9DKP/MKoKWaUSBylrhNZO2prYuqFOLbn5aW5SIFXwSH93yfjbgllL8lxuGKLg==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.28.5.tgz", + "integrity": "sha512-20NUVgOrinudkIBzQ2bNxP08YpKprUkRTiRSd2/Z5GOdPImJGkoN4Z7IQe1T5AdyKI1i5L6RBmluqdSzvaq9/w==", "dev": true, "license": "MIT", "dependencies": { @@ -2121,13 +2121,13 @@ } }, "node_modules/@babel/plugin-transform-spread": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.27.1.tgz", - "integrity": "sha512-kpb3HUqaILBJcRFVhFUs6Trdd4mkrzcGXss+6/mxUd273PfbWqSDHRzMT2234gIg2QYfAjvXLSquP1xECSg09Q==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz", + "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" }, "engines": { @@ -2186,17 +2186,17 @@ } }, "node_modules/@babel/plugin-transform-typescript": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.0.tgz", - "integrity": "sha512-4AEiDEBPIZvLQaWlc9liCavE0xRM0dNca41WtBeM3jgFptfUOSG9z0uteLhq6+3rq+WB6jIvUwKDTpXEHPJ2Vg==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", + "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-create-class-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/plugin-syntax-typescript": "^7.27.1" + "@babel/plugin-syntax-typescript": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -2222,14 +2222,14 @@ } }, "node_modules/@babel/plugin-transform-unicode-property-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.27.1.tgz", - "integrity": "sha512-uW20S39PnaTImxp39O5qFlHLS9LJEmANjMG7SxIhap8rCHqu0Ik+tLEPX5DKmHn6CsWQ7j3lix2tFOa5YtL12Q==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz", + "integrity": "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -2256,14 +2256,14 @@ } }, "node_modules/@babel/plugin-transform-unicode-sets-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.27.1.tgz", - "integrity": "sha512-EtkOujbc4cgvb0mlpQefi4NTPBzhSIevblFevACNLUspmrALgmEBdL/XfnyyITfd8fKBZrZys92zOWcik7j9Tw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz", + "integrity": "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -2273,76 +2273,76 @@ } }, "node_modules/@babel/preset-env": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.28.3.tgz", - "integrity": "sha512-ROiDcM+GbYVPYBOeCR6uBXKkQpBExLl8k9HO1ygXEyds39j+vCCsjmj7S8GOniZQlEs81QlkdJZe76IpLSiqpg==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.28.6.tgz", + "integrity": "sha512-GaTI4nXDrs7l0qaJ6Rg06dtOXTBCG6TMDB44zbqofCIC4PqC7SEvmFFtpxzCDw9W5aJ7RKVshgXTLvLdBFV/qw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.0", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-plugin-utils": "^7.27.1", + "@babel/compat-data": "^7.28.6", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.27.1", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5", "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.3", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.6", "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", - "@babel/plugin-syntax-import-assertions": "^7.27.1", - "@babel/plugin-syntax-import-attributes": "^7.27.1", + "@babel/plugin-syntax-import-assertions": "^7.28.6", + "@babel/plugin-syntax-import-attributes": "^7.28.6", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", "@babel/plugin-transform-arrow-functions": "^7.27.1", - "@babel/plugin-transform-async-generator-functions": "^7.28.0", - "@babel/plugin-transform-async-to-generator": "^7.27.1", + "@babel/plugin-transform-async-generator-functions": "^7.28.6", + "@babel/plugin-transform-async-to-generator": "^7.28.6", "@babel/plugin-transform-block-scoped-functions": "^7.27.1", - "@babel/plugin-transform-block-scoping": "^7.28.0", - "@babel/plugin-transform-class-properties": "^7.27.1", - "@babel/plugin-transform-class-static-block": "^7.28.3", - "@babel/plugin-transform-classes": "^7.28.3", - "@babel/plugin-transform-computed-properties": "^7.27.1", - "@babel/plugin-transform-destructuring": "^7.28.0", - "@babel/plugin-transform-dotall-regex": "^7.27.1", + "@babel/plugin-transform-block-scoping": "^7.28.6", + "@babel/plugin-transform-class-properties": "^7.28.6", + "@babel/plugin-transform-class-static-block": "^7.28.6", + "@babel/plugin-transform-classes": "^7.28.6", + "@babel/plugin-transform-computed-properties": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5", + "@babel/plugin-transform-dotall-regex": "^7.28.6", "@babel/plugin-transform-duplicate-keys": "^7.27.1", - "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.27.1", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.28.6", "@babel/plugin-transform-dynamic-import": "^7.27.1", - "@babel/plugin-transform-explicit-resource-management": "^7.28.0", - "@babel/plugin-transform-exponentiation-operator": "^7.27.1", + "@babel/plugin-transform-explicit-resource-management": "^7.28.6", + "@babel/plugin-transform-exponentiation-operator": "^7.28.6", "@babel/plugin-transform-export-namespace-from": "^7.27.1", "@babel/plugin-transform-for-of": "^7.27.1", "@babel/plugin-transform-function-name": "^7.27.1", - "@babel/plugin-transform-json-strings": "^7.27.1", + "@babel/plugin-transform-json-strings": "^7.28.6", "@babel/plugin-transform-literals": "^7.27.1", - "@babel/plugin-transform-logical-assignment-operators": "^7.27.1", + "@babel/plugin-transform-logical-assignment-operators": "^7.28.6", "@babel/plugin-transform-member-expression-literals": "^7.27.1", "@babel/plugin-transform-modules-amd": "^7.27.1", - "@babel/plugin-transform-modules-commonjs": "^7.27.1", - "@babel/plugin-transform-modules-systemjs": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.28.6", + "@babel/plugin-transform-modules-systemjs": "^7.28.5", "@babel/plugin-transform-modules-umd": "^7.27.1", "@babel/plugin-transform-named-capturing-groups-regex": "^7.27.1", "@babel/plugin-transform-new-target": "^7.27.1", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", - "@babel/plugin-transform-numeric-separator": "^7.27.1", - "@babel/plugin-transform-object-rest-spread": "^7.28.0", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", + "@babel/plugin-transform-numeric-separator": "^7.28.6", + "@babel/plugin-transform-object-rest-spread": "^7.28.6", "@babel/plugin-transform-object-super": "^7.27.1", - "@babel/plugin-transform-optional-catch-binding": "^7.27.1", - "@babel/plugin-transform-optional-chaining": "^7.27.1", + "@babel/plugin-transform-optional-catch-binding": "^7.28.6", + "@babel/plugin-transform-optional-chaining": "^7.28.6", "@babel/plugin-transform-parameters": "^7.27.7", - "@babel/plugin-transform-private-methods": "^7.27.1", - "@babel/plugin-transform-private-property-in-object": "^7.27.1", + "@babel/plugin-transform-private-methods": "^7.28.6", + "@babel/plugin-transform-private-property-in-object": "^7.28.6", "@babel/plugin-transform-property-literals": "^7.27.1", - "@babel/plugin-transform-regenerator": "^7.28.3", - "@babel/plugin-transform-regexp-modifiers": "^7.27.1", + "@babel/plugin-transform-regenerator": "^7.28.6", + "@babel/plugin-transform-regexp-modifiers": "^7.28.6", "@babel/plugin-transform-reserved-words": "^7.27.1", "@babel/plugin-transform-shorthand-properties": "^7.27.1", - "@babel/plugin-transform-spread": "^7.27.1", + "@babel/plugin-transform-spread": "^7.28.6", "@babel/plugin-transform-sticky-regex": "^7.27.1", "@babel/plugin-transform-template-literals": "^7.27.1", "@babel/plugin-transform-typeof-symbol": "^7.27.1", "@babel/plugin-transform-unicode-escapes": "^7.27.1", - "@babel/plugin-transform-unicode-property-regex": "^7.27.1", + "@babel/plugin-transform-unicode-property-regex": "^7.28.6", "@babel/plugin-transform-unicode-regex": "^7.27.1", - "@babel/plugin-transform-unicode-sets-regex": "^7.27.1", + "@babel/plugin-transform-unicode-sets-regex": "^7.28.6", "@babel/preset-modules": "0.1.6-no-external-plugins", "babel-plugin-polyfill-corejs2": "^0.4.14", "babel-plugin-polyfill-corejs3": "^0.13.0", @@ -2386,15 +2386,15 @@ } }, "node_modules/@babel/preset-react": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.27.1.tgz", - "integrity": "sha512-oJHWh2gLhU9dW9HHr42q0cI0/iHHXTLGe39qvpAZZzagHy0MzYLCnCVV0symeRvzmjHyVU7mw2K06E6u/JwbhA==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.28.5.tgz", + "integrity": "sha512-Z3J8vhRq7CeLjdC58jLv4lnZ5RKFUJWqH5emvxmv9Hv3BD1T9R/Im713R4MTKwvFaV74ejZ3sM01LyEKk4ugNQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-transform-react-display-name": "^7.27.1", + "@babel/plugin-transform-react-display-name": "^7.28.0", "@babel/plugin-transform-react-jsx": "^7.27.1", "@babel/plugin-transform-react-jsx-development": "^7.27.1", "@babel/plugin-transform-react-pure-annotations": "^7.27.1" @@ -2407,9 +2407,9 @@ } }, "node_modules/@babel/preset-typescript": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz", - "integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", + "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", "dev": true, "license": "MIT", "dependencies": { @@ -2417,7 +2417,7 @@ "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", - "@babel/plugin-transform-typescript": "^7.27.1" + "@babel/plugin-transform-typescript": "^7.28.5" }, "engines": { "node": ">=6.9.0" @@ -2427,40 +2427,40 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", - "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", - "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", + "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.4", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6", "debug": "^4.3.1" }, "engines": { @@ -2468,13 +2468,13 @@ } }, "node_modules/@babel/types": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", - "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", + "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" @@ -2487,28 +2487,34 @@ "license": "MIT" }, "node_modules/@cloudscape-design/collection-hooks": { - "version": "1.0.74", - "resolved": "https://registry.npmjs.org/@cloudscape-design/collection-hooks/-/collection-hooks-1.0.74.tgz", - "integrity": "sha512-yAcD7vjFqbwqMCamUcKRXp403u8RcmC9izyPEYiWod9elt7x0GT1ypPyo9ZRyQuFrBsv2nwubBUrChcYaWooZw==", + "version": "1.0.80", + "resolved": "https://registry.npmjs.org/@cloudscape-design/collection-hooks/-/collection-hooks-1.0.80.tgz", + "integrity": "sha512-rE8AwpHb7tpo+POGQlWSFAH7CY6pTQ68za4y3g8kGxRtlTfVEE4k2iGwrzocTJHH+9WUEUz2bkvux4nWnRa5Sw==", "license": "Apache-2.0", "peerDependencies": { "react": ">=16.8.0" } }, "node_modules/@cloudscape-design/component-toolkit": { - "version": "1.0.0-beta.120", - "resolved": "https://registry.npmjs.org/@cloudscape-design/component-toolkit/-/component-toolkit-1.0.0-beta.120.tgz", - "integrity": "sha512-QQfquFjubZvDpJ+Tlt3UHI3KWGvMhwoksY6tG7E41qOrS9y+YbDJuJyiqaCbm5S2PzZ33JBL0bWsXrJesZu6tA==", + "version": "1.0.0-beta.131", + "resolved": "https://registry.npmjs.org/@cloudscape-design/component-toolkit/-/component-toolkit-1.0.0-beta.131.tgz", + "integrity": "sha512-ZTx9loMjICU1M/+gnICAIh+QpkLAvwyLQEIXELxSz3NVNaX39dIrGBMStezTqEgv4sLgEA1xaVWc1Mrj0PqUNQ==", "license": "Apache-2.0", "dependencies": { - "@juggle/resize-observer": "^3.3.1", - "tslib": "^2.3.1" + "tslib": "^2.3.1", + "weekstart": "^2.0.0" } }, + "node_modules/@cloudscape-design/component-toolkit/node_modules/weekstart": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/weekstart/-/weekstart-2.0.0.tgz", + "integrity": "sha512-HjYc14IQUwDcnGICuc8tVtqAd6EFpoAQMqgrqcNtWWZB+F1b7iTq44GzwM1qvnH4upFgbhJsaNHuK93NOFheSg==", + "license": "MIT" + }, "node_modules/@cloudscape-design/components": { - "version": "3.0.1091", - "resolved": "https://registry.npmjs.org/@cloudscape-design/components/-/components-3.0.1091.tgz", - "integrity": "sha512-ESV83m/laX9OkuITjeucYRBi4WQSu9w8yniRZjRapiTH+zTlBxQv8Gcnvr9UYPo3cbYyig2HIdbAlOagDplgfA==", + "version": "3.0.1178", + "resolved": "https://registry.npmjs.org/@cloudscape-design/components/-/components-3.0.1178.tgz", + "integrity": "sha512-x//6LceXuwKWYBQ2HqbZDMrrZ9FTb4yKjJTiIcvQbfvTxAuArPNTgPeUr7s7ASirmQ2gYOcSJwKp+AN9pTvfdA==", "license": "Apache-2.0", "dependencies": { "@cloudscape-design/collection-hooks": "^1.0.0", @@ -2519,13 +2525,12 @@ "@dnd-kit/sortable": "^7.0.2", "@dnd-kit/utilities": "^3.2.1", "ace-builds": "^1.34.0", - "balanced-match": "^1.0.2", "clsx": "^1.1.0", "d3-shape": "^1.3.7", "date-fns": "^2.25.0", "intl-messageformat": "^10.3.1", "mnth": "^2.0.0", - "react-keyed-flatten-children": "^2.2.1", + "react-is": ">=16.8.0", "react-transition-group": "^4.4.2", "tslib": "^2.4.0", "weekstart": "^1.1.0" @@ -2535,9 +2540,9 @@ } }, "node_modules/@cloudscape-design/components-themeable": { - "version": "3.0.1100", - "resolved": "https://registry.npmjs.org/@cloudscape-design/components-themeable/-/components-themeable-3.0.1100.tgz", - "integrity": "sha512-YXnm2nC+kaz8Jh/t/Ieq8kD+gYVs+ngJ7tKB7QF07uZzXL8EXmEfbi0VGINLQTSjYgVjkBCCz0w3acVG5zQjwg==", + "version": "3.0.1186", + "resolved": "https://registry.npmjs.org/@cloudscape-design/components-themeable/-/components-themeable-3.0.1186.tgz", + "integrity": "sha512-tEkpK8Gd3eF/IczccCy4EyyAdHoVcyRRzgTp9ZNYyXumerKkR7p6sBLaYIDfEr0ybVWBPGDhj8Ng0llcWYw18A==", "license": "Apache-2.0", "dependencies": { "@cloudscape-design/component-toolkit": "npm:@cloudscape-design/component-toolkit", @@ -2561,21 +2566,21 @@ } }, "node_modules/@cloudscape-design/design-tokens": { - "version": "3.0.60", - "resolved": "https://registry.npmjs.org/@cloudscape-design/design-tokens/-/design-tokens-3.0.60.tgz", - "integrity": "sha512-ybj8FfjdhuHZflVDA//ooHJdwc+vny9MESvB95AJpVDhf6PXoaOpWAObn4hkMC770Wk/YwXtKXbx7rjJJQr6ZA==", + "version": "3.0.68", + "resolved": "https://registry.npmjs.org/@cloudscape-design/design-tokens/-/design-tokens-3.0.68.tgz", + "integrity": "sha512-s4zJHIXJDhTAWmZZXaYNsaRbVyyCod+ToeWDkBZNMBvXrw7uTIvtcsOFZcLcrk435/MWW8NCGrmV4sexXcDrOg==", "license": "Apache-2.0" }, "node_modules/@cloudscape-design/global-styles": { - "version": "1.0.45", - "resolved": "https://registry.npmjs.org/@cloudscape-design/global-styles/-/global-styles-1.0.45.tgz", - "integrity": "sha512-fSrbVpK9W+bg8tmUYqU9Wh2JGciUCGEByVUQDbgMY6feXtYEUKRP2MBL6kEHvoJB7lssZbHdh5/gYaiyxg+P5w==", + "version": "1.0.49", + "resolved": "https://registry.npmjs.org/@cloudscape-design/global-styles/-/global-styles-1.0.49.tgz", + "integrity": "sha512-zN6zKKNJSDAsHtG700RtVuehfRMwBXOX7wi8P8X/ZXIIkAJ0n0RmhgVeP1r/tVHZCdTVof7HKB2DNjrtbMkNFg==", "license": "Apache-2.0" }, "node_modules/@cloudscape-design/test-utils-core": { - "version": "1.0.64", - "resolved": "https://registry.npmjs.org/@cloudscape-design/test-utils-core/-/test-utils-core-1.0.64.tgz", - "integrity": "sha512-uVDAqd3huzDv/Qu4PWe3FW6oUKl/wCvumEf3TxfMifVWkC57al3B7D2junOX+CUmO5GYilpjig0F547laXifSw==", + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@cloudscape-design/test-utils-core/-/test-utils-core-1.0.71.tgz", + "integrity": "sha512-4rXQlsd6Rdg+KCNoltLa1+0wjgNugbduuK7L4nTa/Aw/gJIYqrTU78ip5A2VmbDhiE6fiGqtCFmU6LcKCMP//A==", "license": "Apache-2.0", "dependencies": { "css-selector-tokenizer": "^0.8.0", @@ -2583,13 +2588,14 @@ } }, "node_modules/@cloudscape-design/theming-build": { - "version": "1.0.89", - "resolved": "https://registry.npmjs.org/@cloudscape-design/theming-build/-/theming-build-1.0.89.tgz", - "integrity": "sha512-Eq6N8x66dPJn6cFQqo2K+RYj2fLA8Bk4kLCX/ohEDYlbFCwJc6MZVumeHuFBj5jRu4ASHX8GHckVuZpn4/lCjw==", + "version": "1.0.97", + "resolved": "https://registry.npmjs.org/@cloudscape-design/theming-build/-/theming-build-1.0.97.tgz", + "integrity": "sha512-kksg6sGlXfaDMTv6/fX/WEnCjcOiyfVLp/EfztIA74RvaEkqhk/dCtqwO/TrKsag/LW2uFwlMmdt1pfdHtyMug==", "license": "Apache-2.0", "dependencies": { + "@material/material-color-utilities": "^0.3.0", "autoprefixer": "^10.4.8", - "glob": "^7.2.3", + "glob": "^10.5.0", "jsonschema": "^1.4.1", "loader-utils": "^3.2.1", "lodash": "^4.17.21", @@ -2603,11 +2609,12 @@ } }, "node_modules/@cloudscape-design/theming-runtime": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@cloudscape-design/theming-runtime/-/theming-runtime-1.0.82.tgz", - "integrity": "sha512-YNpr4JZ5tJWjAcfH1JKAup2mZvIeA9YgPfaDpAE3DuD1sgaELb9yGGR+pMc2xWZMO2OEK3BPdZfLiXEWFaIBRg==", + "version": "1.0.90", + "resolved": "https://registry.npmjs.org/@cloudscape-design/theming-runtime/-/theming-runtime-1.0.90.tgz", + "integrity": "sha512-QYFEj3CkfWscpefazwk2U+IP6uWKqr45KmvIbJtVbP80zN6sP8NwKhipxzxjM6tcLXKSc7KZCbsCV8GJXO0QGQ==", "license": "Apache-2.0", "dependencies": { + "@material/material-color-utilities": "^0.3.0", "tslib": "^2.4.0" } }, @@ -3063,9 +3070,9 @@ } }, "node_modules/@emnapi/core": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz", - "integrity": "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", "license": "MIT", "optional": true, "dependencies": { @@ -3074,9 +3081,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", - "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", "license": "MIT", "optional": true, "dependencies": { @@ -3485,9 +3492,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3504,9 +3511,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -3572,13 +3579,13 @@ } }, "node_modules/@formatjs/ecma402-abstract": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.4.tgz", - "integrity": "sha512-qrycXDeaORzIqNhBOx0btnhpD1c+/qFIHAN9znofuMJX6QBwtbrmlpWfD4oiUUD2vJUOIYFA/gYtg2KAMGG7sA==", + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz", + "integrity": "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==", "license": "MIT", "dependencies": { "@formatjs/fast-memoize": "2.2.7", - "@formatjs/intl-localematcher": "0.6.1", + "@formatjs/intl-localematcher": "0.6.2", "decimal.js": "^10.4.3", "tslib": "^2.8.0" } @@ -3593,30 +3600,30 @@ } }, "node_modules/@formatjs/icu-messageformat-parser": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.2.tgz", - "integrity": "sha512-AfiMi5NOSo2TQImsYAg8UYddsNJ/vUEv/HaNqiFjnI3ZFfWihUtD5QtuX6kHl8+H+d3qvnE/3HZrfzgdWpsLNA==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.4.tgz", + "integrity": "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==", "license": "MIT", "dependencies": { - "@formatjs/ecma402-abstract": "2.3.4", - "@formatjs/icu-skeleton-parser": "1.8.14", + "@formatjs/ecma402-abstract": "2.3.6", + "@formatjs/icu-skeleton-parser": "1.8.16", "tslib": "^2.8.0" } }, "node_modules/@formatjs/icu-skeleton-parser": { - "version": "1.8.14", - "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.14.tgz", - "integrity": "sha512-i4q4V4qslThK4Ig8SxyD76cp3+QJ3sAqr7f6q9VVfeGtxG9OhiAk3y9XF6Q41OymsKzsGQ6OQQoJNY4/lI8TcQ==", + "version": "1.8.16", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.16.tgz", + "integrity": "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==", "license": "MIT", "dependencies": { - "@formatjs/ecma402-abstract": "2.3.4", + "@formatjs/ecma402-abstract": "2.3.6", "tslib": "^2.8.0" } }, "node_modules/@formatjs/intl-localematcher": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.1.tgz", - "integrity": "sha512-ePEgLgVCqi2BBFnTMWPfIghu6FkbZnnBVhO2sSxvLfrdFw7wCHAHiDoM2h4NRgjbaY7+B7HgOLZGkK187pZTZg==", + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz", + "integrity": "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==", "license": "MIT", "dependencies": { "tslib": "^2.8.0" @@ -3685,9 +3692,9 @@ "license": "BSD-3-Clause" }, "node_modules/@iconify-json/simple-icons": { - "version": "1.2.51", - "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.51.tgz", - "integrity": "sha512-vFH0QEHFG3rt9hZOR3oE/ZfAKFA7cS11UXttD/IphClEbiSTsPbpeeJ4kRYGDBUDmAKhBzk6jxHNG8VipwA69Q==", + "version": "1.2.67", + "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.67.tgz", + "integrity": "sha512-RGJRwlxyup54L1UDAjCshy3ckX5zcvYIU74YLSnUgHGvqh6B4mvksbGNHAIEp7dZQ6cM13RZVT5KC07CmnFNew==", "dev": true, "license": "CC0-1.0", "dependencies": { @@ -3730,35 +3737,6 @@ "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@isaacs/cliui/node_modules/strip-ansi": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", @@ -3774,23 +3752,6 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -3843,9 +3804,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "license": "MIT", "dependencies": { "argparse": "^1.0.7", @@ -3929,6 +3890,24 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/@jest/console/node_modules/@jest/types": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.5.tgz", + "integrity": "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==", + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/console/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3945,70 +3924,44 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@jest/core": { - "version": "30.1.3", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.1.3.tgz", - "integrity": "sha512-LIQz7NEDDO1+eyOA2ZmkiAyYvZuo6s1UxD/e2IHldR6D7UYogVq3arTmli07MkENLq6/3JEQjp0mA8rrHHJ8KQ==", + "node_modules/@jest/console/node_modules/jest-message-util": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.1.0.tgz", + "integrity": "sha512-HizKDGG98cYkWmaLUHChq4iN+oCENohQLb7Z5guBPumYs+/etonmNFlg1Ps6yN9LTPyZn+M+b/9BbnHx3WTMDg==", "license": "MIT", "dependencies": { - "@jest/console": "30.1.2", - "@jest/pattern": "30.0.1", - "@jest/reporters": "30.1.3", - "@jest/test-result": "30.1.3", - "@jest/transform": "30.1.2", + "@babel/code-frame": "^7.27.1", "@jest/types": "30.0.5", - "@types/node": "*", - "ansi-escapes": "^4.3.2", + "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", - "jest-changed-files": "30.0.5", - "jest-config": "30.1.3", - "jest-haste-map": "30.1.0", - "jest-message-util": "30.1.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.1.3", - "jest-resolve-dependencies": "30.1.3", - "jest-runner": "30.1.3", - "jest-runtime": "30.1.3", - "jest-snapshot": "30.1.2", - "jest-util": "30.0.5", - "jest-validate": "30.1.0", - "jest-watcher": "30.1.3", "micromatch": "^4.0.8", "pretty-format": "30.0.5", - "slash": "^3.0.0" + "slash": "^3.0.0", + "stack-utils": "^2.0.6" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } } }, - "node_modules/@jest/core/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/@jest/console/node_modules/jest-util": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.5.tgz", + "integrity": "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==", "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "@jest/types": "30.0.5", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/core/node_modules/pretty-format": { + "node_modules/@jest/console/node_modules/pretty-format": { "version": "30.0.5", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", @@ -4022,7 +3975,7 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/core/node_modules/pretty-format/node_modules/ansi-styles": { + "node_modules/@jest/console/node_modules/pretty-format/node_modules/ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", @@ -4034,50 +3987,60 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@jest/core/node_modules/react-is": { + "node_modules/@jest/console/node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, - "node_modules/@jest/diff-sequences": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", - "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", - "license": "MIT", - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/environment": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.1.2.tgz", - "integrity": "sha512-N8t1Ytw4/mr9uN28OnVf0SYE2dGhaIxOVYcwsf9IInBKjvofAjbFRvedvBBlyTYk2knbJTiEjEJ2PyyDIBnd9w==", + "node_modules/@jest/core": { + "version": "30.1.3", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.1.3.tgz", + "integrity": "sha512-LIQz7NEDDO1+eyOA2ZmkiAyYvZuo6s1UxD/e2IHldR6D7UYogVq3arTmli07MkENLq6/3JEQjp0mA8rrHHJ8KQ==", "license": "MIT", "dependencies": { - "@jest/fake-timers": "30.1.2", + "@jest/console": "30.1.2", + "@jest/pattern": "30.0.1", + "@jest/reporters": "30.1.3", + "@jest/test-result": "30.1.3", + "@jest/transform": "30.1.2", "@jest/types": "30.0.5", "@types/node": "*", - "jest-mock": "30.0.5" + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-changed-files": "30.0.5", + "jest-config": "30.1.3", + "jest-haste-map": "30.1.0", + "jest-message-util": "30.1.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.1.3", + "jest-resolve-dependencies": "30.1.3", + "jest-runner": "30.1.3", + "jest-runtime": "30.1.3", + "jest-snapshot": "30.1.2", + "jest-util": "30.0.5", + "jest-validate": "30.1.0", + "jest-watcher": "30.1.3", + "micromatch": "^4.0.8", + "pretty-format": "30.0.5", + "slash": "^3.0.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/expect": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.1.2.tgz", - "integrity": "sha512-tyaIExOwQRCxPCGNC05lIjWJztDwk2gPDNSDGg1zitXJJ8dC3++G/CRjE5mb2wQsf89+lsgAgqxxNpDLiCViTA==", - "license": "MIT", - "dependencies": { - "expect": "30.1.2", - "jest-snapshot": "30.1.2" }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } } }, - "node_modules/@jest/expect-utils": { + "node_modules/@jest/core/node_modules/@jest/expect-utils": { "version": "30.1.2", "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.1.2.tgz", "integrity": "sha512-HXy1qT/bfdjCv7iC336ExbqqYtZvljrV8odNdso7dWK9bSeHtLlvwWWC3YSybSPL03Gg5rug6WLCZAZFH72m0A==", @@ -4089,103 +4052,40 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/fake-timers": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.1.2.tgz", - "integrity": "sha512-Beljfv9AYkr9K+ETX9tvV61rJTY706BhBUtiaepQHeEGfe0DbpvUA5Z3fomwc5Xkhns6NWrcFDZn+72fLieUnA==", - "license": "MIT", - "dependencies": { - "@jest/types": "30.0.5", - "@sinonjs/fake-timers": "^13.0.0", - "@types/node": "*", - "jest-message-util": "30.1.0", - "jest-mock": "30.0.5", - "jest-util": "30.0.5" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/get-type": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", - "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", - "license": "MIT", - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/globals": { + "node_modules/@jest/core/node_modules/@jest/snapshot-utils": { "version": "30.1.2", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.1.2.tgz", - "integrity": "sha512-teNTPZ8yZe3ahbYnvnVRDeOjr+3pu2uiAtNtrEsiMjVPPj+cXd5E/fr8BL7v/T7F31vYdEHrI5cC/2OoO/vM9A==", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.1.2.tgz", + "integrity": "sha512-vHoMTpimcPSR7OxS2S0V1Cpg8eKDRxucHjoWl5u4RQcnxqQrV3avETiFpl8etn4dqxEGarBeHbIBety/f8mLXw==", "license": "MIT", "dependencies": { - "@jest/environment": "30.1.2", - "@jest/expect": "30.1.2", "@jest/types": "30.0.5", - "jest-mock": "30.0.5" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/pattern": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", - "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "jest-regex-util": "30.0.1" + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/reporters": { - "version": "30.1.3", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.1.3.tgz", - "integrity": "sha512-VWEQmJWfXMOrzdFEOyGjUEOuVXllgZsoPtEHZzfdNz18RmzJ5nlR6kp8hDdY8dDS1yGOXAY7DHT+AOHIPSBV0w==", + "node_modules/@jest/core/node_modules/@jest/types": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.5.tgz", + "integrity": "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==", "license": "MIT", "dependencies": { - "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "30.1.2", - "@jest/test-result": "30.1.3", - "@jest/transform": "30.1.2", - "@jest/types": "30.0.5", - "@jridgewell/trace-mapping": "^0.3.25", + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", "@types/node": "*", - "chalk": "^4.1.2", - "collect-v8-coverage": "^1.0.2", - "exit-x": "^0.2.2", - "glob": "^10.3.10", - "graceful-fs": "^4.2.11", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-instrument": "^6.0.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^5.0.0", - "istanbul-reports": "^3.1.3", - "jest-message-util": "30.1.0", - "jest-util": "30.0.5", - "jest-worker": "30.1.0", - "slash": "^3.0.0", - "string-length": "^4.0.2", - "v8-to-istanbul": "^9.0.1" + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } } }, - "node_modules/@jest/reporters/node_modules/chalk": { + "node_modules/@jest/core/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", @@ -4201,171 +4101,326 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@jest/reporters/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "license": "ISC", + "node_modules/@jest/core/node_modules/expect": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.1.2.tgz", + "integrity": "sha512-xvHszRavo28ejws8FpemjhwswGj4w/BetHIL8cU49u4sGyXDw2+p3YbeDbj6xzlxi6kWTjIRSTJ+9sNXPnF0Zg==", + "license": "MIT", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" + "@jest/expect-utils": "30.1.2", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.1.2", + "jest-message-util": "30.1.0", + "jest-mock": "30.0.5", + "jest-util": "30.0.5" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/reporters/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "license": "ISC", + "node_modules/@jest/core/node_modules/jest-diff": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.1.2.tgz", + "integrity": "sha512-4+prq+9J61mOVXCa4Qp8ZjavdxzrWQXrI80GNxP8f4tkI2syPuPrJgdRPZRrfUTRvIoUwcmNLbqEJy9W800+NQ==", + "license": "MIT", "dependencies": { - "brace-expansion": "^2.0.1" + "@jest/diff-sequences": "30.0.1", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.0.5" }, "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/schemas": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", - "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "node_modules/@jest/core/node_modules/jest-matcher-utils": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.1.2.tgz", + "integrity": "sha512-7ai16hy4rSbDjvPTuUhuV8nyPBd6EX34HkBsBcBX2lENCuAQ0qKCPb/+lt8OSWUa9WWmGYLy41PrEzkwRwoGZQ==", "license": "MIT", "dependencies": { - "@sinclair/typebox": "^0.34.0" + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.1.2", + "pretty-format": "30.0.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/snapshot-utils": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.1.2.tgz", - "integrity": "sha512-vHoMTpimcPSR7OxS2S0V1Cpg8eKDRxucHjoWl5u4RQcnxqQrV3avETiFpl8etn4dqxEGarBeHbIBety/f8mLXw==", + "node_modules/@jest/core/node_modules/jest-message-util": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.1.0.tgz", + "integrity": "sha512-HizKDGG98cYkWmaLUHChq4iN+oCENohQLb7Z5guBPumYs+/etonmNFlg1Ps6yN9LTPyZn+M+b/9BbnHx3WTMDg==", "license": "MIT", "dependencies": { + "@babel/code-frame": "^7.27.1", "@jest/types": "30.0.5", + "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "natural-compare": "^1.4.0" + "micromatch": "^4.0.8", + "pretty-format": "30.0.5", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/snapshot-utils/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/@jest/core/node_modules/jest-mock": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.0.5.tgz", + "integrity": "sha512-Od7TyasAAQX/6S+QCbN6vZoWOMwlTtzzGuxJku1GhGanAjz9y+QsQkpScDmETvdc9aSXyJ/Op4rhpMYBWW91wQ==", "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "@jest/types": "30.0.5", + "@types/node": "*", + "jest-util": "30.0.5" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/source-map": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", - "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", + "node_modules/@jest/core/node_modules/jest-snapshot": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.1.2.tgz", + "integrity": "sha512-4q4+6+1c8B6Cy5pGgFvjDy/Pa6VYRiGu0yQafKkJ9u6wQx4G5PqI2QR6nxTl43yy7IWsINwz6oT4o6tD12a8Dg==", "license": "MIT", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", - "callsites": "^3.1.0", - "graceful-fs": "^4.2.11" + "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.1.2", + "@jest/get-type": "30.1.0", + "@jest/snapshot-utils": "30.1.2", + "@jest/transform": "30.1.2", + "@jest/types": "30.0.5", + "babel-preset-current-node-syntax": "^1.1.0", + "chalk": "^4.1.2", + "expect": "30.1.2", + "graceful-fs": "^4.2.11", + "jest-diff": "30.1.2", + "jest-matcher-utils": "30.1.2", + "jest-message-util": "30.1.0", + "jest-util": "30.0.5", + "pretty-format": "30.0.5", + "semver": "^7.7.2", + "synckit": "^0.11.8" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/test-result": { - "version": "30.1.3", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.1.3.tgz", - "integrity": "sha512-P9IV8T24D43cNRANPPokn7tZh0FAFnYS2HIfi5vK18CjRkTDR9Y3e1BoEcAJnl4ghZZF4Ecda4M/k41QkvurEQ==", + "node_modules/@jest/core/node_modules/jest-util": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.5.tgz", + "integrity": "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==", "license": "MIT", "dependencies": { - "@jest/console": "30.1.2", "@jest/types": "30.0.5", - "@types/istanbul-lib-coverage": "^2.0.6", - "collect-v8-coverage": "^1.0.2" + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/test-sequencer": { - "version": "30.1.3", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.1.3.tgz", - "integrity": "sha512-82J+hzC0qeQIiiZDThh+YUadvshdBswi5nuyXlEmXzrhw5ZQSRHeQ5LpVMD/xc8B3wPePvs6VMzHnntxL+4E3w==", + "node_modules/@jest/core/node_modules/pretty-format": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", + "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", "license": "MIT", "dependencies": { - "@jest/test-result": "30.1.3", - "graceful-fs": "^4.2.11", - "jest-haste-map": "30.1.0", - "slash": "^3.0.0" + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/transform": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.1.2.tgz", - "integrity": "sha512-UYYFGifSgfjujf1Cbd3iU/IQoSd6uwsj8XHj5DSDf5ERDcWMdJOPTkHWXj4U+Z/uMagyOQZ6Vne8C4nRIrCxqA==", + "node_modules/@jest/core/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@jest/core/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/@jest/core/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@jest/diff-sequences": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", + "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz", + "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", "license": "MIT", "dependencies": { - "@babel/core": "^7.27.4", + "@jest/fake-timers": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-mock": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.2.0.tgz", + "integrity": "sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==", + "license": "MIT", + "dependencies": { + "expect": "30.2.0", + "jest-snapshot": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.2.0.tgz", + "integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==", + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz", + "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@sinonjs/fake-timers": "^13.0.0", + "@types/node": "*", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.2.0.tgz", + "integrity": "sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==", + "license": "MIT", + "dependencies": { + "@jest/environment": "30.2.0", + "@jest/expect": "30.2.0", + "@jest/types": "30.2.0", + "jest-mock": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "30.1.3", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.1.3.tgz", + "integrity": "sha512-VWEQmJWfXMOrzdFEOyGjUEOuVXllgZsoPtEHZzfdNz18RmzJ5nlR6kp8hDdY8dDS1yGOXAY7DHT+AOHIPSBV0w==", + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "30.1.2", + "@jest/test-result": "30.1.3", + "@jest/transform": "30.1.2", "@jest/types": "30.0.5", "@jridgewell/trace-mapping": "^0.3.25", - "babel-plugin-istanbul": "^7.0.0", + "@types/node": "*", "chalk": "^4.1.2", - "convert-source-map": "^2.0.0", - "fast-json-stable-stringify": "^2.1.0", + "collect-v8-coverage": "^1.0.2", + "exit-x": "^0.2.2", + "glob": "^10.3.10", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.1.0", - "jest-regex-util": "30.0.1", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^5.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "30.1.0", "jest-util": "30.0.5", - "micromatch": "^4.0.8", - "pirates": "^4.0.7", + "jest-worker": "30.1.0", "slash": "^3.0.0", - "write-file-atomic": "^5.0.1" + "string-length": "^4.0.2", + "v8-to-istanbul": "^9.0.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/transform/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" }, - "engines": { - "node": ">=10" + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } } }, - "node_modules/@jest/types": { + "node_modules/@jest/reporters/node_modules/@jest/types": { "version": "30.0.5", "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.5.tgz", "integrity": "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==", @@ -4383,7 +4438,7 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/types/node_modules/chalk": { + "node_modules/@jest/reporters/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", @@ -4399,229 +4454,530 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "node_modules/@jest/reporters/node_modules/jest-message-util": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.1.0.tgz", + "integrity": "sha512-HizKDGG98cYkWmaLUHChq4iN+oCENohQLb7Z5guBPumYs+/etonmNFlg1Ps6yN9LTPyZn+M+b/9BbnHx3WTMDg==", "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.0.5", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.0.5", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "node_modules/@jest/reporters/node_modules/jest-util": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.5.tgz", + "integrity": "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==", "license": "MIT", "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" + "@jest/types": "30.0.5", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" + "node_modules/@jest/reporters/node_modules/pretty-format": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", + "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", - "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", - "dev": true, + "node_modules/@jest/reporters/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "node_modules/@jest/reporters/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", "license": "MIT", "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@juggle/resize-observer": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", - "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==", - "license": "Apache-2.0" - }, - "node_modules/@leichtgewicht/ip-codec": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", - "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", - "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "node_modules/@jest/snapshot-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.2.0.tgz", + "integrity": "sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==", "license": "MIT", - "optional": true, "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.10.0" + "@jest/types": "30.2.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { - "version": "5.1.1-v1", - "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", - "integrity": "sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==", - "dev": true, + "node_modules/@jest/snapshot-utils/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "license": "MIT", "dependencies": { - "eslint-scope": "5.1.1" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@nicolo-ribaudo/eslint-scope-5-internals/node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dev": true, - "license": "BSD-2-Clause", + "node_modules/@jest/source-map": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", + "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", + "license": "MIT", "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" + "@jridgewell/trace-mapping": "^0.3.25", + "callsites": "^3.1.0", + "graceful-fs": "^4.2.11" }, "engines": { - "node": ">=8.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@nicolo-ribaudo/eslint-scope-5-internals/node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, - "license": "BSD-2-Clause", + "node_modules/@jest/test-result": { + "version": "30.1.3", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.1.3.tgz", + "integrity": "sha512-P9IV8T24D43cNRANPPokn7tZh0FAFnYS2HIfi5vK18CjRkTDR9Y3e1BoEcAJnl4ghZZF4Ecda4M/k41QkvurEQ==", + "license": "MIT", + "dependencies": { + "@jest/console": "30.1.2", + "@jest/types": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "collect-v8-coverage": "^1.0.2" + }, "engines": { - "node": ">=4.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, + "node_modules/@jest/test-result/node_modules/@jest/types": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.5.tgz", + "integrity": "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==", "license": "MIT", "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" }, "engines": { - "node": ">= 8" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, + "node_modules/@jest/test-result/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, "engines": { - "node": ">= 8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, + "node_modules/@jest/test-sequencer": { + "version": "30.1.3", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.1.3.tgz", + "integrity": "sha512-82J+hzC0qeQIiiZDThh+YUadvshdBswi5nuyXlEmXzrhw5ZQSRHeQ5LpVMD/xc8B3wPePvs6VMzHnntxL+4E3w==", "license": "MIT", "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" + "@jest/test-result": "30.1.3", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.1.0", + "slash": "^3.0.0" }, "engines": { - "node": ">= 8" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@nolyfill/is-core-module": { - "version": "1.0.39", - "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", - "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", - "dev": true, + "node_modules/@jest/transform": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.1.2.tgz", + "integrity": "sha512-UYYFGifSgfjujf1Cbd3iU/IQoSd6uwsj8XHj5DSDf5ERDcWMdJOPTkHWXj4U+Z/uMagyOQZ6Vne8C4nRIrCxqA==", "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/types": "30.0.5", + "@jridgewell/trace-mapping": "^0.3.25", + "babel-plugin-istanbul": "^7.0.0", + "chalk": "^4.1.2", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.1.0", + "jest-regex-util": "30.0.1", + "jest-util": "30.0.5", + "micromatch": "^4.0.8", + "pirates": "^4.0.7", + "slash": "^3.0.0", + "write-file-atomic": "^5.0.1" + }, "engines": { - "node": ">=12.4.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@parcel/watcher": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", - "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", - "hasInstallScript": true, + "node_modules/@jest/transform/node_modules/@jest/types": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.5.tgz", + "integrity": "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==", "license": "MIT", - "optional": true, "dependencies": { - "detect-libc": "^1.0.3", - "is-glob": "^4.0.3", - "micromatch": "^4.0.5", - "node-addon-api": "^7.0.0" + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" }, "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "@parcel/watcher-android-arm64": "2.5.1", - "@parcel/watcher-darwin-arm64": "2.5.1", - "@parcel/watcher-darwin-x64": "2.5.1", - "@parcel/watcher-freebsd-x64": "2.5.1", - "@parcel/watcher-linux-arm-glibc": "2.5.1", - "@parcel/watcher-linux-arm-musl": "2.5.1", - "@parcel/watcher-linux-arm64-glibc": "2.5.1", - "@parcel/watcher-linux-arm64-musl": "2.5.1", - "@parcel/watcher-linux-x64-glibc": "2.5.1", - "@parcel/watcher-linux-x64-musl": "2.5.1", - "@parcel/watcher-win32-arm64": "2.5.1", - "@parcel/watcher-win32-ia32": "2.5.1", - "@parcel/watcher-win32-x64": "2.5.1" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@parcel/watcher-android-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", - "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", - "cpu": [ - "arm64" - ], + "node_modules/@jest/transform/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, "engines": { - "node": ">= 10.0.0" + "node": ">=10" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@parcel/watcher-darwin-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", - "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "node_modules/@jest/transform/node_modules/jest-util": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.5.tgz", + "integrity": "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==", + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", + "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@material/material-color-utilities": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@material/material-color-utilities/-/material-color-utilities-0.3.0.tgz", + "integrity": "sha512-ztmtTd6xwnuh2/xu+Vb01btgV8SQWYCaK56CkRK8gEkWe5TuDyBcYJ0wgkMRn+2VcE9KUmhvkz+N9GHrqw/C0g==", + "license": "Apache-2.0" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { + "version": "5.1.1-v1", + "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", + "integrity": "sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-scope": "5.1.1" + } + }, + "node_modules/@nicolo-ribaudo/eslint-scope-5-internals/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@nicolo-ribaudo/eslint-scope-5-internals/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.4.tgz", + "integrity": "sha512-WYa2tUVV5HiArWPB3ydlOc4R2ivq0IDrlqhMi3l7mVsFEXNcTfxYFPIHXHXIh/ca/y/V5N4E1zecyxdIBjYnkQ==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.4", + "@parcel/watcher-darwin-arm64": "2.5.4", + "@parcel/watcher-darwin-x64": "2.5.4", + "@parcel/watcher-freebsd-x64": "2.5.4", + "@parcel/watcher-linux-arm-glibc": "2.5.4", + "@parcel/watcher-linux-arm-musl": "2.5.4", + "@parcel/watcher-linux-arm64-glibc": "2.5.4", + "@parcel/watcher-linux-arm64-musl": "2.5.4", + "@parcel/watcher-linux-x64-glibc": "2.5.4", + "@parcel/watcher-linux-x64-musl": "2.5.4", + "@parcel/watcher-win32-arm64": "2.5.4", + "@parcel/watcher-win32-ia32": "2.5.4", + "@parcel/watcher-win32-x64": "2.5.4" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.4.tgz", + "integrity": "sha512-hoh0vx4v+b3BNI7Cjoy2/B0ARqcwVNrzN/n7DLq9ZB4I3lrsvhrkCViJyfTj/Qi5xM9YFiH4AmHGK6pgH1ss7g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.4.tgz", + "integrity": "sha512-kphKy377pZiWpAOyTgQYPE5/XEKVMaj6VUjKT5VkNyUJlr2qZAn8gIc7CPzx+kbhvqHDT9d7EqdOqRXT6vk0zw==", "cpu": [ "arm64" ], @@ -4639,9 +4995,9 @@ } }, "node_modules/@parcel/watcher-darwin-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", - "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.4.tgz", + "integrity": "sha512-UKaQFhCtNJW1A9YyVz3Ju7ydf6QgrpNQfRZ35wNKUhTQ3dxJ/3MULXN5JN/0Z80V/KUBDGa3RZaKq1EQT2a2gg==", "cpu": [ "x64" ], @@ -4659,9 +5015,9 @@ } }, "node_modules/@parcel/watcher-freebsd-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", - "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.4.tgz", + "integrity": "sha512-Dib0Wv3Ow/m2/ttvLdeI2DBXloO7t3Z0oCp4bAb2aqyqOjKPPGrg10pMJJAQ7tt8P4V2rwYwywkDhUia/FgS+Q==", "cpu": [ "x64" ], @@ -4679,9 +5035,9 @@ } }, "node_modules/@parcel/watcher-linux-arm-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", - "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.4.tgz", + "integrity": "sha512-I5Vb769pdf7Q7Sf4KNy8Pogl/URRCKu9ImMmnVKYayhynuyGYMzuI4UOWnegQNa2sGpsPSbzDsqbHNMyeyPCgw==", "cpu": [ "arm" ], @@ -4699,9 +5055,9 @@ } }, "node_modules/@parcel/watcher-linux-arm-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", - "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.4.tgz", + "integrity": "sha512-kGO8RPvVrcAotV4QcWh8kZuHr9bXi9a3bSZw7kFarYR0+fGliU7hd/zevhjw8fnvIKG3J9EO5G6sXNGCSNMYPQ==", "cpu": [ "arm" ], @@ -4719,9 +5075,9 @@ } }, "node_modules/@parcel/watcher-linux-arm64-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", - "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.4.tgz", + "integrity": "sha512-KU75aooXhqGFY2W5/p8DYYHt4hrjHZod8AhcGAmhzPn/etTa+lYCDB2b1sJy3sWJ8ahFVTdy+EbqSBvMx3iFlw==", "cpu": [ "arm64" ], @@ -4739,9 +5095,9 @@ } }, "node_modules/@parcel/watcher-linux-arm64-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", - "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.4.tgz", + "integrity": "sha512-Qx8uNiIekVutnzbVdrgSanM+cbpDD3boB1f8vMtnuG5Zau4/bdDbXyKwIn0ToqFhIuob73bcxV9NwRm04/hzHQ==", "cpu": [ "arm64" ], @@ -4759,9 +5115,9 @@ } }, "node_modules/@parcel/watcher-linux-x64-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", - "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.4.tgz", + "integrity": "sha512-UYBQvhYmgAv61LNUn24qGQdjtycFBKSK3EXr72DbJqX9aaLbtCOO8+1SkKhD/GNiJ97ExgcHBrukcYhVjrnogA==", "cpu": [ "x64" ], @@ -4779,9 +5135,9 @@ } }, "node_modules/@parcel/watcher-linux-x64-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", - "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.4.tgz", + "integrity": "sha512-YoRWCVgxv8akZrMhdyVi6/TyoeeMkQ0PGGOf2E4omODrvd1wxniXP+DBynKoHryStks7l+fDAMUBRzqNHrVOpg==", "cpu": [ "x64" ], @@ -4799,9 +5155,9 @@ } }, "node_modules/@parcel/watcher-win32-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", - "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.4.tgz", + "integrity": "sha512-iby+D/YNXWkiQNYcIhg8P5hSjzXEHaQrk2SLrWOUD7VeC4Ohu0WQvmV+HDJokZVJ2UjJ4AGXW3bx7Lls9Ln4TQ==", "cpu": [ "arm64" ], @@ -4819,9 +5175,9 @@ } }, "node_modules/@parcel/watcher-win32-ia32": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", - "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.4.tgz", + "integrity": "sha512-vQN+KIReG0a2ZDpVv8cgddlf67J8hk1WfZMMP7sMeZmJRSmEax5xNDNWKdgqSe2brOKTQQAs3aCCUal2qBHAyg==", "cpu": [ "ia32" ], @@ -4839,9 +5195,9 @@ } }, "node_modules/@parcel/watcher-win32-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", - "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.4.tgz", + "integrity": "sha512-3A6efb6BOKwyw7yk9ro2vus2YTt2nvcd56AuzxdMiVOxL9umDyN5PKkKfZ/gZ9row41SjVmTVQNWQhaRRGpOKw==", "cpu": [ "x64" ], @@ -4985,9 +5341,9 @@ "license": "MIT" }, "node_modules/@remix-run/router": { - "version": "1.23.0", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", - "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==", + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", "license": "MIT", "engines": { "node": ">=14.0.0" @@ -5108,9 +5464,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.50.1.tgz", - "integrity": "sha512-HJXwzoZN4eYTdD8bVV22DN8gsPCAj3V20NHKOs8ezfXanGpmVPR7kalUHd+Y31IJp9stdB87VKPFbsGY3H/2ag==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.2.tgz", + "integrity": "sha512-21J6xzayjy3O6NdnlO6aXi/urvSRjm6nCI6+nF6ra2YofKruGixN9kfT+dt55HVNwfDmpDHJcaS3JuP/boNnlA==", "cpu": [ "arm" ], @@ -5122,9 +5478,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.50.1.tgz", - "integrity": "sha512-PZlsJVcjHfcH53mOImyt3bc97Ep3FJDXRpk9sMdGX0qgLmY0EIWxCag6EigerGhLVuL8lDVYNnSo8qnTElO4xw==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.2.tgz", + "integrity": "sha512-eXBg7ibkNUZ+sTwbFiDKou0BAckeV6kIigK7y5Ko4mB/5A1KLhuzEKovsmfvsL8mQorkoincMFGnQuIT92SKqA==", "cpu": [ "arm64" ], @@ -5136,9 +5492,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.50.1.tgz", - "integrity": "sha512-xc6i2AuWh++oGi4ylOFPmzJOEeAa2lJeGUGb4MudOtgfyyjr4UPNK+eEWTPLvmPJIY/pgw6ssFIox23SyrkkJw==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.2.tgz", + "integrity": "sha512-UCbaTklREjrc5U47ypLulAgg4njaqfOVLU18VrCrI+6E5MQjuG0lSWaqLlAJwsD7NpFV249XgB0Bi37Zh5Sz4g==", "cpu": [ "arm64" ], @@ -5150,9 +5506,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.50.1.tgz", - "integrity": "sha512-2ofU89lEpDYhdLAbRdeyz/kX3Y2lpYc6ShRnDjY35bZhd2ipuDMDi6ZTQ9NIag94K28nFMofdnKeHR7BT0CATw==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.2.tgz", + "integrity": "sha512-dP67MA0cCMHFT2g5XyjtpVOtp7y4UyUxN3dhLdt11at5cPKnSm4lY+EhwNvDXIMzAMIo2KU+mc9wxaAQJTn7sQ==", "cpu": [ "x64" ], @@ -5164,9 +5520,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.50.1.tgz", - "integrity": "sha512-wOsE6H2u6PxsHY/BeFHA4VGQN3KUJFZp7QJBmDYI983fgxq5Th8FDkVuERb2l9vDMs1D5XhOrhBrnqcEY6l8ZA==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.2.tgz", + "integrity": "sha512-WDUPLUwfYV9G1yxNRJdXcvISW15mpvod1Wv3ok+Ws93w1HjIVmCIFxsG2DquO+3usMNCpJQ0wqO+3GhFdl6Fow==", "cpu": [ "arm64" ], @@ -5178,9 +5534,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.50.1.tgz", - "integrity": "sha512-A/xeqaHTlKbQggxCqispFAcNjycpUEHP52mwMQZUNqDUJFFYtPHCXS1VAG29uMlDzIVr+i00tSFWFLivMcoIBQ==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.2.tgz", + "integrity": "sha512-Ng95wtHVEulRwn7R0tMrlUuiLVL/HXA8Lt/MYVpy88+s5ikpntzZba1qEulTuPnPIZuOPcW9wNEiqvZxZmgmqQ==", "cpu": [ "x64" ], @@ -5192,9 +5548,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.50.1.tgz", - "integrity": "sha512-54v4okehwl5TaSIkpp97rAHGp7t3ghinRd/vyC1iXqXMfjYUTm7TfYmCzXDoHUPTTf36L8pr0E7YsD3CfB3ZDg==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.2.tgz", + "integrity": "sha512-AEXMESUDWWGqD6LwO/HkqCZgUE1VCJ1OhbvYGsfqX2Y6w5quSXuyoy/Fg3nRqiwro+cJYFxiw5v4kB2ZDLhxrw==", "cpu": [ "arm" ], @@ -5206,9 +5562,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.50.1.tgz", - "integrity": "sha512-p/LaFyajPN/0PUHjv8TNyxLiA7RwmDoVY3flXHPSzqrGcIp/c2FjwPPP5++u87DGHtw+5kSH5bCJz0mvXngYxw==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.2.tgz", + "integrity": "sha512-ZV7EljjBDwBBBSv570VWj0hiNTdHt9uGznDtznBB4Caj3ch5rgD4I2K1GQrtbvJ/QiB+663lLgOdcADMNVC29Q==", "cpu": [ "arm" ], @@ -5220,9 +5576,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.50.1.tgz", - "integrity": "sha512-2AbMhFFkTo6Ptna1zO7kAXXDLi7H9fGTbVaIq2AAYO7yzcAsuTNWPHhb2aTA6GPiP+JXh85Y8CiS54iZoj4opw==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.2.tgz", + "integrity": "sha512-uvjwc8NtQVPAJtq4Tt7Q49FOodjfbf6NpqXyW/rjXoV+iZ3EJAHLNAnKT5UJBc6ffQVgmXTUL2ifYiLABlGFqA==", "cpu": [ "arm64" ], @@ -5234,9 +5590,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.50.1.tgz", - "integrity": "sha512-Cgef+5aZwuvesQNw9eX7g19FfKX5/pQRIyhoXLCiBOrWopjo7ycfB292TX9MDcDijiuIJlx1IzJz3IoCPfqs9w==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.2.tgz", + "integrity": "sha512-s3KoWVNnye9mm/2WpOZ3JeUiediUVw6AvY/H7jNA6qgKA2V2aM25lMkVarTDfiicn/DLq3O0a81jncXszoyCFA==", "cpu": [ "arm64" ], @@ -5247,10 +5603,10 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.50.1.tgz", - "integrity": "sha512-RPhTwWMzpYYrHrJAS7CmpdtHNKtt2Ueo+BlLBjfZEhYBhK00OsEqM08/7f+eohiF6poe0YRDDd8nAvwtE/Y62Q==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.2.tgz", + "integrity": "sha512-gi21faacK+J8aVSyAUptML9VQN26JRxe484IbF+h3hpG+sNVoMXPduhREz2CcYr5my0NE3MjVvQ5bMKX71pfVA==", "cpu": [ "loong64" ], @@ -5261,10 +5617,38 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.50.1.tgz", - "integrity": "sha512-eSGMVQw9iekut62O7eBdbiccRguuDgiPMsw++BVUg+1K7WjZXHOg/YOT9SWMzPZA+w98G+Fa1VqJgHZOHHnY0Q==", + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.2.tgz", + "integrity": "sha512-qSlWiXnVaS/ceqXNfnoFZh4IiCA0EwvCivivTGbEu1qv2o+WTHpn1zNmCTAoOG5QaVr2/yhCoLScQtc/7RxshA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.2.tgz", + "integrity": "sha512-rPyuLFNoF1B0+wolH277E780NUKf+KoEDb3OyoLbAO18BbeKi++YN6gC/zuJoPPDlQRL3fIxHxCxVEWiem2yXw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.2.tgz", + "integrity": "sha512-g+0ZLMook31iWV4PvqKU0i9E78gaZgYpSrYPed/4Bu+nGTgfOPtfs1h11tSSRPXSjC5EzLTjV/1A7L2Vr8pJoQ==", "cpu": [ "ppc64" ], @@ -5276,9 +5660,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.50.1.tgz", - "integrity": "sha512-S208ojx8a4ciIPrLgazF6AgdcNJzQE4+S9rsmOmDJkusvctii+ZvEuIC4v/xFqzbuP8yDjn73oBlNDgF6YGSXQ==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.2.tgz", + "integrity": "sha512-i+sGeRGsjKZcQRh3BRfpLsM3LX3bi4AoEVqmGDyc50L6KfYsN45wVCSz70iQMwPWr3E5opSiLOwsC9WB4/1pqg==", "cpu": [ "riscv64" ], @@ -5290,9 +5674,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.50.1.tgz", - "integrity": "sha512-3Ag8Ls1ggqkGUvSZWYcdgFwriy2lWo+0QlYgEFra/5JGtAd6C5Hw59oojx1DeqcA2Wds2ayRgvJ4qxVTzCHgzg==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.2.tgz", + "integrity": "sha512-C1vLcKc4MfFV6I0aWsC7B2Y9QcsiEcvKkfxprwkPfLaN8hQf0/fKHwSF2lcYzA9g4imqnhic729VB9Fo70HO3Q==", "cpu": [ "riscv64" ], @@ -5304,9 +5688,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.50.1.tgz", - "integrity": "sha512-t9YrKfaxCYe7l7ldFERE1BRg/4TATxIg+YieHQ966jwvo7ddHJxPj9cNFWLAzhkVsbBvNA4qTbPVNsZKBO4NSg==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.2.tgz", + "integrity": "sha512-68gHUK/howpQjh7g7hlD9DvTTt4sNLp1Bb+Yzw2Ki0xvscm2cOdCLZNJNhd2jW8lsTPrHAHuF751BygifW4bkQ==", "cpu": [ "s390x" ], @@ -5318,9 +5702,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.50.1.tgz", - "integrity": "sha512-MCgtFB2+SVNuQmmjHf+wfI4CMxy3Tk8XjA5Z//A0AKD7QXUYFMQcns91K6dEHBvZPCnhJSyDWLApk40Iq/H3tA==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.2.tgz", + "integrity": "sha512-1e30XAuaBP1MAizaOBApsgeGZge2/Byd6wV4a8oa6jPdHELbRHBiw7wvo4dp7Ie2PE8TZT4pj9RLGZv9N4qwlw==", "cpu": [ "x64" ], @@ -5332,9 +5716,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.50.1.tgz", - "integrity": "sha512-nEvqG+0jeRmqaUMuwzlfMKwcIVffy/9KGbAGyoa26iu6eSngAYQ512bMXuqqPrlTyfqdlB9FVINs93j534UJrg==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.2.tgz", + "integrity": "sha512-4BJucJBGbuGnH6q7kpPqGJGzZnYrpAzRd60HQSt3OpX/6/YVgSsJnNzR8Ot74io50SeVT4CtCWe/RYIAymFPwA==", "cpu": [ "x64" ], @@ -5345,10 +5729,24 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.2.tgz", + "integrity": "sha512-cT2MmXySMo58ENv8p6/O6wI/h/gLnD3D6JoajwXFZH6X9jz4hARqUhWpGuQhOgLNXscfZYRQMJvZDtWNzMAIDw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.50.1.tgz", - "integrity": "sha512-RDsLm+phmT3MJd9SNxA9MNuEAO/J2fhW8GXk62G/B4G7sLVumNFbRwDL6v5NrESb48k+QMqdGbHgEtfU0LCpbA==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.2.tgz", + "integrity": "sha512-sZnyUgGkuzIXaK3jNMPmUIyJrxu/PjmATQrocpGA1WbCPX8H5tfGgRSuYtqBYAvLuIGp8SPRb1O4d1Fkb5fXaQ==", "cpu": [ "arm64" ], @@ -5360,9 +5758,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.50.1.tgz", - "integrity": "sha512-hpZB/TImk2FlAFAIsoElM3tLzq57uxnGYwplg6WDyAxbYczSi8O2eQ+H2Lx74504rwKtZ3N2g4bCUkiamzS6TQ==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.2.tgz", + "integrity": "sha512-sDpFbenhmWjNcEbBcoTV0PWvW5rPJFvu+P7XoTY0YLGRupgLbFY0XPfwIbJOObzO7QgkRDANh65RjhPmgSaAjQ==", "cpu": [ "arm64" ], @@ -5374,9 +5772,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.50.1.tgz", - "integrity": "sha512-SXjv8JlbzKM0fTJidX4eVsH+Wmnp0/WcD8gJxIZyR6Gay5Qcsmdbi9zVtnbkGPG8v2vMR1AD06lGWy5FLMcG7A==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.2.tgz", + "integrity": "sha512-GvJ03TqqaweWCigtKQVBErw2bEhu1tyfNQbarwr94wCGnczA9HF8wqEe3U/Lfu6EdeNP0p6R+APeHVwEqVxpUQ==", "cpu": [ "ia32" ], @@ -5387,10 +5785,24 @@ "win32" ] }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.2.tgz", + "integrity": "sha512-KvXsBvp13oZz9JGe5NYS7FNizLe99Ny+W8ETsuCyjXiKdiGrcz2/J/N8qxZ/RSwivqjQguug07NLHqrIHrqfYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.50.1.tgz", - "integrity": "sha512-StxAO/8ts62KZVRAm4JZYq9+NqNsV7RvimNK+YM7ry//zebEH6meuugqW/P5OFUCjyQgui+9fUxT6d5NShvMvA==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.2.tgz", + "integrity": "sha512-xNO+fksQhsAckRtDSPWaMeT1uIM+JrDRXlerpnWNXhn1TdB3YZ6uKBMBTKP0eX9XtYEP978hHk1f8332i2AW8Q==", "cpu": [ "x64" ], @@ -5409,9 +5821,9 @@ "license": "MIT" }, "node_modules/@rushstack/eslint-patch": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.12.0.tgz", - "integrity": "sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.15.0.tgz", + "integrity": "sha512-ojSshQPKwVvSMR8yT2L/QtUkV5SXi/IfDiJ4/8d6UbTPjiHVmxZzUAzGD8Tzks1b9+qQkZa0isUOvYObedITaw==", "dev": true, "license": "MIT" }, @@ -5503,9 +5915,9 @@ "license": "MIT" }, "node_modules/@sinclair/typebox": { - "version": "0.34.41", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", - "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "version": "0.34.47", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.47.tgz", + "integrity": "sha512-ZGIBQ+XDvO5JQku9wmwtabcVTHJsgSWAHYtVuM9pBNNR5E88v6Jcj/llpmsjivig5X8A8HHOb4/mbEKPS5EvAw==", "license": "MIT" }, "node_modules/@sinonjs/commons": { @@ -6049,9 +6461,9 @@ } }, "node_modules/@tiptap/core": { - "version": "2.26.1", - "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.26.1.tgz", - "integrity": "sha512-fymyd/XZvYiHjBoLt1gxs024xP/LY26d43R1vluYq7AHBL/7DE3ywzy+1GEsGyAv5Je2L0KBhNIR/izbq3Kaqg==", + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.2.tgz", + "integrity": "sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ==", "license": "MIT", "funding": { "type": "github", @@ -6062,9 +6474,9 @@ } }, "node_modules/@tiptap/extension-blockquote": { - "version": "2.26.1", - "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.26.1.tgz", - "integrity": "sha512-viQ6AHRhjCYYipKK6ZepBzwZpkuMvO9yhRHeUZDvlSOAh8rvsUTSre0y74nu8QRYUt4a44lJJ6BpphJK7bEgYA==", + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.27.2.tgz", + "integrity": "sha512-oIGZgiAeA4tG3YxbTDfrmENL4/CIwGuP3THtHsNhwRqwsl9SfMk58Ucopi2GXTQSdYXpRJ0ahE6nPqB5D6j/Zw==", "license": "MIT", "funding": { "type": "github", @@ -6075,9 +6487,9 @@ } }, "node_modules/@tiptap/extension-bold": { - "version": "2.26.1", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.26.1.tgz", - "integrity": "sha512-zCce9PRuTNhadFir71luLo99HERDpGJ0EEflGm7RN8I1SnNi9gD5ooK42BOIQtejGCJqg3hTPZiYDJC2hXvckQ==", + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.27.2.tgz", + "integrity": "sha512-bR7J5IwjCGQ0s3CIxyMvOCnMFMzIvsc5OVZKscTN5UkXzFsaY6muUAIqtKxayBUucjtUskm5qZowJITCeCb1/A==", "license": "MIT", "funding": { "type": "github", @@ -6088,9 +6500,9 @@ } }, "node_modules/@tiptap/extension-bubble-menu": { - "version": "2.26.1", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.26.1.tgz", - "integrity": "sha512-oHevUcZbTMFOTpdCEo4YEDe044MB4P1ZrWyML8CGe5tnnKdlI9BN03AXpI1mEEa5CA3H1/eEckXx8EiCgYwQ3Q==", + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.27.2.tgz", + "integrity": "sha512-VkwlCOcr0abTBGzjPXklJ92FCowG7InU8+Od9FyApdLNmn0utRYGRhw0Zno6VgE9EYr1JY4BRnuSa5f9wlR72w==", "license": "MIT", "dependencies": { "tippy.js": "^6.3.7" @@ -6105,9 +6517,9 @@ } }, "node_modules/@tiptap/extension-bullet-list": { - "version": "2.26.1", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.26.1.tgz", - "integrity": "sha512-HHakuV4ckYCDOnBbne088FvCEP4YICw+wgPBz/V2dfpiFYQ4WzT0LPK9s7OFMCN+ROraoug+1ryN1Z1KdIgujQ==", + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.27.2.tgz", + "integrity": "sha512-gmFuKi97u5f8uFc/GQs+zmezjiulZmFiDYTh3trVoLRoc2SAHOjGEB7qxdx7dsqmMN7gwiAWAEVurLKIi1lnnw==", "license": "MIT", "funding": { "type": "github", @@ -6118,9 +6530,9 @@ } }, "node_modules/@tiptap/extension-code": { - "version": "2.26.1", - "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.26.1.tgz", - "integrity": "sha512-GU9deB1A/Tr4FMPu71CvlcjGKwRhGYz60wQ8m4aM+ELZcVIcZRa1ebR8bExRIEWnvRztQuyRiCQzw2N0xQJ1QQ==", + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.27.2.tgz", + "integrity": "sha512-7X9AgwqiIGXoZX7uvdHQsGsjILnN/JaEVtqfXZnPECzKGaWHeK/Ao4sYvIIIffsyZJA8k5DC7ny2/0sAgr2TuA==", "license": "MIT", "funding": { "type": "github", @@ -6131,9 +6543,9 @@ } }, "node_modules/@tiptap/extension-code-block": { - "version": "2.26.1", - "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.26.1.tgz", - "integrity": "sha512-/TDDOwONl0qEUc4+B6V9NnWtSjz95eg7/8uCb8Y8iRbGvI9vT4/znRKofFxstvKmW4URu/H74/g0ywV57h0B+A==", + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.27.2.tgz", + "integrity": "sha512-KgvdQHS4jXr79aU3wZOGBIZYYl9vCB7uDEuRFV4so2rYrfmiYMw3T8bTnlNEEGe4RUeAms1i4fdwwvQp9nR1Dw==", "license": "MIT", "funding": { "type": "github", @@ -6145,9 +6557,9 @@ } }, "node_modules/@tiptap/extension-color": { - "version": "2.26.1", - "resolved": "https://registry.npmjs.org/@tiptap/extension-color/-/extension-color-2.26.1.tgz", - "integrity": "sha512-lsPw3qpQNes1rHpxBtsV9XniN1dEjYd2nVTpQHGE4XLNwfE5+ejm6ySs8qVLM7+EXWcjANLLh4UA3zqkX6t6HA==", + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-color/-/extension-color-2.27.2.tgz", + "integrity": "sha512-sOKCP8/2V3sRM3FdWgMe1lFE5ewsWNCRafiVoujS1+TTHGCj4jw6W+LiumBUk7cRI8kXW/rqGWVC4RVdknYUCA==", "license": "MIT", "funding": { "type": "github", @@ -6159,9 +6571,9 @@ } }, "node_modules/@tiptap/extension-document": { - "version": "2.26.1", - "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.26.1.tgz", - "integrity": "sha512-2P2IZp1NRAE+21mRuFBiP3X2WKfZ6kUC23NJKpn8bcOamY3obYqCt0ltGPhE4eR8n8QAl2fI/3jIgjR07dC8ow==", + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.27.2.tgz", + "integrity": "sha512-CFhAYsPnyYnosDC4639sCJnBUnYH4Cat9qH5NZWHVvdgtDwu8GZgZn2eSzaKSYXWH1vJ9DSlCK+7UyC3SNXIBA==", "license": "MIT", "funding": { "type": "github", @@ -6172,9 +6584,9 @@ } }, "node_modules/@tiptap/extension-dropcursor": { - "version": "2.26.1", - "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.26.1.tgz", - "integrity": "sha512-JkDQU2ZYFOuT5mNYb8OiWGwD1HcjbtmX8tLNugQbToECmz9WvVPqJmn7V/q8VGpP81iEECz/IsyRmuf2kSD4uA==", + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.27.2.tgz", + "integrity": "sha512-oEu/OrktNoQXq1x29NnH/GOIzQZm8ieTQl3FK27nxfBPA89cNoH4mFEUmBL5/OFIENIjiYG3qWpg6voIqzswNw==", "license": "MIT", "funding": { "type": "github", @@ -6186,9 +6598,9 @@ } }, "node_modules/@tiptap/extension-floating-menu": { - "version": "2.26.1", - "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.26.1.tgz", - "integrity": "sha512-OJF+H6qhQogVTMedAGSWuoL1RPe3LZYXONuFCVyzHnvvMpK+BP1vm180E2zDNFnn/DVA+FOrzNGpZW7YjoFH1w==", + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.27.2.tgz", + "integrity": "sha512-GUN6gPIGXS7ngRJOwdSmtBRBDt9Kt9CM/9pSwKebhLJ+honFoNA+Y6IpVyDvvDMdVNgBchiJLs6qA5H97gAePQ==", "license": "MIT", "dependencies": { "tippy.js": "^6.3.7" @@ -6203,9 +6615,9 @@ } }, "node_modules/@tiptap/extension-gapcursor": { - "version": "2.26.1", - "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.26.1.tgz", - "integrity": "sha512-KOiMZc3PwJS3hR0nSq5d0TJi2jkNZkLZElcT6pCEnhRHzPH6dRMu9GM5Jj798ZRUy0T9UFcKJalFZaDxnmRnpg==", + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.27.2.tgz", + "integrity": "sha512-/c9VF1HBxj+AP54XGVgCmD9bEGYc5w5OofYCFQgM7l7PB1J00A4vOke0oPkHJnqnOOyPlFaxO/7N6l3XwFcnKA==", "license": "MIT", "funding": { "type": "github", @@ -6217,9 +6629,9 @@ } }, "node_modules/@tiptap/extension-hard-break": { - "version": "2.26.1", - "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.26.1.tgz", - "integrity": "sha512-d6uStdNKi8kjPlHAyO59M6KGWATNwhLCD7dng0NXfwGndc22fthzIk/6j9F6ltQx30huy5qQram6j3JXwNACoA==", + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.27.2.tgz", + "integrity": "sha512-kSRVGKlCYK6AGR0h8xRkk0WOFGXHIIndod3GKgWU49APuIGDiXd8sziXsSlniUsWmqgDmDXcNnSzPcV7AQ8YNg==", "license": "MIT", "funding": { "type": "github", @@ -6230,9 +6642,9 @@ } }, "node_modules/@tiptap/extension-heading": { - "version": "2.26.1", - "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.26.1.tgz", - "integrity": "sha512-KSzL8WZV3pjJG9ke4RaU70+B5UlYR2S6olNt5UCAawM+fi11mobVztiBoC19xtpSVqIXC1AmXOqUgnuSvmE4ZA==", + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.27.2.tgz", + "integrity": "sha512-iM3yeRWuuQR/IRQ1djwNooJGfn9Jts9zF43qZIUf+U2NY8IlvdNsk2wTOdBgh6E0CamrStPxYGuln3ZS4fuglw==", "license": "MIT", "funding": { "type": "github", @@ -6243,9 +6655,9 @@ } }, "node_modules/@tiptap/extension-history": { - "version": "2.26.1", - "resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.26.1.tgz", - "integrity": "sha512-m6YR1gkkauIDo3PRl0gP+7Oc4n5OqDzcjVh6LvWREmZP8nmi94hfseYbqOXUb6RPHIc0JKF02eiRifT4MSd2nw==", + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.27.2.tgz", + "integrity": "sha512-+hSyqERoFNTWPiZx4/FCyZ/0eFqB9fuMdTB4AC/q9iwu3RNWAQtlsJg5230bf/qmyO6bZxRUc0k8p4hrV6ybAw==", "license": "MIT", "funding": { "type": "github", @@ -6257,9 +6669,9 @@ } }, "node_modules/@tiptap/extension-horizontal-rule": { - "version": "2.26.1", - "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.26.1.tgz", - "integrity": "sha512-mT6baqOhs/NakgrAeDeed194E/ZJFGL692H0C7f1N7WDRaWxUu2oR0LrnRqSH5OyPjELkzu6nQnNy0+0tFGHHg==", + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.27.2.tgz", + "integrity": "sha512-WGWUSgX+jCsbtf9Y9OCUUgRZYuwjVoieW5n6mAUohJ9/6gc6sGIOrUpBShf+HHo6WD+gtQjRd+PssmX3NPWMpg==", "license": "MIT", "funding": { "type": "github", @@ -6271,9 +6683,9 @@ } }, "node_modules/@tiptap/extension-image": { - "version": "2.26.1", - "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-2.26.1.tgz", - "integrity": "sha512-96+MaYBJebQlR/ik5W72GLUfXdEoxFs+6jsoERxbM5qEdhb7TEnodBFtWZOwgDO27kFd6rSNZuW9r5KJNtljEg==", + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-2.27.2.tgz", + "integrity": "sha512-5zL/BY41FIt72azVrCrv3n+2YJ/JyO8wxCcA4Dk1eXIobcgVyIdo4rG39gCqIOiqziAsqnqoj12QHTBtHsJ6mQ==", "license": "MIT", "funding": { "type": "github", @@ -6284,9 +6696,9 @@ } }, "node_modules/@tiptap/extension-italic": { - "version": "2.26.1", - "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.26.1.tgz", - "integrity": "sha512-pOs6oU4LyGO89IrYE4jbE8ZYsPwMMIiKkYfXcfeD9NtpGNBnjeVXXF5I9ndY2ANrCAgC8k58C3/powDRf0T2yA==", + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.27.2.tgz", + "integrity": "sha512-1OFsw2SZqfaqx5Fa5v90iNlPRcqyt+lVSjBwTDzuPxTPFY4Q0mL89mKgkq2gVHYNCiaRkXvFLDxaSvBWbmthgg==", "license": "MIT", "funding": { "type": "github", @@ -6297,12 +6709,12 @@ } }, "node_modules/@tiptap/extension-link": { - "version": "2.26.1", - "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.26.1.tgz", - "integrity": "sha512-7yfum5Jymkue/uOSTQPt2SmkZIdZx7t3QhZLqBU7R9ettkdSCBgEGok6N+scJM1R1Zes+maSckLm0JZw5BKYNA==", + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.27.2.tgz", + "integrity": "sha512-bnP61qkr0Kj9Cgnop1hxn2zbOCBzNtmawxr92bVTOE31fJv6FhtCnQiD6tuPQVGMYhcmAj7eihtvuEMFfqEPcQ==", "license": "MIT", "dependencies": { - "linkifyjs": "^4.2.0" + "linkifyjs": "^4.3.2" }, "funding": { "type": "github", @@ -6314,9 +6726,9 @@ } }, "node_modules/@tiptap/extension-list-item": { - "version": "2.26.1", - "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.26.1.tgz", - "integrity": "sha512-quOXckC73Luc3x+Dcm88YAEBW+Crh3x5uvtQOQtn2GEG91AshrvbnhGRiYnfvEN7UhWIS+FYI5liHFcRKSUKrQ==", + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.27.2.tgz", + "integrity": "sha512-eJNee7IEGXMnmygM5SdMGDC8m/lMWmwNGf9fPCK6xk0NxuQRgmZHL6uApKcdH6gyNcRPHCqvTTkhEP7pbny/fg==", "license": "MIT", "funding": { "type": "github", @@ -6327,9 +6739,9 @@ } }, "node_modules/@tiptap/extension-ordered-list": { - "version": "2.26.1", - "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.26.1.tgz", - "integrity": "sha512-UHKNRxq6TBnXMGFSq91knD6QaHsyyOwLOsXMzupmKM5Su0s+CRXEjfav3qKlbb9e4m7D7S/a0aPm8nC9KIXNhQ==", + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.27.2.tgz", + "integrity": "sha512-M7A4tLGJcLPYdLC4CI2Gwl8LOrENQW59u3cMVa+KkwG1hzSJyPsbDpa1DI6oXPC2WtYiTf22zrbq3gVvH+KA2w==", "license": "MIT", "funding": { "type": "github", @@ -6340,9 +6752,9 @@ } }, "node_modules/@tiptap/extension-paragraph": { - "version": "2.26.1", - "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.26.1.tgz", - "integrity": "sha512-UezvM9VDRAVJlX1tykgHWSD1g3MKfVMWWZ+Tg+PE4+kizOwoYkRWznVPgCAxjmyHajxpCKRXgqTZkOxjJ9Kjzg==", + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.27.2.tgz", + "integrity": "sha512-elYVn2wHJJ+zB9LESENWOAfI4TNT0jqEN34sMA/hCtA4im1ZG2DdLHwkHIshj/c4H0dzQhmsS/YmNC5Vbqab/A==", "license": "MIT", "funding": { "type": "github", @@ -6353,9 +6765,9 @@ } }, "node_modules/@tiptap/extension-strike": { - "version": "2.26.1", - "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.26.1.tgz", - "integrity": "sha512-CkoRH+pAi6MgdCh7K0cVZl4N2uR4pZdabXAnFSoLZRSg6imLvEUmWHfSi1dl3Z7JOvd3a4yZ4NxerQn5MWbJ7g==", + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.27.2.tgz", + "integrity": "sha512-HHIjhafLhS2lHgfAsCwC1okqMsQzR4/mkGDm4M583Yftyjri1TNA7lzhzXWRFWiiMfJxKtdjHjUAQaHuteRTZw==", "license": "MIT", "funding": { "type": "github", @@ -6366,9 +6778,9 @@ } }, "node_modules/@tiptap/extension-text": { - "version": "2.26.1", - "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.26.1.tgz", - "integrity": "sha512-p2n8WVMd/2vckdJlol24acaTDIZAhI7qle5cM75bn01sOEZoFlSw6SwINOULrUCzNJsYb43qrLEibZb4j2LeQw==", + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.27.2.tgz", + "integrity": "sha512-Xk7nYcigljAY0GO9hAQpZ65ZCxqOqaAlTPDFcKerXmlkQZP/8ndx95OgUb1Xf63kmPOh3xypurGS2is3v0MXSA==", "license": "MIT", "funding": { "type": "github", @@ -6379,9 +6791,9 @@ } }, "node_modules/@tiptap/extension-text-style": { - "version": "2.26.1", - "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.26.1.tgz", - "integrity": "sha512-t9Nc/UkrbCfnSHEUi1gvUQ2ZPzvfdYFT5TExoV2DTiUCkhG6+mecT5bTVFGW3QkPmbToL+nFhGn4ZRMDD0SP3Q==", + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz", + "integrity": "sha512-Omk+uxjJLyEY69KStpCw5fA9asvV+MGcAX2HOxyISDFoLaL49TMrNjhGAuz09P1L1b0KGXo4ml7Q3v/Lfy4WPA==", "license": "MIT", "funding": { "type": "github", @@ -6392,9 +6804,9 @@ } }, "node_modules/@tiptap/pm": { - "version": "2.26.1", - "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.26.1.tgz", - "integrity": "sha512-8aF+mY/vSHbGFqyG663ds84b+vca5Lge3tHdTMTKazxCnhXR9dn2oQJMnZ78YZvdRbkPkMJJHti9h3K7u2UQvw==", + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.2.tgz", + "integrity": "sha512-kaEg7BfiJPDQMKbjVIzEPO3wlcA+pZb2tlcK9gPrdDnEFaec2QTF1sXz2ak2IIb2curvnIrQ4yrfHgLlVA72wA==", "license": "MIT", "dependencies": { "prosemirror-changeset": "^2.3.0", @@ -6422,13 +6834,13 @@ } }, "node_modules/@tiptap/react": { - "version": "2.26.1", - "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.26.1.tgz", - "integrity": "sha512-Zxlwzi1iML7aELa+PyysFD2ncVo2mEcjTkhoDok9iTbMGpm1oU8hgR1i6iHrcSNQLfaRiW6M7HNhZZQPKIC9yw==", + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.27.2.tgz", + "integrity": "sha512-0EAs8Cpkfbvben1PZ34JN2Nd79Dhioynm2jML27DBbf1VWPk+FFWFGTMLUT0bu+Np5iVxio8fqV9t0mc4D6thA==", "license": "MIT", "dependencies": { - "@tiptap/extension-bubble-menu": "^2.26.1", - "@tiptap/extension-floating-menu": "^2.26.1", + "@tiptap/extension-bubble-menu": "^2.27.2", + "@tiptap/extension-floating-menu": "^2.27.2", "@types/use-sync-external-store": "^0.0.6", "fast-deep-equal": "^3", "use-sync-external-store": "^1" @@ -6445,32 +6857,32 @@ } }, "node_modules/@tiptap/starter-kit": { - "version": "2.26.1", - "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.26.1.tgz", - "integrity": "sha512-oziMGCds8SVQ3s5dRpBxVdEKZAmO/O//BjZ69mhA3q4vJdR0rnfLb5fTxSeQvHiqB878HBNn76kNaJrHrV35GA==", - "license": "MIT", - "dependencies": { - "@tiptap/core": "^2.26.1", - "@tiptap/extension-blockquote": "^2.26.1", - "@tiptap/extension-bold": "^2.26.1", - "@tiptap/extension-bullet-list": "^2.26.1", - "@tiptap/extension-code": "^2.26.1", - "@tiptap/extension-code-block": "^2.26.1", - "@tiptap/extension-document": "^2.26.1", - "@tiptap/extension-dropcursor": "^2.26.1", - "@tiptap/extension-gapcursor": "^2.26.1", - "@tiptap/extension-hard-break": "^2.26.1", - "@tiptap/extension-heading": "^2.26.1", - "@tiptap/extension-history": "^2.26.1", - "@tiptap/extension-horizontal-rule": "^2.26.1", - "@tiptap/extension-italic": "^2.26.1", - "@tiptap/extension-list-item": "^2.26.1", - "@tiptap/extension-ordered-list": "^2.26.1", - "@tiptap/extension-paragraph": "^2.26.1", - "@tiptap/extension-strike": "^2.26.1", - "@tiptap/extension-text": "^2.26.1", - "@tiptap/extension-text-style": "^2.26.1", - "@tiptap/pm": "^2.26.1" + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.27.2.tgz", + "integrity": "sha512-bb0gJvPoDuyRUQ/iuN52j1//EtWWttw+RXAv1uJxfR0uKf8X7uAqzaOOgwjknoCIDC97+1YHwpGdnRjpDkOBxw==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^2.27.2", + "@tiptap/extension-blockquote": "^2.27.2", + "@tiptap/extension-bold": "^2.27.2", + "@tiptap/extension-bullet-list": "^2.27.2", + "@tiptap/extension-code": "^2.27.2", + "@tiptap/extension-code-block": "^2.27.2", + "@tiptap/extension-document": "^2.27.2", + "@tiptap/extension-dropcursor": "^2.27.2", + "@tiptap/extension-gapcursor": "^2.27.2", + "@tiptap/extension-hard-break": "^2.27.2", + "@tiptap/extension-heading": "^2.27.2", + "@tiptap/extension-history": "^2.27.2", + "@tiptap/extension-horizontal-rule": "^2.27.2", + "@tiptap/extension-italic": "^2.27.2", + "@tiptap/extension-list-item": "^2.27.2", + "@tiptap/extension-ordered-list": "^2.27.2", + "@tiptap/extension-paragraph": "^2.27.2", + "@tiptap/extension-strike": "^2.27.2", + "@tiptap/extension-text": "^2.27.2", + "@tiptap/extension-text-style": "^2.27.2", + "@tiptap/pm": "^2.27.2" }, "funding": { "type": "github", @@ -6644,22 +7056,22 @@ } }, "node_modules/@types/express": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", - "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", "@types/qs": "*", - "@types/serve-static": "*" + "@types/serve-static": "^1" } }, "node_modules/@types/express-serve-static-core": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz", - "integrity": "sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", "dev": true, "license": "MIT", "dependencies": { @@ -6670,9 +7082,9 @@ } }, "node_modules/@types/express/node_modules/@types/express-serve-static-core": { - "version": "4.19.6", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", - "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", "dev": true, "license": "MIT", "dependencies": { @@ -6728,9 +7140,9 @@ "license": "MIT" }, "node_modules/@types/http-proxy": { - "version": "1.17.16", - "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.16.tgz", - "integrity": "sha512-sdWoUajOB1cd0A8cRRQ1cfyWNbmFKLAqBB89Y8x5iYyG/mkJHc0YUH8pdWBy2omi9qtCpiIgGjuwO0dQST2l5w==", + "version": "1.17.17", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz", + "integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==", "dev": true, "license": "MIT", "dependencies": { @@ -6793,9 +7205,9 @@ "license": "MIT" }, "node_modules/@types/lodash": { - "version": "4.17.20", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", - "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==", "dev": true, "license": "MIT" }, @@ -6894,13 +7306,13 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "18.3.24", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.24.tgz", - "integrity": "sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A==", + "version": "18.3.27", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", + "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "license": "MIT", "dependencies": { "@types/prop-types": "*", - "csstype": "^3.0.2" + "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { @@ -6959,13 +7371,12 @@ "license": "MIT" }, "node_modules/@types/send": { - "version": "0.17.5", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", - "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", "dev": true, "license": "MIT", "dependencies": { - "@types/mime": "^1", "@types/node": "*" } }, @@ -6980,15 +7391,26 @@ } }, "node_modules/@types/serve-static": { - "version": "1.15.8", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz", - "integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==", + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", "@types/node": "*", - "@types/send": "*" + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" } }, "node_modules/@types/sinon": { @@ -7002,9 +7424,9 @@ } }, "node_modules/@types/sinonjs__fake-timers": { - "version": "8.1.5", - "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz", - "integrity": "sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==", + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-15.0.1.tgz", + "integrity": "sha512-Ko2tjWJq8oozHzHV+reuvS5KYIRAokHnGbDwGh/J64LntgpbuylF74ipEL24HCyRjf9FOlBiBHWBR1RlVKsI1w==", "dev": true, "license": "MIT" }, @@ -7078,9 +7500,9 @@ } }, "node_modules/@types/yargs": { - "version": "17.0.33", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", - "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", "license": "MIT", "dependencies": { "@types/yargs-parser": "*" @@ -7257,9 +7679,9 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -7419,9 +7841,9 @@ } }, "node_modules/@typescript-eslint/experimental-utils/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -7538,9 +7960,9 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -7726,9 +8148,9 @@ } }, "node_modules/@typescript-eslint/type-utils/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -7782,9 +8204,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -7821,9 +8243,9 @@ } }, "node_modules/@typescript-eslint/utils/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -7852,9 +8274,9 @@ } }, "node_modules/@uiw/copy-to-clipboard": { - "version": "1.0.17", - "resolved": "https://registry.npmjs.org/@uiw/copy-to-clipboard/-/copy-to-clipboard-1.0.17.tgz", - "integrity": "sha512-O2GUHV90Iw2VrSLVLK0OmNIMdZ5fgEg4NhvtwINsX+eZ/Wf6DWD0TdsK9xwV7dNRnK/UI2mQtl0a2/kRgm1m1A==", + "version": "1.0.19", + "resolved": "https://registry.npmjs.org/@uiw/copy-to-clipboard/-/copy-to-clipboard-1.0.19.tgz", + "integrity": "sha512-AYxzFUBkZrhtExb2QC0C4lFH2+BSx6JVId9iqeGHakBuosqiQHUQaNZCvIBeM97Ucp+nJ22flOh8FBT2pKRRAA==", "license": "MIT", "funding": { "url": "https://jaywcjlove.github.io/#/sponsor" @@ -8145,77 +8567,90 @@ ] }, "node_modules/@vue/compiler-core": { - "version": "3.5.21", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.21.tgz", - "integrity": "sha512-8i+LZ0vf6ZgII5Z9XmUvrCyEzocvWT+TeR2VBUVlzIH6Tyv57E20mPZ1bCS+tbejgUgmjrEh7q/0F0bibskAmw==", + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.27.tgz", + "integrity": "sha512-gnSBQjZA+//qDZen+6a2EdHqJ68Z7uybrMf3SPjEGgG4dicklwDVmMC1AeIHxtLVPT7sn6sH1KOO+tS6gwOUeQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.3", - "@vue/shared": "3.5.21", - "entities": "^4.5.0", + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.27", + "entities": "^7.0.0", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, + "node_modules/@vue/compiler-core/node_modules/entities": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz", + "integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/@vue/compiler-dom": { - "version": "3.5.21", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.21.tgz", - "integrity": "sha512-jNtbu/u97wiyEBJlJ9kmdw7tAr5Vy0Aj5CgQmo+6pxWNQhXZDPsRr1UWPN4v3Zf82s2H3kF51IbzZ4jMWAgPlQ==", + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.27.tgz", + "integrity": "sha512-oAFea8dZgCtVVVTEC7fv3T5CbZW9BxpFzGGxC79xakTr6ooeEqmRuvQydIiDAkglZEAd09LgVf1RoDnL54fu5w==", "dev": true, "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.5.21", - "@vue/shared": "3.5.21" + "@vue/compiler-core": "3.5.27", + "@vue/shared": "3.5.27" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.5.21", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.21.tgz", - "integrity": "sha512-SXlyk6I5eUGBd2v8Ie7tF6ADHE9kCR6mBEuPyH1nUZ0h6Xx6nZI29i12sJKQmzbDyr2tUHMhhTt51Z6blbkTTQ==", + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.27.tgz", + "integrity": "sha512-sHZu9QyDPeDmN/MRoshhggVOWE5WlGFStKFwu8G52swATgSny27hJRWteKDSUUzUH+wp+bmeNbhJnEAel/auUQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.3", - "@vue/compiler-core": "3.5.21", - "@vue/compiler-dom": "3.5.21", - "@vue/compiler-ssr": "3.5.21", - "@vue/shared": "3.5.21", + "@babel/parser": "^7.28.5", + "@vue/compiler-core": "3.5.27", + "@vue/compiler-dom": "3.5.27", + "@vue/compiler-ssr": "3.5.27", + "@vue/shared": "3.5.27", "estree-walker": "^2.0.2", - "magic-string": "^0.30.18", + "magic-string": "^0.30.21", "postcss": "^8.5.6", "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-ssr": { - "version": "3.5.21", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.21.tgz", - "integrity": "sha512-vKQ5olH5edFZdf5ZrlEgSO1j1DMA4u23TVK5XR1uMhvwnYvVdDF0nHXJUblL/GvzlShQbjhZZ2uvYmDlAbgo9w==", + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.27.tgz", + "integrity": "sha512-Sj7h+JHt512fV1cTxKlYhg7qxBvack+BGncSpH+8vnN+KN95iPIcqB5rsbblX40XorP+ilO7VIKlkuu3Xq2vjw==", "dev": true, "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.21", - "@vue/shared": "3.5.21" + "@vue/compiler-dom": "3.5.27", + "@vue/shared": "3.5.27" } }, "node_modules/@vue/devtools-api": { - "version": "7.7.7", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.7.tgz", - "integrity": "sha512-lwOnNBH2e7x1fIIbVT7yF5D+YWhqELm55/4ZKf45R9T8r9dE2AIOy8HKjfqzGsoTHFbWbr337O4E0A0QADnjBg==", + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", "dev": true, "license": "MIT", "dependencies": { - "@vue/devtools-kit": "^7.7.7" + "@vue/devtools-kit": "^7.7.9" } }, "node_modules/@vue/devtools-kit": { - "version": "7.7.7", - "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.7.tgz", - "integrity": "sha512-wgoZtxcTta65cnZ1Q6MbAfePVFxfM+gq0saaeytoph7nEa7yMXoi6sCPy4ufO111B9msnw0VOWjPEFCXuAKRHA==", + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", "dev": true, "license": "MIT", "dependencies": { - "@vue/devtools-shared": "^7.7.7", + "@vue/devtools-shared": "^7.7.9", "birpc": "^2.3.0", "hookable": "^5.5.3", "mitt": "^3.0.1", @@ -8225,9 +8660,9 @@ } }, "node_modules/@vue/devtools-shared": { - "version": "7.7.7", - "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.7.tgz", - "integrity": "sha512-+udSj47aRl5aKb0memBvcUG9koarqnxNM5yjuREvqwK6T3ap4mn3Zqqc17QrBFTqSMjr3HK1cvStEZpMDpfdyw==", + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", "dev": true, "license": "MIT", "dependencies": { @@ -8235,57 +8670,57 @@ } }, "node_modules/@vue/reactivity": { - "version": "3.5.21", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.21.tgz", - "integrity": "sha512-3ah7sa+Cwr9iiYEERt9JfZKPw4A2UlbY8RbbnH2mGCE8NwHkhmlZt2VsH0oDA3P08X3jJd29ohBDtX+TbD9AsA==", + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.27.tgz", + "integrity": "sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==", "dev": true, "license": "MIT", "dependencies": { - "@vue/shared": "3.5.21" + "@vue/shared": "3.5.27" } }, "node_modules/@vue/runtime-core": { - "version": "3.5.21", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.21.tgz", - "integrity": "sha512-+DplQlRS4MXfIf9gfD1BOJpk5RSyGgGXD/R+cumhe8jdjUcq/qlxDawQlSI8hCKupBlvM+3eS1se5xW+SuNAwA==", + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.27.tgz", + "integrity": "sha512-fxVuX/fzgzeMPn/CLQecWeDIFNt3gQVhxM0rW02Tvp/YmZfXQgcTXlakq7IMutuZ/+Ogbn+K0oct9J3JZfyk3A==", "dev": true, "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.21", - "@vue/shared": "3.5.21" + "@vue/reactivity": "3.5.27", + "@vue/shared": "3.5.27" } }, "node_modules/@vue/runtime-dom": { - "version": "3.5.21", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.21.tgz", - "integrity": "sha512-3M2DZsOFwM5qI15wrMmNF5RJe1+ARijt2HM3TbzBbPSuBHOQpoidE+Pa+XEaVN+czbHf81ETRoG1ltztP2em8w==", + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.27.tgz", + "integrity": "sha512-/QnLslQgYqSJ5aUmb5F0z0caZPGHRB8LEAQ1s81vHFM5CBfnun63rxhvE/scVb/j3TbBuoZwkJyiLCkBluMpeg==", "dev": true, "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.21", - "@vue/runtime-core": "3.5.21", - "@vue/shared": "3.5.21", - "csstype": "^3.1.3" + "@vue/reactivity": "3.5.27", + "@vue/runtime-core": "3.5.27", + "@vue/shared": "3.5.27", + "csstype": "^3.2.3" } }, "node_modules/@vue/server-renderer": { - "version": "3.5.21", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.21.tgz", - "integrity": "sha512-qr8AqgD3DJPJcGvLcJKQo2tAc8OnXRcfxhOJCPF+fcfn5bBGz7VCcO7t+qETOPxpWK1mgysXvVT/j+xWaHeMWA==", + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.27.tgz", + "integrity": "sha512-qOz/5thjeP1vAFc4+BY3Nr6wxyLhpeQgAE/8dDtKo6a6xdk+L4W46HDZgNmLOBUDEkFXV3G7pRiUqxjX0/2zWA==", "dev": true, "license": "MIT", "dependencies": { - "@vue/compiler-ssr": "3.5.21", - "@vue/shared": "3.5.21" + "@vue/compiler-ssr": "3.5.27", + "@vue/shared": "3.5.27" }, "peerDependencies": { - "vue": "3.5.21" + "vue": "3.5.27" } }, "node_modules/@vue/shared": { - "version": "3.5.21", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.21.tgz", - "integrity": "sha512-+2k1EQpnYuVuu3N7atWyG3/xoFWIVJZq4Mz8XNOdScFI0etES75fbny/oU4lKWk/577P1zmg0ioYvpGEDZ3DLw==", + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.27.tgz", + "integrity": "sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==", "dev": true, "license": "MIT" }, @@ -8536,9 +8971,9 @@ } }, "node_modules/ace-builds": { - "version": "1.43.3", - "resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.43.3.tgz", - "integrity": "sha512-MCl9rALmXwIty/4Qboijo/yNysx1r6hBTzG+6n/TiOm5LFhZpEvEIcIITPFiEOEFDfgBOEmxu+a4f54LEFM6Sg==", + "version": "1.43.5", + "resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.43.5.tgz", + "integrity": "sha512-iH5FLBKdB7SVn9GR37UgA/tpQS8OTWIxWAuq3Ofaw+Qbc69FfPXsXd9jeW7KRG2xKpKMqBDnu0tHBrCWY5QI7A==", "license": "BSD-3-Clause" }, "node_modules/acorn": { @@ -8733,26 +9168,26 @@ } }, "node_modules/algoliasearch": { - "version": "5.37.0", - "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.37.0.tgz", - "integrity": "sha512-y7gau/ZOQDqoInTQp0IwTOjkrHc4Aq4R8JgpmCleFwiLl+PbN2DMWoDUWZnrK8AhNJwT++dn28Bt4NZYNLAmuA==", + "version": "5.46.4", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.46.4.tgz", + "integrity": "sha512-RRxu8kaZNLfn+7ybzfrFCUt15FL78u18fHrkwq7XZdo/WjO6FZA/RBNvfwcJ4IAoZNFkMgAqj/5idTQgBuxvMQ==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/abtesting": "1.3.0", - "@algolia/client-abtesting": "5.37.0", - "@algolia/client-analytics": "5.37.0", - "@algolia/client-common": "5.37.0", - "@algolia/client-insights": "5.37.0", - "@algolia/client-personalization": "5.37.0", - "@algolia/client-query-suggestions": "5.37.0", - "@algolia/client-search": "5.37.0", - "@algolia/ingestion": "1.37.0", - "@algolia/monitoring": "1.37.0", - "@algolia/recommend": "5.37.0", - "@algolia/requester-browser-xhr": "5.37.0", - "@algolia/requester-fetch": "5.37.0", - "@algolia/requester-node-http": "5.37.0" + "@algolia/abtesting": "1.12.4", + "@algolia/client-abtesting": "5.46.4", + "@algolia/client-analytics": "5.46.4", + "@algolia/client-common": "5.46.4", + "@algolia/client-insights": "5.46.4", + "@algolia/client-personalization": "5.46.4", + "@algolia/client-query-suggestions": "5.46.4", + "@algolia/client-search": "5.46.4", + "@algolia/ingestion": "1.46.4", + "@algolia/monitoring": "1.46.4", + "@algolia/recommend": "5.46.4", + "@algolia/requester-browser-xhr": "5.46.4", + "@algolia/requester-fetch": "5.46.4", + "@algolia/requester-node-http": "5.46.4" }, "engines": { "node": ">= 14.0.0" @@ -9115,9 +9550,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.4.21", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", - "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "version": "10.4.23", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", + "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", "funding": [ { "type": "opencollective", @@ -9134,10 +9569,9 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.24.4", - "caniuse-lite": "^1.0.30001702", - "fraction.js": "^4.3.7", - "normalize-range": "^0.1.2", + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001760", + "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, @@ -9168,9 +9602,9 @@ } }, "node_modules/aws-cdk-lib": { - "version": "2.214.0", - "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.214.0.tgz", - "integrity": "sha512-Mj9GSJkkXj8wjiy2pKARquOsiiHsu7tK1WDfdA8Db39hIznWWP+/KscI2iqnntDMeEmcj1QX25PbYT+6rq8zkw==", + "version": "2.235.1", + "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.235.1.tgz", + "integrity": "sha512-qKAsbunxtveIOIaeJSBgH2PZgdIiPkKtfsa+BxeNDOv2idw6Oa0dJW3Q0sdu8XBopY1KZI2Owrg8r56BOyyohA==", "bundleDependencies": [ "@balena/dockerignore", "case", @@ -9187,18 +9621,18 @@ "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-cdk/asset-awscli-v1": "2.2.242", + "@aws-cdk/asset-awscli-v1": "2.2.261", "@aws-cdk/asset-node-proxy-agent-v6": "^2.1.0", - "@aws-cdk/cloud-assembly-schema": "^48.6.0", + "@aws-cdk/cloud-assembly-schema": "^48.20.0", "@balena/dockerignore": "^1.0.2", "case": "1.6.3", - "fs-extra": "^11.3.1", + "fs-extra": "^11.3.3", "ignore": "^5.3.2", "jsonschema": "^1.5.0", "mime-types": "^2.1.35", "minimatch": "^3.1.2", "punycode": "^2.3.1", - "semver": "^7.7.2", + "semver": "^7.7.3", "table": "^6.9.0", "yaml": "1.10.2" }, @@ -9342,7 +9776,7 @@ "license": "BSD-3-Clause" }, "node_modules/aws-cdk-lib/node_modules/fs-extra": { - "version": "11.3.1", + "version": "11.3.3", "dev": true, "inBundle": true, "license": "MIT", @@ -9464,7 +9898,7 @@ } }, "node_modules/aws-cdk-lib/node_modules/semver": { - "version": "7.7.2", + "version": "7.7.3", "dev": true, "inBundle": true, "license": "ISC", @@ -9553,9 +9987,9 @@ } }, "node_modules/axe-core": { - "version": "4.10.3", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz", - "integrity": "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==", + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz", + "integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==", "dev": true, "license": "MPL-2.0", "engines": { @@ -9563,9 +9997,9 @@ } }, "node_modules/axios": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.1.tgz", - "integrity": "sha512-Kn4kbSXpkFHCGE6rBFNwIv0GQs4AvDT80jlveJDKFxjbTYMUeB4QtsdPCv6H8Cm19Je7IU6VFtRl2zWZI0rudQ==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -9910,9 +10344,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.2.tgz", - "integrity": "sha512-NvcIedLxrs9llVpX7wI+Jz4Hn9vJQkCPKrTaHIE0sW/Rj1iq6Fzby4NbyTZjQJNoypBXNaG7tEHkTgONZpwgxQ==", + "version": "2.9.16", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.16.tgz", + "integrity": "sha512-KeUZdBuxngy825i8xvzaK1Ncnkx0tBmb3k8DkEuqjKRkmtvNTjey2ZsNeh8Dw4lfKvbCOu9oeNx2TKm2vHqcRw==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.js" @@ -9976,9 +10410,9 @@ } }, "node_modules/birpc": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.5.0.tgz", - "integrity": "sha512-VSWO/W6nNQdyP520F1mhf+Lc2f8pjGQOtoHHm7Ze8Go1kX7akpVIrtTa0fn+HB0QJEDVacl6aO08YE0PgXfdnQ==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", "dev": true, "license": "MIT", "funding": { @@ -9993,24 +10427,24 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", - "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", "dev": true, "license": "MIT", "dependencies": { - "bytes": "3.1.2", + "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.13.0", - "raw-body": "2.5.2", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", "type-is": "~1.6.18", - "unpipe": "1.0.0" + "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.8", @@ -10093,9 +10527,9 @@ "license": "BSD-2-Clause" }, "node_modules/browserslist": { - "version": "4.26.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.0.tgz", - "integrity": "sha512-P9go2WrP9FiPwLv3zqRD/Uoxo0RSHjzFCiQz7d4vbmwNqQFo9T9WCeP/Qn5EbcKQY6DBbkxEXNcpJOmncNrb7A==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "funding": [ { "type": "opencollective", @@ -10112,11 +10546,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.8.2", - "caniuse-lite": "^1.0.30001741", - "electron-to-chromium": "^1.5.218", - "node-releases": "^2.0.21", - "update-browserslist-db": "^1.1.3" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -10265,9 +10699,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001741", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001741.tgz", - "integrity": "sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==", + "version": "1.0.30001765", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001765.tgz", + "integrity": "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==", "funding": [ { "type": "opencollective", @@ -10423,9 +10857,9 @@ } }, "node_modules/ci-info": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz", - "integrity": "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", "funding": [ { "type": "github", @@ -10438,9 +10872,9 @@ } }, "node_modules/cjs-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.1.0.tgz", - "integrity": "sha512-UX0OwmYRYQQetfrLEZeewIFFI+wSTofC+pMBLNuH3RUuu/xzG1oz84UCEDOSoQlN3fZ4+AzmV50ZYvGqkMh9yA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", "license": "MIT" }, "node_modules/clean-css": { @@ -10499,6 +10933,60 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-truncate/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -10579,9 +11067,9 @@ } }, "node_modules/collect-v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", - "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", "license": "MIT" }, "node_modules/color-convert": { @@ -10738,9 +11226,9 @@ } }, "node_modules/constructs": { - "version": "10.4.2", - "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.4.2.tgz", - "integrity": "sha512-wsNxBlAott2qg8Zv87q3eYZYgheb9lchtBfjHzzLHtXbttwSrHPs1NNQbBrmbb1YZvYg2+Vh0Dor76w4mFxJkA==", + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.4.5.tgz", + "integrity": "sha512-fOoP70YLevMZr5avJHx2DU3LNYmC6wM8OwdrNewMZou1kZnPGOeVzBrRjZNgFDHUlulYUjkpFRSpTE3D+n+ZSg==", "dev": true, "license": "Apache-2.0", "peer": true @@ -10775,9 +11263,9 @@ "license": "MIT" }, "node_modules/cookie": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", - "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "dev": true, "license": "MIT", "engines": { @@ -10785,32 +11273,32 @@ } }, "node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "dev": true, "license": "MIT" }, "node_modules/copy-anything": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz", - "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", "dev": true, "license": "MIT", "dependencies": { - "is-what": "^4.1.8" + "is-what": "^5.2.0" }, "engines": { - "node": ">=12.13" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/mesqueeb" } }, "node_modules/core-js": { - "version": "3.45.1", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.45.1.tgz", - "integrity": "sha512-L4NPsJlCfZsPeXukyzHFlg/i7IIVwHSItR0wg0FLNqYClJ4MQYTYLbC7EkjKYRLZF2iof2MUgN0EGy7MdQFChg==", + "version": "3.47.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.47.0.tgz", + "integrity": "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -10820,13 +11308,13 @@ } }, "node_modules/core-js-compat": { - "version": "3.45.1", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.45.1.tgz", - "integrity": "sha512-tqTt5T4PzsMIZ430XGviK4vzYSoeNJ6CXODi6c/voxOT6IZqBht5/EKaSNnYiEjjRYxjVz7DQIsOsY0XNi8PIA==", + "version": "3.47.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.47.0.tgz", + "integrity": "sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ==", "dev": true, "license": "MIT", "dependencies": { - "browserslist": "^4.25.3" + "browserslist": "^4.28.0" }, "funding": { "type": "opencollective", @@ -10834,9 +11322,9 @@ } }, "node_modules/core-js-pure": { - "version": "3.45.1", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.45.1.tgz", - "integrity": "sha512-OHnWFKgTUshEU8MK+lOs1H8kC8GkTi9Z1tvNkxrCcw9wl3MJIO7q2ld77wjWn4/xuGrVu2X+nME1iIIPBSdyEQ==", + "version": "3.47.0", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.47.0.tgz", + "integrity": "sha512-BcxeDbzUrRnXGYIVAGFtcGQVNpFcUhVjr6W7F8XktvQW2iJP9e66GP6xdKotCRFlrxBvNIBrhwKteRXqMV86Nw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -11032,9 +11520,9 @@ } }, "node_modules/css-loader/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -11157,9 +11645,9 @@ } }, "node_modules/css-selector-parser": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/css-selector-parser/-/css-selector-parser-3.1.3.tgz", - "integrity": "sha512-gJMigczVZqYAk0hPVzx/M4Hm1D9QOtqkdQk9005TNzDIUGzo5cnHEDiKUT7jGPximL/oYb+LIitcHFQ4aKupxg==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/css-selector-parser/-/css-selector-parser-3.3.0.tgz", + "integrity": "sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g==", "funding": [ { "type": "github", @@ -11419,9 +11907,9 @@ "license": "MIT" }, "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, "node_modules/d3-path": { @@ -11532,9 +12020,9 @@ } }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -11555,9 +12043,9 @@ "license": "MIT" }, "node_modules/decode-named-character-reference": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", - "integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", "license": "MIT", "dependencies": { "character-entities": "^2.0.0" @@ -11568,9 +12056,9 @@ } }, "node_modules/dedent": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", - "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", + "integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==", "license": "MIT", "peerDependencies": { "babel-plugin-macros": "^3.1.0" @@ -11729,16 +12217,13 @@ } }, "node_modules/detect-libc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", - "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "license": "Apache-2.0", "optional": true, - "bin": { - "detect-libc": "bin/detect-libc.js" - }, "engines": { - "node": ">=0.10" + "node": ">=8" } }, "node_modules/detect-newline": { @@ -12066,9 +12551,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.218", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.218.tgz", - "integrity": "sha512-uwwdN0TUHs8u6iRgN8vKeWZMRll4gBkz+QMqdS7DDe49uiK68/UX92lFb61oiFPrpYZNeZIqa4bA7O6Aiasnzg==", + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", "license": "ISC" }, "node_modules/emittery": { @@ -12117,9 +12602,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.18.3", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", - "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "version": "5.18.4", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", + "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", "dev": true, "license": "MIT", "dependencies": { @@ -12156,9 +12641,9 @@ } }, "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -12175,9 +12660,9 @@ } }, "node_modules/es-abstract": { - "version": "1.24.0", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", - "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", "dev": true, "license": "MIT", "dependencies": { @@ -12283,27 +12768,27 @@ } }, "node_modules/es-iterator-helpers": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", - "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.2.tgz", + "integrity": "sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==", "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", - "call-bound": "^1.0.3", + "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.6", + "es-abstract": "^1.24.1", "es-errors": "^1.3.0", - "es-set-tostringtag": "^2.0.3", + "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.6", + "get-intrinsic": "^1.3.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", - "iterator.prototype": "^1.1.4", + "iterator.prototype": "^1.1.5", "safe-array-concat": "^1.1.3" }, "engines": { @@ -12311,9 +12796,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true, "license": "MIT" }, @@ -13187,9 +13672,9 @@ } }, "node_modules/eslint-plugin-testing-library/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -13358,9 +13843,9 @@ } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -13431,9 +13916,9 @@ } }, "node_modules/eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "dev": true, "license": "MIT" }, @@ -13470,6 +13955,12 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, "node_modules/exit": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", @@ -13489,17 +13980,17 @@ } }, "node_modules/expect": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/expect/-/expect-30.1.2.tgz", - "integrity": "sha512-xvHszRavo28ejws8FpemjhwswGj4w/BetHIL8cU49u4sGyXDw2+p3YbeDbj6xzlxi6kWTjIRSTJ+9sNXPnF0Zg==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", + "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==", "license": "MIT", "dependencies": { - "@jest/expect-utils": "30.1.2", + "@jest/expect-utils": "30.2.0", "@jest/get-type": "30.1.0", - "jest-matcher-utils": "30.1.2", - "jest-message-util": "30.1.0", - "jest-mock": "30.0.5", - "jest-util": "30.0.5" + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -13522,39 +14013,39 @@ } }, "node_modules/expect/node_modules/jest-diff": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.1.2.tgz", - "integrity": "sha512-4+prq+9J61mOVXCa4Qp8ZjavdxzrWQXrI80GNxP8f4tkI2syPuPrJgdRPZRrfUTRvIoUwcmNLbqEJy9W800+NQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", + "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", "license": "MIT", "dependencies": { "@jest/diff-sequences": "30.0.1", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "pretty-format": "30.0.5" + "pretty-format": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/expect/node_modules/jest-matcher-utils": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.1.2.tgz", - "integrity": "sha512-7ai16hy4rSbDjvPTuUhuV8nyPBd6EX34HkBsBcBX2lENCuAQ0qKCPb/+lt8OSWUa9WWmGYLy41PrEzkwRwoGZQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", + "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "jest-diff": "30.1.2", - "pretty-format": "30.0.5" + "jest-diff": "30.2.0", + "pretty-format": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/expect/node_modules/pretty-format": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", - "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", "license": "MIT", "dependencies": { "@jest/schemas": "30.0.5", @@ -13584,40 +14075,40 @@ "license": "MIT" }, "node_modules/express": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", - "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "dev": true, "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.3", - "content-disposition": "0.5.4", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", "content-type": "~1.0.4", - "cookie": "0.7.1", - "cookie-signature": "1.0.6", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.3.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.12", + "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "6.13.0", + "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.19.0", - "serve-static": "1.16.2", + "send": "~0.19.0", + "serve-static": "~1.16.2", "setprototypeof": "1.2.0", - "statuses": "2.0.1", + "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" @@ -13726,9 +14217,9 @@ "license": "MIT" }, "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", "dev": true, "license": "ISC", "dependencies": { @@ -13889,18 +14380,18 @@ } }, "node_modules/finalhandler": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", - "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", "dev": true, "license": "MIT", "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "statuses": "2.0.1", + "statuses": "~2.0.2", "unpipe": "~1.0.0" }, "engines": { @@ -13998,13 +14489,13 @@ "license": "ISC" }, "node_modules/focus-trap": { - "version": "7.6.5", - "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.5.tgz", - "integrity": "sha512-7Ke1jyybbbPZyZXFxEftUtxFGLMpE2n6A+z//m4CRDlj0hW+o3iYSmh8nFlYMurOiJVDmJRilUQtJr08KfIxlg==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz", + "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", "dev": true, "license": "MIT", "dependencies": { - "tabbable": "^6.2.0" + "tabbable": "^6.4.0" } }, "node_modules/follow-redirects": { @@ -14059,18 +14550,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/fork-ts-checker-webpack-plugin": { "version": "6.5.3", "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.5.3.tgz", @@ -14172,6 +14651,28 @@ "node": ">=10" } }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/fork-ts-checker-webpack-plugin/node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -14205,9 +14706,9 @@ } }, "node_modules/fork-ts-checker-webpack-plugin/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -14238,9 +14739,9 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -14264,15 +14765,15 @@ } }, "node_modules/fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", "license": "MIT", "engines": { "node": "*" }, "funding": { - "type": "patreon", + "type": "github", "url": "https://github.com/sponsors/rawify" } }, @@ -14368,6 +14869,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/generic-names": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/generic-names/-/generic-names-4.0.0.tgz", @@ -14492,9 +15003,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", - "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", "dev": true, "license": "MIT", "dependencies": { @@ -14520,21 +15031,20 @@ "license": "ISC" }, "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "license": "ISC", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -14560,26 +15070,19 @@ "dev": true, "license": "BSD-2-Clause" }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "license": "ISC", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^2.0.1" }, "engines": { - "node": "*" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/global-modules": { @@ -15061,15 +15564,15 @@ } }, "node_modules/hast-util-to-parse5": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.0.tgz", - "integrity": "sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", - "property-information": "^6.0.0", + "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" @@ -15079,16 +15582,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/hast-util-to-parse5/node_modules/property-information": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz", - "integrity": "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/hast-util-to-string": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz", @@ -15341,9 +15834,9 @@ } }, "node_modules/html-webpack-plugin": { - "version": "5.6.4", - "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.4.tgz", - "integrity": "sha512-V/PZeWsqhfpE27nKeX9EO2sbR+D17A+tLf6qU+ht66jdUsN0QLKJN27Z+1+gHrVMKgndBahes0PU6rRihDgHTw==", + "version": "5.6.6", + "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.6.tgz", + "integrity": "sha512-bLjW01UTrvoWTJQL5LsMRo1SypHW80FTm12OJRSnr3v6YHNhfe+1r0MYUZJMACxnCHURVnBWRwAsWs2yPU9Ezw==", "dev": true, "license": "MIT", "dependencies": { @@ -15400,20 +15893,24 @@ "license": "MIT" }, "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "dev": true, "license": "MIT", "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" }, "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/http-parser-js": { @@ -15597,9 +16094,9 @@ } }, "node_modules/immutable": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.3.tgz", - "integrity": "sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==", + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", + "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", "license": "MIT" }, "node_modules/import-fresh": { @@ -15682,9 +16179,9 @@ "license": "ISC" }, "node_modules/inline-style-parser": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz", - "integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", "license": "MIT" }, "node_modules/internal-slot": { @@ -15703,21 +16200,21 @@ } }, "node_modules/intl-messageformat": { - "version": "10.7.16", - "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.16.tgz", - "integrity": "sha512-UmdmHUmp5CIKKjSoE10la5yfU+AYJAaiYLsodbjL4lji83JNvgOQUjGaGhGrpFCb0Uh7sl7qfP1IyILa8Z40ug==", + "version": "10.7.18", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz", + "integrity": "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==", "license": "BSD-3-Clause", "dependencies": { - "@formatjs/ecma402-abstract": "2.3.4", + "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/fast-memoize": "2.2.7", - "@formatjs/icu-messageformat-parser": "2.11.2", + "@formatjs/icu-messageformat-parser": "2.11.4", "tslib": "^2.8.0" } }, "node_modules/ipaddr.js": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", - "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", "dev": true, "license": "MIT", "engines": { @@ -15866,9 +16363,9 @@ } }, "node_modules/is-bun-module/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -16017,14 +16514,15 @@ } }, "node_modules/is-generator-function": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", - "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "get-proto": "^1.0.0", + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" }, @@ -16341,13 +16839,13 @@ } }, "node_modules/is-what": { - "version": "4.1.16", - "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz", - "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", "dev": true, "license": "MIT", "engines": { - "node": ">=12.13" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/mesqueeb" @@ -16405,9 +16903,9 @@ } }, "node_modules/istanbul-lib-instrument/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -16548,6 +17046,57 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-changed-files/node_modules/@jest/types": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.5.tgz", + "integrity": "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==", + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-changed-files/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-changed-files/node_modules/jest-util": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.5.tgz", + "integrity": "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==", + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/jest-circus": { "version": "30.1.3", "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.1.3.tgz", @@ -16579,117 +17128,97 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-circus/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/jest-circus/node_modules/@jest/environment": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.1.2.tgz", + "integrity": "sha512-N8t1Ytw4/mr9uN28OnVf0SYE2dGhaIxOVYcwsf9IInBKjvofAjbFRvedvBBlyTYk2knbJTiEjEJ2PyyDIBnd9w==", "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "@jest/fake-timers": "30.1.2", + "@jest/types": "30.0.5", + "@types/node": "*", + "jest-mock": "30.0.5" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-circus/node_modules/jest-diff": { + "node_modules/jest-circus/node_modules/@jest/expect": { "version": "30.1.2", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.1.2.tgz", - "integrity": "sha512-4+prq+9J61mOVXCa4Qp8ZjavdxzrWQXrI80GNxP8f4tkI2syPuPrJgdRPZRrfUTRvIoUwcmNLbqEJy9W800+NQ==", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.1.2.tgz", + "integrity": "sha512-tyaIExOwQRCxPCGNC05lIjWJztDwk2gPDNSDGg1zitXJJ8dC3++G/CRjE5mb2wQsf89+lsgAgqxxNpDLiCViTA==", "license": "MIT", "dependencies": { - "@jest/diff-sequences": "30.0.1", - "@jest/get-type": "30.1.0", - "chalk": "^4.1.2", - "pretty-format": "30.0.5" + "expect": "30.1.2", + "jest-snapshot": "30.1.2" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-circus/node_modules/jest-matcher-utils": { + "node_modules/jest-circus/node_modules/@jest/expect-utils": { "version": "30.1.2", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.1.2.tgz", - "integrity": "sha512-7ai16hy4rSbDjvPTuUhuV8nyPBd6EX34HkBsBcBX2lENCuAQ0qKCPb/+lt8OSWUa9WWmGYLy41PrEzkwRwoGZQ==", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.1.2.tgz", + "integrity": "sha512-HXy1qT/bfdjCv7iC336ExbqqYtZvljrV8odNdso7dWK9bSeHtLlvwWWC3YSybSPL03Gg5rug6WLCZAZFH72m0A==", "license": "MIT", "dependencies": { - "@jest/get-type": "30.1.0", - "chalk": "^4.1.2", - "jest-diff": "30.1.2", - "pretty-format": "30.0.5" + "@jest/get-type": "30.1.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-circus/node_modules/pretty-format": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", - "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", + "node_modules/jest-circus/node_modules/@jest/fake-timers": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.1.2.tgz", + "integrity": "sha512-Beljfv9AYkr9K+ETX9tvV61rJTY706BhBUtiaepQHeEGfe0DbpvUA5Z3fomwc5Xkhns6NWrcFDZn+72fLieUnA==", "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", - "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "@jest/types": "30.0.5", + "@sinonjs/fake-timers": "^13.0.0", + "@types/node": "*", + "jest-message-util": "30.1.0", + "jest-mock": "30.0.5", + "jest-util": "30.0.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-circus/node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "node_modules/jest-circus/node_modules/@jest/snapshot-utils": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.1.2.tgz", + "integrity": "sha512-vHoMTpimcPSR7OxS2S0V1Cpg8eKDRxucHjoWl5u4RQcnxqQrV3avETiFpl8etn4dqxEGarBeHbIBety/f8mLXw==", "license": "MIT", - "engines": { - "node": ">=10" + "dependencies": { + "@jest/types": "30.0.5", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-circus/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT" - }, - "node_modules/jest-cli": { - "version": "30.1.3", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.1.3.tgz", - "integrity": "sha512-G8E2Ol3OKch1DEeIBl41NP7OiC6LBhfg25Btv+idcusmoUSpqUkbrneMqbW9lVpI/rCKb/uETidb7DNteheuAQ==", + "node_modules/jest-circus/node_modules/@jest/types": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.5.tgz", + "integrity": "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==", "license": "MIT", "dependencies": { - "@jest/core": "30.1.3", - "@jest/test-result": "30.1.3", - "@jest/types": "30.0.5", - "chalk": "^4.1.2", - "exit-x": "^0.2.2", - "import-local": "^3.2.0", - "jest-config": "30.1.3", - "jest-util": "30.0.5", - "jest-validate": "30.1.0", - "yargs": "^17.7.2" - }, - "bin": { - "jest": "bin/jest.js" + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } } }, - "node_modules/jest-cli/node_modules/chalk": { + "node_modules/jest-circus/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", @@ -16705,109 +17234,137 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/jest-config": { - "version": "30.1.3", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.1.3.tgz", - "integrity": "sha512-M/f7gqdQEPgZNA181Myz+GXCe8jXcJsGjCMXUzRj22FIXsZOyHNte84e0exntOvdPaeh9tA0w+B8qlP2fAezfw==", + "node_modules/jest-circus/node_modules/expect": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.1.2.tgz", + "integrity": "sha512-xvHszRavo28ejws8FpemjhwswGj4w/BetHIL8cU49u4sGyXDw2+p3YbeDbj6xzlxi6kWTjIRSTJ+9sNXPnF0Zg==", "license": "MIT", "dependencies": { - "@babel/core": "^7.27.4", + "@jest/expect-utils": "30.1.2", "@jest/get-type": "30.1.0", - "@jest/pattern": "30.0.1", - "@jest/test-sequencer": "30.1.3", - "@jest/types": "30.0.5", - "babel-jest": "30.1.2", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "deepmerge": "^4.3.1", - "glob": "^10.3.10", - "graceful-fs": "^4.2.11", - "jest-circus": "30.1.3", - "jest-docblock": "30.0.1", - "jest-environment-node": "30.1.2", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.1.3", - "jest-runner": "30.1.3", - "jest-util": "30.0.5", - "jest-validate": "30.1.0", - "micromatch": "^4.0.8", - "parse-json": "^5.2.0", - "pretty-format": "30.0.5", - "slash": "^3.0.0", - "strip-json-comments": "^3.1.1" + "jest-matcher-utils": "30.1.2", + "jest-message-util": "30.1.0", + "jest-mock": "30.0.5", + "jest-util": "30.0.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "@types/node": "*", - "esbuild-register": ">=3.4.0", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "esbuild-register": { - "optional": true - }, - "ts-node": { - "optional": true - } } }, - "node_modules/jest-config/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/jest-circus/node_modules/jest-diff": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.1.2.tgz", + "integrity": "sha512-4+prq+9J61mOVXCa4Qp8ZjavdxzrWQXrI80GNxP8f4tkI2syPuPrJgdRPZRrfUTRvIoUwcmNLbqEJy9W800+NQ==", "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "@jest/diff-sequences": "30.0.1", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.0.5" }, "engines": { - "node": ">=10" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-circus/node_modules/jest-matcher-utils": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.1.2.tgz", + "integrity": "sha512-7ai16hy4rSbDjvPTuUhuV8nyPBd6EX34HkBsBcBX2lENCuAQ0qKCPb/+lt8OSWUa9WWmGYLy41PrEzkwRwoGZQ==", + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.1.2", + "pretty-format": "30.0.5" }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-config/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "license": "ISC", + "node_modules/jest-circus/node_modules/jest-message-util": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.1.0.tgz", + "integrity": "sha512-HizKDGG98cYkWmaLUHChq4iN+oCENohQLb7Z5guBPumYs+/etonmNFlg1Ps6yN9LTPyZn+M+b/9BbnHx3WTMDg==", + "license": "MIT", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.0.5", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.0.5", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" }, - "bin": { - "glob": "dist/esm/bin.mjs" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-circus/node_modules/jest-mock": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.0.5.tgz", + "integrity": "sha512-Od7TyasAAQX/6S+QCbN6vZoWOMwlTtzzGuxJku1GhGanAjz9y+QsQkpScDmETvdc9aSXyJ/Op4rhpMYBWW91wQ==", + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "@types/node": "*", + "jest-util": "30.0.5" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-config/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "license": "ISC", + "node_modules/jest-circus/node_modules/jest-snapshot": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.1.2.tgz", + "integrity": "sha512-4q4+6+1c8B6Cy5pGgFvjDy/Pa6VYRiGu0yQafKkJ9u6wQx4G5PqI2QR6nxTl43yy7IWsINwz6oT4o6tD12a8Dg==", + "license": "MIT", "dependencies": { - "brace-expansion": "^2.0.1" + "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.1.2", + "@jest/get-type": "30.1.0", + "@jest/snapshot-utils": "30.1.2", + "@jest/transform": "30.1.2", + "@jest/types": "30.0.5", + "babel-preset-current-node-syntax": "^1.1.0", + "chalk": "^4.1.2", + "expect": "30.1.2", + "graceful-fs": "^4.2.11", + "jest-diff": "30.1.2", + "jest-matcher-utils": "30.1.2", + "jest-message-util": "30.1.0", + "jest-util": "30.0.5", + "pretty-format": "30.0.5", + "semver": "^7.7.2", + "synckit": "^0.11.8" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-circus/node_modules/jest-util": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.5.tgz", + "integrity": "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==", + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-config/node_modules/pretty-format": { + "node_modules/jest-circus/node_modules/pretty-format": { "version": "30.0.5", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", @@ -16821,7 +17378,7 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-config/node_modules/pretty-format/node_modules/ansi-styles": { + "node_modules/jest-circus/node_modules/pretty-format/node_modules/ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", @@ -16833,74 +17390,75 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest-config/node_modules/react-is": { + "node_modules/jest-circus/node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, - "node_modules/jest-diff": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz", - "integrity": "sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^27.5.1", - "jest-get-type": "^27.5.1", - "pretty-format": "^27.5.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/jest-diff/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "node_modules/jest-circus/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/jest-docblock": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.0.1.tgz", - "integrity": "sha512-/vF78qn3DYphAaIc3jy4gA7XSAz167n9Bm/wn/1XhTLW7tTBIzXtCJpb/vcmc73NIIeeohCbdL94JasyXUZsGA==", + "node_modules/jest-cli": { + "version": "30.1.3", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.1.3.tgz", + "integrity": "sha512-G8E2Ol3OKch1DEeIBl41NP7OiC6LBhfg25Btv+idcusmoUSpqUkbrneMqbW9lVpI/rCKb/uETidb7DNteheuAQ==", "license": "MIT", "dependencies": { - "detect-newline": "^3.1.0" + "@jest/core": "30.1.3", + "@jest/test-result": "30.1.3", + "@jest/types": "30.0.5", + "chalk": "^4.1.2", + "exit-x": "^0.2.2", + "import-local": "^3.2.0", + "jest-config": "30.1.3", + "jest-util": "30.0.5", + "jest-validate": "30.1.0", + "yargs": "^17.7.2" + }, + "bin": { + "jest": "bin/jest.js" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } } }, - "node_modules/jest-each": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.1.0.tgz", - "integrity": "sha512-A+9FKzxPluqogNahpCv04UJvcZ9B3HamqpDNWNKDjtxVRYB8xbZLFuCr8JAJFpNp83CA0anGQFlpQna9Me+/tQ==", + "node_modules/jest-cli/node_modules/@jest/types": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.5.tgz", + "integrity": "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==", "license": "MIT", "dependencies": { - "@jest/get-type": "30.1.0", - "@jest/types": "30.0.5", - "chalk": "^4.1.2", - "jest-util": "30.0.5", - "pretty-format": "30.0.5" + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-each/node_modules/chalk": { + "node_modules/jest-cli/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", @@ -16916,139 +17474,174 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/jest-each/node_modules/pretty-format": { + "node_modules/jest-cli/node_modules/jest-util": { "version": "30.0.5", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", - "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.5.tgz", + "integrity": "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==", "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", - "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "@jest/types": "30.0.5", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-each/node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "node_modules/jest-config": { + "version": "30.1.3", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.1.3.tgz", + "integrity": "sha512-M/f7gqdQEPgZNA181Myz+GXCe8jXcJsGjCMXUzRj22FIXsZOyHNte84e0exntOvdPaeh9tA0w+B8qlP2fAezfw==", "license": "MIT", - "engines": { - "node": ">=10" + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/get-type": "30.1.0", + "@jest/pattern": "30.0.1", + "@jest/test-sequencer": "30.1.3", + "@jest/types": "30.0.5", + "babel-jest": "30.1.2", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "deepmerge": "^4.3.1", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "jest-circus": "30.1.3", + "jest-docblock": "30.0.1", + "jest-environment-node": "30.1.2", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.1.3", + "jest-runner": "30.1.3", + "jest-util": "30.0.5", + "jest-validate": "30.1.0", + "micromatch": "^4.0.8", + "parse-json": "^5.2.0", + "pretty-format": "30.0.5", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "esbuild-register": ">=3.4.0", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "esbuild-register": { + "optional": true + }, + "ts-node": { + "optional": true + } } }, - "node_modules/jest-each/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT" - }, - "node_modules/jest-environment-jsdom": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-27.5.1.tgz", - "integrity": "sha512-TFBvkTC1Hnnnrka/fUb56atfDtJ9VMZ94JkjTbggl1PEpwrYtUBKMezB3inLmWqQsXYLcMwNoDQwoBTAvFfsfw==", - "dev": true, + "node_modules/jest-config/node_modules/@jest/types": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.5.tgz", + "integrity": "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==", "license": "MIT", "dependencies": { - "@jest/environment": "^27.5.1", - "@jest/fake-timers": "^27.5.1", - "@jest/types": "^27.5.1", + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", "@types/node": "*", - "jest-mock": "^27.5.1", - "jest-util": "^27.5.1", - "jsdom": "^16.6.0" + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" }, "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-environment-jsdom/node_modules/@jest/environment": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-27.5.1.tgz", - "integrity": "sha512-/WQjhPJe3/ghaol/4Bq480JKXV/Rfw8nQdN7f41fM8VDHLcxKXou6QyXAh3EFr9/bVG3x74z1NWDkP87EiY8gA==", - "dev": true, + "node_modules/jest-config/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "license": "MIT", "dependencies": { - "@jest/fake-timers": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/node": "*", - "jest-mock": "^27.5.1" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/jest-environment-jsdom/node_modules/@jest/fake-timers": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-27.5.1.tgz", - "integrity": "sha512-/aPowoolwa07k7/oM3aASneNeBGCmGQsc3ugN4u6s4C/+s5M64MFo/+djTdiwcbQlRfFElGuDXWzaWj6QgKObQ==", - "dev": true, + "node_modules/jest-config/node_modules/jest-util": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.5.tgz", + "integrity": "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==", "license": "MIT", "dependencies": { - "@jest/types": "^27.5.1", - "@sinonjs/fake-timers": "^8.0.1", + "@jest/types": "30.0.5", "@types/node": "*", - "jest-message-util": "^27.5.1", - "jest-mock": "^27.5.1", - "jest-util": "^27.5.1" + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" }, "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-environment-jsdom/node_modules/@jest/types": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", - "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", - "dev": true, + "node_modules/jest-config/node_modules/pretty-format": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", + "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", "license": "MIT", "dependencies": { - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^16.0.0", - "chalk": "^4.0.0" + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" }, "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-environment-jsdom/node_modules/@sinonjs/commons": { - "version": "1.8.6", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", - "integrity": "sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "type-detect": "4.0.8" + "node_modules/jest-config/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest-environment-jsdom/node_modules/@sinonjs/fake-timers": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-8.1.0.tgz", - "integrity": "sha512-OAPJUAtgeINhh/TAlUID4QTs53Njm7xzddaVlEs/SXwgtiD1tW22zAB/W1wdqfrpmikgaWQ9Fw6Ws+hsiRm5Vg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^1.7.0" - } + "node_modules/jest-config/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" }, - "node_modules/jest-environment-jsdom/node_modules/@types/yargs": { - "version": "16.0.9", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.9.tgz", - "integrity": "sha512-tHhzvkFXZQeTECenFoRljLBYPZJ7jAVxqqtEI0qTLOmuultnFp4I9yKE17vTuhf7BkhCu7I4XuemPgikDVuYqA==", + "node_modules/jest-diff": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz", + "integrity": "sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==", "dev": true, "license": "MIT", "dependencies": { - "@types/yargs-parser": "*" + "chalk": "^4.0.0", + "diff-sequences": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, - "node_modules/jest-environment-jsdom/node_modules/chalk": { + "node_modules/jest-diff/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", @@ -17065,188 +17658,137 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/jest-environment-jsdom/node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-environment-jsdom/node_modules/jest-message-util": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-27.5.1.tgz", - "integrity": "sha512-rMyFe1+jnyAAf+NHwTclDz0eAaLkVDdKVHHBFWsBWHnnh5YeJMNWWsv7AbFYXfK3oTqvL7VTWkhNLu1jX24D+g==", - "dev": true, + "node_modules/jest-docblock": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.0.1.tgz", + "integrity": "sha512-/vF78qn3DYphAaIc3jy4gA7XSAz167n9Bm/wn/1XhTLW7tTBIzXtCJpb/vcmc73NIIeeohCbdL94JasyXUZsGA==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^27.5.1", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^27.5.1", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" + "detect-newline": "^3.1.0" }, "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-environment-jsdom/node_modules/jest-mock": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-27.5.1.tgz", - "integrity": "sha512-K4jKbY1d4ENhbrG2zuPWaQBvDly+iZ2yAW+T1fATN78hc0sInwn7wZB8XtlNnvHug5RMwV897Xm4LqmPM4e2Og==", - "dev": true, + "node_modules/jest-each": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.1.0.tgz", + "integrity": "sha512-A+9FKzxPluqogNahpCv04UJvcZ9B3HamqpDNWNKDjtxVRYB8xbZLFuCr8JAJFpNp83CA0anGQFlpQna9Me+/tQ==", "license": "MIT", "dependencies": { - "@jest/types": "^27.5.1", - "@types/node": "*" + "@jest/get-type": "30.1.0", + "@jest/types": "30.0.5", + "chalk": "^4.1.2", + "jest-util": "30.0.5", + "pretty-format": "30.0.5" }, "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-environment-jsdom/node_modules/jest-util": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", - "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", - "dev": true, + "node_modules/jest-each/node_modules/@jest/types": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.5.tgz", + "integrity": "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==", "license": "MIT", "dependencies": { - "@jest/types": "^27.5.1", + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" }, "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-environment-jsdom/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, + "node_modules/jest-each/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, "engines": { - "node": ">=8.6" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/jest-environment-node": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.1.2.tgz", - "integrity": "sha512-w8qBiXtqGWJ9xpJIA98M0EIoq079GOQRQUyse5qg1plShUCQ0Ek1VTTcczqKrn3f24TFAgFtT+4q3aOXvjbsuA==", + "node_modules/jest-each/node_modules/jest-util": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.5.tgz", + "integrity": "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==", "license": "MIT", "dependencies": { - "@jest/environment": "30.1.2", - "@jest/fake-timers": "30.1.2", "@jest/types": "30.0.5", "@types/node": "*", - "jest-mock": "30.0.5", - "jest-util": "30.0.5", - "jest-validate": "30.1.0" + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-get-type": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz", - "integrity": "sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/jest-haste-map": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.1.0.tgz", - "integrity": "sha512-JLeM84kNjpRkggcGpQLsV7B8W4LNUWz7oDNVnY1Vjj22b5/fAb3kk3htiD+4Na8bmJmjJR7rBtS2Rmq/NEcADg==", + "node_modules/jest-each/node_modules/pretty-format": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", + "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", "license": "MIT", "dependencies": { - "@jest/types": "30.0.5", - "@types/node": "*", - "anymatch": "^3.1.3", - "fb-watchman": "^2.0.2", - "graceful-fs": "^4.2.11", - "jest-regex-util": "30.0.1", - "jest-util": "30.0.5", - "jest-worker": "30.1.0", - "micromatch": "^4.0.8", - "walker": "^1.0.8" + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.3" } }, - "node_modules/jest-jasmine2": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-27.5.1.tgz", - "integrity": "sha512-jtq7VVyG8SqAorDpApwiJJImd0V2wv1xzdheGHRGyuT7gZm6gG47QEskOlzsN1PG/6WNaCo5pmwMHDf3AkG2pQ==", - "dev": true, + "node_modules/jest-each/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "license": "MIT", - "dependencies": { - "@jest/environment": "^27.5.1", - "@jest/source-map": "^27.5.1", - "@jest/test-result": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/node": "*", - "chalk": "^4.0.0", - "co": "^4.6.0", - "expect": "^27.5.1", - "is-generator-fn": "^2.0.0", - "jest-each": "^27.5.1", - "jest-matcher-utils": "^27.5.1", - "jest-message-util": "^27.5.1", - "jest-runtime": "^27.5.1", - "jest-snapshot": "^27.5.1", - "jest-util": "^27.5.1", - "pretty-format": "^27.5.1", - "throat": "^6.0.1" - }, "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest-jasmine2/node_modules/@jest/console": { + "node_modules/jest-each/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/jest-environment-jsdom": { "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-27.5.1.tgz", - "integrity": "sha512-kZ/tNpS3NXn0mlXXXPNuDZnb4c0oZ20r4K5eemM2k30ZC3G0T02nXUvyhf5YdbXWHPEJLc9qGLxEZ216MdL+Zg==", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-27.5.1.tgz", + "integrity": "sha512-TFBvkTC1Hnnnrka/fUb56atfDtJ9VMZ94JkjTbggl1PEpwrYtUBKMezB3inLmWqQsXYLcMwNoDQwoBTAvFfsfw==", "dev": true, "license": "MIT", "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/fake-timers": "^27.5.1", "@jest/types": "^27.5.1", "@types/node": "*", - "chalk": "^4.0.0", - "jest-message-util": "^27.5.1", + "jest-mock": "^27.5.1", "jest-util": "^27.5.1", - "slash": "^3.0.0" + "jsdom": "^16.6.0" }, "engines": { "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, - "node_modules/jest-jasmine2/node_modules/@jest/environment": { + "node_modules/jest-environment-jsdom/node_modules/@jest/environment": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-27.5.1.tgz", "integrity": "sha512-/WQjhPJe3/ghaol/4Bq480JKXV/Rfw8nQdN7f41fM8VDHLcxKXou6QyXAh3EFr9/bVG3x74z1NWDkP87EiY8gA==", @@ -17262,7 +17804,7 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, - "node_modules/jest-jasmine2/node_modules/@jest/fake-timers": { + "node_modules/jest-environment-jsdom/node_modules/@jest/fake-timers": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-27.5.1.tgz", "integrity": "sha512-/aPowoolwa07k7/oM3aASneNeBGCmGQsc3ugN4u6s4C/+s5M64MFo/+djTdiwcbQlRfFElGuDXWzaWj6QgKObQ==", @@ -17280,80 +17822,7 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, - "node_modules/jest-jasmine2/node_modules/@jest/globals": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-27.5.1.tgz", - "integrity": "sha512-ZEJNB41OBQQgGzgyInAv0UUfDDj3upmHydjieSxFvTRuZElrx7tXg/uVQ5hYVEwiXs3+aMsAeEc9X7xiSKCm4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "^27.5.1", - "@jest/types": "^27.5.1", - "expect": "^27.5.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/jest-jasmine2/node_modules/@jest/source-map": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-27.5.1.tgz", - "integrity": "sha512-y9NIHUYF3PJRlHk98NdC/N1gl88BL08aQQgu4k4ZopQkCw9t9cV8mtl3TV8b/YCB8XaVTFrmUTAJvjsntDireg==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0", - "graceful-fs": "^4.2.9", - "source-map": "^0.6.0" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/jest-jasmine2/node_modules/@jest/test-result": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-27.5.1.tgz", - "integrity": "sha512-EW35l2RYFUcUQxFJz5Cv5MTOxlJIQs4I7gxzi2zVU7PJhOwfYq1MdC5nhSmYjX1gmMmLPvB3sIaC+BkcHRBfag==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/istanbul-lib-coverage": "^2.0.0", - "collect-v8-coverage": "^1.0.0" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/jest-jasmine2/node_modules/@jest/transform": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-27.5.1.tgz", - "integrity": "sha512-ipON6WtYgl/1329g5AIJVbUuEh0wZVbdpGwC99Jw4LwuoBNS95MVphU6zOeD9pDkon+LLbFL7lOQRapbB8SCHw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.1.0", - "@jest/types": "^27.5.1", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", - "convert-source-map": "^1.4.0", - "fast-json-stable-stringify": "^2.0.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^27.5.1", - "jest-regex-util": "^27.5.1", - "jest-util": "^27.5.1", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", - "slash": "^3.0.0", - "source-map": "^0.6.1", - "write-file-atomic": "^3.0.0" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/jest-jasmine2/node_modules/@jest/types": { + "node_modules/jest-environment-jsdom/node_modules/@jest/types": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", @@ -17370,7 +17839,7 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, - "node_modules/jest-jasmine2/node_modules/@sinonjs/commons": { + "node_modules/jest-environment-jsdom/node_modules/@sinonjs/commons": { "version": "1.8.6", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", "integrity": "sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==", @@ -17380,7 +17849,7 @@ "type-detect": "4.0.8" } }, - "node_modules/jest-jasmine2/node_modules/@sinonjs/fake-timers": { + "node_modules/jest-environment-jsdom/node_modules/@sinonjs/fake-timers": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-8.1.0.tgz", "integrity": "sha512-OAPJUAtgeINhh/TAlUID4QTs53Njm7xzddaVlEs/SXwgtiD1tW22zAB/W1wdqfrpmikgaWQ9Fw6Ws+hsiRm5Vg==", @@ -17390,47 +17859,17 @@ "@sinonjs/commons": "^1.7.0" } }, - "node_modules/jest-jasmine2/node_modules/@types/yargs": { - "version": "16.0.9", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.9.tgz", - "integrity": "sha512-tHhzvkFXZQeTECenFoRljLBYPZJ7jAVxqqtEI0qTLOmuultnFp4I9yKE17vTuhf7BkhCu7I4XuemPgikDVuYqA==", + "node_modules/jest-environment-jsdom/node_modules/@types/yargs": { + "version": "16.0.11", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.11.tgz", + "integrity": "sha512-sbtvk8wDN+JvEdabmZExoW/HNr1cB7D/j4LT08rMiuikfA7m/JNJg7ATQcgzs34zHnoScDkY0ZRSl29Fkmk36g==", "dev": true, "license": "MIT", "dependencies": { "@types/yargs-parser": "*" } }, - "node_modules/jest-jasmine2/node_modules/babel-plugin-istanbul": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", - "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-instrument": "^5.0.4", - "test-exclude": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-jasmine2/node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/jest-jasmine2/node_modules/chalk": { + "node_modules/jest-environment-jsdom/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", @@ -17447,7 +17886,7 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/jest-jasmine2/node_modules/ci-info": { + "node_modules/jest-environment-jsdom/node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", @@ -17463,98 +17902,7 @@ "node": ">=8" } }, - "node_modules/jest-jasmine2/node_modules/cjs-module-lexer": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", - "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/jest-jasmine2/node_modules/convert-source-map": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "dev": true, - "license": "MIT" - }, - "node_modules/jest-jasmine2/node_modules/expect": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/expect/-/expect-27.5.1.tgz", - "integrity": "sha512-E1q5hSUG2AmYQwQJ041nvgpkODHQvB+RKlB4IYdru6uJsyFTRyZAP463M+1lINorwbqAmUggi6+WwkD8lCS/Dw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^27.5.1", - "jest-get-type": "^27.5.1", - "jest-matcher-utils": "^27.5.1", - "jest-message-util": "^27.5.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/jest-jasmine2/node_modules/istanbul-lib-instrument": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", - "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^6.3.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-jasmine2/node_modules/jest-each": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-27.5.1.tgz", - "integrity": "sha512-1Ff6p+FbhT/bXQnEouYy00bkNSY7OUpfIcmdl8vZ31A1UUaurOLPA8a8BbJOF2RDUElwJhmeaV7LnagI+5UwNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^27.5.1", - "chalk": "^4.0.0", - "jest-get-type": "^27.5.1", - "jest-util": "^27.5.1", - "pretty-format": "^27.5.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/jest-jasmine2/node_modules/jest-haste-map": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-27.5.1.tgz", - "integrity": "sha512-7GgkZ4Fw4NFbMSDSpZwXeBiIbx+t/46nJ2QitkOjvwPYyZmqttu2TDSimMHP1EkPOi4xUZAN1doE5Vd25H4Jng==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^27.5.1", - "@types/graceful-fs": "^4.1.2", - "@types/node": "*", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "graceful-fs": "^4.2.9", - "jest-regex-util": "^27.5.1", - "jest-serializer": "^27.5.1", - "jest-util": "^27.5.1", - "jest-worker": "^27.5.1", - "micromatch": "^4.0.4", - "walker": "^1.0.7" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.2" - } - }, - "node_modules/jest-jasmine2/node_modules/jest-message-util": { + "node_modules/jest-environment-jsdom/node_modules/jest-message-util": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-27.5.1.tgz", "integrity": "sha512-rMyFe1+jnyAAf+NHwTclDz0eAaLkVDdKVHHBFWsBWHnnh5YeJMNWWsv7AbFYXfK3oTqvL7VTWkhNLu1jX24D+g==", @@ -17575,7 +17923,7 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, - "node_modules/jest-jasmine2/node_modules/jest-mock": { + "node_modules/jest-environment-jsdom/node_modules/jest-mock": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-27.5.1.tgz", "integrity": "sha512-K4jKbY1d4ENhbrG2zuPWaQBvDly+iZ2yAW+T1fATN78hc0sInwn7wZB8XtlNnvHug5RMwV897Xm4LqmPM4e2Og==", @@ -17589,170 +17937,876 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, - "node_modules/jest-jasmine2/node_modules/jest-regex-util": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-27.5.1.tgz", - "integrity": "sha512-4bfKq2zie+x16okqDXjXn9ql2B0dScQu+vcwe4TvFVhkVyuWLqpZrZtXxLLWoXYgn0E87I6r6GRYHF7wFZBUvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/jest-jasmine2/node_modules/jest-resolve": { + "node_modules/jest-environment-jsdom/node_modules/jest-util": { "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-27.5.1.tgz", - "integrity": "sha512-FFDy8/9E6CV83IMbDpcjOhumAQPDyETnU2KZ1O98DwTnz8AOBsW/Xv3GySr1mOZdItLR+zDZ7I/UdTFbgSOVCw==", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", + "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", "dev": true, "license": "MIT", "dependencies": { "@jest/types": "^27.5.1", + "@types/node": "*", "chalk": "^4.0.0", + "ci-info": "^3.2.0", "graceful-fs": "^4.2.9", - "jest-haste-map": "^27.5.1", - "jest-pnp-resolver": "^1.2.2", - "jest-util": "^27.5.1", - "jest-validate": "^27.5.1", - "resolve": "^1.20.0", - "resolve.exports": "^1.1.0", - "slash": "^3.0.0" + "picomatch": "^2.2.3" }, "engines": { "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, - "node_modules/jest-jasmine2/node_modules/jest-runtime": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-27.5.1.tgz", - "integrity": "sha512-o7gxw3Gf+H2IGt8fv0RiyE1+r83FJBRruoA+FXrlHw6xEyBsU8ugA6IPfTdVyA0w8HClpbK+DGJxH59UrNMx8A==", + "node_modules/jest-environment-jsdom/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true, "license": "MIT", - "dependencies": { - "@jest/environment": "^27.5.1", - "@jest/fake-timers": "^27.5.1", - "@jest/globals": "^27.5.1", - "@jest/source-map": "^27.5.1", - "@jest/test-result": "^27.5.1", - "@jest/transform": "^27.5.1", - "@jest/types": "^27.5.1", - "chalk": "^4.0.0", - "cjs-module-lexer": "^1.0.0", - "collect-v8-coverage": "^1.0.0", - "execa": "^5.0.0", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^27.5.1", - "jest-message-util": "^27.5.1", - "jest-mock": "^27.5.1", - "jest-regex-util": "^27.5.1", - "jest-resolve": "^27.5.1", - "jest-snapshot": "^27.5.1", - "jest-util": "^27.5.1", - "slash": "^3.0.0", - "strip-bom": "^4.0.0" - }, "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/jest-jasmine2/node_modules/jest-snapshot": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-27.5.1.tgz", - "integrity": "sha512-yYykXI5a0I31xX67mgeLw1DZ0bJB+gpq5IpSuCAoyDi0+BhgU/RIrL+RTzDmkNTchvDFWKP8lp+w/42Z3us5sA==", - "dev": true, + "node_modules/jest-environment-node": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.1.2.tgz", + "integrity": "sha512-w8qBiXtqGWJ9xpJIA98M0EIoq079GOQRQUyse5qg1plShUCQ0Ek1VTTcczqKrn3f24TFAgFtT+4q3aOXvjbsuA==", "license": "MIT", "dependencies": { - "@babel/core": "^7.7.2", - "@babel/generator": "^7.7.2", - "@babel/plugin-syntax-typescript": "^7.7.2", - "@babel/traverse": "^7.7.2", - "@babel/types": "^7.0.0", - "@jest/transform": "^27.5.1", - "@jest/types": "^27.5.1", - "@types/babel__traverse": "^7.0.4", - "@types/prettier": "^2.1.5", - "babel-preset-current-node-syntax": "^1.0.0", - "chalk": "^4.0.0", - "expect": "^27.5.1", - "graceful-fs": "^4.2.9", - "jest-diff": "^27.5.1", - "jest-get-type": "^27.5.1", - "jest-haste-map": "^27.5.1", - "jest-matcher-utils": "^27.5.1", - "jest-message-util": "^27.5.1", - "jest-util": "^27.5.1", - "natural-compare": "^1.4.0", - "pretty-format": "^27.5.1", - "semver": "^7.3.2" + "@jest/environment": "30.1.2", + "@jest/fake-timers": "30.1.2", + "@jest/types": "30.0.5", + "@types/node": "*", + "jest-mock": "30.0.5", + "jest-util": "30.0.5", + "jest-validate": "30.1.0" }, "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-jasmine2/node_modules/jest-snapshot/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "node_modules/jest-environment-node/node_modules/@jest/environment": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.1.2.tgz", + "integrity": "sha512-N8t1Ytw4/mr9uN28OnVf0SYE2dGhaIxOVYcwsf9IInBKjvofAjbFRvedvBBlyTYk2knbJTiEjEJ2PyyDIBnd9w==", + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.1.2", + "@jest/types": "30.0.5", + "@types/node": "*", + "jest-mock": "30.0.5" }, "engines": { - "node": ">=10" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-jasmine2/node_modules/jest-util": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", - "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", - "dev": true, + "node_modules/jest-environment-node/node_modules/@jest/fake-timers": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.1.2.tgz", + "integrity": "sha512-Beljfv9AYkr9K+ETX9tvV61rJTY706BhBUtiaepQHeEGfe0DbpvUA5Z3fomwc5Xkhns6NWrcFDZn+72fLieUnA==", "license": "MIT", "dependencies": { - "@jest/types": "^27.5.1", + "@jest/types": "30.0.5", + "@sinonjs/fake-timers": "^13.0.0", "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" + "jest-message-util": "30.1.0", + "jest-mock": "30.0.5", + "jest-util": "30.0.5" }, "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-jasmine2/node_modules/jest-validate": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-27.5.1.tgz", - "integrity": "sha512-thkNli0LYTmOI1tDB3FI1S1RTp/Bqyd9pTarJwL87OIBFuqEb5Apv5EaApEudYg4g86e3CT6kM0RowkhtEnCBQ==", - "dev": true, + "node_modules/jest-environment-node/node_modules/@jest/types": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.5.tgz", + "integrity": "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==", "license": "MIT", "dependencies": { - "@jest/types": "^27.5.1", - "camelcase": "^6.2.0", - "chalk": "^4.0.0", - "jest-get-type": "^27.5.1", - "leven": "^3.1.0", - "pretty-format": "^27.5.1" + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" }, "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-jasmine2/node_modules/jest-worker": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", - "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", - "dev": true, + "node_modules/jest-environment-node/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "license": "MIT", "dependencies": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "engines": { - "node": ">= 10.13.0" - } - }, + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-environment-node/node_modules/jest-message-util": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.1.0.tgz", + "integrity": "sha512-HizKDGG98cYkWmaLUHChq4iN+oCENohQLb7Z5guBPumYs+/etonmNFlg1Ps6yN9LTPyZn+M+b/9BbnHx3WTMDg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.0.5", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.0.5", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-node/node_modules/jest-mock": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.0.5.tgz", + "integrity": "sha512-Od7TyasAAQX/6S+QCbN6vZoWOMwlTtzzGuxJku1GhGanAjz9y+QsQkpScDmETvdc9aSXyJ/Op4rhpMYBWW91wQ==", + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "@types/node": "*", + "jest-util": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-node/node_modules/jest-util": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.5.tgz", + "integrity": "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==", + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-node/node_modules/pretty-format": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", + "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-node/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-environment-node/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/jest-get-type": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz", + "integrity": "sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.1.0.tgz", + "integrity": "sha512-JLeM84kNjpRkggcGpQLsV7B8W4LNUWz7oDNVnY1Vjj22b5/fAb3kk3htiD+4Na8bmJmjJR7rBtS2Rmq/NEcADg==", + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "@types/node": "*", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.0.1", + "jest-util": "30.0.5", + "jest-worker": "30.1.0", + "micromatch": "^4.0.8", + "walker": "^1.0.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.3" + } + }, + "node_modules/jest-haste-map/node_modules/@jest/types": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.5.tgz", + "integrity": "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==", + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-haste-map/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-haste-map/node_modules/jest-util": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.5.tgz", + "integrity": "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==", + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-jasmine2": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-27.5.1.tgz", + "integrity": "sha512-jtq7VVyG8SqAorDpApwiJJImd0V2wv1xzdheGHRGyuT7gZm6gG47QEskOlzsN1PG/6WNaCo5pmwMHDf3AkG2pQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/source-map": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "expect": "^27.5.1", + "is-generator-fn": "^2.0.0", + "jest-each": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "pretty-format": "^27.5.1", + "throat": "^6.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-jasmine2/node_modules/@jest/console": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-27.5.1.tgz", + "integrity": "sha512-kZ/tNpS3NXn0mlXXXPNuDZnb4c0oZ20r4K5eemM2k30ZC3G0T02nXUvyhf5YdbXWHPEJLc9qGLxEZ216MdL+Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^27.5.1", + "jest-util": "^27.5.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-jasmine2/node_modules/@jest/environment": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-27.5.1.tgz", + "integrity": "sha512-/WQjhPJe3/ghaol/4Bq480JKXV/Rfw8nQdN7f41fM8VDHLcxKXou6QyXAh3EFr9/bVG3x74z1NWDkP87EiY8gA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "jest-mock": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-jasmine2/node_modules/@jest/fake-timers": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-27.5.1.tgz", + "integrity": "sha512-/aPowoolwa07k7/oM3aASneNeBGCmGQsc3ugN4u6s4C/+s5M64MFo/+djTdiwcbQlRfFElGuDXWzaWj6QgKObQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@sinonjs/fake-timers": "^8.0.1", + "@types/node": "*", + "jest-message-util": "^27.5.1", + "jest-mock": "^27.5.1", + "jest-util": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-jasmine2/node_modules/@jest/globals": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-27.5.1.tgz", + "integrity": "sha512-ZEJNB41OBQQgGzgyInAv0UUfDDj3upmHydjieSxFvTRuZElrx7tXg/uVQ5hYVEwiXs3+aMsAeEc9X7xiSKCm4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/types": "^27.5.1", + "expect": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-jasmine2/node_modules/@jest/source-map": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-27.5.1.tgz", + "integrity": "sha512-y9NIHUYF3PJRlHk98NdC/N1gl88BL08aQQgu4k4ZopQkCw9t9cV8mtl3TV8b/YCB8XaVTFrmUTAJvjsntDireg==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9", + "source-map": "^0.6.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-jasmine2/node_modules/@jest/test-result": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-27.5.1.tgz", + "integrity": "sha512-EW35l2RYFUcUQxFJz5Cv5MTOxlJIQs4I7gxzi2zVU7PJhOwfYq1MdC5nhSmYjX1gmMmLPvB3sIaC+BkcHRBfag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-jasmine2/node_modules/@jest/transform": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-27.5.1.tgz", + "integrity": "sha512-ipON6WtYgl/1329g5AIJVbUuEh0wZVbdpGwC99Jw4LwuoBNS95MVphU6zOeD9pDkon+LLbFL7lOQRapbB8SCHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.1.0", + "@jest/types": "^27.5.1", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^1.4.0", + "fast-json-stable-stringify": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-util": "^27.5.1", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "source-map": "^0.6.1", + "write-file-atomic": "^3.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-jasmine2/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-jasmine2/node_modules/@sinonjs/commons": { + "version": "1.8.6", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", + "integrity": "sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/jest-jasmine2/node_modules/@sinonjs/fake-timers": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-8.1.0.tgz", + "integrity": "sha512-OAPJUAtgeINhh/TAlUID4QTs53Njm7xzddaVlEs/SXwgtiD1tW22zAB/W1wdqfrpmikgaWQ9Fw6Ws+hsiRm5Vg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^1.7.0" + } + }, + "node_modules/jest-jasmine2/node_modules/@types/yargs": { + "version": "16.0.11", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.11.tgz", + "integrity": "sha512-sbtvk8wDN+JvEdabmZExoW/HNr1cB7D/j4LT08rMiuikfA7m/JNJg7ATQcgzs34zHnoScDkY0ZRSl29Fkmk36g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-jasmine2/node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-jasmine2/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/jest-jasmine2/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-jasmine2/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-jasmine2/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-jasmine2/node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-jasmine2/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-jasmine2/node_modules/expect": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-27.5.1.tgz", + "integrity": "sha512-E1q5hSUG2AmYQwQJ041nvgpkODHQvB+RKlB4IYdru6uJsyFTRyZAP463M+1lINorwbqAmUggi6+WwkD8lCS/Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-jasmine2/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-jasmine2/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-jasmine2/node_modules/jest-each": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-27.5.1.tgz", + "integrity": "sha512-1Ff6p+FbhT/bXQnEouYy00bkNSY7OUpfIcmdl8vZ31A1UUaurOLPA8a8BbJOF2RDUElwJhmeaV7LnagI+5UwNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "jest-get-type": "^27.5.1", + "jest-util": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-jasmine2/node_modules/jest-haste-map": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-27.5.1.tgz", + "integrity": "sha512-7GgkZ4Fw4NFbMSDSpZwXeBiIbx+t/46nJ2QitkOjvwPYyZmqttu2TDSimMHP1EkPOi4xUZAN1doE5Vd25H4Jng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/graceful-fs": "^4.1.2", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^27.5.1", + "jest-serializer": "^27.5.1", + "jest-util": "^27.5.1", + "jest-worker": "^27.5.1", + "micromatch": "^4.0.4", + "walker": "^1.0.7" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-jasmine2/node_modules/jest-message-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-27.5.1.tgz", + "integrity": "sha512-rMyFe1+jnyAAf+NHwTclDz0eAaLkVDdKVHHBFWsBWHnnh5YeJMNWWsv7AbFYXfK3oTqvL7VTWkhNLu1jX24D+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^27.5.1", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^27.5.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-jasmine2/node_modules/jest-mock": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-27.5.1.tgz", + "integrity": "sha512-K4jKbY1d4ENhbrG2zuPWaQBvDly+iZ2yAW+T1fATN78hc0sInwn7wZB8XtlNnvHug5RMwV897Xm4LqmPM4e2Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-jasmine2/node_modules/jest-regex-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-27.5.1.tgz", + "integrity": "sha512-4bfKq2zie+x16okqDXjXn9ql2B0dScQu+vcwe4TvFVhkVyuWLqpZrZtXxLLWoXYgn0E87I6r6GRYHF7wFZBUvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-jasmine2/node_modules/jest-resolve": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-27.5.1.tgz", + "integrity": "sha512-FFDy8/9E6CV83IMbDpcjOhumAQPDyETnU2KZ1O98DwTnz8AOBsW/Xv3GySr1mOZdItLR+zDZ7I/UdTFbgSOVCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "resolve": "^1.20.0", + "resolve.exports": "^1.1.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-jasmine2/node_modules/jest-runtime": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-27.5.1.tgz", + "integrity": "sha512-o7gxw3Gf+H2IGt8fv0RiyE1+r83FJBRruoA+FXrlHw6xEyBsU8ugA6IPfTdVyA0w8HClpbK+DGJxH59UrNMx8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/fake-timers": "^27.5.1", + "@jest/globals": "^27.5.1", + "@jest/source-map": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "execa": "^5.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-mock": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-jasmine2/node_modules/jest-snapshot": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-27.5.1.tgz", + "integrity": "sha512-yYykXI5a0I31xX67mgeLw1DZ0bJB+gpq5IpSuCAoyDi0+BhgU/RIrL+RTzDmkNTchvDFWKP8lp+w/42Z3us5sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.7.2", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/traverse": "^7.7.2", + "@babel/types": "^7.0.0", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/babel__traverse": "^7.0.4", + "@types/prettier": "^2.1.5", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^27.5.1", + "graceful-fs": "^4.2.9", + "jest-diff": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-haste-map": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-util": "^27.5.1", + "natural-compare": "^1.4.0", + "pretty-format": "^27.5.1", + "semver": "^7.3.2" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-jasmine2/node_modules/jest-snapshot/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-jasmine2/node_modules/jest-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", + "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-jasmine2/node_modules/jest-validate": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-27.5.1.tgz", + "integrity": "sha512-thkNli0LYTmOI1tDB3FI1S1RTp/Bqyd9pTarJwL87OIBFuqEb5Apv5EaApEudYg4g86e3CT6kM0RowkhtEnCBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^27.5.1", + "leven": "^3.1.0", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-jasmine2/node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, "node_modules/jest-jasmine2/node_modules/jest-worker/node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -17760,65 +18814,506 @@ "dev": true, "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jest-jasmine2/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/jest-jasmine2/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/jest-jasmine2/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jest-jasmine2/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jest-jasmine2/node_modules/write-file-atomic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", + "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } + }, + "node_modules/jest-leak-detector": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.1.0.tgz", + "integrity": "sha512-AoFvJzwxK+4KohH60vRuHaqXfWmeBATFZpzpmzNmYTtmRMiyGPVhkXpBqxUQunw+dQB48bDf4NpUs6ivVbRv1g==", + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "pretty-format": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-leak-detector/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-leak-detector/node_modules/pretty-format": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", + "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-leak-detector/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/jest-matcher-utils": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz", + "integrity": "sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-matcher-utils/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-message-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", + "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.2.0", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.2.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-message-util/node_modules/pretty-format": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-message-util/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/jest-mock": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", + "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-util": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-mock-axios": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/jest-mock-axios/-/jest-mock-axios-4.9.0.tgz", + "integrity": "sha512-3hPmRhuf4z0+YzXfAm2TTMS+U/1bOlYtrf1D1UT+73On1Q/8GbVP/U0UM2Ojl9c7/5mBgMk5R3i4gdla4qCCGA==", + "license": "MIT", + "dependencies": { + "@jest/globals": "^30.1.2", + "jest": "~30.1.3", + "synchronous-promise": "^2.0.17" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "30.1.3", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.1.3.tgz", + "integrity": "sha512-DI4PtTqzw9GwELFS41sdMK32Ajp3XZQ8iygeDMWkxlRhm7uUTOFSZFVZABFuxr0jvspn8MAYy54NxZCsuCTSOw==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.1.0", + "jest-pnp-resolver": "^1.2.3", + "jest-util": "30.0.5", + "jest-validate": "30.1.0", + "slash": "^3.0.0", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "30.1.3", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.1.3.tgz", + "integrity": "sha512-DNfq3WGmuRyHRHfEet+Zm3QOmVFtIarUOQHHryKPc0YL9ROfgWZxl4+aZq/VAzok2SS3gZdniP+dO4zgo59hBg==", + "license": "MIT", + "dependencies": { + "jest-regex-util": "30.0.1", + "jest-snapshot": "30.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve-dependencies/node_modules/@jest/expect-utils": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.1.2.tgz", + "integrity": "sha512-HXy1qT/bfdjCv7iC336ExbqqYtZvljrV8odNdso7dWK9bSeHtLlvwWWC3YSybSPL03Gg5rug6WLCZAZFH72m0A==", + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve-dependencies/node_modules/@jest/snapshot-utils": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.1.2.tgz", + "integrity": "sha512-vHoMTpimcPSR7OxS2S0V1Cpg8eKDRxucHjoWl5u4RQcnxqQrV3avETiFpl8etn4dqxEGarBeHbIBety/f8mLXw==", + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve-dependencies/node_modules/@jest/types": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.5.tgz", + "integrity": "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==", + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve-dependencies/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "engines": { "node": ">=10" }, "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/jest-jasmine2/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, + "node_modules/jest-resolve-dependencies/node_modules/expect": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.1.2.tgz", + "integrity": "sha512-xvHszRavo28ejws8FpemjhwswGj4w/BetHIL8cU49u4sGyXDw2+p3YbeDbj6xzlxi6kWTjIRSTJ+9sNXPnF0Zg==", "license": "MIT", - "engines": { - "node": ">=8.6" + "dependencies": { + "@jest/expect-utils": "30.1.2", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.1.2", + "jest-message-util": "30.1.0", + "jest-mock": "30.0.5", + "jest-util": "30.0.5" }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-jasmine2/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", + "node_modules/jest-resolve-dependencies/node_modules/jest-diff": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.1.2.tgz", + "integrity": "sha512-4+prq+9J61mOVXCa4Qp8ZjavdxzrWQXrI80GNxP8f4tkI2syPuPrJgdRPZRrfUTRvIoUwcmNLbqEJy9W800+NQ==", + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.0.1", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.0.5" + }, "engines": { - "node": ">=0.10.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-jasmine2/node_modules/write-file-atomic": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", - "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", - "dev": true, - "license": "ISC", + "node_modules/jest-resolve-dependencies/node_modules/jest-matcher-utils": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.1.2.tgz", + "integrity": "sha512-7ai16hy4rSbDjvPTuUhuV8nyPBd6EX34HkBsBcBX2lENCuAQ0qKCPb/+lt8OSWUa9WWmGYLy41PrEzkwRwoGZQ==", + "license": "MIT", "dependencies": { - "imurmurhash": "^0.1.4", - "is-typedarray": "^1.0.0", - "signal-exit": "^3.0.2", - "typedarray-to-buffer": "^3.1.5" + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.1.2", + "pretty-format": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-leak-detector": { + "node_modules/jest-resolve-dependencies/node_modules/jest-message-util": { "version": "30.1.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.1.0.tgz", - "integrity": "sha512-AoFvJzwxK+4KohH60vRuHaqXfWmeBATFZpzpmzNmYTtmRMiyGPVhkXpBqxUQunw+dQB48bDf4NpUs6ivVbRv1g==", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.1.0.tgz", + "integrity": "sha512-HizKDGG98cYkWmaLUHChq4iN+oCENohQLb7Z5guBPumYs+/etonmNFlg1Ps6yN9LTPyZn+M+b/9BbnHx3WTMDg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.0.5", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.0.5", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve-dependencies/node_modules/jest-mock": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.0.5.tgz", + "integrity": "sha512-Od7TyasAAQX/6S+QCbN6vZoWOMwlTtzzGuxJku1GhGanAjz9y+QsQkpScDmETvdc9aSXyJ/Op4rhpMYBWW91wQ==", + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "@types/node": "*", + "jest-util": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve-dependencies/node_modules/jest-snapshot": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.1.2.tgz", + "integrity": "sha512-4q4+6+1c8B6Cy5pGgFvjDy/Pa6VYRiGu0yQafKkJ9u6wQx4G5PqI2QR6nxTl43yy7IWsINwz6oT4o6tD12a8Dg==", "license": "MIT", "dependencies": { + "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.1.2", "@jest/get-type": "30.1.0", - "pretty-format": "30.0.5" + "@jest/snapshot-utils": "30.1.2", + "@jest/transform": "30.1.2", + "@jest/types": "30.0.5", + "babel-preset-current-node-syntax": "^1.1.0", + "chalk": "^4.1.2", + "expect": "30.1.2", + "graceful-fs": "^4.2.11", + "jest-diff": "30.1.2", + "jest-matcher-utils": "30.1.2", + "jest-message-util": "30.1.0", + "jest-util": "30.0.5", + "pretty-format": "30.0.5", + "semver": "^7.7.2", + "synckit": "^0.11.8" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-leak-detector/node_modules/ansi-styles": { + "node_modules/jest-resolve-dependencies/node_modules/jest-util": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.5.tgz", + "integrity": "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==", + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve-dependencies/node_modules/pretty-format": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", + "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve-dependencies/node_modules/pretty-format/node_modules/ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", @@ -17826,51 +19321,166 @@ "engines": { "node": ">=10" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-resolve-dependencies/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/jest-resolve-dependencies/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-resolve/node_modules/@jest/types": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.5.tgz", + "integrity": "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==", + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-resolve/node_modules/jest-util": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.5.tgz", + "integrity": "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==", + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runner": { + "version": "30.1.3", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.1.3.tgz", + "integrity": "sha512-dd1ORcxQraW44Uz029TtXj85W11yvLpDuIzNOlofrC8GN+SgDlgY4BvyxJiVeuabA1t6idjNbX59jLd2oplOGQ==", + "license": "MIT", + "dependencies": { + "@jest/console": "30.1.2", + "@jest/environment": "30.1.2", + "@jest/test-result": "30.1.3", + "@jest/transform": "30.1.2", + "@jest/types": "30.0.5", + "@types/node": "*", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-docblock": "30.0.1", + "jest-environment-node": "30.1.2", + "jest-haste-map": "30.1.0", + "jest-leak-detector": "30.1.0", + "jest-message-util": "30.1.0", + "jest-resolve": "30.1.3", + "jest-runtime": "30.1.3", + "jest-util": "30.0.5", + "jest-watcher": "30.1.3", + "jest-worker": "30.1.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runner/node_modules/@jest/environment": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.1.2.tgz", + "integrity": "sha512-N8t1Ytw4/mr9uN28OnVf0SYE2dGhaIxOVYcwsf9IInBKjvofAjbFRvedvBBlyTYk2knbJTiEjEJ2PyyDIBnd9w==", + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.1.2", + "@jest/types": "30.0.5", + "@types/node": "*", + "jest-mock": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-leak-detector/node_modules/pretty-format": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", - "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", + "node_modules/jest-runner/node_modules/@jest/fake-timers": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.1.2.tgz", + "integrity": "sha512-Beljfv9AYkr9K+ETX9tvV61rJTY706BhBUtiaepQHeEGfe0DbpvUA5Z3fomwc5Xkhns6NWrcFDZn+72fLieUnA==", "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", - "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "@jest/types": "30.0.5", + "@sinonjs/fake-timers": "^13.0.0", + "@types/node": "*", + "jest-message-util": "30.1.0", + "jest-mock": "30.0.5", + "jest-util": "30.0.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-leak-detector/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT" - }, - "node_modules/jest-matcher-utils": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz", - "integrity": "sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw==", - "dev": true, + "node_modules/jest-runner/node_modules/@jest/types": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.5.tgz", + "integrity": "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==", "license": "MIT", "dependencies": { - "chalk": "^4.0.0", - "jest-diff": "^27.5.1", - "jest-get-type": "^27.5.1", - "pretty-format": "^27.5.1" + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" }, "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-matcher-utils/node_modules/chalk": { + "node_modules/jest-runner/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -17883,7 +19493,7 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/jest-message-util": { + "node_modules/jest-runner/node_modules/jest-message-util": { "version": "30.1.0", "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.1.0.tgz", "integrity": "sha512-HizKDGG98cYkWmaLUHChq4iN+oCENohQLb7Z5guBPumYs+/etonmNFlg1Ps6yN9LTPyZn+M+b/9BbnHx3WTMDg==", @@ -17903,23 +19513,38 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-message-util/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/jest-runner/node_modules/jest-mock": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.0.5.tgz", + "integrity": "sha512-Od7TyasAAQX/6S+QCbN6vZoWOMwlTtzzGuxJku1GhGanAjz9y+QsQkpScDmETvdc9aSXyJ/Op4rhpMYBWW91wQ==", "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "@jest/types": "30.0.5", + "@types/node": "*", + "jest-util": "30.0.5" }, "engines": { - "node": ">=10" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runner/node_modules/jest-util": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.5.tgz", + "integrity": "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==", + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-message-util/node_modules/pretty-format": { + "node_modules/jest-runner/node_modules/pretty-format": { "version": "30.0.5", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", @@ -17933,7 +19558,7 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-message-util/node_modules/pretty-format/node_modules/ansi-styles": { + "node_modules/jest-runner/node_modules/pretty-format/node_modules/ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", @@ -17945,242 +19570,338 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest-message-util/node_modules/react-is": { + "node_modules/jest-runner/node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, - "node_modules/jest-mock": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.0.5.tgz", - "integrity": "sha512-Od7TyasAAQX/6S+QCbN6vZoWOMwlTtzzGuxJku1GhGanAjz9y+QsQkpScDmETvdc9aSXyJ/Op4rhpMYBWW91wQ==", + "node_modules/jest-runtime": { + "version": "30.1.3", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.1.3.tgz", + "integrity": "sha512-WS8xgjuNSphdIGnleQcJ3AKE4tBKOVP+tKhCD0u+Tb2sBmsU8DxfbBpZX7//+XOz81zVs4eFpJQwBNji2Y07DA==", "license": "MIT", "dependencies": { + "@jest/environment": "30.1.2", + "@jest/fake-timers": "30.1.2", + "@jest/globals": "30.1.2", + "@jest/source-map": "30.0.1", + "@jest/test-result": "30.1.3", + "@jest/transform": "30.1.2", "@jest/types": "30.0.5", "@types/node": "*", - "jest-util": "30.0.5" + "chalk": "^4.1.2", + "cjs-module-lexer": "^2.1.0", + "collect-v8-coverage": "^1.0.2", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.1.0", + "jest-message-util": "30.1.0", + "jest-mock": "30.0.5", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.1.3", + "jest-snapshot": "30.1.2", + "jest-util": "30.0.5", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-mock-axios": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/jest-mock-axios/-/jest-mock-axios-4.9.0.tgz", - "integrity": "sha512-3hPmRhuf4z0+YzXfAm2TTMS+U/1bOlYtrf1D1UT+73On1Q/8GbVP/U0UM2Ojl9c7/5mBgMk5R3i4gdla4qCCGA==", + "node_modules/jest-runtime/node_modules/@jest/environment": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.1.2.tgz", + "integrity": "sha512-N8t1Ytw4/mr9uN28OnVf0SYE2dGhaIxOVYcwsf9IInBKjvofAjbFRvedvBBlyTYk2knbJTiEjEJ2PyyDIBnd9w==", "license": "MIT", "dependencies": { - "@jest/globals": "^30.1.2", - "jest": "~30.1.3", - "synchronous-promise": "^2.0.17" + "@jest/fake-timers": "30.1.2", + "@jest/types": "30.0.5", + "@types/node": "*", + "jest-mock": "30.0.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-pnp-resolver": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", - "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "node_modules/jest-runtime/node_modules/@jest/expect": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.1.2.tgz", + "integrity": "sha512-tyaIExOwQRCxPCGNC05lIjWJztDwk2gPDNSDGg1zitXJJ8dC3++G/CRjE5mb2wQsf89+lsgAgqxxNpDLiCViTA==", "license": "MIT", + "dependencies": { + "expect": "30.1.2", + "jest-snapshot": "30.1.2" + }, "engines": { - "node": ">=6" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runtime/node_modules/@jest/expect-utils": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.1.2.tgz", + "integrity": "sha512-HXy1qT/bfdjCv7iC336ExbqqYtZvljrV8odNdso7dWK9bSeHtLlvwWWC3YSybSPL03Gg5rug6WLCZAZFH72m0A==", + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0" }, - "peerDependencies": { - "jest-resolve": "*" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runtime/node_modules/@jest/fake-timers": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.1.2.tgz", + "integrity": "sha512-Beljfv9AYkr9K+ETX9tvV61rJTY706BhBUtiaepQHeEGfe0DbpvUA5Z3fomwc5Xkhns6NWrcFDZn+72fLieUnA==", + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "@sinonjs/fake-timers": "^13.0.0", + "@types/node": "*", + "jest-message-util": "30.1.0", + "jest-mock": "30.0.5", + "jest-util": "30.0.5" }, - "peerDependenciesMeta": { - "jest-resolve": { - "optional": true - } + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-regex-util": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", - "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "node_modules/jest-runtime/node_modules/@jest/globals": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.1.2.tgz", + "integrity": "sha512-teNTPZ8yZe3ahbYnvnVRDeOjr+3pu2uiAtNtrEsiMjVPPj+cXd5E/fr8BL7v/T7F31vYdEHrI5cC/2OoO/vM9A==", "license": "MIT", + "dependencies": { + "@jest/environment": "30.1.2", + "@jest/expect": "30.1.2", + "@jest/types": "30.0.5", + "jest-mock": "30.0.5" + }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-resolve": { - "version": "30.1.3", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.1.3.tgz", - "integrity": "sha512-DI4PtTqzw9GwELFS41sdMK32Ajp3XZQ8iygeDMWkxlRhm7uUTOFSZFVZABFuxr0jvspn8MAYy54NxZCsuCTSOw==", + "node_modules/jest-runtime/node_modules/@jest/snapshot-utils": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.1.2.tgz", + "integrity": "sha512-vHoMTpimcPSR7OxS2S0V1Cpg8eKDRxucHjoWl5u4RQcnxqQrV3avETiFpl8etn4dqxEGarBeHbIBety/f8mLXw==", "license": "MIT", "dependencies": { + "@jest/types": "30.0.5", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.1.0", - "jest-pnp-resolver": "^1.2.3", - "jest-util": "30.0.5", - "jest-validate": "30.1.0", - "slash": "^3.0.0", - "unrs-resolver": "^1.7.11" + "natural-compare": "^1.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runtime/node_modules/@jest/types": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.5.tgz", + "integrity": "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==", + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runtime/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-runtime/node_modules/expect": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.1.2.tgz", + "integrity": "sha512-xvHszRavo28ejws8FpemjhwswGj4w/BetHIL8cU49u4sGyXDw2+p3YbeDbj6xzlxi6kWTjIRSTJ+9sNXPnF0Zg==", + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.1.2", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.1.2", + "jest-message-util": "30.1.0", + "jest-mock": "30.0.5", + "jest-util": "30.0.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-resolve-dependencies": { - "version": "30.1.3", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.1.3.tgz", - "integrity": "sha512-DNfq3WGmuRyHRHfEet+Zm3QOmVFtIarUOQHHryKPc0YL9ROfgWZxl4+aZq/VAzok2SS3gZdniP+dO4zgo59hBg==", + "node_modules/jest-runtime/node_modules/jest-diff": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.1.2.tgz", + "integrity": "sha512-4+prq+9J61mOVXCa4Qp8ZjavdxzrWQXrI80GNxP8f4tkI2syPuPrJgdRPZRrfUTRvIoUwcmNLbqEJy9W800+NQ==", "license": "MIT", "dependencies": { - "jest-regex-util": "30.0.1", - "jest-snapshot": "30.1.2" + "@jest/diff-sequences": "30.0.1", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.0.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-resolve/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/jest-runtime/node_modules/jest-matcher-utils": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.1.2.tgz", + "integrity": "sha512-7ai16hy4rSbDjvPTuUhuV8nyPBd6EX34HkBsBcBX2lENCuAQ0qKCPb/+lt8OSWUa9WWmGYLy41PrEzkwRwoGZQ==", "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.1.2", + "pretty-format": "30.0.5" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-runner": { - "version": "30.1.3", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.1.3.tgz", - "integrity": "sha512-dd1ORcxQraW44Uz029TtXj85W11yvLpDuIzNOlofrC8GN+SgDlgY4BvyxJiVeuabA1t6idjNbX59jLd2oplOGQ==", + "node_modules/jest-runtime/node_modules/jest-message-util": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.1.0.tgz", + "integrity": "sha512-HizKDGG98cYkWmaLUHChq4iN+oCENohQLb7Z5guBPumYs+/etonmNFlg1Ps6yN9LTPyZn+M+b/9BbnHx3WTMDg==", "license": "MIT", "dependencies": { - "@jest/console": "30.1.2", - "@jest/environment": "30.1.2", - "@jest/test-result": "30.1.3", - "@jest/transform": "30.1.2", + "@babel/code-frame": "^7.27.1", "@jest/types": "30.0.5", - "@types/node": "*", + "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", - "emittery": "^0.13.1", - "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", - "jest-docblock": "30.0.1", - "jest-environment-node": "30.1.2", - "jest-haste-map": "30.1.0", - "jest-leak-detector": "30.1.0", - "jest-message-util": "30.1.0", - "jest-resolve": "30.1.3", - "jest-runtime": "30.1.3", - "jest-util": "30.0.5", - "jest-watcher": "30.1.3", - "jest-worker": "30.1.0", - "p-limit": "^3.1.0", - "source-map-support": "0.5.13" + "micromatch": "^4.0.8", + "pretty-format": "30.0.5", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-runner/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/jest-runtime/node_modules/jest-mock": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.0.5.tgz", + "integrity": "sha512-Od7TyasAAQX/6S+QCbN6vZoWOMwlTtzzGuxJku1GhGanAjz9y+QsQkpScDmETvdc9aSXyJ/Op4rhpMYBWW91wQ==", "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "@jest/types": "30.0.5", + "@types/node": "*", + "jest-util": "30.0.5" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-runtime": { - "version": "30.1.3", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.1.3.tgz", - "integrity": "sha512-WS8xgjuNSphdIGnleQcJ3AKE4tBKOVP+tKhCD0u+Tb2sBmsU8DxfbBpZX7//+XOz81zVs4eFpJQwBNji2Y07DA==", + "node_modules/jest-runtime/node_modules/jest-snapshot": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.1.2.tgz", + "integrity": "sha512-4q4+6+1c8B6Cy5pGgFvjDy/Pa6VYRiGu0yQafKkJ9u6wQx4G5PqI2QR6nxTl43yy7IWsINwz6oT4o6tD12a8Dg==", "license": "MIT", "dependencies": { - "@jest/environment": "30.1.2", - "@jest/fake-timers": "30.1.2", - "@jest/globals": "30.1.2", - "@jest/source-map": "30.0.1", - "@jest/test-result": "30.1.3", + "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.1.2", + "@jest/get-type": "30.1.0", + "@jest/snapshot-utils": "30.1.2", "@jest/transform": "30.1.2", "@jest/types": "30.0.5", - "@types/node": "*", + "babel-preset-current-node-syntax": "^1.1.0", "chalk": "^4.1.2", - "cjs-module-lexer": "^2.1.0", - "collect-v8-coverage": "^1.0.2", - "glob": "^10.3.10", + "expect": "30.1.2", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.1.0", + "jest-diff": "30.1.2", + "jest-matcher-utils": "30.1.2", "jest-message-util": "30.1.0", - "jest-mock": "30.0.5", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.1.3", - "jest-snapshot": "30.1.2", "jest-util": "30.0.5", - "slash": "^3.0.0", - "strip-bom": "^4.0.0" + "pretty-format": "30.0.5", + "semver": "^7.7.2", + "synckit": "^0.11.8" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-runtime/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/jest-runtime/node_modules/jest-util": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.5.tgz", + "integrity": "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==", "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "@jest/types": "30.0.5", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-runtime/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "license": "ISC", + "node_modules/jest-runtime/node_modules/pretty-format": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", + "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", + "license": "MIT", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" }, - "bin": { - "glob": "dist/esm/bin.mjs" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runtime/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "engines": { + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest-runtime/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "node_modules/jest-runtime/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/jest-runtime/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" + "bin": { + "semver": "bin/semver.js" }, "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=10" } }, "node_modules/jest-serializer": { @@ -18198,9 +19919,9 @@ } }, "node_modules/jest-snapshot": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.1.2.tgz", - "integrity": "sha512-4q4+6+1c8B6Cy5pGgFvjDy/Pa6VYRiGu0yQafKkJ9u6wQx4G5PqI2QR6nxTl43yy7IWsINwz6oT4o6tD12a8Dg==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.2.0.tgz", + "integrity": "sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==", "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", @@ -18208,20 +19929,20 @@ "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/types": "^7.27.3", - "@jest/expect-utils": "30.1.2", + "@jest/expect-utils": "30.2.0", "@jest/get-type": "30.1.0", - "@jest/snapshot-utils": "30.1.2", - "@jest/transform": "30.1.2", - "@jest/types": "30.0.5", - "babel-preset-current-node-syntax": "^1.1.0", + "@jest/snapshot-utils": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "babel-preset-current-node-syntax": "^1.2.0", "chalk": "^4.1.2", - "expect": "30.1.2", + "expect": "30.2.0", "graceful-fs": "^4.2.11", - "jest-diff": "30.1.2", - "jest-matcher-utils": "30.1.2", - "jest-message-util": "30.1.0", - "jest-util": "30.0.5", - "pretty-format": "30.0.5", + "jest-diff": "30.2.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", + "pretty-format": "30.2.0", "semver": "^7.7.2", "synckit": "^0.11.8" }, @@ -18229,56 +19950,137 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-snapshot/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/jest-snapshot/node_modules/@jest/transform": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.2.0.tgz", + "integrity": "sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/types": "30.2.0", + "@jridgewell/trace-mapping": "^0.3.25", + "babel-plugin-istanbul": "^7.0.1", + "chalk": "^4.1.2", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-util": "30.2.0", + "micromatch": "^4.0.8", + "pirates": "^4.0.7", + "slash": "^3.0.0", + "write-file-atomic": "^5.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-snapshot/node_modules/jest-diff": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", + "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.0.1", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/jest-haste-map": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.2.0.tgz", + "integrity": "sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==", + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.0.1", + "jest-util": "30.2.0", + "jest-worker": "30.2.0", + "micromatch": "^4.0.8", + "walker": "^1.0.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.3" + } + }, + "node_modules/jest-snapshot/node_modules/jest-matcher-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", + "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.2.0", + "pretty-format": "30.2.0" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-snapshot/node_modules/jest-diff": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.1.2.tgz", - "integrity": "sha512-4+prq+9J61mOVXCa4Qp8ZjavdxzrWQXrI80GNxP8f4tkI2syPuPrJgdRPZRrfUTRvIoUwcmNLbqEJy9W800+NQ==", + "node_modules/jest-snapshot/node_modules/jest-worker": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.2.0.tgz", + "integrity": "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==", "license": "MIT", "dependencies": { - "@jest/diff-sequences": "30.0.1", - "@jest/get-type": "30.1.0", - "chalk": "^4.1.2", - "pretty-format": "30.0.5" + "@types/node": "*", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.2.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.1.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-snapshot/node_modules/jest-matcher-utils": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.1.2.tgz", - "integrity": "sha512-7ai16hy4rSbDjvPTuUhuV8nyPBd6EX34HkBsBcBX2lENCuAQ0qKCPb/+lt8OSWUa9WWmGYLy41PrEzkwRwoGZQ==", + "node_modules/jest-snapshot/node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "license": "MIT", "dependencies": { - "@jest/get-type": "30.1.0", - "chalk": "^4.1.2", - "jest-diff": "30.1.2", - "pretty-format": "30.0.5" + "has-flag": "^4.0.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, "node_modules/jest-snapshot/node_modules/pretty-format": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", - "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", "license": "MIT", "dependencies": { "@jest/schemas": "30.0.5", @@ -18308,9 +20110,9 @@ "license": "MIT" }, "node_modules/jest-snapshot/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -18320,12 +20122,12 @@ } }, "node_modules/jest-util": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.5.tgz", - "integrity": "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", + "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", "license": "MIT", "dependencies": { - "@jest/types": "30.0.5", + "@jest/types": "30.2.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", @@ -18369,6 +20171,24 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-validate/node_modules/@jest/types": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.5.tgz", + "integrity": "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==", + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/jest-validate/node_modules/camelcase": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", @@ -18448,6 +20268,24 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-watcher/node_modules/@jest/types": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.5.tgz", + "integrity": "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==", + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/jest-watcher/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -18464,6 +20302,23 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/jest-watcher/node_modules/jest-util": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.5.tgz", + "integrity": "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==", + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/jest-worker": { "version": "30.1.0", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.1.0.tgz", @@ -18480,6 +20335,69 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-worker/node_modules/@jest/types": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.5.tgz", + "integrity": "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==", + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-worker/node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-worker/node_modules/jest-util": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.5.tgz", + "integrity": "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==", + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/jest-worker/node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -18495,6 +20413,40 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jest/node_modules/@jest/types": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.5.tgz", + "integrity": "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==", + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -18512,9 +20464,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -18850,9 +20802,9 @@ } }, "node_modules/launch-editor": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.11.1.tgz", - "integrity": "sha512-SEET7oNfgSaB6Ym0jufAdCeo3meJVeCaaDyzRygy0xsp2BFKCprcfHljTq4QkzTLUxEKkFK6OK4811YM2oSrRg==", + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.12.0.tgz", + "integrity": "sha512-giOHXoOtifjdHqUamwKq6c49GzBdLjvxrd2D+Q4V6uOHopJv7p9VJxikDsQ/CBXZbEITgUqSVHXLTG3VhPP1Dg==", "dev": true, "license": "MIT", "dependencies": { @@ -19053,81 +21005,157 @@ "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", "dev": true, "license": "MIT", - "dependencies": { - "mimic-fn": "^4.0.0" - }, + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.3.3.tgz", + "integrity": "sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^4.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/listr2/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/lint-staged/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", "engines": { "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/lint-staged/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "node_modules/listr2/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "dev": true, - "license": "ISC", + "license": "MIT" + }, + "node_modules/listr2/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, "engines": { - "node": ">=14" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lint-staged/node_modules/strip-final-newline": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "node_modules/listr2/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "dev": true, "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, "engines": { "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/listr2": { - "version": "8.3.3", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.3.3.tgz", - "integrity": "sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==", + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, "license": "MIT", "dependencies": { - "cli-truncate": "^4.0.0", - "colorette": "^2.0.20", - "eventemitter3": "^5.0.1", - "log-update": "^6.1.0", - "rfdc": "^1.4.1", - "wrap-ansi": "^9.0.0" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/loader-runner": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", - "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", + "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", "dev": true, "license": "MIT", "engines": { "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/loader-utils": { @@ -19230,9 +21258,9 @@ } }, "node_modules/log-update/node_modules/ansi-escapes": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.1.0.tgz", - "integrity": "sha512-YdhtCd19sKRKfAAUsrcC1wzm4JuzJoiX4pOJqIoW2qmKj5WzG/dL8uUJ0361zaXtHqK7gEhOwtAtz7t3Yq3X5g==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz", + "integrity": "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==", "dev": true, "license": "MIT", "dependencies": { @@ -19271,6 +21299,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, "node_modules/log-update/node_modules/is-fullwidth-code-point": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", @@ -19304,6 +21339,24 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/log-update/node_modules/strip-ansi": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", @@ -19320,6 +21373,24 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -19372,9 +21443,9 @@ } }, "node_modules/magic-string": { - "version": "0.30.19", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", - "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { @@ -19397,9 +21468,9 @@ } }, "node_modules/make-dir/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -19688,9 +21759,9 @@ } }, "node_modules/mdast-util-to-hast": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz", - "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==", + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", @@ -20469,9 +22540,9 @@ } }, "node_modules/mini-css-extract-plugin": { - "version": "2.9.4", - "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.4.tgz", - "integrity": "sha512-ZWYT7ln73Hptxqxk2DxPU9MmapXRhxkJD6tkSR04dnQxm8BGu2hzgKLugK5yySD97u/8yy7Ma7E76k9ZdvtjkQ==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.10.0.tgz", + "integrity": "sha512-540P2c5dYnJlyJxTaSloliZexv8rji6rY8FhQN+WF/82iHQfA23j/xtJx97L+mXOML27EqksSek/g4eK7jaL3g==", "dev": true, "license": "MIT", "dependencies": { @@ -20532,9 +22603,9 @@ } }, "node_modules/minisearch": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.1.2.tgz", - "integrity": "sha512-R1Pd9eF+MD5JYDDSPAp/q1ougKglm14uEkPMvQ/05RGmx6G9wvmLTrTI/Q5iPNJLYqNdsDQ7qTGIcNWR+FrHmA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz", + "integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==", "dev": true, "license": "MIT" }, @@ -20608,9 +22679,9 @@ } }, "node_modules/napi-postinstall": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.3.tgz", - "integrity": "sha512-uTp172LLXSxuSYHv/kou+f6KW3SMppU9ivthaVTXian9sOt3XM/zHYHpRZiLgQoxeWfYUnslNWQHF1+G71xcow==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", "license": "MIT", "bin": { "napi-postinstall": "lib/cli.js" @@ -20671,9 +22742,9 @@ "optional": true }, "node_modules/node-forge": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", - "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", + "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", "dev": true, "license": "(BSD-3-Clause OR GPL-2.0)", "engines": { @@ -20687,9 +22758,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.21", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz", - "integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==", + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "license": "MIT" }, "node_modules/normalize-path": { @@ -20701,15 +22772,6 @@ "node": ">=0.10.0" } }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/normalize-url": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", @@ -20748,9 +22810,9 @@ } }, "node_modules/nwsapi": { - "version": "2.2.22", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.22.tgz", - "integrity": "sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==", + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", "dev": true, "license": "MIT" }, @@ -20911,9 +22973,9 @@ "license": "MIT" }, "node_modules/oidc-client-ts": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-2.4.1.tgz", - "integrity": "sha512-IxlGMsbkZPsHJGCliWT3LxjUcYzmiN21656n/Zt2jDncZlBFc//cd8WqFF0Lt681UT3AImM57E6d4N53ziTCYA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-2.5.0.tgz", + "integrity": "sha512-JZ/Sp+AoML4sBWCn8ShAjnIMKx3GXwU/8sQY2btRPOUS8kBZltC2dFqOdN5Mimc4g7oVGSTC/bVDBviYcuud9g==", "license": "Apache-2.0", "peer": true, "dependencies": { @@ -22084,10 +24146,20 @@ } }, "node_modules/postcss-js": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", - "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { "camelcase-css": "^2.0.1" @@ -22095,10 +24167,6 @@ "engines": { "node": "^12 || ^14 || >= 16" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, "peerDependencies": { "postcss": "^8.4.21" } @@ -22125,9 +24193,9 @@ } }, "node_modules/postcss-load-config": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", - "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", "dev": true, "funding": [ { @@ -22141,21 +24209,28 @@ ], "license": "MIT", "dependencies": { - "lilconfig": "^3.0.0", - "yaml": "^2.3.4" + "lilconfig": "^3.1.1" }, "engines": { - "node": ">= 14" + "node": ">= 18" }, "peerDependencies": { + "jiti": ">=1.21.0", "postcss": ">=8.0.9", - "ts-node": ">=9.0.0" + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { + "jiti": { + "optional": true + }, "postcss": { "optional": true }, - "ts-node": { + "tsx": { + "optional": true + }, + "yaml": { "optional": true } } @@ -22201,9 +24276,9 @@ } }, "node_modules/postcss-loader/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -22983,9 +25058,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -23198,9 +25273,9 @@ "license": "MIT" }, "node_modules/preact": { - "version": "10.27.1", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.27.1.tgz", - "integrity": "sha512-V79raXEWch/rbqoNc7nT9E4ep7lu+mI3+sBmfRD4i1M73R3WLYcCtdI0ibxGVf4eQL8ZIz2nFacqEC+rmnOORQ==", + "version": "10.28.2", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.2.tgz", + "integrity": "sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==", "dev": true, "license": "MIT", "funding": { @@ -23270,6 +25345,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -23369,9 +25451,9 @@ } }, "node_modules/prosemirror-gapcursor": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.3.2.tgz", - "integrity": "sha512-wtjswVBd2vaQRrnYZaBCbyDqr232Ed4p2QPtRIUK5FuqHYKGWkEwl08oQM4Tw7DOR0FsasARV5uJFvMZWxdNxQ==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.0.tgz", + "integrity": "sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==", "license": "MIT", "dependencies": { "prosemirror-keymap": "^1.0.0", @@ -23381,9 +25463,9 @@ } }, "node_modules/prosemirror-history": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.4.1.tgz", - "integrity": "sha512-2JZD8z2JviJrboD9cPuX/Sv/1ChFng+xh2tChQ2X4bB2HeK+rra/bmJ3xGntCcjhOqIzSDG6Id7e8RJ9QPXLEQ==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", "license": "MIT", "dependencies": { "prosemirror-state": "^1.2.2", @@ -23393,9 +25475,9 @@ } }, "node_modules/prosemirror-inputrules": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.0.tgz", - "integrity": "sha512-K0xJRCmt+uSw7xesnHmcn72yBGTbY45vm8gXI4LZXbx2Z0jwh5aF9xrGQgrVPu0WbyFVFF3E/o9VhJYz6SQWnA==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", "license": "MIT", "dependencies": { "prosemirror-state": "^1.0.0", @@ -23413,9 +25495,9 @@ } }, "node_modules/prosemirror-markdown": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.2.tgz", - "integrity": "sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==", + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.3.tgz", + "integrity": "sha512-3E+Et6cdXIH0EgN2tGYQ+EBT7N4kMiZFsW+hzx+aPtOmADDHWCdd2uUQb7yklJrfUYUOjEEu22BiN6UFgPe4cQ==", "license": "MIT", "dependencies": { "@types/markdown-it": "^14.0.0", @@ -23436,9 +25518,9 @@ } }, "node_modules/prosemirror-model": { - "version": "1.25.3", - "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.3.tgz", - "integrity": "sha512-dY2HdaNXlARknJbrManZ1WyUtos+AP97AmvqdOQtWtrrC5g4mohVX5DTi9rXNFSk09eczLq9GuNTtq3EfMeMGA==", + "version": "1.25.4", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", + "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", "dependencies": { "orderedmap": "^2.0.0" @@ -23465,9 +25547,9 @@ } }, "node_modules/prosemirror-state": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.3.tgz", - "integrity": "sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==", + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", "dependencies": { "prosemirror-model": "^1.0.0", @@ -23476,16 +25558,16 @@ } }, "node_modules/prosemirror-tables": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.1.tgz", - "integrity": "sha512-DAgDoUYHCcc6tOGpLVPSU1k84kCUWTWnfWX3UDy2Delv4ryH0KqTD6RBI6k4yi9j9I8gl3j8MkPpRD/vWPZbug==", + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", "license": "MIT", "dependencies": { - "prosemirror-keymap": "^1.2.2", - "prosemirror-model": "^1.25.0", - "prosemirror-state": "^1.4.3", - "prosemirror-transform": "^1.10.3", - "prosemirror-view": "^1.39.1" + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" } }, "node_modules/prosemirror-trailing-node": { @@ -23504,18 +25586,18 @@ } }, "node_modules/prosemirror-transform": { - "version": "1.10.4", - "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.4.tgz", - "integrity": "sha512-pwDy22nAnGqNR1feOQKHxoFkkUtepoFAd3r2hbEDsnf4wp57kKA36hXsB3njA9FtONBEwSDnDeCiJe+ItD+ykw==", + "version": "1.10.5", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.5.tgz", + "integrity": "sha512-RPDQCxIDhIBb1o36xxwsaeAvivO8VLJcgBtzmOwQ64bMtsVFh5SSuJ6dWSxO1UsHTiTXPCgQm3PDJt7p6IOLbw==", "license": "MIT", "dependencies": { "prosemirror-model": "^1.21.0" } }, "node_modules/prosemirror-view": { - "version": "1.41.0", - "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.0.tgz", - "integrity": "sha512-FatMIIl0vRHMcNc3sPy3cMw5MMyWuO1nWQxqvYpJvXAruucGvmQ2tyyjT2/Lbok77T9a/qZqBVCq4sj43V2ihw==", + "version": "1.41.5", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.5.tgz", + "integrity": "sha512-UDQbIPnDrjE8tqUBbPmCOZgtd75htE6W3r0JCmY9bL6W1iemDM37MZEKC49d+tdQ0v/CKx4gjxLoLsfkD2NiZA==", "license": "MIT", "dependencies": { "prosemirror-model": "^1.20.0", @@ -23602,13 +25684,13 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.0.6" + "side-channel": "^1.1.0" }, "engines": { "node": ">=0.6" @@ -23676,16 +25758,16 @@ } }, "node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", "dev": true, "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.8" @@ -23808,28 +25890,9 @@ "license": "MIT" }, "node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT" - }, - "node_modules/react-keyed-flatten-children": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/react-keyed-flatten-children/-/react-keyed-flatten-children-2.2.1.tgz", - "integrity": "sha512-6yBLVO6suN8c/OcJk1mzIrUHdeEzf5rtRVBhxEXAHO49D7SlJ70cG4xrSJrBIAG7MMeQ+H/T151mM2dRDNnFaA==", - "license": "MIT", - "dependencies": { - "react-is": "^18.2.0" - }, - "peerDependencies": { - "react": ">=15.0.0" - } - }, - "node_modules/react-keyed-flatten-children/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz", + "integrity": "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==", "license": "MIT" }, "node_modules/react-markdown": { @@ -23933,12 +25996,12 @@ } }, "node_modules/react-router": { - "version": "6.30.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz", - "integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==", + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.0" + "@remix-run/router": "1.23.2" }, "engines": { "node": ">=14.0.0" @@ -23948,13 +26011,13 @@ } }, "node_modules/react-router-dom": { - "version": "6.30.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz", - "integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==", + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.0", - "react-router": "6.30.1" + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" }, "engines": { "node": ">=14.0.0" @@ -24330,9 +26393,9 @@ } }, "node_modules/react-scripts/node_modules/@types/yargs": { - "version": "16.0.9", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.9.tgz", - "integrity": "sha512-tHhzvkFXZQeTECenFoRljLBYPZJ7jAVxqqtEI0qTLOmuultnFp4I9yKE17vTuhf7BkhCu7I4XuemPgikDVuYqA==", + "version": "16.0.11", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.11.tgz", + "integrity": "sha512-sbtvk8wDN+JvEdabmZExoW/HNr1cB7D/j4LT08rMiuikfA7m/JNJg7ATQcgzs34zHnoScDkY0ZRSl29Fkmk36g==", "dev": true, "license": "MIT", "dependencies": { @@ -24412,6 +26475,17 @@ "@babel/core": "^7.0.0" } }, + "node_modules/react-scripts/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/react-scripts/node_modules/camelcase": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", @@ -24527,6 +26601,28 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, + "node_modules/react-scripts/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/react-scripts/node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -25123,9 +27219,9 @@ } }, "node_modules/react-scripts/node_modules/jest-watch-typeahead/node_modules/@types/yargs": { - "version": "17.0.33", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", - "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", "dev": true, "license": "MIT", "dependencies": { @@ -25399,6 +27495,19 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/react-scripts/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/react-scripts/node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -25420,9 +27529,9 @@ "license": "MIT" }, "node_modules/react-scripts/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -25432,6 +27541,13 @@ "node": ">=10" } }, + "node_modules/react-scripts/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, "node_modules/react-scripts/node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -25766,9 +27882,9 @@ "license": "MIT" }, "node_modules/regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/regex/-/regex-6.0.1.tgz", - "integrity": "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", "dev": true, "license": "MIT", "dependencies": { @@ -25821,16 +27937,16 @@ } }, "node_modules/regexpu-core": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.3.1.tgz", - "integrity": "sha512-DzcswPr252wEr7Qz8AyAVbfyBDKLoYp6eRA1We2Fa9qirRFSdtkP5sHr3yglDKy2BbA0fd2T+j/CUSKes3FeVQ==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", "dev": true, "license": "MIT", "dependencies": { "regenerate": "^1.4.2", "regenerate-unicode-properties": "^10.2.2", "regjsgen": "^0.8.0", - "regjsparser": "^0.12.0", + "regjsparser": "^0.13.0", "unicode-match-property-ecmascript": "^2.0.0", "unicode-match-property-value-ecmascript": "^2.2.1" }, @@ -25846,29 +27962,16 @@ "license": "MIT" }, "node_modules/regjsparser": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.12.0.tgz", - "integrity": "sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz", + "integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "jsesc": "~3.0.2" - }, - "bin": { - "regjsparser": "bin/parser" - } - }, - "node_modules/regjsparser/node_modules/jsesc": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", - "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" + "jsesc": "~3.1.0" }, - "engines": { - "node": ">=6" + "bin": { + "regjsparser": "bin/parser" } }, "node_modules/rehype-attr": { @@ -25906,9 +28009,9 @@ } }, "node_modules/rehype-ignore": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/rehype-ignore/-/rehype-ignore-2.0.2.tgz", - "integrity": "sha512-BpAT/3lU9DMJ2siYVD/dSR0A/zQgD6Fb+fxkJd4j+wDVy6TYbYpK+FZqu8eM9EuNKGvi4BJR7XTZ/+zF02Dq8w==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/rehype-ignore/-/rehype-ignore-2.0.3.tgz", + "integrity": "sha512-IzhP6/u/6sm49sdktuYSmeIuObWB+5yC/5eqVws8BhuGA9kY25/byz6uCy/Ravj6lXUShEd2ofHM5MyAIj86Sg==", "license": "MIT", "dependencies": { "hast-util-select": "^6.0.0", @@ -25967,9 +28070,9 @@ } }, "node_modules/rehype-rewrite": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/rehype-rewrite/-/rehype-rewrite-4.0.2.tgz", - "integrity": "sha512-rjLJ3z6fIV11phwCqHp/KRo8xuUCO8o9bFJCNw5o6O2wlLk6g8r323aRswdGBQwfXPFYeSuZdAjp4tzo6RGqEg==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/rehype-rewrite/-/rehype-rewrite-4.0.4.tgz", + "integrity": "sha512-L/FO96EOzSA6bzOam4DVu61/PB3AGKcSPXpa53yMIozoxH4qg1+bVZDF8zh1EsuxtSauAhzt5cCnvoplAaSLrw==", "license": "MIT", "dependencies": { "hast-util-select": "^6.0.0", @@ -26231,13 +28334,13 @@ "license": "MIT" }, "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "devOptional": true, "license": "MIT", "dependencies": { - "is-core-module": "^2.16.0", + "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -26384,19 +28487,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/restore-cursor/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/retry": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", @@ -26442,6 +28532,52 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/rollup": { "version": "2.79.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", @@ -26621,9 +28757,9 @@ "license": "CC0-1.0" }, "node_modules/sass": { - "version": "1.92.1", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.92.1.tgz", - "integrity": "sha512-ffmsdbwqb3XeyR8jJR6KelIXARM9bFQe8A6Q3W4Klmwy5Ckd5gz7jgUNHo4UOqutU5Sk1DtKLbpDP0nLCg1xqQ==", + "version": "1.97.2", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.97.2.tgz", + "integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==", "license": "MIT", "dependencies": { "chokidar": "^4.0.0", @@ -26730,9 +28866,9 @@ } }, "node_modules/schema-utils": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", - "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", "dependencies": { @@ -26825,25 +28961,25 @@ } }, "node_modules/send": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", - "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", "dev": true, "license": "MIT", "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "range-parser": "~1.2.1", - "statuses": "2.0.1" + "statuses": "~2.0.2" }, "engines": { "node": ">= 0.8.0" @@ -26866,16 +29002,6 @@ "dev": true, "license": "MIT" }, - "node_modules/send/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/serialize-javascript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", @@ -26973,16 +29099,16 @@ } }, "node_modules/serve-static": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", - "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", "dev": true, "license": "MIT", "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.19.0" + "send": "~0.19.1" }, "engines": { "node": ">= 0.8.0" @@ -27172,10 +29298,16 @@ } }, "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC" + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } }, "node_modules/sisteransi": { "version": "1.0.5", @@ -27433,9 +29565,9 @@ } }, "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "dev": true, "license": "MIT", "engines": { @@ -27503,18 +29635,17 @@ "license": "MIT" }, "node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "license": "MIT", "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" }, "engines": { - "node": ">=18" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -27554,7 +29685,6 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -27563,18 +29693,10 @@ "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/string-width/node_modules/emoji-regex": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", - "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", - "dev": true, - "license": "MIT" - }, "node_modules/string-width/node_modules/strip-ansi": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -27824,21 +29946,21 @@ } }, "node_modules/style-to-js": { - "version": "1.1.17", - "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.17.tgz", - "integrity": "sha512-xQcBGDxJb6jjFCTzvQtfiPn6YvvP2O8U1MDIPNfJQlWMYfktPy+iGsHE7cssjs7y84d9fQaK4UF3RIJaAHSoYA==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", "license": "MIT", "dependencies": { - "style-to-object": "1.0.9" + "style-to-object": "1.0.14" } }, "node_modules/style-to-object": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.9.tgz", - "integrity": "sha512-G4qppLgKu/k6FwRpHiGiKPaPTFcG3g4wNVX/Qsfu+RqQM30E7Tyu/TEgxcL9PNLF5pdRLwQdE3YKKf+KF2Dzlw==", + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", "license": "MIT", "dependencies": { - "inline-style-parser": "0.2.4" + "inline-style-parser": "0.2.7" } }, "node_modules/stylehacks": { @@ -27873,18 +29995,18 @@ } }, "node_modules/sucrase": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", - "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", - "glob": "^10.3.10", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { @@ -27905,51 +30027,14 @@ "node": ">= 6" } }, - "node_modules/sucrase/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/sucrase/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/superjson": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.2.tgz", - "integrity": "sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==", + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", "dev": true, "license": "MIT", "dependencies": { - "copy-anything": "^3.0.2" + "copy-anything": "^4" }, "engines": { "node": ">=16" @@ -28051,9 +30136,9 @@ "license": "BSD-3-Clause" }, "node_modules/synckit": { - "version": "0.11.11", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", - "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==", + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", "license": "MIT", "dependencies": { "@pkgr/core": "^0.2.9" @@ -28066,16 +30151,16 @@ } }, "node_modules/tabbable": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", - "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", "dev": true, "license": "MIT" }, "node_modules/tailwindcss": { - "version": "3.4.17", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", - "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "dev": true, "license": "MIT", "dependencies": { @@ -28087,7 +30172,7 @@ "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", - "jiti": "^1.21.6", + "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", @@ -28096,7 +30181,7 @@ "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.2", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", @@ -28125,9 +30210,9 @@ } }, "node_modules/tapable": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.3.tgz", - "integrity": "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", "dev": true, "license": "MIT", "engines": { @@ -28198,9 +30283,9 @@ } }, "node_modules/terser": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz", - "integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==", + "version": "5.46.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", + "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -28217,9 +30302,9 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.14", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", - "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", + "version": "5.3.16", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz", + "integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==", "dev": true, "license": "MIT", "dependencies": { @@ -28334,6 +30419,27 @@ "concat-map": "0.0.1" } }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/test-exclude/node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -28779,9 +30885,9 @@ "license": "MIT" }, "node_modules/undici-types": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", - "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "dev": true, "license": "MIT", "optional": true, @@ -28822,9 +30928,9 @@ } }, "node_modules/unicode-property-aliases-ecmascript": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", - "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", "dev": true, "license": "MIT", "engines": { @@ -28875,9 +30981,9 @@ } }, "node_modules/unist-util-is": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", - "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", "license": "MIT", "dependencies": { "@types/unist": "^3.0.0" @@ -28929,9 +31035,9 @@ } }, "node_modules/unist-util-visit-parents": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz", - "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", @@ -29008,9 +31114,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "funding": [ { "type": "opencollective", @@ -29059,9 +31165,9 @@ } }, "node_modules/use-sync-external-store": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", - "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -29220,15 +31326,15 @@ } }, "node_modules/vitepress/node_modules/@types/node": { - "version": "24.3.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.2.tgz", - "integrity": "sha512-6L8PkB+m1SSb2kaGGFk3iXENxl8lrs7cyVl7AXH6pgdMfulDfM6yUrVdjtxdnGrLrGzzuav8fFnZMY+rcscqcA==", + "version": "25.0.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.9.tgz", + "integrity": "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==", "dev": true, "license": "MIT", "optional": true, "peer": true, "dependencies": { - "undici-types": "~7.10.0" + "undici-types": "~7.16.0" } }, "node_modules/vitepress/node_modules/@vitejs/plugin-vue": { @@ -29325,9 +31431,9 @@ } }, "node_modules/vitepress/node_modules/rollup": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.50.1.tgz", - "integrity": "sha512-78E9voJHwnXQMiQdiqswVLZwJIzdBKJ1GdI5Zx6XwoFKUIk09/sSrr+05QFzvYb8q6Y9pPV45zzDuYa3907TZA==", + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.2.tgz", + "integrity": "sha512-PggGy4dhwx5qaW+CKBilA/98Ql9keyfnb7lh4SR6shQ91QQQi1ORJ1v4UinkdP2i87OBs9AQFooQylcrrRfIcg==", "dev": true, "license": "MIT", "dependencies": { @@ -29341,34 +31447,38 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.50.1", - "@rollup/rollup-android-arm64": "4.50.1", - "@rollup/rollup-darwin-arm64": "4.50.1", - "@rollup/rollup-darwin-x64": "4.50.1", - "@rollup/rollup-freebsd-arm64": "4.50.1", - "@rollup/rollup-freebsd-x64": "4.50.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.50.1", - "@rollup/rollup-linux-arm-musleabihf": "4.50.1", - "@rollup/rollup-linux-arm64-gnu": "4.50.1", - "@rollup/rollup-linux-arm64-musl": "4.50.1", - "@rollup/rollup-linux-loongarch64-gnu": "4.50.1", - "@rollup/rollup-linux-ppc64-gnu": "4.50.1", - "@rollup/rollup-linux-riscv64-gnu": "4.50.1", - "@rollup/rollup-linux-riscv64-musl": "4.50.1", - "@rollup/rollup-linux-s390x-gnu": "4.50.1", - "@rollup/rollup-linux-x64-gnu": "4.50.1", - "@rollup/rollup-linux-x64-musl": "4.50.1", - "@rollup/rollup-openharmony-arm64": "4.50.1", - "@rollup/rollup-win32-arm64-msvc": "4.50.1", - "@rollup/rollup-win32-ia32-msvc": "4.50.1", - "@rollup/rollup-win32-x64-msvc": "4.50.1", + "@rollup/rollup-android-arm-eabi": "4.55.2", + "@rollup/rollup-android-arm64": "4.55.2", + "@rollup/rollup-darwin-arm64": "4.55.2", + "@rollup/rollup-darwin-x64": "4.55.2", + "@rollup/rollup-freebsd-arm64": "4.55.2", + "@rollup/rollup-freebsd-x64": "4.55.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.55.2", + "@rollup/rollup-linux-arm-musleabihf": "4.55.2", + "@rollup/rollup-linux-arm64-gnu": "4.55.2", + "@rollup/rollup-linux-arm64-musl": "4.55.2", + "@rollup/rollup-linux-loong64-gnu": "4.55.2", + "@rollup/rollup-linux-loong64-musl": "4.55.2", + "@rollup/rollup-linux-ppc64-gnu": "4.55.2", + "@rollup/rollup-linux-ppc64-musl": "4.55.2", + "@rollup/rollup-linux-riscv64-gnu": "4.55.2", + "@rollup/rollup-linux-riscv64-musl": "4.55.2", + "@rollup/rollup-linux-s390x-gnu": "4.55.2", + "@rollup/rollup-linux-x64-gnu": "4.55.2", + "@rollup/rollup-linux-x64-musl": "4.55.2", + "@rollup/rollup-openbsd-x64": "4.55.2", + "@rollup/rollup-openharmony-arm64": "4.55.2", + "@rollup/rollup-win32-arm64-msvc": "4.55.2", + "@rollup/rollup-win32-ia32-msvc": "4.55.2", + "@rollup/rollup-win32-x64-gnu": "4.55.2", + "@rollup/rollup-win32-x64-msvc": "4.55.2", "fsevents": "~2.3.2" } }, "node_modules/vitepress/node_modules/vite": { - "version": "5.4.20", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.20.tgz", - "integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==", + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", "dependencies": { @@ -29426,17 +31536,17 @@ } }, "node_modules/vue": { - "version": "3.5.21", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.21.tgz", - "integrity": "sha512-xxf9rum9KtOdwdRkiApWL+9hZEMWE90FHh8yS1+KJAiWYh+iGWV1FquPjoO9VUHQ+VIhsCXNNyZ5Sf4++RVZBA==", + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz", + "integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==", "dev": true, "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.21", - "@vue/compiler-sfc": "3.5.21", - "@vue/runtime-dom": "3.5.21", - "@vue/server-renderer": "3.5.21", - "@vue/shared": "3.5.21" + "@vue/compiler-dom": "3.5.27", + "@vue/compiler-sfc": "3.5.27", + "@vue/runtime-dom": "3.5.27", + "@vue/server-renderer": "3.5.27", + "@vue/shared": "3.5.27" }, "peerDependencies": { "typescript": "*" @@ -29487,9 +31597,9 @@ } }, "node_modules/watchpack": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", - "integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", "dev": true, "license": "MIT", "dependencies": { @@ -29531,9 +31641,9 @@ } }, "node_modules/webpack": { - "version": "5.101.3", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.101.3.tgz", - "integrity": "sha512-7b0dTKR3Ed//AD/6kkx/o7duS8H3f1a4w3BYpIriX4BzIhjkn4teo05cptsxvLesHFKK5KObnadmCHBwGc+51A==", + "version": "5.104.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz", + "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, "license": "MIT", "dependencies": { @@ -29545,22 +31655,22 @@ "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.15.0", "acorn-import-phases": "^1.0.3", - "browserslist": "^4.24.0", + "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.3", - "es-module-lexer": "^1.2.1", + "enhanced-resolve": "^5.17.4", + "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", + "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", - "schema-utils": "^4.3.2", - "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.11", - "watchpack": "^2.4.1", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.16", + "watchpack": "^2.4.4", "webpack-sources": "^3.3.3" }, "bin": { @@ -29773,6 +31883,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz", "integrity": "sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", "dev": true, "license": "MIT", "dependencies": { @@ -29904,9 +32015,9 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", - "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -30040,6 +32151,17 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/workbox-build/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/workbox-build/node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -30056,6 +32178,28 @@ "node": ">=10" } }, + "node_modules/workbox-build/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/workbox-build/node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -30063,6 +32207,19 @@ "dev": true, "license": "MIT" }, + "node_modules/workbox-build/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/workbox-build/node_modules/source-map": { "version": "0.8.0-beta.0", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", @@ -30287,18 +32444,17 @@ } }, "node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", "license": "MIT", "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" }, "engines": { - "node": ">=18" + "node": ">=12" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" @@ -30355,7 +32511,6 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -30368,7 +32523,6 @@ "version": "6.2.3", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -30381,7 +32535,6 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -30412,22 +32565,10 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/write-file-atomic/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "dev": true, "license": "MIT", "engines": { @@ -30476,9 +32617,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "dev": true, "license": "ISC", "bin": { @@ -30486,6 +32627,9 @@ }, "engines": { "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/yargs": { diff --git a/frontend/package.json b/frontend/package.json index 9f94d9d6..4a034382 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,7 +26,6 @@ "lodash": "4.17.21", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-oidc-context": "^2.4.0", "react-redux": "^8.1.3", "react-router-dom": "^6.29.0", "redux-persist": "^6.0.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1affe982..bf790d97 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,7 +21,7 @@ import ErrorBoundary from './shared/error/error-boundary'; import SideNavigation from './shared/layout/navigation/side-navigation'; import React from 'react'; -import { useAuth } from 'react-oidc-context'; +import { useAuth } from './shared/auth/hooks'; import { useAppSelector } from './config/store'; import DeleteModal, { DeleteModalProps } from './modules/modal/delete-modal'; import ResourceScheduleModal, { @@ -69,7 +69,7 @@ export default function App () { contentHeader={
} headerSelector='#topBanner' footerSelector='#bottomBanner' - navigationHide={!auth.isAuthenticated} + navigationHide={auth.status !== 'authenticated'} navigation={} content={} notifications={} diff --git a/frontend/src/config/oidc.config.ts b/frontend/src/config/oidc.config.ts deleted file mode 100644 index dce79841..00000000 --- a/frontend/src/config/oidc.config.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - - Licensed under the Apache License, Version 2.0 (the "License"). - You may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -import { AuthProviderProps } from 'react-oidc-context'; -import { User } from 'oidc-client-ts'; -import axios from '../shared/util/axios-utils'; -import { IUser } from '../shared/model/user.model'; - -export const oidcConfig: AuthProviderProps = { - authority: window.env.OIDC_URL, - client_id: window.env.OIDC_CLIENT_NAME, - redirect_uri: window.env.OIDC_REDIRECT_URI, - post_logout_redirect_uri: window.env.OIDC_REDIRECT_URI, - scope: 'openid profile email', - onSigninCallback: async (user: User | void) => { - window.history.replaceState( - {}, - document.title, - `${window.location.pathname}${window.location.hash}` - ); - user!.profile.preferred_username = user!.profile.preferred_username!.replace(/,|=| /gi, '-'); - const mlspaceUser = { - username: user!.profile.preferred_username, - email: user!.profile.email, - name: user!.profile.name, - }; - try { - await axios.post('/user', mlspaceUser); - } catch (err) { - // Do nothing here because an error just means the user already exists - } - // Update user's lastLogin attribute after page refresh/login - await axios.put('/login', mlspaceUser); - }, -}; diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx new file mode 100644 index 00000000..899da0da --- /dev/null +++ b/frontend/src/contexts/AuthContext.tsx @@ -0,0 +1,251 @@ +/** +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"). +You may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import axios, { axiosCatch } from '../shared/util/axios-utils'; +import React, { useState, useEffect, useRef, useMemo, useCallback, createContext } from 'react'; + +// Types and Interfaces +export type AuthUser = { + id: string; + displayName: string; + email: string; + groups: string[]; + attributes: Record; +}; + +export type AuthSession = { + expiresAt: string; + refreshAt: string; + provider: string; + refreshed?: boolean; +}; + +export type AuthState = { + status: 'loading' | 'authenticated' | 'unauthenticated'; + user: AuthUser | null; + session: AuthSession | null; + error: string | null; +}; + +export type AuthContextValue = AuthState & { + // Actions + login: (redirectUrl?: string) => void; + logout: (logoutFromIdp?: boolean) => Promise; + clearError: () => void; +}; + +enum AuthBroadcastMessageType { + 'AUTH_STATE_CHANGED', + 'SESSION_EXPIRED', + 'LOGOUT_INITIATED' +} + +// Cross-tab synchronization message types +type AuthBroadcastMessage = { type: AuthBroadcastMessageType; senderId: string }; + +// Cross-tab synchronization manager +class AuthSyncManager { + private channel: BroadcastChannel; + private readonly id: string; + private onStateChangeRef: { current: () => void }; + + constructor (onStateChangeRef: { current: () => void }) { + this.id = `auth-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + this.channel = new BroadcastChannel('mlspace-auth'); + this.onStateChangeRef = onStateChangeRef; + this.channel.addEventListener('message', this.handleMessage); + } + + private handleMessage = (event: MessageEvent) => { + // Ignore messages from this instance + if (event.data.senderId === this.id) { + return; + } + + switch (event.data.type) { + case AuthBroadcastMessageType.AUTH_STATE_CHANGED: + case AuthBroadcastMessageType.SESSION_EXPIRED: + this.onStateChangeRef.current(); + break; + case AuthBroadcastMessageType.LOGOUT_INITIATED: + // Immediate logout without API call (already done in originating tab) + this.onStateChangeRef.current(); + break; + } + }; + + broadcast (type: AuthBroadcastMessageType) { + this.channel.postMessage({ type, senderId: this.id }); + } + + destroy () { + this.channel.removeEventListener('message', this.handleMessage); + this.channel.close(); + } +} + +// Create the context +export const AuthContext = createContext(null); + +// AuthProvider props +type AuthProviderProps = { + children: React.ReactNode; + checkInterval?: number; // Default: 60000ms (1 minute) +}; + +// AuthProvider component +export const AuthProvider: React.FC = ({ + children, + checkInterval = 60000 +}) => { + const [state, setState] = useState({ + status: 'loading', + user: null, + session: null, + error: null + }); + + // Cross-tab synchronization + const syncManagerRef = useRef(); + const checkAuthStatusRef = useRef<() => void>(); + + const checkAuthStatus = useCallback(async () => { + console.log('checking auth status'); + const wasAuthenticated = state.status === 'authenticated'; + + try { + // Use axios-utils for standardized baseURL configuration + const response = await axios.get('/auth/identity').catch(axiosCatch); + + setState({ + status: 'authenticated', + user: response.data.user, + session: response.data.session, + error: null + }); + + // Broadcast auth state changes to other tabs + if (!wasAuthenticated || response.data.session.refreshed) { + syncManagerRef.current?.broadcast(AuthBroadcastMessageType.AUTH_STATE_CHANGED); + } + } catch (error: any) { + if (error?.code === 401) { + setState({ + status: 'unauthenticated', + user: null, + session: null, + error: null + }); + + // Broadcast session expiration to other tabs + if (wasAuthenticated) { + syncManagerRef.current?.broadcast(AuthBroadcastMessageType.SESSION_EXPIRED); + } + } else { + setState((prev) => ({ + ...prev, + status: 'unauthenticated', + error: 'Failed to check authentication status' + })); + } + } + }, [state.status]); + + // Keep ref updated for AuthSyncManager + useEffect(() => { + checkAuthStatusRef.current = checkAuthStatus; + }, [checkAuthStatus]); + + useEffect(() => { + syncManagerRef.current = new AuthSyncManager(checkAuthStatusRef as { current: () => void }); + return () => { + syncManagerRef.current?.destroy(); + }; + }, []); + + // Initial authentication check on mount + useEffect(() => { + checkAuthStatus(); + }, [checkAuthStatus]); + + // Periodic session validation + useEffect(() => { + console.log('reset periodic refresh'); + const interval = setInterval(checkAuthStatus, checkInterval); + return () => clearInterval(interval); + }, [checkInterval, checkAuthStatus]); + + + + const login = (redirectUrl?: string) => { + // For login redirect, we need to construct the full URL since it's a page redirect + const baseUrl = (window as any).env?.LAMBDA_ENDPOINT || window.location.origin; + const loginUrl = new URL('/auth/login', baseUrl); + if (redirectUrl) { + loginUrl.searchParams.set('redirectUrl', redirectUrl); + } + window.location.href = loginUrl.toString(); + }; + + const logout = async (logoutFromIdp = false) => { + try { + // Use axios-utils for standardized baseURL configuration + const response = await axios.post('/auth/logout', { logoutFromIdp }).catch(axiosCatch); + + setState({ + status: 'unauthenticated', + user: null, + session: null, + error: null + }); + + // Notify other tabs + syncManagerRef.current?.broadcast(AuthBroadcastMessageType.LOGOUT_INITIATED); + + // Redirect to IdP logout if provided + if (response.data.idpLogoutUrl) { + window.location.href = response.data.idpLogoutUrl; + } + } catch (error) { + setState((prev) => ({ + ...prev, + error: 'Failed to logout' + })); + } + }; + + const clearError = () => setState((prev) => ({ ...prev, error: null })); + + const contextValue = useMemo(() => ({ + ...state, + login, + logout, + clearError + // eslint-disable-next-line react-hooks/exhaustive-deps + }), [state]); + + return ( + + {children} + + ); +}; + +// Custom hooks for consuming the context are now in shared/auth/hooks.ts +// Import them from there: import { useAuth, useUser, useAuthStatus, useRequireAuth } from '../shared/auth/hooks'; + +// Authentication components are now in shared/auth/components.tsx +// Import them from there: import { ProtectedRoute, AuthLoadingWrapper, SessionExpirationNotice, AuthErrorBoundary } from '../shared/auth/components'; \ No newline at end of file diff --git a/frontend/src/contexts/AuthSyncManager.test.ts b/frontend/src/contexts/AuthSyncManager.test.ts new file mode 100644 index 00000000..a51d41bf --- /dev/null +++ b/frontend/src/contexts/AuthSyncManager.test.ts @@ -0,0 +1,161 @@ +/** +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"). +You may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Mock BroadcastChannel +class MockBroadcastChannel { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + private listeners: Array<(event: MessageEvent) => void> = []; + + constructor (public name: string) {} + + addEventListener (type: string, listener: (event: MessageEvent) => void) { + if (type === 'message') { + this.listeners.push(listener); + } + } + + removeEventListener (type: string, listener: (event: MessageEvent) => void) { + if (type === 'message') { + const index = this.listeners.indexOf(listener); + if (index > -1) { + this.listeners.splice(index, 1); + } + } + } + + postMessage (data: any) { + const event = new MessageEvent('message', { data }); + this.listeners.forEach((listener) => listener(event)); + } + + close () { + this.listeners = []; + } +} + +// Store original BroadcastChannel +const originalBroadcastChannel = global.BroadcastChannel; + +describe('AuthSyncManager', () => { + let mockChannel: MockBroadcastChannel; + let onStateChangeMock: jest.Mock; + + beforeEach(() => { + // Mock BroadcastChannel + global.BroadcastChannel = jest.fn().mockImplementation((name: string) => { + mockChannel = new MockBroadcastChannel(name); + return mockChannel; + }); + + onStateChangeMock = jest.fn(); + }); + + afterEach(() => { + // Restore original BroadcastChannel + global.BroadcastChannel = originalBroadcastChannel; + jest.clearAllMocks(); + }); + + // Extract AuthSyncManager class for testing + class AuthSyncManager { + private channel: BroadcastChannel; + + constructor (private onStateChange: () => void) { + this.channel = new BroadcastChannel('mlspace-auth'); + this.channel.addEventListener('message', this.handleMessage); + } + + private handleMessage = (event: MessageEvent) => { + switch (event.data.type) { + case 'AUTH_STATE_CHANGED': + case 'SESSION_EXPIRED': + this.onStateChange(); + break; + case 'LOGOUT_INITIATED': + this.onStateChange(); + break; + } + }; + + broadcast (message: any) { + this.channel.postMessage(message); + } + + destroy () { + this.channel.removeEventListener('message', this.handleMessage); + this.channel.close(); + } + } + + it('should create BroadcastChannel with correct name', () => { + new AuthSyncManager(onStateChangeMock); + + expect(global.BroadcastChannel).toHaveBeenCalledWith('mlspace-auth'); + expect(mockChannel.name).toBe('mlspace-auth'); + }); + + it('should handle AUTH_STATE_CHANGED message', () => { + new AuthSyncManager(onStateChangeMock); + + mockChannel.postMessage({ type: 'AUTH_STATE_CHANGED' }); + + expect(onStateChangeMock).toHaveBeenCalledTimes(1); + }); + + it('should handle SESSION_EXPIRED message', () => { + new AuthSyncManager(onStateChangeMock); + + mockChannel.postMessage({ type: 'SESSION_EXPIRED' }); + + expect(onStateChangeMock).toHaveBeenCalledTimes(1); + }); + + it('should handle LOGOUT_INITIATED message', () => { + new AuthSyncManager(onStateChangeMock); + + mockChannel.postMessage({ type: 'LOGOUT_INITIATED' }); + + expect(onStateChangeMock).toHaveBeenCalledTimes(1); + }); + + it('should broadcast messages', () => { + const syncManager = new AuthSyncManager(onStateChangeMock); + const postMessageSpy = jest.spyOn(mockChannel, 'postMessage'); + + syncManager.broadcast({ type: 'AUTH_STATE_CHANGED' }); + + expect(postMessageSpy).toHaveBeenCalledWith({ type: 'AUTH_STATE_CHANGED' }); + }); + + it('should clean up on destroy', () => { + const syncManager = new AuthSyncManager(onStateChangeMock); + const removeEventListenerSpy = jest.spyOn(mockChannel, 'removeEventListener'); + const closeSpy = jest.spyOn(mockChannel, 'close'); + + syncManager.destroy(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith('message', expect.any(Function)); + expect(closeSpy).toHaveBeenCalled(); + }); + + it('should ignore unknown message types', () => { + new AuthSyncManager(onStateChangeMock); + + mockChannel.postMessage({ type: 'UNKNOWN_MESSAGE' }); + + expect(onStateChangeMock).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/frontend/src/entities/batch-translate/create/batch-translate-create.tsx b/frontend/src/entities/batch-translate/create/batch-translate-create.tsx index a630f14e..d7dc2536 100644 --- a/frontend/src/entities/batch-translate/create/batch-translate-create.tsx +++ b/frontend/src/entities/batch-translate/create/batch-translate-create.tsx @@ -43,7 +43,6 @@ import { defaultEncryptionKey, } from '../../../shared/model/translate.model'; import { enumToOptions } from '../../../shared/util/enum-utils'; -import { useAuth } from 'react-oidc-context'; import { OptionDefinition } from '@cloudscape-design/components/internal/components/option/interfaces'; import { getCustomTerminologyList, @@ -57,6 +56,7 @@ import { isFulfilled } from '@reduxjs/toolkit'; import { AUTO_SOURCE_LANGUAGE_UNSUPPORTED } from '..'; import ContentLayout from '../../../shared/layout/content-layout'; import { useNotificationService } from '../../../shared/util/hooks'; +import { useUsername } from '../../../shared/util/auth-utils'; export function BatchTranslateCreate () { const [errorText] = useState(''); @@ -68,7 +68,7 @@ export function BatchTranslateCreate () { const dispatch = useAppDispatch(); const notificationService = useNotificationService(dispatch); const navigate = useNavigate(); - const auth = useAuth(); + const userName = useUsername(); const plainTextOption: OptionDefinition = { label: 'Plain text (.txt)', value: 'text/plain', @@ -94,7 +94,6 @@ export function BatchTranslateCreate () { } ]; const [docType, setDocType] = useState(plainTextOption); - const userName = auth.user!.profile.preferred_username; const nameConstraintText = 'Maximum of 255 alphanumeric characters. Can include hyphens (-), but not spaces. Must be unique within your account in an AWS Region.'; scrollToPageHeader(); diff --git a/frontend/src/entities/dataset/create/dataset-create.tsx b/frontend/src/entities/dataset/create/dataset-create.tsx index 0ea00017..286d2f87 100644 --- a/frontend/src/entities/dataset/create/dataset-create.tsx +++ b/frontend/src/entities/dataset/create/dataset-create.tsx @@ -71,10 +71,8 @@ const formSchema = z.object({ }), description: z .string() - .regex(/^[\w\-\s'.]+$/, { - message: 'Dataset description can contain only alphanumeric characters.', - }) .max(254) + .default('') }); export function DatasetCreate () { diff --git a/frontend/src/entities/dataset/update/dataset-update.tsx b/frontend/src/entities/dataset/update/dataset-update.tsx index 13a51e79..d22645fc 100644 --- a/frontend/src/entities/dataset/update/dataset-update.tsx +++ b/frontend/src/entities/dataset/update/dataset-update.tsx @@ -50,9 +50,7 @@ import { IGroup } from '../../../shared/model/group.model'; import { DatasetProperties } from '../dataset'; const formSchema = z.object({ - description: z.string().regex(/^[\w\-\s']+$/, { - message: 'Dataset description can contain only alphanumeric characters.', - }), + description: z.string().max(254).default('') }); export function DatasetUpdate ({isAdmin}: DatasetProperties) { diff --git a/frontend/src/entities/jobs/hpo/create/hpo-job-create.tsx b/frontend/src/entities/jobs/hpo/create/hpo-job-create.tsx index ea21ebcd..90187218 100644 --- a/frontend/src/entities/jobs/hpo/create/hpo-job-create.tsx +++ b/frontend/src/entities/jobs/hpo/create/hpo-job-create.tsx @@ -38,7 +38,6 @@ import _ from 'lodash'; import { createHPOJobThunk, CreateHPOJobThunkPayload } from '../hpo-job.reducer'; import { ConfigureTuningJobResources } from './configure-tuning-job-resources'; import { EditTrainingJobDefinition } from './edit-training-job-definition'; -import { useAuth } from 'react-oidc-context'; import { getBase } from '../../../../shared/util/breadcrumb-utils'; import { DocTitle, scrollToPageHeader } from '../../../../../src/shared/doc'; import { getDate, getPaddedNumberString } from '../../../../shared/util/date-utils'; @@ -48,6 +47,7 @@ import { tryCreateDataset } from '../../../dataset/dataset.service'; import { datasetFromS3Uri } from '../../../../shared/util/dataset-utils'; import { useNotificationService } from '../../../../shared/util/hooks'; import '../../../../wizard.css'; +import { useUsername } from '../../../../shared/util/auth-utils'; export type HPOJobCreateState = { editingJobDefinition: any; @@ -62,8 +62,7 @@ export function HPOJobCreate () { const navigate = useNavigate(); const location = useLocation(); const { projectName } = useParams(); - const auth = useAuth(); - const userName = auth.user!.profile.preferred_username; + const userName = useUsername(); const dispatch = useAppDispatch(); const notificationService = useNotificationService(dispatch); diff --git a/frontend/src/entities/notebook/notebook.reducer.spec.ts b/frontend/src/entities/notebook/notebook.reducer.spec.ts index d20dff6e..55318def 100644 --- a/frontend/src/entities/notebook/notebook.reducer.spec.ts +++ b/frontend/src/entities/notebook/notebook.reducer.spec.ts @@ -310,9 +310,7 @@ describe('Entities reducer tests', () => { const mockOidcSessionStorageValue = `{"id_token":"${mockToken}"}`; const expectedRequestConfig = { baseURL: mockLambdaEndpoint, - headers: { - Authorization: `Bearer ${mockToken}` - } + withCredentials: true }; const resolvedObject = { data: [{ id: 1 }, { id: 2 }] }; @@ -405,7 +403,6 @@ describe('Entities reducer tests', () => { expect(mockAxios.post).toHaveBeenCalledWith('/notebook/42666/start', undefined, { ... expectedRequestConfig, headers: { - ...expectedRequestConfig.headers, 'x-mlspace-project': 'testProject' }, }); @@ -428,7 +425,6 @@ describe('Entities reducer tests', () => { expect(mockAxios.post).toHaveBeenCalledWith('/notebook/42666/stop', undefined, { ... expectedRequestConfig, headers: { - ...expectedRequestConfig.headers, 'x-mlspace-project': 'testProject' }, }); @@ -482,7 +478,6 @@ describe('Entities reducer tests', () => { expect(mockAxios.post).toHaveBeenCalledWith('/notebook', createdNotebook, { ... expectedRequestConfig, headers: { - ...expectedRequestConfig.headers, 'x-mlspace-project': 'testProject' }, }); diff --git a/frontend/src/entities/project/detail/project-detail.actions.tsx b/frontend/src/entities/project/detail/project-detail.actions.tsx index e62d576a..0125ccbb 100644 --- a/frontend/src/entities/project/detail/project-detail.actions.tsx +++ b/frontend/src/entities/project/detail/project-detail.actions.tsx @@ -23,24 +23,23 @@ import { IProject } from '../../../shared/model/project.model'; import { removeUserFromProject, selectCurrentUser } from '../../user/user.reducer'; import { selectProject } from '../card/project-card.reducer'; import { deleteProject, getProject, listProjectsForUser, updateProject } from '../project.reducer'; -import { useAuth } from 'react-oidc-context'; import { hasPermission } from '../../../shared/util/permission-utils'; import { Permission } from '../../../shared/model/user.model'; import Modal, { ModalProps } from '../../../modules/modal'; import { useNotificationService } from '../../../shared/util/hooks'; import { INotificationService } from '../../../shared/layout/notification/notification.service'; +import { useUsername } from '../../../shared/util/auth-utils'; function ProjectDetailActions () { const dispatch = useAppDispatch(); const navigate = useNavigate(); - const auth = useAuth(); const nav = (endpoint: string) => navigate(endpoint); const { projectName } = useParams(); let project: IProject = useAppSelector((state) => state.project.project); if (!project) { project = { name: projectName }; } - const username = auth.user!.profile.preferred_username!; + const username = useUsername(); return ( diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 20e58955..f9da9c51 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -19,8 +19,8 @@ import ReactDOM from 'react-dom/client'; import { Provider } from 'react-redux'; import App from './App'; import getStore, { persistor } from './config/store'; -import { AuthProvider } from 'react-oidc-context'; -import { oidcConfig } from './config/oidc.config'; +import { AuthProvider } from './contexts/AuthContext'; +import { AuthErrorBoundary } from './shared/auth/components'; import { PersistGate } from 'redux-persist/integration/react'; import { I18nProvider } from '@cloudscape-design/components/i18n'; // Only import English @@ -34,13 +34,15 @@ root.render( - -
- - - -
-
+ + +
+ + + +
+
+
diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index 7a093c3a..e32ab4ba 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -24,7 +24,7 @@ import { import { useAppDispatch, useAppSelector } from './config/store'; import EntitiesRoutes from './entities/routes'; import Home from './shared/layout/home/home'; -import { hasAuthParams, useAuth } from 'react-oidc-context'; +import { useAuth } from './shared/auth/hooks'; import Condition from './modules/condition'; import { Button, ColumnLayout, Container, SpaceBetween } from '@cloudscape-design/components'; import { getCurrentUser, selectCurrentUser, setCurrentUser } from './entities/user/user.reducer'; @@ -51,7 +51,7 @@ export default function AppRoutes () { const [markdown, setMarkdown] = useState(''); useEffect(() => { - if (hasAuthParams() || auth.isAuthenticated || auth.activeNavigator || auth.isLoading) { + if (auth.status === 'authenticated') { getCurrentUser() .then((response) => { if (response.status === 200) { @@ -73,11 +73,7 @@ export default function AppRoutes () { dispatch(setBreadcrumbs([])); }, [ dispatch, - auth, - auth.isAuthenticated, - auth.activeNavigator, - auth.isLoading, - auth.signinRedirect, + auth.status, ]); useMemo(async () => { @@ -121,7 +117,7 @@ export default function AppRoutes () { return (
- + { - auth.signinRedirect(); + auth.login(); }}> Login @@ -202,7 +198,7 @@ export default function AppRoutes () { - + - + } /> } /> diff --git a/frontend/src/shared/auth/README.md b/frontend/src/shared/auth/README.md new file mode 100644 index 00000000..9e1eea4f --- /dev/null +++ b/frontend/src/shared/auth/README.md @@ -0,0 +1,315 @@ +# Authentication Hooks and Utilities + +This directory contains authentication hooks, components, and utilities for the MLSpace enhanced authentication system. + +## Overview + +The BFF authentication pattern abstracts authentication complexity from the frontend and centralizes all Identity Provider integration in the backend. This provides better security, simplified frontend code, and improved session management. + +## Files + +- `hooks.ts` - Authentication hooks for accessing auth state and user information +- `components.tsx` - React components for authentication UI and route protection +- `index.ts` - Main export file for all authentication utilities +- `auth.css` - CSS styles for authentication components +- `private-route.tsx` - Legacy private route component (deprecated) + +## Hooks + +### Core Hooks + +#### `useAuth()` +Main hook to access the authentication context. + +```typescript +const { status, user, session, login, logout, refresh, clearError } = useAuth(); +``` + +#### `useUser()` +Convenience hook to get the current authenticated user. + +```typescript +const user = useUser(); // AuthUser | null +``` + +#### `useAuthStatus()` +Convenience hook to get the current authentication status. + +```typescript +const status = useAuthStatus(); // 'loading' | 'authenticated' | 'unauthenticated' +``` + +#### `useRequireAuth()` +Hook that automatically redirects to login if user is not authenticated. + +```typescript +const isAuthenticated = useRequireAuth(); // boolean +``` + +### Authorization Hooks + +#### `useHasGroups(requiredGroups: string[])` +Check if user has any of the required groups. + +```typescript +const hasAccess = useHasGroups(['admin', 'moderator']); +``` + +#### `useHasGroup(group: string)` +Check if user has a specific group. + +```typescript +const isAdmin = useHasGroup('admin'); +``` + +### User Data Hooks + +#### `useUserAttributes()` +Get all user attributes. + +```typescript +const attributes = useUserAttributes(); // Record +``` + +#### `useUserAttribute(attributeName: string)` +Get a specific user attribute. + +```typescript +const department = useUserAttribute('department'); +``` + +## Components + +### Route Protection + +#### `ProtectedRoute` +Component that protects routes by requiring authentication and optionally specific groups. + +```typescript +Loading...
} + accessDeniedMessage="Admin access required" +> + + +``` + +Props: +- `children` - Content to render when access is granted +- `fallback?` - Component to show while loading (default: "Loading...") +- `requiredGroups?` - Array of groups that grant access +- `accessDeniedMessage?` - Custom access denied message + +#### `ConditionalRender` +Component that conditionally renders content based on authentication and group membership. + +```typescript +Access denied
} +> + + +``` + +### Loading States + +#### `AuthLoadingWrapper` +Component that shows loading state while authentication is being checked. + +```typescript +} + showSpinner={true} +> + + +``` + +### Session Management + +#### `SessionExpirationNotice` +Component that shows a warning when the session is about to expire. + +```typescript + console.log('Extending session')} + onDismiss={() => console.log('Dismissed warning')} +/> +``` + +### Error Handling + +#### `AuthErrorBoundary` +Error boundary component specifically for authentication errors. + +```typescript + console.error(error)} +> + + +``` + +### User Information + +#### `UserInfo` +Component that displays current user information. + +```typescript + +``` + +## Usage Examples + +### Basic Authentication Setup + +```typescript +import React from 'react'; +import { AuthProvider, AuthErrorBoundary, AuthLoadingWrapper } from '../shared/auth'; + +function App() { + return ( + + + + + + + + ); +} +``` + +### Protected Routes + +```typescript +import { ProtectedRoute, useAuth } from '../shared/auth'; + +function AdminPage() { + return ( + +

Admin Dashboard

+ +
+ ); +} +``` + +### Conditional UI Elements + +```typescript +import { ConditionalRender, useUser } from '../shared/auth'; + +function Navigation() { + const user = useUser(); + + return ( + + ); +} +``` + +### Session Management + +```typescript +import { SessionExpirationNotice, useAuth } from '../shared/auth'; + +function Layout() { + const { status } = useAuth(); + + return ( +
+
+
+ +
+ {status === 'authenticated' && } +
+ ); +} +``` + +## CSS Classes + +The authentication components use the following CSS classes (defined in `auth.css`): + +- `.auth-loading` - Loading state container +- `.auth-access-denied` - Access denied message +- `.session-expiration-notice` - Session expiration warning +- `.auth-error` - Authentication error display +- `.user-info` - User information display + +## Migration from Legacy OIDC + +When migrating from the legacy OIDC system: + +1. Replace `PrivateRoute` with `ProtectedRoute` +2. Update imports to use the new authentication hooks +3. Remove direct OIDC token handling +4. Use the new session-based authentication + +### Before (Legacy) +```typescript +import { PrivateRoute } from '../shared/auth/private-route'; + + + + +``` + +### After (BFF) +```typescript +import { ProtectedRoute } from '../shared/auth'; + + + + +``` + +## Type Definitions + +All TypeScript types are exported from the main index file: + +```typescript +import type { + AuthUser, + AuthSession, + AuthState, + AuthContextValue, + ProtectedRouteProps, + AuthLoadingWrapperProps +} from '../shared/auth'; +``` + +## Error Handling + +The authentication system provides comprehensive error handling: + +1. **Network Errors** - Handled gracefully with retry logic +2. **Session Expiration** - Automatic detection and user notification +3. **Authorization Errors** - Clear access denied messages +4. **Component Errors** - Error boundaries prevent app crashes + +## Security Considerations + +- All authentication state is managed server-side +- Session cookies are HTTP-only and secure +- Cross-tab synchronization prevents state inconsistencies +- Automatic token refresh prevents session interruption +- Error boundaries prevent sensitive information leakage \ No newline at end of file diff --git a/frontend/src/shared/auth/auth.css b/frontend/src/shared/auth/auth.css new file mode 100644 index 00000000..79f53d5b --- /dev/null +++ b/frontend/src/shared/auth/auth.css @@ -0,0 +1,231 @@ +/** +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"). +You may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* Authentication Loading States */ +.auth-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem; + text-align: center; +} + +.auth-loading .spinner { + width: 2rem; + height: 2rem; + border: 2px solid #f3f3f3; + border-top: 2px solid #0073bb; + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 1rem; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Access Denied */ +.auth-access-denied { + padding: 2rem; + text-align: center; + background-color: #fef2f2; + border: 1px solid #fecaca; + border-radius: 0.5rem; + color: #991b1b; +} + +.auth-access-denied h3 { + margin: 0 0 1rem 0; + color: #dc2626; +} + +/* Session Expiration Notice */ +.session-expiration-notice { + position: fixed; + top: 1rem; + right: 1rem; + background-color: #fef3c7; + border: 1px solid #f59e0b; + border-radius: 0.5rem; + padding: 1rem; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + z-index: 1000; + max-width: 400px; +} + +.session-expiration-content p { + margin: 0 0 1rem 0; + color: #92400e; +} + +.session-expiration-actions { + display: flex; + gap: 0.5rem; +} + +.session-extend-button { + background-color: #0073bb; + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 0.25rem; + cursor: pointer; + font-size: 0.875rem; +} + +.session-extend-button:hover { + background-color: #005a94; +} + +.session-dismiss-button { + background-color: #6b7280; + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 0.25rem; + cursor: pointer; + font-size: 0.875rem; +} + +.session-dismiss-button:hover { + background-color: #4b5563; +} + +/* Authentication Error */ +.auth-error { + padding: 2rem; + text-align: center; + background-color: #fef2f2; + border: 1px solid #fecaca; + border-radius: 0.5rem; + color: #991b1b; + max-width: 600px; + margin: 2rem auto; +} + +.auth-error h2 { + margin: 0 0 1rem 0; + color: #dc2626; +} + +.auth-error-actions { + display: flex; + gap: 1rem; + justify-content: center; + margin-top: 1rem; +} + +.auth-error-actions button { + background-color: #dc2626; + color: white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: 0.25rem; + cursor: pointer; + font-size: 0.875rem; +} + +.auth-error-actions button:hover { + background-color: #b91c1c; +} + +.auth-error-details { + margin-top: 1rem; + text-align: left; + background-color: #f9fafb; + border: 1px solid #d1d5db; + border-radius: 0.25rem; + padding: 1rem; +} + +.auth-error-details summary { + cursor: pointer; + font-weight: 600; + margin-bottom: 0.5rem; +} + +.auth-error-details pre { + font-size: 0.75rem; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-all; +} + +/* User Info Component */ +.user-info { + padding: 1rem; + background-color: #f9fafb; + border: 1px solid #d1d5db; + border-radius: 0.5rem; +} + +.user-display-name { + font-weight: 600; + font-size: 1.125rem; + margin-bottom: 0.25rem; +} + +.user-email { + color: #6b7280; + font-size: 0.875rem; + margin-bottom: 0.5rem; +} + +.user-groups { + font-size: 0.875rem; + margin-bottom: 0.5rem; +} + +.user-attributes { + font-size: 0.875rem; +} + +.user-attribute { + display: flex; + margin-bottom: 0.25rem; +} + +.attribute-key { + font-weight: 500; + margin-right: 0.5rem; + min-width: 100px; +} + +.attribute-value { + color: #6b7280; +} + +/* Responsive Design */ +@media (max-width: 640px) { + .session-expiration-notice { + position: relative; + top: auto; + right: auto; + margin: 1rem; + max-width: none; + } + + .session-expiration-actions { + flex-direction: column; + } + + .auth-error-actions { + flex-direction: column; + align-items: center; + } +} \ No newline at end of file diff --git a/frontend/src/shared/auth/components.test.tsx b/frontend/src/shared/auth/components.test.tsx new file mode 100644 index 00000000..953b4747 --- /dev/null +++ b/frontend/src/shared/auth/components.test.tsx @@ -0,0 +1,163 @@ +/** +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"). +You may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { AuthErrorBoundary } from './components'; + +// Mock component that throws an error +const ThrowError: React.FC<{ shouldThrow: boolean }> = ({ shouldThrow }) => { + if (shouldThrow) { + throw new Error('Test authentication error'); + } + return
No error
; +}; + +describe('AuthErrorBoundary', () => { + // Mock console.error to avoid noise in test output + const originalConsoleError = console.error; + beforeAll(() => { + console.error = jest.fn(); + }); + + afterAll(() => { + console.error = originalConsoleError; + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render children when there is no error', () => { + render( + + + + ); + + expect(screen.getByText('No error')).toBeInTheDocument(); + }); + + it('should catch errors and display error UI', () => { + render( + + + + ); + + expect(screen.getByText('Authentication Error')).toBeInTheDocument(); + expect(screen.getByText('Something went wrong with authentication. Please try refreshing the page.')).toBeInTheDocument(); + expect(screen.getByText('Refresh Page')).toBeInTheDocument(); + expect(screen.getByText('Try Again')).toBeInTheDocument(); + }); + + it('should log errors to console', () => { + render( + + + + ); + + expect(console.error).toHaveBeenCalledWith( + 'Authentication error:', + expect.any(Error), + expect.any(Object) + ); + }); + + it('should call custom error handler when provided', () => { + const mockOnError = jest.fn(); + + render( + + + + ); + + expect(mockOnError).toHaveBeenCalledWith( + expect.any(Error), + expect.any(Object) + ); + }); + + it('should provide resetError function to custom fallback', () => { + let capturedResetError: (() => void) | undefined; + + const CustomFallback: React.FC<{ error: Error; resetError: () => void }> = ({ error, resetError }) => { + capturedResetError = resetError; + return ( +
+

Custom Error: {error.message}

+ +
+ ); + }; + + render( + + + + ); + + // Error UI should be displayed + expect(screen.getByText('Custom Error: Test authentication error')).toBeInTheDocument(); + expect(screen.getByText('Reset Error')).toBeInTheDocument(); + + // Verify resetError function is provided + expect(capturedResetError).toBeDefined(); + expect(typeof capturedResetError).toBe('function'); + }); + + it('should use custom fallback component when provided', () => { + const CustomFallback: React.FC<{ error: Error; resetError: () => void }> = ({ error, resetError }) => ( +
+

Custom Error: {error.message}

+ +
+ ); + + render( + + + + ); + + expect(screen.getByText('Custom Error: Test authentication error')).toBeInTheDocument(); + expect(screen.getByText('Reset')).toBeInTheDocument(); + }); + + it('should track analytics when window.analytics is available', () => { + const mockAnalytics = { + track: jest.fn() + }; + (window as any).analytics = mockAnalytics; + + render( + + + + ); + + expect(mockAnalytics.track).toHaveBeenCalledWith('Auth Error', { + error: 'Test authentication error', + stack: expect.any(String), + componentStack: expect.any(String) + }); + + // Clean up + delete (window as any).analytics; + }); +}); \ No newline at end of file diff --git a/frontend/src/shared/auth/components.tsx b/frontend/src/shared/auth/components.tsx new file mode 100644 index 00000000..a82fbf5d --- /dev/null +++ b/frontend/src/shared/auth/components.tsx @@ -0,0 +1,365 @@ +/** +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"). +You may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { useState, useEffect } from 'react'; +import { useAuth } from './hooks'; + +/** + * Props for ProtectedRoute component + */ +export type ProtectedRouteProps = { + children: React.ReactNode; + fallback?: React.ReactNode; + requiredGroups?: string[]; + accessDeniedMessage?: string; +}; + +/** + * Component that protects routes by requiring authentication and optionally specific groups + */ +export const ProtectedRoute: React.FC = ({ + children, + fallback =
Loading...
, + requiredGroups = [], + accessDeniedMessage +}) => { + const { status, user, login } = useAuth(); + + if (status === 'loading') { + return <>{fallback}; + } + + if (status === 'unauthenticated') { + login(window.location.pathname); + return <>{fallback}; + } + + if (requiredGroups.length > 0 && user) { + const hasRequiredGroup = requiredGroups.some((group) => + user.groups.includes(group) + ); + + if (!hasRequiredGroup) { + const defaultMessage = `Access denied. Required groups: ${requiredGroups.join(', ')}`; + return ( +
+

Access Denied

+

{accessDeniedMessage || defaultMessage}

+
+ ); + } + } + + return <>{children}; +}; + +/** + * Props for AuthLoadingWrapper component + */ +export type AuthLoadingWrapperProps = { + children: React.ReactNode; + loadingComponent?: React.ReactNode; + showSpinner?: boolean; +}; + +/** + * Component that shows loading state while authentication is being checked + */ +export const AuthLoadingWrapper: React.FC = ({ + children, + loadingComponent, + showSpinner = true +}) => { + const { status } = useAuth(); + + if (status === 'loading') { + if (loadingComponent) { + return <>{loadingComponent}; + } + + return ( +
+ {showSpinner &&
} +

Checking authentication...

+
+ ); + } + + return <>{children}; +}; + +/** + * Props for SessionExpirationNotice component + */ +export type SessionExpirationNoticeProps = { + warningMinutes?: number; + onExtend?: () => void; + onDismiss?: () => void; + customMessage?: string; +}; + +/** + * Component that shows a warning when the session is about to expire + */ +export const SessionExpirationNotice: React.FC = ({ + warningMinutes = 5, + onExtend, + onDismiss, + customMessage +}) => { + const { status, session, login } = useAuth(); + const [showWarning, setShowWarning] = useState(false); + + useEffect(() => { + if (status === 'authenticated' && session) { + const expiryTime = new Date(session.expiresAt).getTime(); + const warningTime = expiryTime - (warningMinutes * 60 * 1000); + const now = Date.now(); + + if (now >= warningTime) { + setShowWarning(true); + } else { + const timeout = setTimeout(() => setShowWarning(true), warningTime - now); + return () => clearTimeout(timeout); + } + } + }, [status, session, warningMinutes]); + + const handleExtend = () => { + if (onExtend) { + onExtend(); + } else { + login(); + } + }; + + const handleDismiss = () => { + if (onDismiss) { + onDismiss(); + } else { + setShowWarning(false); + } + }; + + if (!showWarning || status !== 'authenticated') { + return null; + } + + const defaultMessage = `Your session will expire in ${warningMinutes} minutes. Click to extend your session.`; + + return ( +
+
+

{customMessage || defaultMessage}

+
+ + +
+
+
+ ); +}; + +/** + * Props for AuthErrorBoundary component + */ +export type AuthErrorBoundaryProps = { + children: React.ReactNode; + fallback?: React.ComponentType<{ error: Error; resetError: () => void }>; + onError?: (error: Error, errorInfo: React.ErrorInfo) => void; +}; + +/** + * State for AuthErrorBoundary component + */ +type AuthErrorBoundaryState = { + hasError: boolean; + error: Error | null; +}; + +/** + * Error boundary component specifically for authentication errors + */ +export class AuthErrorBoundary extends React.Component< + AuthErrorBoundaryProps, + AuthErrorBoundaryState +> { + constructor (props: AuthErrorBoundaryProps) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError (error: Error): AuthErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch (error: Error, errorInfo: React.ErrorInfo) { + console.error('Authentication error:', error, errorInfo); + + // Call custom error handler if provided + if (this.props.onError) { + this.props.onError(error, errorInfo); + } + + // Report to monitoring service if available + if ((window as any).analytics) { + (window as any).analytics.track('Auth Error', { + error: error.message, + stack: error.stack, + componentStack: errorInfo.componentStack + }); + } + } + + resetError = () => { + this.setState({ hasError: false, error: null }); + }; + + render () { + if (this.state.hasError && this.state.error) { + // Use custom fallback component if provided + if (this.props.fallback) { + const FallbackComponent = this.props.fallback; + return ; + } + + // Default error UI + return ( +
+

Authentication Error

+

Something went wrong with authentication. Please try refreshing the page.

+
+ + +
+ {process.env.NODE_ENV === 'development' && ( +
+ Error Details +
{this.state.error.stack}
+
+ )} +
+ ); + } + + return this.props.children; + } +} + +/** + * Props for ConditionalRender component + */ +export type ConditionalRenderProps = { + children: React.ReactNode; + requiredGroups?: string[]; + fallback?: React.ReactNode; + requireAuth?: boolean; +}; + +/** + * Component that conditionally renders content based on authentication and group membership + */ +export const ConditionalRender: React.FC = ({ + children, + requiredGroups = [], + fallback = null, + requireAuth = true +}) => { + const { status, user } = useAuth(); + + // If authentication is required but user is not authenticated + if (requireAuth && status !== 'authenticated') { + return <>{fallback}; + } + + // If specific groups are required + if (requiredGroups.length > 0 && user) { + const hasRequiredGroup = requiredGroups.some((group) => + user.groups.includes(group) + ); + + if (!hasRequiredGroup) { + return <>{fallback}; + } + } + + return <>{children}; +}; + +/** + * Props for UserInfo component + */ +export type UserInfoProps = { + showEmail?: boolean; + showGroups?: boolean; + showAttributes?: boolean; + className?: string; +}; + +/** + * Component that displays current user information + */ +export const UserInfo: React.FC = ({ + showEmail = true, + showGroups = false, + showAttributes = false, + className = 'user-info' +}) => { + const { user } = useAuth(); + + if (!user) { + return null; + } + + return ( +
+
{user.displayName}
+ {showEmail && user.email && ( +
{user.email}
+ )} + {showGroups && user.groups.length > 0 && ( +
+ Groups: + {user.groups.join(', ')} +
+ )} + {showAttributes && Object.keys(user.attributes).length > 0 && ( +
+ {Object.entries(user.attributes).map(([key, value]) => ( +
+ {key}: + {value} +
+ ))} +
+ )} +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/shared/auth/hooks.ts b/frontend/src/shared/auth/hooks.ts new file mode 100644 index 00000000..933c3395 --- /dev/null +++ b/frontend/src/shared/auth/hooks.ts @@ -0,0 +1,108 @@ +/** +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"). +You may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { useContext, useEffect } from 'react'; +import { AuthContext, AuthContextValue } from '../../contexts/AuthContext'; + +/** + * Hook to access the authentication context + * @returns AuthContextValue containing auth state and actions + * @throws Error if used outside of AuthProvider + */ +export const useAuth = (): AuthContextValue => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; + +/** + * Convenience hook to get the current user + * @returns Current authenticated user or null + */ +export const useUser = () => { + const { user } = useAuth(); + return user; +}; + +/** + * Convenience hook to get the current authentication status + * @returns Authentication status: 'loading' | 'authenticated' | 'unauthenticated' + */ +export const useAuthStatus = () => { + const { status } = useAuth(); + return status; +}; + +/** + * Hook that automatically redirects to login if user is not authenticated + * @returns boolean indicating if user is authenticated + */ +export const useRequireAuth = () => { + const { status, login } = useAuth(); + + useEffect(() => { + if (status === 'unauthenticated') { + login(); + } + }, [status, login]); + + return status === 'authenticated'; +}; + +/** + * Hook to check if user has any of the required groups + * @param requiredGroups Array of group names that grant access + * @returns boolean indicating if user has required access + */ +export const useHasGroups = (requiredGroups: string[]) => { + const { user } = useAuth(); + + if (!user || requiredGroups.length === 0) { + return true; // No groups required or no user + } + + return requiredGroups.some((group) => user.groups.includes(group)); +}; + +/** + * Hook to check if user has a specific group + * @param group Group name to check + * @returns boolean indicating if user has the group + */ +export const useHasGroup = (group: string) => { + return useHasGroups([group]); +}; + +/** + * Hook to get user attributes + * @returns User attributes object or empty object if no user + */ +export const useUserAttributes = () => { + const { user } = useAuth(); + return user?.attributes || {}; +}; + +/** + * Hook to get a specific user attribute + * @param attributeName Name of the attribute to retrieve + * @returns Attribute value or undefined if not found + */ +export const useUserAttribute = (attributeName: string) => { + const attributes = useUserAttributes(); + return attributes[attributeName]; +}; \ No newline at end of file diff --git a/frontend/src/shared/auth/index.ts b/frontend/src/shared/auth/index.ts new file mode 100644 index 00000000..834f74ea --- /dev/null +++ b/frontend/src/shared/auth/index.ts @@ -0,0 +1,56 @@ +/** +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"). +You may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Export all hooks +export { + useAuth, + useUser, + useAuthStatus, + useRequireAuth, + useHasGroups, + useHasGroup, + useUserAttributes, + useUserAttribute +} from './hooks'; + +// Export all components +export { + ProtectedRoute, + AuthLoadingWrapper, + SessionExpirationNotice, + AuthErrorBoundary, + ConditionalRender, + UserInfo +} from './components'; + +// Export component prop types for external use +export type { + ProtectedRouteProps, + AuthLoadingWrapperProps, + SessionExpirationNoticeProps, + AuthErrorBoundaryProps, + ConditionalRenderProps, + UserInfoProps +} from './components'; + +// Re-export context types and provider from the main context file +export { + AuthProvider, + type AuthUser, + type AuthSession, + type AuthState, + type AuthContextValue +} from '../../contexts/AuthContext'; \ No newline at end of file diff --git a/frontend/src/shared/layout/header/header.tsx b/frontend/src/shared/layout/header/header.tsx index 6d8c2551..a8ddcab6 100644 --- a/frontend/src/shared/layout/header/header.tsx +++ b/frontend/src/shared/layout/header/header.tsx @@ -17,7 +17,7 @@ import React from 'react'; import { Header as CloudscapeHeader, ButtonDropdown } from '@cloudscape-design/components'; import { persistor, useAppDispatch, useAppSelector } from '../../../config/store'; -import { useAuth } from 'react-oidc-context'; +import { useAuth } from '../../auth/hooks'; import Condition from '../../../modules/condition'; import { Timezone } from '../../model/user.model'; import { selectCurrentUser, updateUser } from '../../../entities/user/user.reducer'; @@ -35,7 +35,7 @@ export default function Header () { + { persistor.flush().then(() => { persistor.pause(); - auth.signoutRedirect(); + auth.logout(); }); }); } else if ( @@ -118,7 +118,7 @@ export default function Header () { } }} > - Greetings, {auth.user?.profile.name}! + Greetings, {auth.user?.displayName}! } diff --git a/frontend/src/shared/util/auth-utils.spec.ts b/frontend/src/shared/util/auth-utils.spec.ts index 7fc854a1..d8b6c508 100644 --- a/frontend/src/shared/util/auth-utils.spec.ts +++ b/frontend/src/shared/util/auth-utils.spec.ts @@ -16,41 +16,36 @@ import { describe, test, expect } from '@jest/globals'; import { useUsername } from './auth-utils'; -import { useAuth } from 'react-oidc-context'; +import { useAuth } from '../auth/hooks'; // Mocking library for useAuth which is used in the auth-util -jest.mock('react-oidc-context'); +jest.mock('../auth/hooks'); // Mocked user authentication object const validUserAuth = { - 'user': { - 'id_token': 'id.token.simulated-for-unit-testing-functions', - 'session_state': null, - 'access_token': 'simulated.access.token-for-unit-test--q-sim-functions', - 'refresh_token': 'refresh.token-simulated-for-unit.tests.and-demonstration-of-how-this-would-work-but-it-is-it-valid-nor-does-it-have-the-real-proper-number-of.characters', - 'token_type': 'Bearer', - 'scope': 'openid profile email', - 'profile': { - 'sub': '12345678-1234-1234-1234-1234567890ab', - 'iss': 'https://cognito-idp.us-east-2.amazonaws.com/us-east-2_asdfghjk', - 'cognito:username': 'co', - 'preferred_username': 'co', - 'origin_jti': '12345678-1234-1234-1234-1234567890ab', - 'aud': '1234567890asdfghjklzxcvbnm', - 'event_id': '12345678-1234-1234-1234-1234567890ab', - 'token_use': 'id', - 'name': 'co', - 'exp': 1715199886, - 'iat': 1715196286, - 'email': 'co@amazon.com' - }, - 'expires_at': 1715199886 - } + status: 'authenticated' as const, + user: { + id: 'co', + displayName: 'co', + email: 'co@amazon.com', + groups: [], + attributes: {} + }, + session: { + expiresAt: '2024-01-15T10:30:00Z', + refreshAt: '2024-01-15T09:30:00Z', + provider: 'oidc' + }, + error: null, + login: jest.fn(), + logout: jest.fn(), + refresh: jest.fn(), + clearError: jest.fn() }; describe('Test useUsername', () => { test.concurrent('Valid user', async () => { - useAuth.mockImplementation(() => { + (useAuth as jest.Mock).mockImplementation(() => { return validUserAuth; }); @@ -58,8 +53,11 @@ describe('Test useUsername', () => { }); test.concurrent('Invalid user', async () => { - useAuth.mockImplementation(() => { - return {}; + (useAuth as jest.Mock).mockImplementation(() => { + return { + ...validUserAuth, + user: null + }; }); expect(() => { diff --git a/frontend/src/shared/util/auth-utils.ts b/frontend/src/shared/util/auth-utils.ts index cece4fa6..758dee68 100644 --- a/frontend/src/shared/util/auth-utils.ts +++ b/frontend/src/shared/util/auth-utils.ts @@ -14,17 +14,17 @@ limitations under the License. */ -import { useAuth } from 'react-oidc-context'; +import { useAuth } from '../auth/hooks'; /** * React hook to get username of current signed in user. * * @returns {string} */ -export const useUsername = (): string => { +export const useUsername = (): string => { const auth = useAuth(); - const username = auth.user?.profile.preferred_username; + const username = auth.user?.displayName; if (!username) { throw new Error('No username available.'); } diff --git a/frontend/src/shared/util/axios-utils.spec.ts b/frontend/src/shared/util/axios-utils.spec.ts index 8685dc0b..ad48722b 100644 --- a/frontend/src/shared/util/axios-utils.spec.ts +++ b/frontend/src/shared/util/axios-utils.spec.ts @@ -30,7 +30,7 @@ limitations under the License. */ -import { describe, test, expect } from '@jest/globals'; +import { describe, test, expect, beforeEach, afterEach } from '@jest/globals'; import { default as Axios } from 'axios'; import { default as axios } from './axios-utils'; @@ -43,9 +43,7 @@ const mockOidcSessionStorageName = `oidc.user:${mockOidcUrl}:${mockOidcClientNam const mockOidcSessionStorageValue = `{"id_token":"${mockToken}"}`; const expectedRequestConfig = { baseURL: mockLambdaEndpoint, - headers: { - Authorization: `Bearer ${mockToken}` - } + withCredentials: true }; // When mocking with jest, target the library, not the class @@ -99,8 +97,13 @@ describe('Test AxiosHelper', () => { test('Test Post Request With Config', () => { Axios.post.mockResolvedValue(Promise.resolve({data: {dummy: 'data'}})); + const expectedConfigWithHeaders = { + ...expectedRequestConfig, + headers: {} + }; + expect(axios.post(dummyUrl, {dummyPostData: 'postValue'}, {headers:{}})).toEqual(Promise.resolve({data: {dummy: 'data'}})); - expect(Axios.post).toHaveBeenCalledWith(dummyUrl, {dummyPostData: 'postValue'}, expectedRequestConfig); + expect(Axios.post).toHaveBeenCalledWith(dummyUrl, {dummyPostData: 'postValue'}, expectedConfigWithHeaders); }); test('Test Put Request', () => { diff --git a/frontend/src/shared/util/axios-utils.ts b/frontend/src/shared/util/axios-utils.ts index 1f1186d8..f0f2bbd1 100644 --- a/frontend/src/shared/util/axios-utils.ts +++ b/frontend/src/shared/util/axios-utils.ts @@ -50,15 +50,7 @@ class AxiosHelper { const config = (requestConfig: AxiosRequestConfig = {}) => { requestConfig.baseURL = `${window.env.LAMBDA_ENDPOINT}`; - const oidcString = sessionStorage.getItem( - `oidc.user:${window.env.OIDC_URL}:${window.env.OIDC_CLIENT_NAME}` - ); - const token = oidcString ? JSON.parse(oidcString).id_token : ''; - - if (requestConfig.headers === undefined) { - requestConfig.headers = {}; - } - requestConfig.headers['Authorization'] = `Bearer ${token}`; + requestConfig.withCredentials = true; return requestConfig; }; diff --git a/frontend/src/types/index.d.ts b/frontend/src/types/index.d.ts index 8d3e9003..e8e2bafa 100644 --- a/frontend/src/types/index.d.ts +++ b/frontend/src/types/index.d.ts @@ -22,9 +22,6 @@ declare global { // eslint-disable-next-line @typescript-eslint/consistent-type-definitions interface Window { env: { - OIDC_URL: string; - OIDC_REDIRECT_URI: string; - OIDC_CLIENT_NAME: string; LAMBDA_ENDPOINT: string; DATASET_BUCKET: string; MANAGE_IAM_ROLES?: boolean; diff --git a/lambda_dependencies/common/create.sh b/lambda_dependencies/common/create.sh index be05111a..62166a79 100644 --- a/lambda_dependencies/common/create.sh +++ b/lambda_dependencies/common/create.sh @@ -9,4 +9,8 @@ python3 -m pip install --no-deps dynamodb_json -t . python3 -m pip install --no-deps simplejson -t . python3 -m pip install --no-deps cachetools -t . python3 -m pip install --no-cache-dir pyseto -t . +python3 -m pip install --no-cache-dir authlib -t . +python3 -m pip install --no-cache-dir pydantic -t . +python3 -m pip install --no-cache-dir requests -t . +python3 -m pip install --no-cache-dir cryptography -t . python3 -m pip install boto3 -t . \ No newline at end of file diff --git a/lib/constants.ts b/lib/constants.ts index ad64cbc0..5e29e022 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -34,7 +34,7 @@ export const GROUP_USERS_TABLE_NAME = 'mlspace-group-users'; export const CONFIG_BUCKET_NAME = 'mlspace-config'; export const DATA_BUCKET_NAME = 'mlspace-data'; export const LOGS_BUCKET_NAME = 'mlspace-logs'; -export const ACCESS_LOGS_BUCKET_NAME = 'mlspace-access-logs'; +export const ACCESS_LOGS_BUCKET_NAME = 'mlspace-access-logs-alpha'; export const WEBSITE_BUCKET_NAME = 'mlspace-website'; export const MLSPACE_LIFECYCLE_CONFIG_NAME = 'mlspace-notebook-lifecycle-config'; export const NOTEBOOK_PARAMETERS_FILE_NAME = 'notebook-params.json'; @@ -51,6 +51,9 @@ export const AWS_REGION = ''; export const SYSTEM_TAG = 'MLSpace'; export const IAM_RESOURCE_PREFIX = 'MLSpace'; +// The prefix to use for system resources +export const SYSTEM_RESOURCE_PREFIX = 'mls'; + // Set this to false if you do not want MLSpace to dynamically manage roles for project users export const MANAGE_IAM_ROLES = true; @@ -115,20 +118,47 @@ export const ENDPOINT_CONFIG_INSTANCE_CONSTRAINT_POLICY_ARN = ''; export const JOB_INSTANCE_CONSTRAINT_POLICY_ARN = ''; /* Web app properties */ -export const IDP_ENDPOINT_SSM_PARAM = ''; -export const OIDC_URL = ''; + +// BFF Authentication Configuration +// Authentication session table +export const AUTH_SESSION_TABLE_NAME = 'mlspace-auth-sessions'; + +// Authentication configuration +export const AUTH_IDP_TYPE = 'oidc'; // Currently only 'oidc' is supported +export const AUTH_OIDC_URL = ''; // OIDC issuer URL +export const AUTH_OIDC_CLIENT_ID = ''; // OIDC client ID +export const AUTH_OIDC_CLIENT_SECRET_NAME = 'mlspace/auth/oidc-client-secret'; // Secrets Manager name for OIDC client secret +export const AUTH_OIDC_CLIENT_SECRET_VALUE = ''; // Optional OIDC client secret value for deployment-time configuration +export const AUTH_OIDC_USE_PKCE = true; // Whether to use PKCE flow (recommended even with client_secret) +export const AUTH_OIDC_VERIFY_SSL = true; // Whether to verify SSL certificates for OIDC requests +export const AUTH_OIDC_VERIFY_SIGNATURE = true; // Whether to verify OIDC token signatures + +// Domain configuration for cross-domain cookie sync +export const AUTH_SYNC_DOMAINS = ''; // Optional: Comma-separated list of additional domains for cookie sync + +// Session configuration +export const AUTH_SESSION_TTL_HOURS = 24; // Session duration in hours + +// Encryption configuration +export const AUTH_TOKEN_ENCRYPTION_KEY_SECRET_NAME = 'mlspace/auth/token-encryption-keys'; // Versioned secret for token encryption keys (rotatable) +export const AUTH_STATE_ENCRYPTION_KEY_SECRET_NAME = 'mlspace/auth/state-encryption-key'; // Simple secret for state encryption key (deploy-time generated) + +// Legacy OIDC configuration (deprecated - maintained for backward compatibility during migration) +// Use AUTH_OIDC_URL, AUTH_OIDC_CLIENT_ID, and other AUTH_* constants instead +export const IDP_ENDPOINT_SSM_PARAM = undefined; // Deprecated: Use AUTH_OIDC_URL instead +export const OIDC_URL = undefined; // Deprecated: Use AUTH_OIDC_URL instead // OIDC URL that can be hit by authorizer lambda for token validation. If the OIDC endpoint is // exposed publicly and can be hit by from the MLSpace VPC this value does not need to be set. // If the OIDC endpoint is not accessible directly from VPC and requires peering or some other // proxy, this can be set to something which the lambda can traverse in order to reach the OIDC // instance. -export const INTERNAL_OIDC_URL = ''; -export const OIDC_CLIENT_NAME = ''; +export const INTERNAL_OIDC_URL = undefined; // Deprecated: No longer needed with BFF pattern +export const OIDC_CLIENT_NAME = undefined; // Deprecated: Use AUTH_OIDC_CLIENT_ID instead // If your OIDC server is using a self signed cert set this to false -export const OIDC_VERIFY_SSL = true; -export const OIDC_VERIFY_SIGNATURE = true; +export const OIDC_VERIFY_SSL = undefined; // Deprecated: Use AUTH_OIDC_VERIFY_SSL instead +export const OIDC_VERIFY_SIGNATURE = undefined; // Deprecated: Use AUTH_OIDC_VERIFY_SIGNATURE instead // This defaults to the APIGW url but if you're using custom DNS you should set this to that -export const OIDC_REDIRECT_URI = ''; +export const OIDC_REDIRECT_URI = undefined; // Deprecated: No longer needed with BFF pattern // Interval (in minutes) to run the resource termination cleanup lambda export const RESOURCE_TERMINATION_INTERVAL = 60; // Interval (in minutes) to run background resource data updates @@ -143,4 +173,8 @@ export const SHOW_MIGRATION_OPTIONS = false; // Set this to true to enable customer-managed KMS encryption for DynamoDB tables // Requires EXISTING_KMS_MASTER_KEY_ARN to be set. Defaults to false for backward compatibility. -export const ENABLE_DDB_KMS_CMK_ENCRYPTION = true; \ No newline at end of file +export const ENABLE_DDB_KMS_CMK_ENCRYPTION = true; + +// An optional custom domain name to use in place of the default API Gateway URL +// eg: 'https://mlspace.mycompany.com' +export const WEB_CUSTOM_DOMAIN_NAME = undefined; \ No newline at end of file diff --git a/lib/constructs/api/adminConstruct.ts b/lib/constructs/api/adminConstruct.ts index 60af8eb8..ee3794ca 100644 --- a/lib/constructs/api/adminConstruct.ts +++ b/lib/constructs/api/adminConstruct.ts @@ -53,16 +53,6 @@ export class AdminApiConstruct extends Construct { path: 'user/{username}', method: 'DELETE', }, - { - name: 'create', - resource: 'user', - description: 'Creates a user for the system', - path: 'user', - method: 'POST', - environment: { - NEW_USER_SUSPENSION_DEFAULT: props.mlspaceConfig.NEW_USERS_SUSPENDED ? 'True' : 'False', - }, - }, { name: 'get', resource: 'user', diff --git a/lib/constructs/api/authConstruct.ts b/lib/constructs/api/authConstruct.ts new file mode 100644 index 00000000..4ff6091d --- /dev/null +++ b/lib/constructs/api/authConstruct.ts @@ -0,0 +1,155 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { Stack } from 'aws-cdk-lib'; +import { LayerVersion } from 'aws-cdk-lib/aws-lambda'; +import { StringParameter } from 'aws-cdk-lib/aws-ssm'; +import { MLSpacePythonLambdaFunction, registerAPIEndpoint } from '../../utils/apiFunction'; +import { ApiStackProperties } from './restApiConstruct'; +import { RestApi } from 'aws-cdk-lib/aws-apigateway'; +import { Construct } from 'constructs'; + +export class AuthApiConstruct extends Construct { + constructor (scope: Stack, id: string, props: ApiStackProperties) { + super(scope, id); + + // Get common layer based on arn from SSM due to issues with cross stack references + const commonLambdaLayer = LayerVersion.fromLayerVersionArn( + scope, + 'mls-auth-common-lambda-layer', + StringParameter.valueForStringParameter(scope, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) + ); + + const restApi = RestApi.fromRestApiAttributes(scope, 'AuthRestApi', { + restApiId: props.restApiId, + rootResourceId: props.rootResourceId, + }); + + // Common environment variables for all auth endpoints + const authCommonEnv = { + AUTH_SESSION_TABLE_NAME: props.mlspaceConfig.AUTH_SESSION_TABLE_NAME, + AUTH_IDP_TYPE: props.mlspaceConfig.AUTH_IDP_TYPE, + AUTH_SYNC_DOMAINS: props.mlspaceConfig.AUTH_SYNC_DOMAINS, + AUTH_SESSION_TTL_HOURS: props.mlspaceConfig.AUTH_SESSION_TTL_HOURS.toString(), + AUTH_TOKEN_ENCRYPTION_KEY_SECRET_NAME: props.mlspaceConfig.AUTH_TOKEN_ENCRYPTION_KEY_SECRET_NAME, + AUTH_STATE_ENCRYPTION_KEY_SECRET_NAME: props.mlspaceConfig.AUTH_STATE_ENCRYPTION_KEY_SECRET_NAME, + NEW_USERS_SUSPENDED: props.mlspaceConfig.NEW_USERS_SUSPENDED ? 'True' : 'False', + WEB_CUSTOM_DOMAIN_NAME: props.mlspaceConfig.WEB_CUSTOM_DOMAIN_NAME || '' + }; + + // OIDC-specific environment variables + const oidcEnv = { + AUTH_OIDC_URL: props.mlspaceConfig.AUTH_OIDC_URL, + AUTH_OIDC_CLIENT_ID: props.mlspaceConfig.AUTH_OIDC_CLIENT_ID, + AUTH_OIDC_CLIENT_SECRET_NAME: props.mlspaceConfig.AUTH_OIDC_CLIENT_SECRET_NAME, + AUTH_OIDC_USE_PKCE: props.mlspaceConfig.AUTH_OIDC_USE_PKCE ? 'True' : 'False', + AUTH_OIDC_VERIFY_SSL: props.mlspaceConfig.AUTH_OIDC_VERIFY_SSL ? 'True' : 'False', + AUTH_OIDC_VERIFY_SIGNATURE: props.mlspaceConfig.AUTH_OIDC_VERIFY_SIGNATURE ? 'True' : 'False', + }; + + const apis: MLSpacePythonLambdaFunction[] = [ + { + name: 'login', + resource: 'auth', + description: 'Initiates authentication flow by redirecting to Identity Provider', + path: 'auth/login', + method: 'GET', + environment: { + ...authCommonEnv, + ...oidcEnv, + }, + noAuthorizer: true, + }, + { + name: 'callback', + resource: 'auth', + description: 'Handles Identity Provider callback (GET)', + path: 'auth/callback', + method: 'GET', + environment: { + ...authCommonEnv, + ...oidcEnv, + }, + noAuthorizer: true, + }, + { + name: 'callback_post', + resource: 'auth', + description: 'Handles Identity Provider callback (POST)', + path: 'auth/callback', + method: 'POST', + environment: { + ...authCommonEnv, + ...oidcEnv, + }, + noAuthorizer: true, + }, + { + name: 'logout', + resource: 'auth', + description: 'Terminates user session and optionally logs out from IdP', + path: 'auth/logout', + method: 'POST', + environment: { + ...authCommonEnv, + ...oidcEnv, + }, + noAuthorizer: true, + }, + { + name: 'identity', + resource: 'auth', + description: 'Retrieves current user identity and authentication status', + path: 'auth/identity', + method: 'GET', + environment: { + ...authCommonEnv, + ...oidcEnv, + }, + noAuthorizer: true, + }, + { + name: 'sync', + resource: 'auth', + description: 'Handles cross-domain cookie synchronization', + path: 'auth/sync', + method: 'GET', + environment: { + ...authCommonEnv, + }, + noAuthorizer: true, + }, + ]; + + apis.forEach((f) => { + registerAPIEndpoint( + scope, + restApi, + props.authorizer, + props.applicationRole, + props.applicationRole.roleName, + props.notebookInstanceRole.roleName, + props.lambdaSourcePath, + [commonLambdaLayer], + f, + props.mlSpaceVPC, + props.securityGroups, + props.mlspaceConfig, + props.permissionsBoundaryArn + ); + }); + } +} \ No newline at end of file diff --git a/lib/constructs/api/restApiConstruct.ts b/lib/constructs/api/restApiConstruct.ts index 0bc83683..f12d96e6 100644 --- a/lib/constructs/api/restApiConstruct.ts +++ b/lib/constructs/api/restApiConstruct.ts @@ -72,7 +72,6 @@ export type RestApiStackProperties = { readonly websiteBucketName: string; readonly websiteS3ReaderRole: IRole; readonly mlSpaceAppRole: IRole; - readonly verifyOIDCTokenSignature: boolean; readonly mlSpaceVPC: IVpc; readonly lambdaSecurityGroups: ISecurityGroup[]; readonly isIso?: boolean; @@ -244,11 +243,6 @@ export class RestApiConstruct extends Construct { StringParameter.valueForStringParameter(scope, props.mlspaceConfig.COMMON_LAYER_ARN_PARAM) ); - let ssmIdPEndpoint; - if (props.mlspaceConfig.IDP_ENDPOINT_SSM_PARAM) { - ssmIdPEndpoint = StringParameter.valueForStringParameter(scope, props.mlspaceConfig.IDP_ENDPOINT_SSM_PARAM); - } - const authorizerLambda = new Function(scope, 'MLSpaceAuthorizerLambda', { runtime: props.mlspaceConfig.LAMBDA_RUNTIME, architecture: props.mlspaceConfig.LAMBDA_ARCHITECTURE, @@ -261,10 +255,8 @@ export class RestApiConstruct extends Construct { role: props.mlSpaceAppRole, layers: [jwtDependencyLayer.layerVersion, commonLambdaLayer], environment: { - OIDC_URL: ssmIdPEndpoint || props.mlspaceConfig.INTERNAL_OIDC_URL || props.mlspaceConfig.OIDC_URL, - OIDC_CLIENT_NAME: props.mlspaceConfig.OIDC_CLIENT_NAME, - OIDC_VERIFY_SSL: props.mlspaceConfig.OIDC_VERIFY_SSL ? 'True' : 'False', - OIDC_VERIFY_SIGNATURE: props.verifyOIDCTokenSignature ? 'True' : 'False', + AUTH_SESSION_TABLE_NAME: props.mlspaceConfig.AUTH_SESSION_TABLE_NAME, + AUTH_TOKEN_ENCRYPTION_KEY_SECRET_NAME: props.mlspaceConfig.AUTH_TOKEN_ENCRYPTION_KEY_SECRET_NAME, ...props.mlspaceConfig.ADDITIONAL_LAMBDA_ENVIRONMENT_VARS, }, vpc: props.mlSpaceVPC, @@ -274,17 +266,14 @@ export class RestApiConstruct extends Construct { this.mlspaceRequestAuthorizer = new RequestAuthorizer(scope, 'MLSpaceAPIGWAuthorizer', { handler: authorizerLambda, resultsCacheTtl: Duration.seconds(0), - identitySources: [IdentitySource.header('Authorization')], + identitySources: [IdentitySource.header('Cookie')] }); this.mlspaceRequestAuthorizer._attachToApi(mlSpaceRestApi); // Dynamic config relies on api URL and we don't want to do this in a separate stack const appEnvironmentConfig = { - OIDC_URL: ssmIdPEndpoint || props.mlspaceConfig.OIDC_URL, - OIDC_REDIRECT_URI: props.mlspaceConfig.OIDC_REDIRECT_URI || mlSpaceRestApi.url, - OIDC_CLIENT_NAME: props.mlspaceConfig.OIDC_CLIENT_NAME, - LAMBDA_ENDPOINT: mlSpaceRestApi.url, + LAMBDA_ENDPOINT: props.mlspaceConfig.WEB_CUSTOM_DOMAIN_NAME || mlSpaceRestApi.url, MANAGE_IAM_ROLES: props.mlspaceConfig.MANAGE_IAM_ROLES, SHOW_MIGRATION_OPTIONS: props.mlspaceConfig.SHOW_MIGRATION_OPTIONS, ENABLE_TRANSLATE: props.enableTranslate, diff --git a/lib/constructs/auth/authSecretsConstruct.ts b/lib/constructs/auth/authSecretsConstruct.ts new file mode 100644 index 00000000..ea275ca0 --- /dev/null +++ b/lib/constructs/auth/authSecretsConstruct.ts @@ -0,0 +1,206 @@ +/** +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"). +You may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { Duration } from 'aws-cdk-lib'; +import { ISecurityGroup, IVpc } from 'aws-cdk-lib/aws-ec2'; +import { PolicyStatement, Effect, IRole } from 'aws-cdk-lib/aws-iam'; +import { Code, Function, IFunction, ILayerVersion, Runtime } from 'aws-cdk-lib/aws-lambda'; +import { Secret, RotationSchedule } from 'aws-cdk-lib/aws-secretsmanager'; +import { SecretValue } from 'aws-cdk-lib'; +import { Construct } from 'constructs'; +import { MLSpaceConfig } from '../../utils/configTypes'; + +export type AuthSecretsConstructProps = { + readonly config: MLSpaceConfig; + readonly layers?: ILayerVersion[]; + readonly lambdaSourcePath: string; + readonly vpc?: IVpc; + readonly securityGroups?: ISecurityGroup[]; + readonly enableStateKeyRotation?: boolean; + readonly enableTokenKeyRotation?: boolean; + readonly stateKeyRotationDays?: number; + readonly tokenKeyRotationDays?: number; + readonly mlSpaceAppRole: IRole; + readonly oidcClientSecret?: string; // Optional OIDC client secret value +}; + +export class AuthSecretsConstruct extends Construct { + public readonly stateEncryptionSecret: Secret; + public readonly tokenEncryptionSecret: Secret; + public readonly oidcClientSecret: Secret; + public stateKeyRotationFunction?: IFunction; + public tokenKeyRotationFunction?: IFunction; + public stateKeyRotationSchedule?: RotationSchedule; + public tokenKeyRotationSchedule?: RotationSchedule; + + constructor (scope: Construct, id: string, props: AuthSecretsConstructProps) { + super(scope, id); + + // Create state encryption secret with placeholder + + this.stateEncryptionSecret = new Secret(this, 'StateEncryptionSecret1', { + secretName: props.config.AUTH_STATE_ENCRYPTION_KEY_SECRET_NAME, + description: 'State encryption key for BFF authentication (Fernet key)', + }); + + // Create token encryption secret with placeholder + this.tokenEncryptionSecret = new Secret(this, 'TokenEncryptionSecret1', { + secretName: props.config.AUTH_TOKEN_ENCRYPTION_KEY_SECRET_NAME, + description: 'Versioned token encryption keys for BFF authentication (PASETO keys)', + }); + + // Create OIDC client secret + this.oidcClientSecret = new Secret(this, 'OidcClientSecret', { + secretName: props.config.AUTH_OIDC_CLIENT_SECRET_NAME, + description: 'OIDC client secret for authentication', + // Set value if provided, otherwise create placeholder for manual configuration + secretStringValue: SecretValue.unsafePlainText(JSON.stringify({ + client_secret: props.oidcClientSecret, + configured: !!props.oidcClientSecret, + configured_at: new Date().toISOString() + })) + }); + + // Set up state key rotation if enabled + if (props.enableStateKeyRotation) { + this.setupStateKeyRotation(props); + } + + // Set up token key rotation if enabled + if (props.enableTokenKeyRotation) { + this.setupTokenKeyRotation(props); + } + } + + private setupStateKeyRotation (props: AuthSecretsConstructProps) { + // Create state key rotation Lambda function + this.stateKeyRotationFunction = new Function(this, 'StateKeyRotationFunction', { + functionName: 'mls-lambda-state-key-rotation', + runtime: props.config.LAMBDA_RUNTIME, + code: Code.fromAsset(props.lambdaSourcePath), + handler: 'ml_space_lambda.auth.utils.rotation_handlers.state_key_secrets_manager_rotation_handler', + timeout: Duration.minutes(5), + memorySize: 256, + layers: props.layers, + environment: { + STATE_ENCRYPTION_SECRET_ARN: this.stateEncryptionSecret.secretArn, + }, + vpc: props.vpc, + securityGroups: props.securityGroups, + }); + + // Grant permissions to access and update state secret + this.stateKeyRotationFunction.addToRolePolicy( + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 'secretsmanager:GetSecretValue', + 'secretsmanager:UpdateSecret', + 'secretsmanager:DescribeSecret', + 'secretsmanager:PutSecretValue', + 'secretsmanager:UpdateSecretVersionStage', + 'secretsmanager:DeleteSecret', // For version cleanup + ], + resources: [this.stateEncryptionSecret.secretArn], + }) + ); + + // Create rotation schedule for state key + this.stateKeyRotationSchedule = this.stateEncryptionSecret.addRotationSchedule('StateKeyRotationSchedule', { + rotationLambda: this.stateKeyRotationFunction, + automaticallyAfter: Duration.days(props.stateKeyRotationDays || 90), + rotateImmediatelyOnUpdate: true, + }); + } + + private setupTokenKeyRotation (props: AuthSecretsConstructProps) { + // Create token key rotation Lambda function + this.tokenKeyRotationFunction = new Function(this, 'TokenKeyRotationFunction', { + functionName: 'mls-lambda-token-key-rotation', + runtime: props.config.LAMBDA_RUNTIME, + code: Code.fromAsset(props.lambdaSourcePath), + handler: 'ml_space_lambda.auth.utils.rotation_handlers.token_key_secrets_manager_rotation_handler', + timeout: Duration.minutes(5), + memorySize: 256, + layers: props.layers, + environment: { + TOKEN_ENCRYPTION_SECRET_ARN: this.tokenEncryptionSecret.secretArn, + }, + vpc: props.vpc, + securityGroups: props.securityGroups, + }); + + // Grant permissions to access and update token secret + this.tokenKeyRotationFunction.addToRolePolicy( + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 'secretsmanager:GetSecretValue', + 'secretsmanager:UpdateSecret', + 'secretsmanager:DescribeSecret', + 'secretsmanager:PutSecretValue', + 'secretsmanager:UpdateSecretVersionStage', + 'secretsmanager:DeleteSecret', // For version cleanup + ], + resources: [this.tokenEncryptionSecret.secretArn], + }) + ); + + // Create rotation schedule for token key + this.tokenKeyRotationSchedule = this.tokenEncryptionSecret.addRotationSchedule('TokenKeyRotationSchedule', { + rotationLambda: this.tokenKeyRotationFunction, + automaticallyAfter: Duration.days(props.tokenKeyRotationDays || 90), + rotateImmediatelyOnUpdate: true, + }); + } + + /** + * Create manual initialization functions for secrets. + * These can be invoked on-demand to initialize secrets with proper key structures. + */ + public createManualInitializationFunctions (): { stateInit: IFunction; tokenInit: IFunction } { + const stateInitFunction = new Function(this, 'ManualStateInitFunction', { + runtime: Runtime.PYTHON_3_11, + code: Code.fromAsset('backend/src'), + handler: 'ml_space_lambda.auth.utils.rotation_handlers.initialize_secret_handler', + timeout: Duration.minutes(5), + memorySize: 256, + environment: { + STATE_ENCRYPTION_SECRET_ARN: this.stateEncryptionSecret.secretArn, + }, + }); + + const tokenInitFunction = new Function(this, 'ManualTokenInitFunction', { + runtime: Runtime.PYTHON_3_11, + code: Code.fromAsset('backend/src'), + handler: 'ml_space_lambda.auth.utils.rotation_handlers.initialize_secret_handler', + timeout: Duration.minutes(5), + memorySize: 256, + environment: { + TOKEN_ENCRYPTION_SECRET_ARN: this.tokenEncryptionSecret.secretArn, + }, + }); + + // Grant permissions + this.stateEncryptionSecret.grantWrite(stateInitFunction); + this.tokenEncryptionSecret.grantWrite(tokenInitFunction); + + return { + stateInit: stateInitFunction, + tokenInit: tokenInitFunction + }; + } +} \ No newline at end of file diff --git a/lib/constructs/iamConstruct.ts b/lib/constructs/iamConstruct.ts index 8f703c77..6f8446bc 100644 --- a/lib/constructs/iamConstruct.ts +++ b/lib/constructs/iamConstruct.ts @@ -760,6 +760,16 @@ export class IAMConstruct extends Construct { `arn:${scope.partition}:dynamodb:${Aws.REGION}:${scope.account}:table/mlspace-*`, ], }), + // General Permissions - Secrets Manager permissions for authentication encryption keys + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 'secretsmanager:GetSecretValue', + ], + resources: [ + `arn:${scope.partition}:secretsmanager:${Aws.REGION}:${scope.account}:secret:mlspace/auth/*`, + ], + }), /** * EMR Permissions * EMR specific permission to allow communication between notebook instances and diff --git a/lib/constructs/infra/coreConstruct.ts b/lib/constructs/infra/coreConstruct.ts index e4a7f740..6df74290 100644 --- a/lib/constructs/infra/coreConstruct.ts +++ b/lib/constructs/infra/coreConstruct.ts @@ -36,12 +36,13 @@ import { BucketDeployment, Source } from 'aws-cdk-lib/aws-s3-deployment'; import { LambdaDestination } from 'aws-cdk-lib/aws-s3-notifications'; import { Subscription, SubscriptionProtocol, Topic } from 'aws-cdk-lib/aws-sns'; import { StringParameter } from 'aws-cdk-lib/aws-ssm'; -import { ADCLambdaCABundleAspect } from '../../utils/adcCertBundleAspect'; +import { AuthSecretsConstruct } from '../auth/authSecretsConstruct'; import { createLambdaLayer } from '../../utils/layers'; import { MLSpaceConfig } from '../../utils/configTypes'; import { AwsCustomResource, PhysicalResourceId } from 'aws-cdk-lib/custom-resources'; import { generateAppConfig } from '../../utils/initialAppConfig'; import { Construct } from 'constructs'; +import { ADCLambdaCABundleAspect } from '../../utils/adcCertBundleAspect'; export type CoreStackProps = { readonly lambdaSourcePath: string; @@ -605,6 +606,28 @@ export class CoreConstruct extends Construct { ...(props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN && props.mlspaceConfig.ENABLE_DDB_KMS_CMK_ENCRYPTION) ? {encryptionKey: props.encryptionKey} : {encryption: TableEncryption.AWS_MANAGED}, }); + // Create authentication secrets with key rotation support + new AuthSecretsConstruct(scope, 'AuthSecrets', { + config: props.mlspaceConfig, + lambdaSourcePath: props.lambdaSourcePath, + layers: [commonLambdaLayer.layerVersion], + vpc: props.mlSpaceVPC, + securityGroups: props.lambdaSecurityGroups, + enableTokenKeyRotation: true, // Enable automated token key rotation + enableStateKeyRotation: true, + mlSpaceAppRole: props.mlSpaceAppRole, + oidcClientSecret: props.mlspaceConfig.AUTH_OIDC_CLIENT_SECRET_VALUE || undefined, + }); + + // Authentication Sessions Table + new Table(scope, 'mlspace-ddb-auth-sessions', { + tableName: props.mlspaceConfig.AUTH_SESSION_TABLE_NAME, + partitionKey: { name: 'pk', type: AttributeType.STRING }, + billingMode: BillingMode.PAY_PER_REQUEST, + timeToLiveAttribute: 'ttl', + ...(props.mlspaceConfig.EXISTING_KMS_MASTER_KEY_ARN && props.mlspaceConfig.ENABLE_DDB_KMS_CMK_ENCRYPTION) ? {encryptionKey: props.encryptionKey} : {encryption: TableEncryption.AWS_MANAGED}, + }); + // Populate the App Config table with default config new AwsCustomResource(scope, 'mlspace-init-ddb-app-config', { onCreate: { diff --git a/lib/stacks/api/auth.ts b/lib/stacks/api/auth.ts new file mode 100644 index 00000000..d398dabf --- /dev/null +++ b/lib/stacks/api/auth.ts @@ -0,0 +1,29 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { Stack } from 'aws-cdk-lib'; +import { ApiStackProperties } from './restApi'; +import { AuthApiConstruct } from '../../constructs/api/authConstruct'; + +export class AuthApiStack extends Stack { + constructor (scope: any, id: string, props: ApiStackProperties) { + super(scope, id, props); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const authApiConstruct = new AuthApiConstruct(this, id, props); + + } +} \ No newline at end of file diff --git a/lib/stacks/api/restApi.ts b/lib/stacks/api/restApi.ts index 72be3066..2220356f 100644 --- a/lib/stacks/api/restApi.ts +++ b/lib/stacks/api/restApi.ts @@ -56,7 +56,6 @@ export type RestApiStackProperties = { readonly websiteBucketName: string; readonly websiteS3ReaderRole: IRole; readonly mlSpaceAppRole: IRole; - readonly verifyOIDCTokenSignature: boolean; readonly mlSpaceVPC: IVpc; readonly lambdaSecurityGroups: ISecurityGroup[]; readonly isIso?: boolean; diff --git a/lib/utils/configTypes.ts b/lib/utils/configTypes.ts index 40b38db1..34ff44dc 100644 --- a/lib/utils/configTypes.ts +++ b/lib/utils/configTypes.ts @@ -20,6 +20,19 @@ import { APIGATEWAY_CLOUDWATCH_ROLE_ARN, APPLICATION_NAME, APP_ROLE_ARN, + AUTH_TOKEN_ENCRYPTION_KEY_SECRET_NAME, + AUTH_IDP_TYPE, + AUTH_OIDC_CLIENT_ID, + AUTH_OIDC_CLIENT_SECRET_NAME, + AUTH_OIDC_CLIENT_SECRET_VALUE, + AUTH_OIDC_URL, + AUTH_OIDC_USE_PKCE, + AUTH_OIDC_VERIFY_SIGNATURE, + AUTH_OIDC_VERIFY_SSL, + AUTH_SESSION_TABLE_NAME, + AUTH_SESSION_TTL_HOURS, + AUTH_STATE_ENCRYPTION_KEY_SECRET_NAME, + AUTH_SYNC_DOMAINS, AWS_ACCOUNT, AWS_REGION, BACKGROUND_REFRESH_INTERVAL, @@ -78,6 +91,7 @@ import { WEBSITE_BUCKET_NAME, SYSTEM_ROLE_ARN, SHOW_MIGRATION_OPTIONS, + WEB_CUSTOM_DOMAIN_NAME } from '../constants'; import * as fs from 'fs'; import { Architecture, Runtime } from 'aws-cdk-lib/aws-lambda'; @@ -108,12 +122,26 @@ export type MLSpaceConfig = { // EMR settings EMR_SECURITY_CONFIG_NAME: string, EMR_EC2_SSH_KEY: string, - // OIDC settings - IDP_ENDPOINT_SSM_PARAM: string, - INTERNAL_OIDC_URL: string, - OIDC_VERIFY_SSL: boolean, - OIDC_VERIFY_SIGNATURE: boolean, - OIDC_REDIRECT_URI: string, + // OIDC settings (legacy - deprecated, use AUTH_OIDC_URL instead) + IDP_ENDPOINT_SSM_PARAM: string | undefined, + INTERNAL_OIDC_URL: string | undefined, + OIDC_VERIFY_SSL: boolean | undefined, + OIDC_VERIFY_SIGNATURE: boolean | undefined, + OIDC_REDIRECT_URI: string | undefined, + // BFF Authentication settings + AUTH_SESSION_TABLE_NAME: string, + AUTH_IDP_TYPE: string, + AUTH_OIDC_URL: string, + AUTH_OIDC_CLIENT_ID: string, + AUTH_OIDC_CLIENT_SECRET_NAME: string, + AUTH_OIDC_CLIENT_SECRET_VALUE?: string, // Optional for deployment-time configuration + AUTH_OIDC_USE_PKCE: boolean, + AUTH_OIDC_VERIFY_SSL: boolean, + AUTH_OIDC_VERIFY_SIGNATURE: boolean, + AUTH_SYNC_DOMAINS: string, + AUTH_SESSION_TTL_HOURS: number, + AUTH_TOKEN_ENCRYPTION_KEY_SECRET_NAME: string, + AUTH_STATE_ENCRYPTION_KEY_SECRET_NAME: string, // Other properties not handled in config.json SYSTEM_TAG: string, IAM_RESOURCE_PREFIX: string, @@ -139,8 +167,9 @@ export type MLSpaceConfig = { //Properties that can optionally be set in config.json AWS_ACCOUNT: string, AWS_REGION: string, - OIDC_URL: string, - OIDC_CLIENT_NAME: string, + OIDC_URL: string | undefined, + OIDC_CLIENT_NAME: string | undefined + , EXISTING_VPC_NAME: string, EXISTING_VPC_ID: string, EXISTING_VPC_DEFAULT_SECURITY_GROUP: string, @@ -157,7 +186,7 @@ export type MLSpaceConfig = { BACKGROUND_REFRESH_INTERVAL: number, SHOW_MIGRATION_OPTIONS?: boolean, - + WEB_CUSTOM_DOMAIN_NAME?: string }; const validateRequiredProperty = (val: string, name: string) => { @@ -201,12 +230,26 @@ export function generateConfig (accountId?: string) { // EMR settings EMR_SECURITY_CONFIG_NAME: EMR_SECURITY_CONFIG_NAME, EMR_EC2_SSH_KEY: EMR_EC2_SSH_KEY, - // OIDC settings + // OIDC settings (legacy) IDP_ENDPOINT_SSM_PARAM: IDP_ENDPOINT_SSM_PARAM, INTERNAL_OIDC_URL: INTERNAL_OIDC_URL, OIDC_VERIFY_SSL: OIDC_VERIFY_SSL, OIDC_VERIFY_SIGNATURE: OIDC_VERIFY_SIGNATURE, OIDC_REDIRECT_URI: OIDC_REDIRECT_URI, + // BFF Authentication settings + AUTH_SESSION_TABLE_NAME: AUTH_SESSION_TABLE_NAME, + AUTH_IDP_TYPE: AUTH_IDP_TYPE, + AUTH_OIDC_URL: AUTH_OIDC_URL, + AUTH_OIDC_CLIENT_ID: AUTH_OIDC_CLIENT_ID, + AUTH_OIDC_CLIENT_SECRET_NAME: AUTH_OIDC_CLIENT_SECRET_NAME, + AUTH_OIDC_CLIENT_SECRET_VALUE: AUTH_OIDC_CLIENT_SECRET_VALUE, + AUTH_OIDC_USE_PKCE: AUTH_OIDC_USE_PKCE, + AUTH_OIDC_VERIFY_SSL: AUTH_OIDC_VERIFY_SSL, + AUTH_OIDC_VERIFY_SIGNATURE: AUTH_OIDC_VERIFY_SIGNATURE, + AUTH_SYNC_DOMAINS: AUTH_SYNC_DOMAINS, + AUTH_SESSION_TTL_HOURS: AUTH_SESSION_TTL_HOURS, + AUTH_TOKEN_ENCRYPTION_KEY_SECRET_NAME: AUTH_TOKEN_ENCRYPTION_KEY_SECRET_NAME, + AUTH_STATE_ENCRYPTION_KEY_SECRET_NAME: AUTH_STATE_ENCRYPTION_KEY_SECRET_NAME, // Other properties not prompted for in config-helper SYSTEM_TAG: SYSTEM_TAG, IAM_RESOURCE_PREFIX: IAM_RESOURCE_PREFIX, @@ -244,7 +287,8 @@ export function generateConfig (accountId?: string) { NEW_USERS_SUSPENDED: NEW_USERS_SUSPENDED, BACKGROUND_REFRESH_INTERVAL: BACKGROUND_REFRESH_INTERVAL, - SHOW_MIGRATION_OPTIONS: SHOW_MIGRATION_OPTIONS + SHOW_MIGRATION_OPTIONS: SHOW_MIGRATION_OPTIONS, + WEB_CUSTOM_DOMAIN_NAME: WEB_CUSTOM_DOMAIN_NAME }; //Try to load account-specific config or config generated by config-helper @@ -268,10 +312,54 @@ export function generateConfig (accountId?: string) { } validateRequiredProperty(config.AWS_ACCOUNT, 'AWS_ACCOUNT'); - validateRequiredProperty(config.OIDC_URL, 'OIDC_URL'); - validateRequiredProperty(config.OIDC_CLIENT_NAME, 'OIDC_CLIENT_NAME'); validateRequiredProperty(config.AWS_REGION, 'AWS_REGION'); + // Check for deprecated OIDC_* environment variables + const deprecatedOidcVars = [ + 'OIDC_URL', + 'OIDC_CLIENT_NAME', + 'OIDC_VERIFY_SSL', + 'OIDC_VERIFY_SIGNATURE', + 'OIDC_REDIRECT_URI', + 'INTERNAL_OIDC_URL', + 'IDP_ENDPOINT_SSM_PARAM' + ]; + + const foundDeprecatedVars = deprecatedOidcVars.filter ((varName) => { + const envValue = process.env[varName]; + const configValue = config[varName as keyof MLSpaceConfig]; + const value = (envValue || configValue); + return value !== undefined && value !== ''; + }); + + if (foundDeprecatedVars.length > 0) { + throw new Error( + `\n${'='.repeat(80)}\n` + + 'ERROR: Deprecated OIDC_* configuration variables detected!\n' + + `${'='.repeat(80)}\n\n` + + 'The following deprecated configuration variables are still set:\n' + + ` ${foundDeprecatedVars.map ((v) => `- ${v}`).join('\n ')}\n\n` + + 'These have been replaced with AUTH_* settings for the new enhanced authentication.\n\n' + + 'Please update your configuration:\n' + + ' 1. Remove the deprecated OIDC_* environment variables\n' + + ' 2. Set the new AUTH_* environment variables instead:\n' + + ' - AUTH_OIDC_URL (replaces OIDC_URL)\n' + + ' - AUTH_OIDC_CLIENT_ID (replaces OIDC_CLIENT_NAME)\n' + + ' - AUTH_OIDC_CLIENT_SECRET_NAME\n' + + ' - AUTH_OIDC_VERIFY_SSL (replaces OIDC_VERIFY_SSL)\n' + + ' - AUTH_OIDC_VERIFY_SIGNATURE (replaces OIDC_VERIFY_SIGNATURE)\n\n' + + 'For migration guidance, see:\n' + + ' - docs/BFF_AUTHENTICATION_KEY_ROTATION.md\n' + + ' - frontend/docs/admin-guide/bff-authentication-migration.md\n' + + `${'='.repeat(80)}\n` + ); + } + + // Validate BFF authentication configuration + validateRequiredProperty(config.AUTH_OIDC_URL, 'AUTH_OIDC_URL'); + validateRequiredProperty(config.AUTH_OIDC_CLIENT_ID, 'AUTH_OIDC_CLIENT_ID'); + validateRequiredProperty(config.AUTH_OIDC_CLIENT_SECRET_NAME, 'AUTH_OIDC_CLIENT_SECRET_NAME'); + if (!config.EXISTING_KMS_MASTER_KEY_ARN) { validateRequiredProperty(config.KEY_MANAGER_ROLE_NAME, 'KEY_MANAGER_ROLE_NAME'); } From c68df37b8a06bc0e2c5f829845cc485f3431eb4b Mon Sep 17 00:00:00 2001 From: Dustin Sweigart Date: Thu, 22 Jan 2026 17:02:04 +0000 Subject: [PATCH 22/32] baseurl fix --- frontend/src/contexts/AuthContext.tsx | 2 +- frontend/src/shared/util/axios-utils.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 899da0da..5aa84253 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -193,7 +193,7 @@ export const AuthProvider: React.FC = ({ const login = (redirectUrl?: string) => { // For login redirect, we need to construct the full URL since it's a page redirect const baseUrl = (window as any).env?.LAMBDA_ENDPOINT || window.location.origin; - const loginUrl = new URL('/auth/login', baseUrl); + const loginUrl = new URL('auth/login', baseUrl); if (redirectUrl) { loginUrl.searchParams.set('redirectUrl', redirectUrl); } diff --git a/frontend/src/shared/util/axios-utils.ts b/frontend/src/shared/util/axios-utils.ts index f0f2bbd1..92ca70a7 100644 --- a/frontend/src/shared/util/axios-utils.ts +++ b/frontend/src/shared/util/axios-utils.ts @@ -49,7 +49,7 @@ class AxiosHelper { } const config = (requestConfig: AxiosRequestConfig = {}) => { - requestConfig.baseURL = `${window.env.LAMBDA_ENDPOINT}`; + requestConfig.baseURL = (window as any).env?.LAMBDA_ENDPOINT || window.location.origin; requestConfig.withCredentials = true; return requestConfig; From 4906487c572cb9e5501505579d8d30b99f8ddc9f Mon Sep 17 00:00:00 2001 From: Dustin Sweigart Date: Thu, 22 Jan 2026 17:25:39 +0000 Subject: [PATCH 23/32] fix redirect baseurl --- .../ml_space_lambda/auth/lambda_functions.py | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/backend/src/ml_space_lambda/auth/lambda_functions.py b/backend/src/ml_space_lambda/auth/lambda_functions.py index 5a3aa3cf..4ef9ff40 100644 --- a/backend/src/ml_space_lambda/auth/lambda_functions.py +++ b/backend/src/ml_space_lambda/auth/lambda_functions.py @@ -324,13 +324,14 @@ def _get_base_url(event: Dict) -> str: """ Get base URL for building redirect URIs. - Uses WEB_CUSTOM_DOMAIN_NAME if configured, otherwise falls back to Host header. + Uses WEB_CUSTOM_DOMAIN_NAME if configured, otherwise falls back to Host header + with stage path from requestContext. Args: event: Lambda event Returns: - Base URL (e.g., "https://mlspace.example.com" or "https://api-id.execute-api.region.amazonaws.com/stage") + Base URL (e.g., "https://mlspace.example.com" or "https://api-id.execute-api.region.amazonaws.com/Prod") """ # Check for custom domain configuration custom_domain = os.environ.get("WEB_CUSTOM_DOMAIN_NAME", "").strip() @@ -338,7 +339,7 @@ def _get_base_url(event: Dict) -> str: # Remove trailing slash if present return custom_domain.rstrip("/") - # Fall back to Host header + # Fall back to Host header with stage path host = event.get("headers", {}).get("Host") or event.get("headers", {}).get("host", "") # Determine protocol @@ -346,7 +347,16 @@ def _get_base_url(event: Dict) -> str: if host.startswith("localhost") or "127.0.0.1" in host: protocol = "http" - return f"{protocol}://{host}" + # Get stage from requestContext (API Gateway includes this) + request_context = event.get("requestContext", {}) + stage = request_context.get("stage", "") + + # Build base URL with stage if present + base_url = f"{protocol}://{host}" + if stage: + base_url = f"{base_url}/{stage}" + + return base_url def _get_redirect_uri(event: Dict) -> str: @@ -1314,10 +1324,10 @@ def _validate_requesting_domain(event, config) -> Tuple[Optional[str], Optional[ requesting_domain = normalize_domain(host_header) - # Build allowed domains list (current domain + sync domains) - # The host header represents the primary domain where the request is being made - sync_domains = build_domain_list(requesting_domain, config.get("sync_domains", "")) - allowed_domains = [requesting_domain] + sync_domains + # Build allowed domains list (primary + sync domains) + primary_domain = normalize_domain(config.get("primary_domain", "") or host_header) + sync_domains = build_domain_list(primary_domain, config.get("sync_domains", "")) + allowed_domains = [primary_domain] + sync_domains # Validate requesting domain is in allowed list if requesting_domain not in allowed_domains: From 8270bb8302dff5d7030c1962da230e9fe7593dd8 Mon Sep 17 00:00:00 2001 From: Dustin Sweigart Date: Thu, 22 Jan 2026 18:13:40 +0000 Subject: [PATCH 24/32] fix path prefix issue for cookies --- .../ml_space_lambda/auth/lambda_functions.py | 88 +++++++++++++++++-- .../src/ml_space_lambda/auth/utils/cookies.py | 29 ++++-- 2 files changed, 101 insertions(+), 16 deletions(-) diff --git a/backend/src/ml_space_lambda/auth/lambda_functions.py b/backend/src/ml_space_lambda/auth/lambda_functions.py index 4ef9ff40..c6e93fd9 100644 --- a/backend/src/ml_space_lambda/auth/lambda_functions.py +++ b/backend/src/ml_space_lambda/auth/lambda_functions.py @@ -373,6 +373,66 @@ def _get_redirect_uri(event: Dict) -> str: return f"{base_url}/auth/callback" +def _get_root_path(event: Dict) -> str: + """ + Get the root path for session cookie configuration. + + For custom domains, returns "/". + For stage-based deployments, returns "/{stage}". + + Args: + event: Lambda event + + Returns: + Root path for session cookies (e.g., "/" or "/Prod") + """ + # Check for custom domain configuration + custom_domain = os.environ.get("WEB_CUSTOM_DOMAIN_NAME", "").strip() + if custom_domain: + # Custom domain doesn't include stage in path + return "/" + + # Get stage from requestContext (API Gateway includes this) + request_context = event.get("requestContext", {}) + stage = request_context.get("stage", "") + + # Build root path with stage if present + if stage: + return f"/{stage}" + + return "/" + + +def _get_auth_path(event: Dict) -> str: + """ + Get the auth path for state cookie configuration. + + For custom domains, returns "/auth". + For stage-based deployments, returns "/{stage}/auth". + + Args: + event: Lambda event + + Returns: + Auth path for state cookies (e.g., "/auth" or "/Prod/auth") + """ + # Check for custom domain configuration + custom_domain = os.environ.get("WEB_CUSTOM_DOMAIN_NAME", "").strip() + if custom_domain: + # Custom domain doesn't include stage in path + return "/auth" + + # Get stage from requestContext (API Gateway includes this) + request_context = event.get("requestContext", {}) + stage = request_context.get("stage", "") + + # Build auth path with stage if present + if stage: + return f"/{stage}/auth" + + return "/auth" + + def _validate_redirect_url(redirect_url: str, host_header: str) -> bool: """ Validate that redirect URL is safe and belongs to the same origin. @@ -460,7 +520,10 @@ def login(event, context): # Create state cookie secure_flag = should_set_secure_flag(host_header) - state_cookie = create_state_cookie(nonce=nonce, max_age_seconds=600, secure=secure_flag, same_site="Lax") # 10 minutes + auth_path = _get_auth_path(event) + state_cookie = create_state_cookie( + nonce=nonce, max_age_seconds=600, secure=secure_flag, same_site="Lax", path=auth_path + ) # 10 minutes logger.info(f"Login initiated for domain: {domain}, redirecting to IdP") @@ -772,13 +835,15 @@ def callback(event, context): host_header = event.get("headers", {}).get("Host") or event.get("headers", {}).get("host", "") secure_flag = should_set_secure_flag(host_header) domain = extract_domain_from_host(host_header) + root_path = _get_root_path(event) session_cookie = create_session_cookie( - session_id=session_id, max_age_seconds=int(expires_at), domain=domain, secure=secure_flag + session_id=session_id, max_age_seconds=int(expires_at), domain=domain, secure=secure_flag, path=root_path ) # Clear state cookie - clear_state = clear_state_cookie() + auth_path = _get_auth_path(event) + clear_state = clear_state_cookie(path=auth_path) # Handle multi-domain synchronization should_sync, sync_response = _handle_multi_domain_sync( @@ -798,7 +863,8 @@ def callback(event, context): logger.error(f"Callback processing failed: {e}") # Clear state cookie on error - clear_state = clear_state_cookie() + auth_path = _get_auth_path(event) + clear_state = clear_state_cookie(path=auth_path) error_url = "/?error=internal_error&message=Authentication processing failed" return create_redirect_response(location=error_url, cookies=[clear_state], status_code=302) @@ -956,7 +1022,8 @@ def _create_logout_error_response(context, event) -> Dict: try: host_header = event.get("headers", {}).get("Host") or event.get("headers", {}).get("host", "") domain = extract_domain_from_host(host_header) - clear_session = clear_session_cookie(domain=domain) + root_path = _get_root_path(event) + clear_session = clear_session_cookie(domain=domain, path=root_path) cookies = [clear_session] except Exception: cookies = None @@ -1011,7 +1078,8 @@ def logout(event, context): # Get host header and clear session cookie host_header = event.get("headers", {}).get("Host") or event.get("headers", {}).get("host", "") domain = extract_domain_from_host(host_header) - clear_session = clear_session_cookie(domain=domain) + root_path = _get_root_path(event) + clear_session = clear_session_cookie(domain=domain, path=root_path) # Prepare response response_body = {"status": "LOGGED_OUT"} @@ -1146,9 +1214,10 @@ def _attempt_token_refresh( host_header = event.get("headers", {}).get("Host") or event.get("headers", {}).get("host", "") secure_flag = should_set_secure_flag(host_header) domain = extract_domain_from_host(host_header) + root_path = _get_root_path(event) new_session_cookie = create_session_cookie( - session_id=session_id, max_age_seconds=int(access_expires), domain=domain, secure=secure_flag + session_id=session_id, max_age_seconds=int(access_expires), domain=domain, secure=secure_flag, path=root_path ) logger.info(f"Token refresh successful for session: {session_id}") @@ -1375,11 +1444,14 @@ def _set_session_cookie_for_domain(session_id, event, config) -> str: host_header = event.get("headers", {}).get("Host") or event.get("headers", {}).get("host", "") secure_flag = should_set_secure_flag(host_header) domain = extract_domain_from_host(host_header) + root_path = _get_root_path(event) # Use default session TTL (24 hours) for sync cookies session_ttl = int(config.get("session_ttl_hours", "24")) * 3600 - return create_session_cookie(session_id=session_id, max_age_seconds=session_ttl, domain=domain, secure=secure_flag) + return create_session_cookie( + session_id=session_id, max_age_seconds=session_ttl, domain=domain, secure=secure_flag, path=root_path + ) def _handle_sync_chain_continuation( diff --git a/backend/src/ml_space_lambda/auth/utils/cookies.py b/backend/src/ml_space_lambda/auth/utils/cookies.py index 31fb8659..7be0f098 100644 --- a/backend/src/ml_space_lambda/auth/utils/cookies.py +++ b/backend/src/ml_space_lambda/auth/utils/cookies.py @@ -26,7 +26,12 @@ def create_session_cookie( - session_id: str, max_age_seconds: int = 86400, domain: Optional[str] = None, secure: bool = True, same_site: str = "Strict" + session_id: str, + max_age_seconds: int = 86400, + domain: Optional[str] = None, + secure: bool = True, + same_site: str = "Strict", + path: str = "/", ) -> str: """ Create a secure session cookie. @@ -37,6 +42,7 @@ def create_session_cookie( domain: Cookie domain (optional) secure: Whether to set Secure flag (default True) same_site: SameSite attribute value (default "Strict") + path: Cookie path (default "/", should be "/{stage}" for stage-based deployments) Returns: Set-Cookie header value @@ -45,7 +51,7 @@ def create_session_cookie( cookie["mlspace_session"] = session_id cookie["mlspace_session"]["httponly"] = True cookie["mlspace_session"]["max-age"] = max_age_seconds - cookie["mlspace_session"]["path"] = "/" + cookie["mlspace_session"]["path"] = path cookie["mlspace_session"]["samesite"] = same_site if secure: @@ -57,7 +63,9 @@ def create_session_cookie( return cookie.output(header="").strip() -def create_state_cookie(nonce: str, max_age_seconds: int = 600, secure: bool = True, same_site: str = "Strict") -> str: +def create_state_cookie( + nonce: str, max_age_seconds: int = 600, secure: bool = True, same_site: str = "Strict", path: str = "/auth" +) -> str: """ Create a state cookie for CSRF protection. @@ -66,6 +74,7 @@ def create_state_cookie(nonce: str, max_age_seconds: int = 600, secure: bool = T max_age_seconds: Cookie lifetime in seconds (default 10 minutes) secure: Whether to set Secure flag (default True) same_site: SameSite attribute value (default "Strict") + path: Cookie path (default "/auth", should include stage if not using custom domain) Returns: Set-Cookie header value @@ -74,7 +83,7 @@ def create_state_cookie(nonce: str, max_age_seconds: int = 600, secure: bool = T cookie["mlspace_auth_state"] = nonce cookie["mlspace_auth_state"]["httponly"] = True cookie["mlspace_auth_state"]["max-age"] = max_age_seconds - cookie["mlspace_auth_state"]["path"] = "/auth" + cookie["mlspace_auth_state"]["path"] = path cookie["mlspace_auth_state"]["samesite"] = same_site if secure: @@ -83,12 +92,13 @@ def create_state_cookie(nonce: str, max_age_seconds: int = 600, secure: bool = T return cookie.output(header="").strip() -def clear_session_cookie(domain: Optional[str] = None) -> str: +def clear_session_cookie(domain: Optional[str] = None, path: str = "/") -> str: """ Create a cookie header to clear the session cookie. Args: domain: Cookie domain (optional) + path: Cookie path (default "/", should match the path used when creating the cookie) Returns: Set-Cookie header value to clear the session cookie @@ -97,7 +107,7 @@ def clear_session_cookie(domain: Optional[str] = None) -> str: cookie["mlspace_session"] = "" cookie["mlspace_session"]["httponly"] = True cookie["mlspace_session"]["max-age"] = 0 - cookie["mlspace_session"]["path"] = "/" + cookie["mlspace_session"]["path"] = path cookie["mlspace_session"]["samesite"] = "Strict" cookie["mlspace_session"]["secure"] = True @@ -107,10 +117,13 @@ def clear_session_cookie(domain: Optional[str] = None) -> str: return cookie.output(header="").strip() -def clear_state_cookie() -> str: +def clear_state_cookie(path: str = "/auth") -> str: """ Create a cookie header to clear the state cookie. + Args: + path: Cookie path (default "/auth", should include stage if not using custom domain) + Returns: Set-Cookie header value to clear the state cookie """ @@ -118,7 +131,7 @@ def clear_state_cookie() -> str: cookie["mlspace_auth_state"] = "" cookie["mlspace_auth_state"]["httponly"] = True cookie["mlspace_auth_state"]["max-age"] = 0 - cookie["mlspace_auth_state"]["path"] = "/auth" + cookie["mlspace_auth_state"]["path"] = path cookie["mlspace_auth_state"]["samesite"] = "Strict" cookie["mlspace_auth_state"]["secure"] = True From ab725b2336681544b35a2979a60d9a390ae90383 Mon Sep 17 00:00:00 2001 From: Dustin Sweigart Date: Thu, 22 Jan 2026 18:42:08 +0000 Subject: [PATCH 25/32] fixed base url issue --- .../ml_space_lambda/auth/lambda_functions.py | 52 ++++++++++++------- 1 file changed, 33 insertions(+), 19 deletions(-) diff --git a/backend/src/ml_space_lambda/auth/lambda_functions.py b/backend/src/ml_space_lambda/auth/lambda_functions.py index c6e93fd9..9df39950 100644 --- a/backend/src/ml_space_lambda/auth/lambda_functions.py +++ b/backend/src/ml_space_lambda/auth/lambda_functions.py @@ -485,13 +485,14 @@ def login(event, context): # Get redirect URL from query parameters query_params = event.get("queryStringParameters") or {} - redirect_url = query_params.get("redirectUrl", "/") + root_path = _get_root_path(event) + redirect_url = query_params.get("redirectUrl", root_path) host_header = event.get("headers", {}).get("Host") or event.get("headers", {}).get("host", "") # Validate redirect URL if not _validate_redirect_url(redirect_url, host_header): logger.warning(f"Invalid redirect URL: {redirect_url}") - redirect_url = "/" + redirect_url = root_path # Get domain for state and cookies domain = extract_domain_from_host(host_header) @@ -570,17 +571,18 @@ def _validate_callback_parameters(event): encrypted_state = query_params.get("state") error_param = query_params.get("error") error_description = query_params.get("error_description", "") + root_path = _get_root_path(event) # Check for IdP error response if error_param: logger.warning(f"IdP returned error: {error_param} - {error_description}") - error_url = f"/?error=authentication_failed&message={error_param}" + error_url = f"{root_path}?error=authentication_failed&message={error_param}" return None, None, create_redirect_response(location=error_url, status_code=302) # Validate required parameters if not auth_code or not encrypted_state: logger.warning("Missing required callback parameters") - error_url = "/?error=invalid_request&message=Missing required parameters" + error_url = f"{root_path}?error=invalid_request&message=Missing required parameters" return None, None, create_redirect_response(location=error_url, status_code=302) return auth_code, encrypted_state, None @@ -602,12 +604,13 @@ def _validate_state_parameter(event, state_manager, encrypted_state): # Extract state cookie cookie_header = event.get("headers", {}).get("Cookie") or event.get("headers", {}).get("cookie", "") state_nonce = get_cookie_value(cookie_header, "mlspace_auth_state") + root_path = _get_root_path(event) # Validate state parameter state_data = state_manager.validate_state(encrypted_state, state_nonce) if not state_data: logger.warning("Invalid or expired state parameter") - error_url = "/?error=invalid_state&message=Authentication request expired or invalid" + error_url = f"{root_path}?error=invalid_state&message=Authentication request expired or invalid" return None, create_redirect_response(location=error_url, status_code=302) return state_data, None @@ -629,6 +632,7 @@ def _exchange_code_for_tokens(auth_handler, auth_code, state_data, event): """ # Get redirect URI for token exchange redirect_uri = _get_redirect_uri(event) + root_path = _get_root_path(event) # Extract code_verifier from protocol_data if present (for PKCE flow) protocol_data = state_data.get("protocol_data", {}) @@ -639,7 +643,7 @@ def _exchange_code_for_tokens(auth_handler, auth_code, state_data, event): if not auth_result.success: logger.error(f"Token exchange failed: {auth_result.error}") - error_url = f"/?error=token_exchange_failed&message={auth_result.error}" + error_url = f"{root_path}?error=token_exchange_failed&message={auth_result.error}" return None, create_redirect_response(location=error_url, status_code=302) return auth_result, None @@ -864,8 +868,9 @@ def callback(event, context): # Clear state cookie on error auth_path = _get_auth_path(event) + root_path = _get_root_path(event) clear_state = clear_state_cookie(path=auth_path) - error_url = "/?error=internal_error&message=Authentication processing failed" + error_url = f"{root_path}?error=internal_error&message=Authentication processing failed" return create_redirect_response(location=error_url, cookies=[clear_state], status_code=302) @@ -887,11 +892,12 @@ def callback_post(event, context): try: # Get authentication configuration config = _get_auth_config() + root_path = _get_root_path(event) # Currently only OIDC is supported, which uses GET callbacks if config["idp_type"] != IdPType.SAML: logger.warning(f"POST callback not supported for IdP type: {config['idp_type']}") - error_url = "/?error=unsupported_callback&message=POST callback not supported for this IdP type" + error_url = f"{root_path}?error=unsupported_callback&message=POST callback not supported for this IdP type" return create_redirect_response(location=error_url, status_code=302) # TODO: Implement SAML POST callback handling @@ -903,12 +909,13 @@ def callback_post(event, context): # 5. Handle multi-domain sync if configured logger.error("SAML POST callback not yet implemented") - error_url = "/?error=not_implemented&message=SAML authentication not yet supported" + error_url = f"{root_path}?error=not_implemented&message=SAML authentication not yet supported" return create_redirect_response(location=error_url, status_code=302) except Exception as e: logger.error(f"POST callback processing failed: {e}") - error_url = "/?error=internal_error&message=Authentication processing failed" + root_path = _get_root_path(event) + error_url = f"{root_path}?error=internal_error&message=Authentication processing failed" return create_redirect_response(location=error_url, status_code=302) @@ -1352,6 +1359,7 @@ def _validate_sync_parameters(event) -> Tuple[Optional[str], List[str], Optional from ml_space_lambda.auth.utils.otac import parse_sync_request, validate_otac_format query_params = event.get("queryStringParameters") or {} + root_path = _get_root_path(event) # Parse sync parameters otac, remaining_domains, final_redirect_url = parse_sync_request(query_params) @@ -1359,13 +1367,13 @@ def _validate_sync_parameters(event) -> Tuple[Optional[str], List[str], Optional # Validate OTAC format if not otac or not validate_otac_format(otac): logger.warning("Invalid or missing OTAC in sync request") - error_url = "/?error=invalid_otac&message=Invalid or missing authentication code" + error_url = f"{root_path}?error=invalid_otac&message=Invalid or missing authentication code" return None, [], None, create_redirect_response(location=error_url, status_code=302) # Validate final redirect URL if not final_redirect_url: logger.warning("Missing final redirect URL in sync request") - error_url = "/?error=invalid_request&message=Missing final redirect URL" + error_url = f"{root_path}?error=invalid_request&message=Missing final redirect URL" return None, [], None, create_redirect_response(location=error_url, status_code=302) return otac, remaining_domains, final_redirect_url, None @@ -1386,9 +1394,11 @@ def _validate_requesting_domain(event, config) -> Tuple[Optional[str], Optional[ from ml_space_lambda.auth.utils.otac import build_domain_list, normalize_domain host_header = event.get("headers", {}).get("Host") or event.get("headers", {}).get("host", "") + root_path = _get_root_path(event) + if not host_header: logger.warning("Missing Host header in sync request") - error_url = "/?error=invalid_request&message=Invalid request" + error_url = f"{root_path}?error=invalid_request&message=Invalid request" return None, create_redirect_response(location=error_url, status_code=302) requesting_domain = normalize_domain(host_header) @@ -1401,19 +1411,20 @@ def _validate_requesting_domain(event, config) -> Tuple[Optional[str], Optional[ # Validate requesting domain is in allowed list if requesting_domain not in allowed_domains: logger.warning(f"Unauthorized domain in sync request: {requesting_domain}") - error_url = "/?error=unauthorized_domain&message=Domain not authorized for sync" + error_url = f"{root_path}?error=unauthorized_domain&message=Domain not authorized for sync" return None, create_redirect_response(location=error_url, status_code=302) return requesting_domain, None -def _validate_and_consume_otac(otac_manager, otac) -> Tuple[Optional[Dict], Optional[Dict]]: +def _validate_and_consume_otac(otac_manager, otac, event) -> Tuple[Optional[Dict], Optional[Dict]]: """ Validate OTAC and mark it as used. Args: otac_manager: OTACManager instance otac: OTAC identifier to validate + event: Lambda event for building error URLs Returns: Tuple of (otac_data, error_response) @@ -1423,7 +1434,8 @@ def _validate_and_consume_otac(otac_manager, otac) -> Tuple[Optional[Dict], Opti if not otac_data: logger.warning(f"Invalid or expired OTAC: {otac}") - error_url = "/?error=invalid_otac&message=Authentication code is invalid or expired" + root_path = _get_root_path(event) + error_url = f"{root_path}?error=invalid_otac&message=Authentication code is invalid or expired" return None, create_redirect_response(location=error_url, status_code=302) return otac_data, None @@ -1501,7 +1513,8 @@ def _handle_sync_chain_continuation( except Exception as e: logger.error(f"Failed to create OTAC for sync chain continuation: {e}") - error_url = "/?error=sync_failed&message=Failed to continue synchronization" + root_path = _get_root_path(event) + error_url = f"{root_path}?error=sync_failed&message=Failed to continue synchronization" return False, None, create_redirect_response(location=error_url, status_code=302) @@ -1537,7 +1550,7 @@ def sync(event, context): return error_response # Validate OTAC and mark as used (strong consistency) - otac_data, error_response = _validate_and_consume_otac(otac_manager, otac) + otac_data, error_response = _validate_and_consume_otac(otac_manager, otac, event) if error_response: return error_response @@ -1568,5 +1581,6 @@ def sync(event, context): logger.error(f"Sync processing failed: {e}") # Return error redirect - error_url = "/?error=sync_failed&message=Cross-domain synchronization failed" + root_path = _get_root_path(event) + error_url = f"{root_path}?error=sync_failed&message=Cross-domain synchronization failed" return create_redirect_response(location=error_url, status_code=302) From 03e8903da9df7ecac37ef7e6a4bfdc566c8617f1 Mon Sep 17 00:00:00 2001 From: dustins Date: Thu, 22 Jan 2026 19:59:40 +0000 Subject: [PATCH 26/32] Updating version for release v1.7 --- frontend/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/package.json b/frontend/package.json index 4a034382..95845d4c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "@amzn/mlspace", "homepage": "/Prod", - "version": "1.6.11", + "version": "1.7", "main": "dist/lib/index.js", "types": "dist/types/index.d.ts", "private": true, From afade9bb20a093804e1a89c743d9fd4af396e8dc Mon Sep 17 00:00:00 2001 From: Dustin Sweigart Date: Thu, 22 Jan 2026 15:03:48 -0500 Subject: [PATCH 27/32] Documentation and helper fixes (#346) * doc updates * updated config helper --- BFF_AUTHENTICATION_DOCS_README.md | 10 +- bin/scripts/config-helper.mjs | 51 +- .../auth-configuration-reference.md | 200 +------ .../bff-authentication-migration.md | 489 ++---------------- .../docs/admin-guide/bff-authentication.md | 489 +----------------- .../docs/admin-guide/configure-cognito.md | 7 +- frontend/docs/admin-guide/custom-domain.md | 7 +- frontend/docs/admin-guide/getting-started.md | 2 +- frontend/docs/admin-guide/install.md | 23 +- 9 files changed, 130 insertions(+), 1148 deletions(-) diff --git a/BFF_AUTHENTICATION_DOCS_README.md b/BFF_AUTHENTICATION_DOCS_README.md index 22529bf3..b86cab8d 100644 --- a/BFF_AUTHENTICATION_DOCS_README.md +++ b/BFF_AUTHENTICATION_DOCS_README.md @@ -33,7 +33,7 @@ aws secretsmanager update-secret \ ## Complete AUTH_* Parameter List -All authentication configuration now uses `AUTH_*` parameters. **Legacy `OIDC_*` parameters are deprecated and not supported.** +All authentication configuration now uses `AUTH_*` parameters. **Deprecated `OIDC_*` parameters are no longer supported.** ### Required Parameters - **AUTH_IDP_TYPE**: Identity Provider type (currently only `"oidc"` is supported) @@ -63,7 +63,7 @@ All authentication configuration now uses `AUTH_*` parameters. **Legacy `OIDC_*` - Configuration setup instructions - SSM parameter setup for client secrets - Multi-domain cookie synchronization configuration -- Migration instructions from legacy OIDC +- Migration instructions from deprecated OIDC - Troubleshooting guide - Security considerations - Performance monitoring @@ -97,7 +97,7 @@ All authentication configuration now uses `AUTH_*` parameters. **Legacy `OIDC_*` **File**: `frontend/docs/admin-guide/install.md` (updated) **Changes**: - Added warning about enhanced authentication for new deployments -- Updated OIDC parameter descriptions to indicate legacy status +- Updated OIDC parameter descriptions to indicate deprecated status - Added references to enhanced authentication documentation - Maintained backward compatibility information @@ -143,7 +143,7 @@ frontend/docs/admin-guide/ - Security best practices ### Deprecated Parameters -**All legacy `OIDC_*` parameters are deprecated and not supported:** +**All deprecated `OIDC_*` parameters are no longer supported:** - `OIDC_URL` → Use `AUTH_OIDC_URL` - `OIDC_CLIENT_NAME` → Use `AUTH_OIDC_CLIENT_ID` - `OIDC_REDIRECT_URL` → No longer needed (automatic `/auth/callback`) @@ -222,7 +222,7 @@ This documentation satisfies all requirements from task 19: - Environment-specific configuration examples - Validation rules and scripts -✅ **Document migration steps from legacy OIDC configuration** +✅ **Document migration steps from deprecated OIDC configuration** - Detailed step-by-step migration guide - Pre-migration assessment procedures - Configuration file update instructions diff --git a/bin/scripts/config-helper.mjs b/bin/scripts/config-helper.mjs index 65018d0e..09767d23 100644 --- a/bin/scripts/config-helper.mjs +++ b/bin/scripts/config-helper.mjs @@ -75,6 +75,11 @@ async function advancedConfigPrompts () { // List of other advanced settings which don't fit into a category const otherAdvancedSettings = [ + { + type: 'input', + name: 'WEB_CUSTOM_DOMAIN_NAME', + message: 'Custom Domain Name: optional custom domain name for the MLSpace web application (leave empty to use default API Gateway domain)', + }, { type: 'confirm', name: 'NEW_USERS_SUSPENDED', @@ -100,16 +105,6 @@ async function basicConfigPrompts () { name: 'AWS_REGION', message: 'AWS Region: the region that MLSpace resources will be deployed into', }, - { - type: 'input', - name: 'OIDC_URL', - message: 'OIDC URL: the OIDC endpoint that will be used for MLSpace authentication', - }, - { - type: 'input', - name: 'OIDC_CLIENT_NAME', - message: 'OIDC Client Name: the OIDC client name that should be used by MLSpace for authentication', - }, { type: 'input', name: 'KEY_MANAGER_ROLE_NAME', @@ -120,6 +115,42 @@ async function basicConfigPrompts () { const basicPromptAnswers = await prompt(basicQuestions); answers = {...answers, ...basicPromptAnswers}; + // Ask OIDC questions after basic AWS configuration + await askOidcQuestions(); +} + +async function askOidcQuestions () { + const oidcQuestions = [ + { + type: 'input', + name: 'AUTH_OIDC_URL', + message: 'OIDC URL: the OIDC endpoint that will be used for MLSpace authentication', + }, + { + type: 'input', + name: 'AUTH_OIDC_CLIENT_ID', + message: 'OIDC Client ID: the OIDC client ID that should be used by MLSpace for authentication', + }, + ]; + + const oidcPromptAnswers = await prompt(oidcQuestions); + answers = {...answers, ...oidcPromptAnswers}; + + // Ask if using confidential client + const confidentialClientResponse = await prompt({ + type: 'confirm', + name: 'isConfidentialClient', + message: 'Are you using a confidential OIDC client? (requires client secret)', + }); + + if (confidentialClientResponse.isConfidentialClient) { + const clientSecretResponse = await prompt({ + type: 'password', + name: 'AUTH_OIDC_CLIENT_SECRET', + message: 'OIDC Client Secret: the client secret for your confidential OIDC client', + }); + answers = {...answers, ...clientSecretResponse}; + } } async function askVpcQuestions () { diff --git a/frontend/docs/admin-guide/auth-configuration-reference.md b/frontend/docs/admin-guide/auth-configuration-reference.md index 95e8e2a9..537005fa 100644 --- a/frontend/docs/admin-guide/auth-configuration-reference.md +++ b/frontend/docs/admin-guide/auth-configuration-reference.md @@ -9,7 +9,7 @@ outline: deep This page provides a quick reference for all AUTH_* configuration parameters used in the enhanced authentication system. ::: danger OIDC_* PARAMETERS NOT SUPPORTED -The legacy `OIDC_*` configuration parameters (such as `OIDC_URL`, `OIDC_CLIENT_NAME`, `OIDC_VERIFY_SSL`, etc.) are **deprecated and no longer supported**. You must use the `AUTH_*` parameters documented on this page. See the [Migration Mapping](#migration-mapping) section below for the complete mapping from legacy to new parameters. +The deprecated `OIDC_*` configuration parameters (such as `OIDC_URL`, `OIDC_CLIENT_NAME`, `OIDC_VERIFY_SSL`, etc.) are **no longer supported**. You must use the `AUTH_*` parameters documented on this page. See the [Migration Mapping](#migration-mapping) section below for the complete mapping from deprecated to new parameters. ::: ## Required Parameters @@ -28,7 +28,7 @@ The legacy `OIDC_*` configuration parameters (such as `OIDC_URL`, `OIDC_CLIENT_N - **Default**: None - **Description**: OIDC issuer URL for authentication - **Example**: `"https://auth.example.com"` -- **Notes**: Replaces legacy `OIDC_URL` parameter +- **Notes**: Replaces deprecated `OIDC_URL` parameter ### AUTH_OIDC_CLIENT_ID - **Type**: String @@ -36,7 +36,7 @@ The legacy `OIDC_*` configuration parameters (such as `OIDC_URL`, `OIDC_CLIENT_N - **Default**: None - **Description**: OIDC client identifier - **Example**: `"mlspace-client"` -- **Notes**: Replaces legacy `OIDC_CLIENT_NAME` parameter +- **Notes**: Replaces deprecated `OIDC_CLIENT_NAME` parameter ## Optional Parameters @@ -121,79 +121,35 @@ The legacy `OIDC_*` configuration parameters (such as `OIDC_URL`, `OIDC_CLIENT_N - **Example**: `"mlspace/auth/state-encryption-key"` - **Notes**: Used for encrypting OAuth state parameter; automatically created during deployment -## Secrets Manager Configuration - -### mlspace/auth/oidc-client-secret -- **Type**: SecureString -- **Required**: No (only for confidential OIDC clients) -- **Description**: OIDC client secret for confidential client flow -- **Creation**: - ```bash - aws ssm put-parameter \ - --name "mlspace/auth/oidc-client-secret" \ - --value "your-client-secret" \ - --type "SecureString" - ``` - -### mlspace/auth/encryption-key -- **Type**: SecureString -- **Required**: No (auto-generated if not provided) -- **Description**: AES-256 key for token encryption -- **Notes**: Automatically generated during deployment if not specified - ## Environment-Specific Examples ### Development Environment (Minimal) ```json { - "AUTH_IDP_TYPE": "oidc", "AUTH_OIDC_URL": "https://auth.dev.example.com", "AUTH_OIDC_CLIENT_ID": "mlspace-dev-client", - "AUTH_SESSION_TTL_HOURS": 8 } ``` ### Development Environment (With Client Secret) ```json { - "AUTH_IDP_TYPE": "oidc", "AUTH_OIDC_URL": "https://auth.dev.example.com", "AUTH_OIDC_CLIENT_ID": "mlspace-dev-client", "AUTH_OIDC_CLIENT_SECRET_VALUE": "dev-client-secret-here", "AUTH_SESSION_TTL_HOURS": 8, - "AUTH_OIDC_USE_PKCE": true, - "AUTH_OIDC_VERIFY_SSL": true, - "AUTH_OIDC_VERIFY_SIGNATURE": true + "AUTH_OIDC_VERIFY_SSL": false, + "AUTH_OIDC_VERIFY_SIGNATURE": false } ``` ### Production Environment ```json { - "AUTH_IDP_TYPE": "oidc", "AUTH_OIDC_URL": "https://auth.example.com", "AUTH_OIDC_CLIENT_ID": "mlspace-prod-client", "AUTH_OIDC_CLIENT_SECRET_VALUE": "prod-client-secret-here", "AUTH_SESSION_TTL_HOURS": 24, - "AUTH_SYNC_DOMAINS": "notebooks.mlspace.com,admin.mlspace.com", - "AUTH_OIDC_USE_PKCE": true, - "AUTH_OIDC_VERIFY_SSL": true, - "AUTH_OIDC_VERIFY_SIGNATURE": true -} -``` - -### Multi-Domain Production Environment -```json -{ - "AUTH_IDP_TYPE": "oidc", - "AUTH_OIDC_URL": "https://sso.company.com", - "AUTH_OIDC_CLIENT_ID": "mlspace-enterprise", - "AUTH_OIDC_CLIENT_SECRET_VALUE": "enterprise-client-secret-here", - "AUTH_SESSION_TTL_HOURS": 12, - "AUTH_SYNC_DOMAINS": "mlspace-notebooks.company.com,mlspace-admin.company.com", - "AUTH_OIDC_USE_PKCE": true, - "AUTH_OIDC_VERIFY_SSL": true, - "AUTH_OIDC_VERIFY_SIGNATURE": true } ``` @@ -201,10 +157,9 @@ The legacy `OIDC_*` configuration parameters (such as `OIDC_URL`, `OIDC_CLIENT_N ### Required Validation Rules -1. **AUTH_IDP_TYPE**: Must be `"oidc"` (case-sensitive) -2. **AUTH_OIDC_URL**: Must be valid HTTPS URL when `AUTH_IDP_TYPE` is `"oidc"` -3. **AUTH_OIDC_CLIENT_ID**: Must be non-empty string when `AUTH_IDP_TYPE` is `"oidc"` -4. **AUTH_SESSION_TTL_HOURS**: Must be positive integer between 1 and 168 +1. **AUTH_OIDC_URL**: Must be valid HTTPS URL when `AUTH_IDP_TYPE` is `"oidc"` +2. **AUTH_OIDC_CLIENT_ID**: Must be non-empty string when `AUTH_IDP_TYPE` is `"oidc"` +3. **AUTH_SESSION_TTL_HOURS**: Must be positive integer between 1 and 168 ### Optional Validation Rules @@ -216,54 +171,16 @@ The legacy `OIDC_*` configuration parameters (such as `OIDC_URL`, `OIDC_CLIENT_N 7. **AUTH_TOKEN_ENCRYPTION_KEY_SECRET_NAME**: Must be valid Secrets Manager secret name if specified 8. **AUTH_STATE_ENCRYPTION_KEY_SECRET_NAME**: Must be valid Secrets Manager secret name if specified -### Validation Script - -```bash -#!/bin/bash -# validate-auth-config.sh - -CONFIG_FILE="lib/config.json" -ENV=${1:-"dev"} - -# Extract configuration for environment -AUTH_IDP_TYPE=$(jq -r ".${ENV}.AUTH_IDP_TYPE // empty" "$CONFIG_FILE") -AUTH_OIDC_URL=$(jq -r ".${ENV}.AUTH_OIDC_URL // empty" "$CONFIG_FILE") -AUTH_OIDC_CLIENT_ID=$(jq -r ".${ENV}.AUTH_OIDC_CLIENT_ID // empty" "$CONFIG_FILE") -AUTH_SESSION_TTL_HOURS=$(jq -r ".${ENV}.AUTH_SESSION_TTL_HOURS // 24" "$CONFIG_FILE") - -# Validate required parameters -if [ "$AUTH_IDP_TYPE" != "oidc" ]; then - echo "ERROR: AUTH_IDP_TYPE must be 'oidc'" - exit 1 -fi - -if [ -z "$AUTH_OIDC_URL" ]; then - echo "ERROR: AUTH_OIDC_URL is required when AUTH_IDP_TYPE is 'oidc'" - exit 1 -fi - -if [ -z "$AUTH_OIDC_CLIENT_ID" ]; then - echo "ERROR: AUTH_OIDC_CLIENT_ID is required when AUTH_IDP_TYPE is 'oidc'" - exit 1 -fi - -if [ "$AUTH_SESSION_TTL_HOURS" -lt 1 ] || [ "$AUTH_SESSION_TTL_HOURS" -gt 168 ]; then - echo "ERROR: AUTH_SESSION_TTL_HOURS must be between 1 and 168" - exit 1 -fi - -echo "✅ Configuration validation passed for environment: $ENV" -``` ## Migration Mapping -::: danger LEGACY PARAMETERS NOT SUPPORTED -All `OIDC_*` parameters listed below are **deprecated and no longer supported**. You must migrate to the corresponding `AUTH_*` parameters. Attempting to use legacy parameters will result in configuration errors. +::: danger DEPRECATED PARAMETERS NOT SUPPORTED +All `OIDC_*` parameters listed below are **no longer supported**. You must migrate to the corresponding `AUTH_*` parameters. Attempting to use deprecated parameters will result in configuration errors. ::: -### Legacy to New Parameter Mapping +### Deprecated to New Parameter Mapping -| Legacy Parameter | New Parameter | Migration Notes | +| Deprecated Parameter | New Parameter | Migration Notes | |------------------|---------------|-----------------| | `OIDC_URL` | `AUTH_OIDC_URL` | Direct replacement - use the same OIDC issuer URL | | `OIDC_CLIENT_NAME` | `AUTH_OIDC_CLIENT_ID` | Direct replacement - use the same client identifier | @@ -281,87 +198,6 @@ All `OIDC_*` parameters listed below are **deprecated and no longer supported**. | _(none)_ | `AUTH_TOKEN_ENCRYPTION_KEY_SECRET_NAME` | **New** - Token encryption keys (rotatable) | | _(none)_ | `AUTH_STATE_ENCRYPTION_KEY_SECRET_NAME` | **New** - State encryption key | -### Configuration File Migration - -**Before (legacy):** -```typescript -// lib/constants.ts -export const OIDC_URL = 'https://auth.example.com'; -export const OIDC_CLIENT_NAME = 'mlspace-client'; -export const OIDC_VERIFY_SSL = true; -export const OIDC_VERIFY_SIGNATURE = true; -``` - -**After (Enhanced Authentication):** -```typescript -// lib/constants.ts -export const AUTH_IDP_TYPE = 'oidc'; -export const AUTH_OIDC_URL = 'https://auth.example.com'; -export const AUTH_OIDC_CLIENT_ID = 'mlspace-client'; -export const AUTH_SESSION_TTL_HOURS = 24; -``` - -## Troubleshooting Configuration Issues - -### Common Configuration Errors - -#### Error: "Invalid AUTH_IDP_TYPE" -``` -Cause: AUTH_IDP_TYPE is not set to "oidc" -Solution: Set AUTH_IDP_TYPE to "oidc" (case-sensitive) -``` - -#### Error: "OIDC URL not configured" -``` -Cause: AUTH_OIDC_URL is missing or empty -Solution: Set AUTH_OIDC_URL to your OIDC issuer URL -``` - -#### Error: "OIDC Client ID not configured" -``` -Cause: AUTH_OIDC_CLIENT_ID is missing or empty -Solution: Set AUTH_OIDC_CLIENT_ID to your OIDC client identifier -``` - -#### Error: "Invalid session TTL" -``` -Cause: AUTH_SESSION_TTL_HOURS is outside valid range (1-168) -Solution: Set AUTH_SESSION_TTL_HOURS to a value between 1 and 168 -``` - -### Configuration Testing - -#### Test OIDC Connectivity -```bash -# Test OIDC discovery endpoint -curl -s "https://your-oidc-url/.well-known/openid-configuration" | jq . - -# Verify required endpoints are available -curl -s "https://your-oidc-url/.well-known/openid-configuration" | \ - jq -r '.authorization_endpoint, .token_endpoint, .userinfo_endpoint' -``` - -#### Test Secrets Manager Access -```bash -# Verify client secret exists -aws secretsmanager describe-secret \ - --secret-id "mlspace/auth/oidc-client-secret" - -# Test secret access (requires appropriate IAM permissions) -aws secretsmanager get-secret-value \ - --secret-id "mlspace/auth/oidc-client-secret" \ - --query 'SecretString' \ - --output text - -# Verify token encryption keys secret -aws secretsmanager describe-secret \ - --secret-id "mlspace/auth/token-encryption-keys" - -# Verify state encryption key secret -aws secretsmanager describe-secret \ - --secret-id "mlspace/auth/state-encryption-key" -``` - ## Security Considerations ### Parameter Security @@ -377,16 +213,8 @@ aws secretsmanager describe-secret \ ### Access Control 1. **Secrets Manager Permissions**: Limit secret access to MLSpace Lambda execution role only -2. **KMS Keys**: Use appropriate KMS keys for Secrets Manager encryption -3. **Domain Validation**: Ensure sync domains are under your control -4. **Secret Rotation**: Use versioned secrets (token encryption keys) for rotation support - -### Monitoring - -1. **Configuration Changes**: Monitor changes to AUTH_* parameters in constants.ts and config.json -2. **Secrets Access**: Monitor access to `mlspace/auth/*` secrets in CloudTrail -3. **Failed Authentication**: Monitor authentication failures for configuration issues -4. **Secret Rotation**: Monitor secret rotation events and ensure smooth transitions +2. **Domain Validation**: Ensure sync domains are under your control +3. **Secret Rotation**: Use versioned secrets (token encryption keys) for rotation support ## Related Documentation diff --git a/frontend/docs/admin-guide/bff-authentication-migration.md b/frontend/docs/admin-guide/bff-authentication-migration.md index a90dc65e..9052ce0b 100644 --- a/frontend/docs/admin-guide/bff-authentication-migration.md +++ b/frontend/docs/admin-guide/bff-authentication-migration.md @@ -6,7 +6,7 @@ outline: deep ## Overview -This guide provides detailed step-by-step instructions for migrating from the legacy OIDC authentication system to the enhanced authentication system. The new system provides improved security, better enterprise IdP support, and simplified application code. +This guide provides detailed step-by-step instructions for migrating from the deprecated OIDC authentication system to the enhanced authentication system. The new system provides improved security, better enterprise IdP support, and simplified application code. ## Migration Benefits @@ -16,6 +16,16 @@ This guide provides detailed step-by-step instructions for migrating from the le - **Cross-Domain Support**: Seamless authentication across multiple domains - **Automatic Token Refresh**: Server-side token management and automatic refresh +## Pre-Migration Checklist + +Before starting the migration, ensure you have: + +- [ ] Backup of current `lib/constants.ts` and `lib/config.json` +- [ ] OIDC client secret (if using confidential client flow) +- [ ] Understanding of your current OIDC configuration +- [ ] Planned maintenance window for deployment +- [ ] Reviewed the [Enhanced Authentication Configuration Guide](./bff-authentication.md) + ## Pre-Migration Assessment ### Current Configuration Review @@ -40,7 +50,6 @@ Before starting the migration, document your current configuration: 3. **Current Deployment:** - Environment (dev/staging/prod) - Custom domains in use - - Multi-domain setup requirements ### Compatibility Check @@ -69,21 +78,16 @@ Create backups before migration: # Backup configuration files cp lib/constants.ts lib/constants.ts.backup cp lib/config.json lib/config.json.backup -cp lib/utils/configTypes.ts lib/utils/configTypes.ts.backup - -# Backup current deployment -git tag pre-bff-migration -git push origin pre-bff-migration ``` ## Step-by-Step Migration -### Step 1: Update Configuration Files +### Step 1: Update Configuration Files (example) -#### 1.1 Update lib/constants.ts +#### 1.1 Update lib/constants.ts (if this was used for previous configuration) ```typescript -// REMOVE these legacy constants: +// REMOVE these deprecated constants: export const OIDC_URL = 'https://auth.example.com'; export const OIDC_CLIENT_NAME = 'mlspace-client'; export const OIDC_REDIRECT_URL = undefined; @@ -91,115 +95,46 @@ export const OIDC_VERIFY_SSL = true; export const OIDC_VERIFY_SIGNATURE = true; // ADD these new AUTH constants: -export const AUTH_IDP_TYPE = 'oidc'; export const AUTH_OIDC_URL = 'https://auth.example.com'; export const AUTH_OIDC_CLIENT_ID = 'mlspace-client'; -export const AUTH_SESSION_TTL_HOURS = 24; -export const AUTH_SYNC_DOMAINS = ''; + +// ADD any additioanl AUTH_OIDC* configuration parameters. ``` -#### 1.2 Update lib/config.json +#### 1.2 Update lib/config.json (if this was used for previous configuration) ```json { - "dev": { - // REMOVE legacy OIDC config: - // "OIDC_URL": "https://auth.dev.example.com", - // "OIDC_CLIENT_NAME": "mlspace-dev-client", - - // ADD new AUTH config: - "AUTH_IDP_TYPE": "oidc", - "AUTH_OIDC_URL": "https://auth.dev.example.com", - "AUTH_OIDC_CLIENT_ID": "mlspace-dev-client", - "AUTH_SESSION_TTL_HOURS": 8 - }, - "prod": { - // REMOVE legacy OIDC config: - // "OIDC_URL": "https://auth.example.com", - // "OIDC_CLIENT_NAME": "mlspace-prod-client", - - // ADD new AUTH config: - "AUTH_IDP_TYPE": "oidc", - "AUTH_OIDC_URL": "https://auth.example.com", - "AUTH_OIDC_CLIENT_ID": "mlspace-prod-client", - "AUTH_SESSION_TTL_HOURS": 24, - "AUTH_SYNC_DOMAINS": "notebooks.mlspace.com" - } -} -``` - -#### 1.3 Update lib/utils/configTypes.ts - -```typescript -export interface MLSpaceConfig { - // ... existing properties ... - - // REMOVE legacy OIDC properties: - // OIDC_URL?: string; - // OIDC_CLIENT_NAME?: string; - // OIDC_REDIRECT_URL?: string; - // OIDC_VERIFY_SSL?: boolean; - // OIDC_VERIFY_SIGNATURE?: boolean; + // REMOVE deprecated OIDC config: + // "OIDC_URL": "https://auth.dev.example.com", + // "OIDC_CLIENT_NAME": "mlspace-dev-client", - // ADD new AUTH properties: - AUTH_IDP_TYPE: string; - AUTH_OIDC_URL?: string; - AUTH_OIDC_CLIENT_ID?: string; - AUTH_SESSION_TTL_HOURS: number; - AUTH_SYNC_DOMAINS?: string; + // ADD new AUTH config: + "AUTH_IDP_TYPE": "oidc", + "AUTH_OIDC_URL": "https://auth.dev.example.com", + "AUTH_OIDC_CLIENT_ID": "mlspace-dev-client", + "AUTH_SESSION_TTL_HOURS": 24 } ``` -### Step 2: Configure Client Secret (If Required) - -If your OIDC provider requires a client secret: - -#### 2.1 Store Client Secret in SSM - -```bash -# For development environment -aws ssm put-parameter \ - --name "mlspace/auth/oidc-client-secret" \ - --value "your-dev-client-secret" \ - --type "SecureString" \ - --description "OIDC client secret for MLSpace development" - -# For production environment -aws ssm put-parameter \ - --name "mlspace/auth/oidc-client-secret" \ - --value "your-prod-client-secret" \ - --type "SecureString" \ - --description "OIDC client secret for MLSpace production" -``` -#### 2.2 Verify SSM Parameter -```bash -# Verify parameter exists -aws ssm describe-parameters \ - --parameter-filters "Key=Name,Values=mlspace/auth/oidc-client-secret" - -# Test parameter access (will show encrypted value) -aws ssm get-parameter \ - --name "mlspace/auth/oidc-client-secret" \ - --with-decryption -``` -### Step 3: Update OIDC Provider Configuration +### Step 2: Update OIDC Provider Configuration Update your OIDC provider's redirect URI configuration: -#### 3.1 Current Redirect URI +#### 2.1 Current Redirect URI ``` https://your-api-gateway.execute-api.region.amazonaws.com/Prod/ ``` -#### 3.2 New Redirect URI +#### 2.2 New Redirect URI ``` https://your-api-gateway.execute-api.region.amazonaws.com/Prod/auth/callback ``` -#### 3.3 Provider-Specific Instructions +#### 2.3 Provider-Specific Instructions **AWS Cognito:** 1. Go to AWS Cognito Console @@ -219,128 +154,17 @@ https://your-api-gateway.execute-api.region.amazonaws.com/Prod/auth/callback 2. Select your realm → Clients → Your MLSpace client 3. Update "Valid Redirect URIs" to include `/auth/callback` -### Step 4: Build and Deploy - -#### 4.1 Build Frontend - -```bash -cd frontend/ -npm run clean -npm install -npm run build -``` - -#### 4.2 Deploy CDK Changes - -```bash -cd ../ -npm install -cdk deploy --all --require-approval never -``` - -#### 4.3 Monitor Deployment - -```bash -# Monitor CloudFormation stack -aws cloudformation describe-stacks \ - --stack-name MLSpaceStack \ - --query 'Stacks[0].StackStatus' - -# Check Lambda function updates -aws lambda list-functions \ - --query 'Functions[?contains(FunctionName, `mls-lambda-auth`)].FunctionName' -``` - -### Step 5: Verification and Testing - -#### 5.1 Basic Authentication Test - -1. **Clear Browser Data:** - - Clear cookies for your MLSpace domain - - Clear localStorage and sessionStorage - -2. **Test Authentication Flow:** - ```bash - # Visit your MLSpace application - curl -I https://your-mlspace-domain.com/ - - # Should redirect to /auth/login - # Follow redirect chain to OIDC provider - # Complete authentication - # Verify redirect back to /auth/callback - # Verify final redirect to application - ``` - -3. **Verify Session Cookies:** - - Open browser developer tools - - Check Application → Cookies - - Verify `mlspace_session` cookie exists - - Verify cookie has `HttpOnly` and `Secure` flags - -#### 5.2 API Authentication Test - -```bash -# Test API call with session cookie -curl -b "mlspace_session=your-session-id" \ - https://your-api-gateway.execute-api.region.amazonaws.com/Prod/user/current - -# Should return user information without Authorization header -``` - -#### 5.3 Cross-Domain Test (If Configured) - -If you configured `AUTH_SYNC_DOMAINS`: - -1. Authenticate on primary domain -2. Visit each sync domain -3. Verify automatic authentication without re-login -4. Check that each domain has its own session cookie - -#### 5.4 Token Refresh Test - -```bash -# Wait for token refresh threshold (default: 5 minutes before expiry) -# Make API call to trigger refresh -curl -b "mlspace_session=your-session-id" \ - https://your-api-gateway.execute-api.region.amazonaws.com/Prod/auth/identity +### Step 3: Build and Deploy -# Verify response includes "refreshed": true -``` - -### Step 6: Post-Migration Cleanup +#### 3.1 Build Frontend -#### 6.1 Remove Legacy Code References +Follow the [instructions](./install.md#production-web-app) for building the frontend. -Search for and remove any remaining legacy OIDC references: +#### 3.2 Deploy CDK Changes -```bash -# Search for legacy OIDC usage -grep -r "OIDC_URL\|OIDC_CLIENT_NAME" --exclude-dir=node_modules . -grep -r "oidc.user:" frontend/src/ -grep -r "sessionStorage.*oidc" frontend/src/ -``` +Follow the [instructions](./install.md#deploying-the-cdk-application) for deploying the CDK application. -#### 6.2 Update Documentation -Update any internal documentation that references: -- Legacy OIDC configuration -- Frontend token management -- Authentication troubleshooting procedures - -#### 6.3 Monitor CloudWatch Logs - -Monitor authentication-related log groups: - -```bash -# Check authentication logs -aws logs filter-log-events \ - --log-group-name "/aws/lambda/mls-lambda-auth-login" \ - --start-time $(date -d "1 hour ago" +%s)000 - -aws logs filter-log-events \ - --log-group-name "/aws/lambda/mls-lambda-auth-callback" \ - --start-time $(date -d "1 hour ago" +%s)000 -``` ## Rollback Procedure @@ -352,7 +176,6 @@ If issues occur during migration, follow this rollback procedure: # Restore backup files cp lib/constants.ts.backup lib/constants.ts cp lib/config.json.backup lib/config.json -cp lib/utils/configTypes.ts.backup lib/utils/configTypes.ts ``` ### Step 2: Revert OIDC Provider Configuration @@ -362,246 +185,16 @@ Restore the original redirect URI in your OIDC provider: https://your-api-gateway.execute-api.region.amazonaws.com/Prod/ ``` -### Step 3: Rebuild and Redeploy - -```bash -# Rebuild frontend with legacy configuration -cd frontend/ -npm run clean && npm run build - -# Redeploy CDK -cd ../ -cdk deploy --all --require-approval never -``` - -### Step 4: Verify Rollback - -Test the legacy authentication flow to ensure it's working correctly. - -## Troubleshooting Common Issues - -### Issue: Authentication Redirect Loop - -**Symptoms:** User gets stuck redirecting between IdP and MLSpace - -**Diagnosis:** -```bash -# Check redirect URI configuration -curl -I https://your-mlspace-domain.com/auth/login -# Should redirect to OIDC provider with correct callback URI -``` - -**Solution:** -1. Verify OIDC provider redirect URI includes `/auth/callback` -2. Check `AUTH_OIDC_CLIENT_ID` matches IdP configuration -3. Verify IdP is accessible from Lambda functions - -### Issue: Session Cookies Not Set - -**Symptoms:** Authentication succeeds but API calls return 401 - -**Diagnosis:** -```bash -# Check session creation in logs -aws logs filter-log-events \ - --log-group-name "/aws/lambda/mls-lambda-auth-callback" \ - --filter-pattern "Session created" -``` - -**Solution:** -1. Check cookie domain settings in browser -2. Ensure HTTPS is used (cookies won't set over HTTP) - -### Issue: Client Secret Authentication Fails +### Step 3: Build and Deploy -**Symptoms:** "invalid_client" error during token exchange +#### 3.1 Build Frontend -**Diagnosis:** -```bash -# Check SSM parameter access -aws ssm get-parameter \ - --name "mlspace/auth/oidc-client-secret" \ - --with-decryption - -# Check Lambda execution role permissions -aws iam simulate-principal-policy \ - --policy-source-arn "arn:aws:iam::ACCOUNT:role/MLSpaceAppRole" \ - --action-names "ssm:GetParameter" \ - --resource-arns "arn:aws:ssm:REGION:ACCOUNT:parameter/mlspace/auth/oidc-client-secret" -``` +Follow the [instructions](./install.md#production-web-app) for building the frontend. -**Solution:** -1. Verify SSM parameter exists and has correct value -2. Check Lambda execution role has SSM read permissions -3. Verify KMS key permissions if using custom encryption +#### 3.2 Deploy CDK Changes -### Issue: Cross-Domain Sync Failures +Follow the [instructions](./install.md#deploying-the-cdk-application) for deploying the CDK application. -**Symptoms:** Authentication works on primary domain but fails on sync domains - -**Diagnosis:** -```bash -# Check OTAC generation and validation -aws logs filter-log-events \ - --log-group-name "/aws/lambda/mls-lambda-auth-sync" \ - --filter-pattern "OTAC" -``` - -**Solution:** -1. Verify `AUTH_SYNC_DOMAINS` configuration -2. Check DNS resolution for all sync domains -3. Ensure all domains point to the same API Gateway -4. Verify OTAC TTL and usage patterns - -## Performance Optimization - -### Session Table Scaling - -Monitor DynamoDB session table performance: - -```bash -# Check table metrics -aws cloudwatch get-metric-statistics \ - --namespace "AWS/DynamoDB" \ - --metric-name "ConsumedReadCapacityUnits" \ - --dimensions Name=TableName,Value=mlspace-auth-sessions \ - --start-time $(date -d "1 hour ago" --iso-8601) \ - --end-time $(date --iso-8601) \ - --period 300 \ - --statistics Sum -``` - -### Lambda Cold Start Optimization - -Monitor authentication endpoint performance: - -```bash -# Check Lambda duration metrics -aws cloudwatch get-metric-statistics \ - --namespace "AWS/Lambda" \ - --metric-name "Duration" \ - --dimensions Name=FunctionName,Value=mls-lambda-auth-login \ - --start-time $(date -d "1 hour ago" --iso-8601) \ - --end-time $(date --iso-8601) \ - --period 300 \ - --statistics Average,Maximum -``` - -## Security Validation - -### Session Security Audit - -Verify session security configuration: - -```bash -# Check session cookie attributes -curl -I https://your-mlspace-domain.com/auth/callback -# Look for: HttpOnly; Secure; SameSite=Strict - -# Verify session encryption -aws dynamodb scan \ - --table-name mlspace-auth-sessions \ - --limit 1 \ - --query 'Items[0].data.session.accessToken.S' -# Should show encrypted token, not plain text -``` - -### Token Storage Audit - -Verify tokens are not exposed in browser: - -1. Open browser developer tools -2. Check Application → Local Storage (should be empty of tokens) -3. Check Application → Session Storage (should be empty of tokens) -4. Check Network → Response bodies (should not contain tokens) - -## Monitoring and Alerting - -Set up CloudWatch alarms for authentication health: - -```bash -# Authentication failure rate alarm -aws cloudwatch put-metric-alarm \ - --alarm-name "MLSpace-Auth-Failure-Rate" \ - --alarm-description "High authentication failure rate" \ - --metric-name "Errors" \ - --namespace "AWS/Lambda" \ - --statistic "Sum" \ - --period 300 \ - --threshold 10 \ - --comparison-operator "GreaterThanThreshold" \ - --dimensions Name=FunctionName,Value=mls-lambda-auth-callback - -# Session creation rate alarm -aws cloudwatch put-metric-alarm \ - --alarm-name "MLSpace-Session-Creation-Rate" \ - --alarm-description "Unusual session creation pattern" \ - --metric-name "Invocations" \ - --namespace "AWS/Lambda" \ - --statistic "Sum" \ - --period 300 \ - --threshold 100 \ - --comparison-operator "GreaterThanThreshold" \ - --dimensions Name=FunctionName,Value=mls-lambda-auth-callback -``` +### Step 4: Verify Rollback -## Migration Checklist - -Use this checklist to track migration progress: - -### Pre-Migration -- [ ] Document current OIDC configuration -- [ ] Verify OIDC provider compatibility -- [ ] Create configuration backups -- [ ] Plan maintenance window -- [ ] Notify users of upcoming changes - -### Configuration Updates -- [ ] Update `lib/constants.ts` -- [ ] Update `lib/config.json` -- [ ] Update `lib/utils/configTypes.ts` -- [ ] Store client secret in SSM (if required) -- [ ] Update OIDC provider redirect URI - -### Deployment -- [ ] Build frontend with new configuration -- [ ] Deploy CDK changes -- [ ] Monitor deployment progress -- [ ] Verify Lambda function updates - -### Testing -- [ ] Test basic authentication flow -- [ ] Verify session cookie creation -- [ ] Test API authentication -- [ ] Test cross-domain sync (if configured) -- [ ] Test token refresh functionality -- [ ] Test logout functionality - -### Post-Migration -- [ ] Remove legacy code references -- [ ] Update internal documentation -- [ ] Monitor CloudWatch logs -- [ ] Set up performance monitoring -- [ ] Configure security alerts -- [ ] Validate security configuration - -### Rollback (If Needed) -- [ ] Revert configuration files -- [ ] Revert OIDC provider settings -- [ ] Rebuild and redeploy -- [ ] Verify legacy functionality - -## Support and Next Steps - -After successful migration: - -1. **Monitor Performance**: Watch authentication metrics for the first week -2. **User Training**: Update user documentation if authentication flow changes -3. **Security Review**: Conduct security audit of new authentication system -4. **Optimization**: Tune session TTL and refresh thresholds based on usage patterns -5. **Future Enhancements**: Consider SAML integration or additional IdP support - -For additional support, refer to: -- [Enhanced Authentication Configuration Guide](./bff-authentication.md) -- [MLSpace Security Documentation](./security/intro.md) -- CloudWatch logs for detailed troubleshooting \ No newline at end of file +Test the deprecated authentication flow to ensure it's working correctly. \ No newline at end of file diff --git a/frontend/docs/admin-guide/bff-authentication.md b/frontend/docs/admin-guide/bff-authentication.md index 1f5f2bcf..3186f614 100644 --- a/frontend/docs/admin-guide/bff-authentication.md +++ b/frontend/docs/admin-guide/bff-authentication.md @@ -8,15 +8,15 @@ outline: deep The enhanced authentication system provides improved security and enterprise Identity Provider (IdP) integration. Authentication is handled server-side, enabling support for enterprise IdPs that require client secrets, while providing better security through secure cookies (HttpOnly cookies that can't be accessed by JavaScript and are only sent to the server with requests) and simplified application code. -::: danger LEGACY OIDC_* PARAMETERS NOT SUPPORTED -The legacy `OIDC_*` configuration parameters (such as `OIDC_URL`, `OIDC_CLIENT_NAME`, `OIDC_VERIFY_SSL`, `OIDC_REDIRECT_URL`, etc.) are **deprecated and no longer supported**. You must use the `AUTH_*` parameters documented below. See the [Migration from Legacy OIDC Configuration](#migration-from-legacy-oidc-configuration) section for migration instructions. +::: danger DEPRECATED OIDC_* PARAMETERS NOT SUPPORTED +The deprecated `OIDC_*` configuration parameters (such as `OIDC_URL`, `OIDC_CLIENT_NAME`, `OIDC_VERIFY_SSL`, `OIDC_REDIRECT_URL`, etc.) are **no longer supported**. You must use the `AUTH_*` parameters documented below. See the [Migration from Deprecated OIDC Configuration](#migration-from-deprecated-oidc-configuration) section for migration instructions. ::: ## Configuration Parameters ### Required AUTH_* Parameters -The enhanced authentication system uses `AUTH_*` configuration parameters that replace the legacy `OIDC_*` parameters: +The enhanced authentication system uses `AUTH_*` configuration parameters that replace the deprecated `OIDC_*` parameters: | Parameter | Description | Example | Required | |-----------|-------------|---------|----------| @@ -41,38 +41,12 @@ The enhanced authentication system uses `AUTH_*` configuration parameters that r ## Configuration Setup -### 1. Update lib/constants.ts - -Replace the legacy OIDC constants with new AUTH constants: - -```typescript -// Remove these legacy constants: -// export const OIDC_URL = ''; -// export const OIDC_CLIENT_NAME = ''; - -// Add these new AUTH constants: -export const AUTH_IDP_TYPE = 'oidc'; -export const AUTH_OIDC_URL = ''; -export const AUTH_OIDC_CLIENT_ID = ''; -export const AUTH_OIDC_CLIENT_SECRET_NAME = 'mlspace/auth/oidc-client-secret'; -export const AUTH_OIDC_CLIENT_SECRET_VALUE = ''; // Optional: set during deployment -export const AUTH_OIDC_USE_PKCE = true; -export const AUTH_OIDC_VERIFY_SSL = true; -export const AUTH_OIDC_VERIFY_SIGNATURE = true; -export const AUTH_SESSION_TTL_HOURS = 24; -export const AUTH_SYNC_DOMAINS = ''; -export const AUTH_SESSION_TABLE_NAME = 'mlspace-auth-sessions'; -export const AUTH_TOKEN_ENCRYPTION_KEY_SECRET_NAME = 'mlspace/auth/token-encryption-keys'; -export const AUTH_STATE_ENCRYPTION_KEY_SECRET_NAME = 'mlspace/auth/state-encryption-key'; -``` - -### 2. Update lib/config.json +### Update lib/config.json Update your environment-specific configuration file: ```json { - "dev": { "AUTH_IDP_TYPE": "oidc", "AUTH_OIDC_URL": "https://auth.dev.example.com", "AUTH_OIDC_CLIENT_ID": "mlspace-dev-client", @@ -81,460 +55,9 @@ Update your environment-specific configuration file: "AUTH_OIDC_VERIFY_SSL": true, "AUTH_OIDC_VERIFY_SIGNATURE": true, "AUTH_SESSION_TTL_HOURS": 8, - "AUTH_SYNC_DOMAINS": "" - }, - "prod": { - "AUTH_IDP_TYPE": "oidc", - "AUTH_OIDC_URL": "https://auth.example.com", - "AUTH_OIDC_CLIENT_ID": "mlspace-prod-client", - "AUTH_OIDC_CLIENT_SECRET_VALUE": "prod-client-secret-here", - "AUTH_OIDC_USE_PKCE": true, - "AUTH_OIDC_VERIFY_SSL": true, - "AUTH_OIDC_VERIFY_SIGNATURE": true, - "AUTH_SESSION_TTL_HOURS": 24, - "AUTH_SYNC_DOMAINS": "notebooks.mlspace.com,admin.mlspace.com" - } -} -``` - -### 3. Update lib/utils/configTypes.ts - -Add the new AUTH properties to the MLSpaceConfig interface: - -```typescript -export interface MLSpaceConfig { - // ... existing properties ... - - // Remove legacy OIDC properties: - // OIDC_URL?: string; - // OIDC_CLIENT_NAME?: string; - - // Add new AUTH properties: - AUTH_IDP_TYPE: string; - AUTH_OIDC_URL?: string; - AUTH_OIDC_CLIENT_ID?: string; - AUTH_OIDC_CLIENT_SECRET_NAME?: string; - AUTH_OIDC_CLIENT_SECRET_VALUE?: string; - AUTH_OIDC_USE_PKCE?: boolean; - AUTH_OIDC_VERIFY_SSL?: boolean; - AUTH_OIDC_VERIFY_SIGNATURE?: boolean; - AUTH_SESSION_TTL_HOURS?: number; - AUTH_SYNC_DOMAINS?: string; - AUTH_SESSION_TABLE_NAME?: string; - AUTH_TOKEN_ENCRYPTION_KEY_SECRET_NAME?: string; - AUTH_STATE_ENCRYPTION_KEY_SECRET_NAME?: string; -} -``` - -## Secrets Manager Setup for Client Secret - -For OIDC deployments that require client secrets (confidential client flow), you can configure the client secret in two ways: - -### Option 1: Deployment-Time Configuration (Recommended) - -Add the client secret to your `lib/config.json` file: - -```json -{ - "AUTH_OIDC_CLIENT_SECRET_VALUE": "your-client-secret-here" -} -``` - -The secret will be automatically created in AWS Secrets Manager during deployment. - -### Option 2: Manual Secrets Manager Configuration - -If you prefer to manage the secret manually: - -Using AWS CLI: - -```bash -# Create new secret -aws secretsmanager create-secret \ - --name "mlspace/auth/oidc-client-secret" \ - --secret-string '{"client_secret":"your-client-secret-here","configured":true}' \ - --description "OIDC client secret for MLSpace authentication" - -# Or update existing secret -aws secretsmanager update-secret \ - --secret-id "mlspace/auth/oidc-client-secret" \ - --secret-string '{"client_secret":"your-new-secret-here","configured":true}' -``` - -Using AWS Console: -1. Navigate to AWS Secrets Manager -2. Click "Store a new secret" -3. Select "Other type of secret" -4. Add key-value pairs: - - Key: `client_secret`, Value: Your OIDC client secret - - Key: `configured`, Value: `true` -5. Set Secret name: `mlspace/auth/oidc-client-secret` -6. Click "Store" - -::: info SECRETS MANAGER VS SSM PARAMETER STORE -MLSpace uses AWS Secrets Manager (not SSM Parameter Store) for authentication secrets. Secrets Manager provides better support for secret rotation, versioning, and automatic generation. -::: - -### Lambda Access Permissions - -The MLSpace Lambda execution role needs permission to read secrets: - -```json -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "secretsmanager:GetSecretValue", - "secretsmanager:DescribeSecret" - ], - "Resource": [ - "arn:aws:secretsmanager:{AWS_REGION}:{AWS_ACCOUNT}:secret:mlspace/auth/*" - ] - } - ] } ``` -### Encryption Key Access - -If using a custom KMS key for Secrets Manager encryption, ensure the Lambda execution role has decrypt permissions: - -```json -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "kms:Decrypt", - "kms:DescribeKey" - ], - "Resource": [ - "arn:aws:kms:{AWS_REGION}:{AWS_ACCOUNT}:key/{KMS_KEY_ID}" - ] - } - ] -} -``` - -## Multi-Domain Cookie Synchronization - -For deployments spanning multiple domains (e.g., separate domains for API, notebooks, and admin interfaces), configure cross-domain cookie synchronization. - -### Configuration - -Set the `AUTH_SYNC_DOMAINS` parameter with a comma-separated list of additional domains: - -```json -{ - "AUTH_SYNC_DOMAINS": "notebooks.mlspace.com,admin.mlspace.com" -} -``` - -::: tip Domain Detection -The system automatically uses the Host header to determine the primary domain where authentication is initiated. -::: - -### How It Works - -1. **Primary Authentication**: User authenticates on the current domain (detected from Host header) -2. **OTAC Generation**: System generates One-Time Authentication Code (OTAC) -3. **Domain Chain**: Browser is redirected through each sync domain in sequence -4. **Cookie Setting**: Each domain validates the OTAC and sets its own session cookie -5. **Final Redirect**: User is redirected to their original destination - -### Security Considerations - -- OTACs expire after 5 minutes -- OTACs are single-use only -- Each domain validates OTAC independently -- Session cookies are domain-specific with `HttpOnly` and `Secure` flags - -### Example Flow - -``` -1. User visits: https://app.mlspace.com/dashboard -2. Redirected to: https://api.mlspace.com/auth/login -3. OIDC authentication completes -4. Redirected to: https://notebooks.mlspace.com/auth/sync?otac=xyz&next=admin.mlspace.com&final=https://app.mlspace.com/dashboard -5. Redirected to: https://admin.mlspace.com/auth/sync?otac=abc&final=https://app.mlspace.com/dashboard -6. Final redirect: https://app.mlspace.com/dashboard -``` - -## Migration from Legacy OIDC Configuration - -### Pre-Migration Checklist - -Before migrating to the enhanced authentication system, ensure you have: - -- [ ] Backup of current `lib/constants.ts` and `lib/config.json` -- [ ] OIDC client secret (if using confidential client flow) -- [ ] Access to AWS Systems Manager Parameter Store -- [ ] Understanding of your current OIDC configuration -- [ ] Planned maintenance window for deployment - -### Migration Steps - -#### 1. Update Configuration Files - -**lib/constants.ts changes:** -```typescript -// BEFORE (Legacy OIDC) -export const OIDC_URL = 'https://auth.example.com'; -export const OIDC_CLIENT_NAME = 'mlspace-client'; -export const OIDC_REDIRECT_URL = undefined; -export const OIDC_VERIFY_SSL = true; -export const OIDC_VERIFY_SIGNATURE = true; - -// AFTER (Enhanced Authentication) -export const AUTH_IDP_TYPE = 'oidc'; -export const AUTH_OIDC_URL = 'https://auth.example.com'; -export const AUTH_OIDC_CLIENT_ID = 'mlspace-client'; -export const AUTH_OIDC_CLIENT_SECRET_NAME = 'mlspace/auth/oidc-client-secret'; -export const AUTH_OIDC_CLIENT_SECRET_VALUE = ''; // Optional: set in config.json -export const AUTH_OIDC_USE_PKCE = true; -export const AUTH_OIDC_VERIFY_SSL = true; -export const AUTH_OIDC_VERIFY_SIGNATURE = true; -export const AUTH_SESSION_TTL_HOURS = 24; -export const AUTH_SYNC_DOMAINS = ''; -``` - -**lib/config.json changes:** -```json -// BEFORE -{ - "OIDC_URL": "https://auth.example.com", - "OIDC_CLIENT_NAME": "mlspace-client" -} - -// AFTER -{ - "AUTH_IDP_TYPE": "oidc", - "AUTH_OIDC_URL": "https://auth.example.com", - "AUTH_OIDC_CLIENT_ID": "mlspace-client", - "AUTH_OIDC_CLIENT_SECRET_VALUE": "your-client-secret-here", - "AUTH_OIDC_USE_PKCE": true, - "AUTH_OIDC_VERIFY_SSL": true, - "AUTH_OIDC_VERIFY_SIGNATURE": true, - "AUTH_SESSION_TTL_HOURS": 24 -} -``` - -#### 2. Set Up Client Secret (If Required) - -If your OIDC provider requires a client secret, add it to your `lib/config.json`: - -```json -{ - "AUTH_OIDC_CLIENT_SECRET_VALUE": "your-client-secret" -} -``` - -Or create it manually in Secrets Manager: - -```bash -# Store client secret in Secrets Manager -aws secretsmanager create-secret \ - --name "mlspace/auth/oidc-client-secret" \ - --secret-string '{"client_secret":"your-client-secret","configured":true}' -``` - -#### 3. Update OIDC Provider Configuration - -Update your OIDC provider's redirect URI configuration: - -**Before (Legacy):** -- Redirect URI: `https://your-api-gateway.execute-api.region.amazonaws.com/Prod/` - -**After (Enhanced Authentication):** -- Redirect URI: `https://your-api-gateway.execute-api.region.amazonaws.com/Prod/auth/callback` - -#### 4. Deploy Updated Configuration - -```bash -# Build frontend with new configuration -cd frontend/ -npm run clean && npm install && npm run build - -# Deploy CDK changes -cd ../ -cdk deploy --all -``` - -#### 5. Verify Migration - -After deployment, verify the migration: - -1. **Test Authentication Flow:** - - Visit your MLSpace application - - Verify redirect to `/auth/login` - - Complete OIDC authentication - - Verify successful redirect back to application - -2. **Check Session Management:** - - Verify session cookies are set with `HttpOnly` flag - - Test automatic token refresh - - Test logout functionality - -3. **Validate API Calls:** - - Verify API calls work without Authorization header - - Check that session cookies are included in requests - -### Rollback Plan - -If issues occur during migration, you can rollback: - -1. **Revert Configuration Files:** - ```bash - git checkout HEAD~1 -- lib/constants.ts lib/config.json lib/utils/configTypes.ts - ``` - -2. **Rebuild and Redeploy:** - ```bash - cd frontend/ - npm run build - cd ../ - cdk deploy --all - ``` - -3. **Update OIDC Provider:** - - Revert redirect URI to original value - -### Common Migration Issues - -#### Issue: Authentication Loops - -**Symptoms:** User gets stuck in redirect loop between IdP and MLSpace - -**Solution:** -- Verify OIDC redirect URI is correctly set to `/auth/callback` -- Check that `AUTH_OIDC_CLIENT_ID` matches IdP configuration - -#### Issue: Session Cookies Not Set - -**Symptoms:** User can authenticate but gets 401 errors on API calls - -**Solution:** -- Verify cookies are being set with correct domain -- Check browser developer tools for cookie presence -- Ensure HTTPS is being used (cookies won't set over HTTP in production) - -#### Issue: Client Secret Errors - -**Symptoms:** Authentication fails with "invalid_client" error - -**Solution:** -- Verify SSM parameter `mlspace/auth/oidc-client-secret` exists -- Check Lambda execution role has SSM read permissions -- Ensure client secret value is correct - -#### Issue: Cross-Domain Sync Failures - -**Symptoms:** Authentication works on primary domain but fails on sync domains - -**Solution:** -- Verify `AUTH_SYNC_DOMAINS` configuration -- Check that all domains resolve correctly -- Ensure OTAC generation and validation is working - -## Troubleshooting - -### Enable Debug Logging - -Add debug environment variables to Lambda functions: - -```typescript -const authCommonEnv = { - // ... existing environment variables ... - LOG_LEVEL: 'DEBUG', - DEBUG_AUTH: 'true' -}; -``` - -### Check CloudWatch Logs - -Monitor these log groups for authentication issues: - -- `/aws/lambda/mls-lambda-auth-login` -- `/aws/lambda/mls-lambda-auth-callback` -- `/aws/lambda/mls-lambda-auth-identity` -- `/aws/lambda/mls-lambda-auth-logout` -- `/aws/lambda/mls-lambda-authorizer` - -### Common Log Messages - -**Successful Authentication:** -``` -[INFO] User authenticated successfully: user@example.com -[INFO] Session created: session:550e8400-e29b-41d4-a716-446655440000 -[INFO] Session cookie set for domain: api.mlspace.com -``` - -**Authentication Failures:** -``` -[ERROR] OIDC token exchange failed: invalid_grant -[ERROR] Session validation failed: session not found -[ERROR] OTAC validation failed: code expired -``` - -### Performance Monitoring - -Monitor these CloudWatch metrics: - -- **Authentication Success Rate:** Custom metric tracking successful logins -- **Session Duration:** Average time between login and logout -- **Token Refresh Rate:** Frequency of automatic token refreshes -- **OTAC Usage:** Cross-domain sync success rate - -## Security Considerations - -### Session Security - -- **HttpOnly Cookies:** Prevents JavaScript access to session tokens -- **Secure Flag:** Ensures cookies only sent over HTTPS -- **SameSite=Strict:** Prevents CSRF attacks -- **Session Encryption:** All IdP tokens encrypted before storage - -### Token Management - -- **Automatic Refresh:** Tokens refreshed transparently before expiration -- **Secure Storage:** Tokens stored encrypted in DynamoDB -- **TTL Cleanup:** Expired sessions automatically deleted - -### Cross-Domain Security - -- **OTAC Expiration:** One-time codes expire after 5 minutes -- **Single Use:** OTACs can only be used once -- **Domain Validation:** Each domain validates OTACs independently - -### Monitoring and Alerting - -Set up CloudWatch alarms for: - -- High authentication failure rates -- Unusual session creation patterns -- Failed OTAC validations -- SSM parameter access failures - -## Support and Maintenance - -### Regular Maintenance Tasks - -1. **Monitor SSM Parameters:** Ensure client secrets remain valid -2. **Review Session Metrics:** Check for unusual authentication patterns -3. **Update Dependencies:** Keep authentication libraries current -4. **Rotate Secrets:** Periodically rotate client secrets and encryption keys - -### Backup and Recovery - -1. **Configuration Backup:** Regularly backup configuration files -2. **SSM Parameter Backup:** Export SSM parameters for disaster recovery -3. **Session Data:** DynamoDB sessions are automatically backed up with TTL - -### Scaling Considerations +## Migration from Deprecated OIDC Configuration -- **DynamoDB Capacity:** Monitor read/write capacity for session table -- **Lambda Concurrency:** Ensure sufficient concurrency for auth endpoints -- **API Gateway Limits:** Monitor request rates for authentication endpoints \ No newline at end of file +If you're migrating from the deprecated `OIDC_*` parameters, see the [Enhanced Authentication Migration Guide](./bff-authentication-migration.md) for detailed step-by-step instructions. \ No newline at end of file diff --git a/frontend/docs/admin-guide/configure-cognito.md b/frontend/docs/admin-guide/configure-cognito.md index fa1e1d2c..aea7c44e 100644 --- a/frontend/docs/admin-guide/configure-cognito.md +++ b/frontend/docs/admin-guide/configure-cognito.md @@ -69,8 +69,8 @@ The value for User Pool ID should be combined with the correct region endpoint f export const AUTH_OIDC_URL = 'https://cognito-idp.us-east-2.amazonaws.com/us-east-2_oUmWoN1YP'; ``` -::: warning LEGACY OIDC_* PARAMETERS NOT SUPPORTED -The legacy `OIDC_URL` and `OIDC_CLIENT_NAME` parameters are deprecated and no longer supported. You must use the new `AUTH_OIDC_URL` and `AUTH_OIDC_CLIENT_ID` parameters instead. See the [Enhanced Authentication Configuration Guide](./bff-authentication.md) for complete details on all AUTH_* parameters. +::: warning DEPRECATED OIDC_* PARAMETERS NOT SUPPORTED +The deprecated `OIDC_URL` and `OIDC_CLIENT_NAME` parameters are no longer supported. You must use the new `AUTH_OIDC_URL` and `AUTH_OIDC_CLIENT_ID` parameters instead. See the [Enhanced Authentication Configuration Guide](./bff-authentication.md) for complete details on all AUTH_* parameters. ::: Once both values have been updated, you can build and deploy {{ $params.APPLICATION_NAME }}, and it will use Cognito as the IdP. Once {{ $params.APPLICATION_NAME }} is deployed, you will have to update your Cognito app client to add the {{ $params.APPLICATION_NAME }} API Gateway endpoint to the list of "Allowed callback URLs". You can do this by navigating to the App Client details page, scrolling down to the hosted UI, and clicking the edit button. From there, you will need to add your custom domain or the {{ $params.APPLICATION_NAME }} API Gateway endpoint with the `/auth/callback` path to the URL list. If you aren't using a custom domain, that value should be something similar to `https://.execute-api..amazonaws.com/Prod/auth/callback`. @@ -78,5 +78,4 @@ Once both values have been updated, you can build and deploy {{ $params.APPLICAT ## Troubleshooting - If you log in to Cognito but it doesn’t redirect you to {{ $params.APPLICATION_NAME }} but rather to a Cognito hosted error page, you can check if the URL includes a reason for the failure (typically `redirect_mismatch`). -- If you log in to Cognito and get redirected to {{ $params.APPLICATION_NAME }} but do not see your name in the top right on the `Greetings !` button, then you’re missing a required `name` parameter in your OIDC profile. -- You can use your browser's dev tools to check if the `POST /user` request is failing. Failing calls to `GET /currentUser` are expected until the user exists. \ No newline at end of file +- If you log in to Cognito and get redirected to {{ $params.APPLICATION_NAME }} but do not see your name in the top right on the `Greetings !` button, then you’re missing a required `name` parameter in your OIDC profile. \ No newline at end of file diff --git a/frontend/docs/admin-guide/custom-domain.md b/frontend/docs/admin-guide/custom-domain.md index 8c43f0eb..40d29fd1 100644 --- a/frontend/docs/admin-guide/custom-domain.md +++ b/frontend/docs/admin-guide/custom-domain.md @@ -16,7 +16,7 @@ Before configuring a custom domain, ensure you have: - A registered domain name - An SSL/TLS certificate in AWS Certificate Manager (ACM) for your domain - - For CloudFront distributions or edge-optimized API Gateway endpoints, the certificate must be in the `us-east-1` region + - For CloudFront distributions or edge-optimized API Gateway endpoints - For regional API Gateway endpoints, the certificate must be in the same region as your API - Appropriate DNS access to create CNAME or A records - Admin access to your AWS account @@ -25,14 +25,14 @@ Before configuring a custom domain, ensure you have: ### Step 1: Update CDK Configuration -1. Open `lib/constants.ts` in your {{ $params.APPLICATION_NAME }} deployment directory. +1. Open `lib/config.json` in your {{ $params.APPLICATION_NAME }} deployment directory. 2. Set the `WEB_CUSTOM_DOMAIN_NAME` constant to your custom domain URL: ```typescript // An optional custom domain name to use in place of the default API Gateway URL // eg: 'https://mlspace.mycompany.com' -export const WEB_CUSTOM_DOMAIN_NAME = 'https://mlspace.mycompany.com'; + "WEB_CUSTOM_DOMAIN_NAME": 'https://mlspace.mycompany.com' ``` ::: warning @@ -279,7 +279,6 @@ https://mlspace.mycompany.com - Use TLS 1.2 or higher for API Gateway security policy - Regularly rotate SSL/TLS certificates before expiration - Update your identity provider configuration to only allow redirects to your custom domain -- Consider using AWS WAF with your API Gateway for additional protection - Enable API Gateway access logging to monitor traffic to your custom domain ## Additional Resources diff --git a/frontend/docs/admin-guide/getting-started.md b/frontend/docs/admin-guide/getting-started.md index affebdda..3b335601 100644 --- a/frontend/docs/admin-guide/getting-started.md +++ b/frontend/docs/admin-guide/getting-started.md @@ -86,7 +86,7 @@ The current deployment method is to deploy the entire codebase to each lambda ra The {{ $params.APPLICATION_NAME }} web application is a React Redux-based TypeScript application. Code is generally grouped by functionality in the `entities/` or `modules/` directories, depending on whether or not the code is related to {{ $params.APPLICATION_NAME }} resources or is a generic component. The package.json file defines a number of scripts that can be used to execute tests, create a production-optimized build, or run the app in development mode. When running in development mode, you’ll need to set the values in `public/env.js` based on the target environment (in production deployments, this file is generated dynamically via CDK). -The frontend leverages a number of open-source libraries to both speed up development and reinforce best practices. The UI is built around [CloudScape](https://cloudscape.design/), an open-source component library developed by AWS that aims to assist in building accessible user interfaces that share a similar UX with the AWS console. In order to seamlessly integrate with spec-compliant OIDC providers, `react-oidc-context` is used. This library handles the PKCE authentication flow as well as periodic token refreshing. _Eslint_ and _Husky_ are used to enforce common coding standards, consistent styling, and best practices throughout the codebase. +The frontend leverages a number of open-source libraries to both speed up development and reinforce best practices. The UI is built around [CloudScape](https://cloudscape.design/), an open-source component library developed by AWS that aims to assist in building accessible user interfaces that share a similar UX with the AWS console. _Eslint_ and _Husky_ are used to enforce common coding standards, consistent styling, and best practices throughout the codebase. ## High Level Architecture Diagram diff --git a/frontend/docs/admin-guide/install.md b/frontend/docs/admin-guide/install.md index 9adcbd56..6f229fc9 100644 --- a/frontend/docs/admin-guide/install.md +++ b/frontend/docs/admin-guide/install.md @@ -1610,15 +1610,17 @@ Use the MLSpace Config Wizard by running `npm run config` and select "Advanced C #### Required Parameters ::: warning Authentication Configuration -{{ $params.APPLICATION_NAME }} now supports enhanced authentication for improved security and enterprise IdP integration. For new deployments, see the [Enhanced Authentication Configuration Guide](./bff-authentication.md) for the recommended AUTH_* parameters. +{{ $params.APPLICATION_NAME }} uses enhanced authentication for improved security and enterprise IdP integration. See the [Enhanced Authentication Configuration Guide](./bff-authentication.md) for detailed setup instructions. + +**Note:** The deprecated `OIDC_URL` and `OIDC_CLIENT_NAME` parameters are no longer supported and will cause deployment errors if used. Use the `AUTH_*` parameters below instead. ::: | Variable | Description | Default | |----------|:-------------|------:| | `AWS_ACCOUNT` | The account number that {{ $params.APPLICATION_NAME }} is being deployed into. Used to disambiguate S3 buckets within a region | - | | `AWS_REGION` | The region that {{ $params.APPLICATION_NAME }} is being deployed into. This is only needed when you are using an existing VPC or KMS key and `EXISTING_KMS_MASTER_KEY_ARN` or `EXISTING_VPC_ID` is set | - | -| `OIDC_URL` | **Legacy:** The OIDC endpoint for authentication. For new deployments, use `AUTH_OIDC_URL` instead. See [Enhanced Authentication Guide](./bff-authentication.md) | - | -| `OIDC_CLIENT_NAME` | **Legacy:** The OIDC client name for authentication. For new deployments, use `AUTH_OIDC_CLIENT_ID` instead. See [Enhanced Authentication Guide](./bff-authentication.md) | - | +| `AUTH_OIDC_URL` | The OIDC endpoint URL for your identity provider (e.g., `https://your-idp.example.com`) | - | +| `AUTH_OIDC_CLIENT_ID` | The OIDC client ID registered with your identity provider | - | | `KEY_MANAGER_ROLE_NAME` | Name of the IAM role with permissions to manage the KMS Key. If this property is set, you _do not_ need to set `EXISTING_KMS_MASTER_KEY_ARN`. | - |
@@ -1630,10 +1632,17 @@ Use the MLSpace Config Wizard by running `npm run config` and select "Advanced C | Variable | Description | Default | |--------------------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------:| -| `IDP_ENDPOINT_SSM_PARAM` | **Legacy:** If set, {{ $params.APPLICATION_NAME }} will use the value of this parameter as the `OIDC_URL`. For enhanced authentication, client secrets are stored in `mlspace/auth/oidc-client-secret`. See [Enhanced Authentication Guide](./bff-authentication.md) | - | -| `OIDC_REDIRECT_URL` | **Legacy:** The redirect URL after OIDC authentication. Enhanced authentication uses `/auth/callback` automatically. See [Enhanced Authentication Guide](./bff-authentication.md) | `undefined` | -| `OIDC_VERIFY_SSL` | **Legacy:** Whether to validate OIDC server certificates. Enhanced authentication handles SSL validation internally | `True` | -| `OIDC_VERIFY_SIGNATURE` | **Legacy:** Whether to verify JWT token signatures. Enhanced authentication handles token validation server-side | `True` | +| `IDP_ENDPOINT_SSM_PARAM` | **Deprecated:** If set, {{ $params.APPLICATION_NAME }} will use the value of this parameter as the `OIDC_URL`. For enhanced authentication, client secrets are stored in `mlspace/auth/oidc-client-secret`. See [Enhanced Authentication Guide](./bff-authentication.md) | - | +| `OIDC_REDIRECT_URL` | **Deprecated:** The redirect URL after OIDC authentication. Enhanced authentication uses `/auth/callback` automatically. See [Enhanced Authentication Guide](./bff-authentication.md) | `undefined` | +| `OIDC_VERIFY_SSL` | **Deprecated:** Whether to validate OIDC server certificates. Enhanced authentication handles SSL validation internally | `True` | +| `OIDC_VERIFY_SIGNATURE` | **Deprecated:** Whether to verify JWT token signatures. Enhanced authentication handles token validation server-side | `True` | +| `AUTH_OIDC_CLIENT_SECRET_NAME` | The name of the AWS Secrets Manager secret where the OIDC client secret is stored (required for confidential clients only) | `mlspace/auth/oidc-client-secret` | +| `AUTH_OIDC_CLIENT_SECRET_VALUE` | Optional OIDC client secret value for deployment-time configuration if using a confidential client. | - | +| `AUTH_OIDC_USE_PKCE` | Whether to use PKCE (Proof Key for Code Exchange) flow for enhanced security. Recommended for public clients and optional for confidential clients | `true` | +| `AUTH_OIDC_VERIFY_SSL` | Whether to verify SSL certificates when making requests to the OIDC provider. Should be `true` in production | `true` | +| `AUTH_OIDC_VERIFY_SIGNATURE` | Whether to verify OIDC token signatures. Should be `true` in production | `true` | +| `AUTH_SESSION_TTL_HOURS` | The time-to-live (TTL) in hours for authentication sessions | `24` | +| `AUTH_SYNC_DOMAINS` | Comma-separated list of domains to sync authentication state across (for multi-domain deployments) | - | | `ADDITIONAL_LAMBDA_ENVIRONMENT_VARS` | A map of key-value pairs which will be set as environment variables on every {{ $params.APPLICATION_NAME }} lambda | `{}` | | `RESOURCE_TERMINATION_INTERVAL` | Interval (in minutes) to run the resource termination cleanup lambda | `60` | | `DATASETS_TABLE_NAME` | DynamoDB table to hold dataset-related metadata | `mlspace-datasets` | From c1156a0f764be324f904ddfac7cb019a0ab351c7 Mon Sep 17 00:00:00 2001 From: Dustin Sweigart Date: Fri, 23 Jan 2026 16:24:56 +0000 Subject: [PATCH 28/32] updated documentation --- frontend/docs/.vitepress/theme/custom.scss | 2 +- .../admin-guide/auth-configuration-reference.md | 17 ++++++++--------- .../admin-guide/bff-authentication-migration.md | 8 ++++---- frontend/docs/admin-guide/bff-authentication.md | 16 ++++++++-------- frontend/docs/admin-guide/configure-cognito.md | 2 +- frontend/docs/admin-guide/custom-domain.md | 2 +- frontend/docs/admin-guide/install.md | 16 +++++++--------- 7 files changed, 30 insertions(+), 33 deletions(-) diff --git a/frontend/docs/.vitepress/theme/custom.scss b/frontend/docs/.vitepress/theme/custom.scss index da5bac6c..42fe03e5 100644 --- a/frontend/docs/.vitepress/theme/custom.scss +++ b/frontend/docs/.vitepress/theme/custom.scss @@ -3,7 +3,7 @@ } /* Keeps headings in line with as collapse status indicator */ -details summary h1,h2,h3,h4,h5,h6 { +details summary :is(h1, h2, h3, h4, h5, h6) { display: inline } diff --git a/frontend/docs/admin-guide/auth-configuration-reference.md b/frontend/docs/admin-guide/auth-configuration-reference.md index 537005fa..0076aa34 100644 --- a/frontend/docs/admin-guide/auth-configuration-reference.md +++ b/frontend/docs/admin-guide/auth-configuration-reference.md @@ -6,7 +6,7 @@ outline: deep ## Quick Reference -This page provides a quick reference for all AUTH_* configuration parameters used in the enhanced authentication system. +This page provides a quick reference for all AUTH_* configuration parameters used in the authentication system. ::: danger OIDC_* PARAMETERS NOT SUPPORTED The deprecated `OIDC_*` configuration parameters (such as `OIDC_URL`, `OIDC_CLIENT_NAME`, `OIDC_VERIFY_SSL`, etc.) are **no longer supported**. You must use the `AUTH_*` parameters documented on this page. See the [Migration Mapping](#migration-mapping) section below for the complete mapping from deprecated to new parameters. @@ -53,9 +53,9 @@ The deprecated `OIDC_*` configuration parameters (such as `OIDC_URL`, `OIDC_CLIE - **Type**: String (comma-separated) - **Required**: No - **Default**: None -- **Description**: Additional domains for cross-domain cookie sync +- **Description**: **Not currently needed.** Reserved for future multi-domain cookie sync functionality - **Example**: `"notebooks.mlspace.com,admin.mlspace.com"` -- **Notes**: Enables seamless authentication across multiple domains. The primary domain is automatically detected from the Host header. +- **Notes**: This parameter is not currently used or expected to be set. It is reserved for future functionality to enable seamless authentication across multiple domains. ### AUTH_OIDC_CLIENT_SECRET_NAME - **Type**: String @@ -163,7 +163,7 @@ The deprecated `OIDC_*` configuration parameters (such as `OIDC_URL`, `OIDC_CLIE ### Optional Validation Rules -1. **AUTH_SYNC_DOMAINS**: Must be comma-separated list of valid domain names if specified +1. **AUTH_SYNC_DOMAINS**: Not currently used; reserved for future functionality 2. **AUTH_OIDC_USE_PKCE**: Must be boolean (true/false) 4. **AUTH_OIDC_VERIFY_SSL**: Must be boolean (true/false); should be true in production 5. **AUTH_OIDC_VERIFY_SIGNATURE**: Must be boolean (true/false); should be true in production @@ -193,7 +193,7 @@ All `OIDC_*` parameters listed below are **no longer supported**. You must migra | _(none)_ | `AUTH_OIDC_CLIENT_SECRET_VALUE` | **New** - Optional deployment-time secret value | | _(none)_ | `AUTH_OIDC_USE_PKCE` | **New** - Enable PKCE flow (default: true) | | _(none)_ | `AUTH_SESSION_TTL_HOURS` | **New** - Session duration configuration | -| _(none)_ | `AUTH_SYNC_DOMAINS` | **New** - Multi-domain cookie sync | +| _(none)_ | `AUTH_SYNC_DOMAINS` | **New** - Reserved for future multi-domain cookie sync (not currently used) | | _(none)_ | `AUTH_SESSION_TABLE_NAME` | **New** - DynamoDB session table name | | _(none)_ | `AUTH_TOKEN_ENCRYPTION_KEY_SECRET_NAME` | **New** - Token encryption keys (rotatable) | | _(none)_ | `AUTH_STATE_ENCRYPTION_KEY_SECRET_NAME` | **New** - State encryption key | @@ -204,8 +204,7 @@ All `OIDC_*` parameters listed below are **no longer supported**. You must migra 1. **Client Secrets**: Always store in Secrets Manager (not SSM Parameter Store) 2. **URLs**: Use HTTPS for all AUTH_OIDC_URL values -3. **Domains**: Ensure AUTH_SYNC_DOMAINS use HTTPS -4. **TTL**: Set appropriate AUTH_SESSION_TTL_HOURS based on security requirements +3. **TTL**: Set appropriate AUTH_SESSION_TTL_HOURS based on security requirements 5. **SSL Verification**: Keep AUTH_OIDC_VERIFY_SSL=true in production 6. **Signature Verification**: Keep AUTH_OIDC_VERIFY_SIGNATURE=true in production 7. **PKCE**: Keep AUTH_OIDC_USE_PKCE=true for enhanced security @@ -218,7 +217,7 @@ All `OIDC_*` parameters listed below are **no longer supported**. You must migra ## Related Documentation -- [Enhanced Authentication Configuration Guide](./bff-authentication.md) -- [Enhanced Authentication Migration Guide](./bff-authentication-migration.md) +- [Authentication Configuration Guide](./bff-authentication.md) +- [Authentication Migration Guide](./bff-authentication-migration.md) - [Install Guide](./install.md) - [Security Documentation](./security/intro.md) \ No newline at end of file diff --git a/frontend/docs/admin-guide/bff-authentication-migration.md b/frontend/docs/admin-guide/bff-authentication-migration.md index 9052ce0b..7eb398af 100644 --- a/frontend/docs/admin-guide/bff-authentication-migration.md +++ b/frontend/docs/admin-guide/bff-authentication-migration.md @@ -2,11 +2,11 @@ outline: deep --- -# Enhanced Authentication Migration Guide +# Authentication Migration Guide ## Overview -This guide provides detailed step-by-step instructions for migrating from the deprecated OIDC authentication system to the enhanced authentication system. The new system provides improved security, better enterprise IdP support, and simplified application code. +This guide provides detailed step-by-step instructions for migrating from the deprecated OIDC authentication system to the current authentication system. ## Migration Benefits @@ -24,7 +24,7 @@ Before starting the migration, ensure you have: - [ ] OIDC client secret (if using confidential client flow) - [ ] Understanding of your current OIDC configuration - [ ] Planned maintenance window for deployment -- [ ] Reviewed the [Enhanced Authentication Configuration Guide](./bff-authentication.md) +- [ ] Reviewed the [Authentication Configuration Guide](./bff-authentication.md) ## Pre-Migration Assessment @@ -53,7 +53,7 @@ Before starting the migration, document your current configuration: ### Compatibility Check -Verify your OIDC provider supports the enhanced authentication requirements: +Verify your OIDC provider supports the authentication requirements: - ✅ Authorization Code flow - ✅ Client secret support (for confidential clients) diff --git a/frontend/docs/admin-guide/bff-authentication.md b/frontend/docs/admin-guide/bff-authentication.md index 3186f614..969de2f2 100644 --- a/frontend/docs/admin-guide/bff-authentication.md +++ b/frontend/docs/admin-guide/bff-authentication.md @@ -2,11 +2,11 @@ outline: deep --- -# Enhanced Authentication Configuration +# Authentication Configuration ## Overview -The enhanced authentication system provides improved security and enterprise Identity Provider (IdP) integration. Authentication is handled server-side, enabling support for enterprise IdPs that require client secrets, while providing better security through secure cookies (HttpOnly cookies that can't be accessed by JavaScript and are only sent to the server with requests) and simplified application code. +Authentication is handled server-side, enabling support for enterprise IdPs that require client secrets, while providing better security through secure cookies (HttpOnly cookies that can't be accessed by JavaScript and are only sent to the server with requests) and simplified application code. ::: danger DEPRECATED OIDC_* PARAMETERS NOT SUPPORTED The deprecated `OIDC_*` configuration parameters (such as `OIDC_URL`, `OIDC_CLIENT_NAME`, `OIDC_VERIFY_SSL`, `OIDC_REDIRECT_URL`, etc.) are **no longer supported**. You must use the `AUTH_*` parameters documented below. See the [Migration from Deprecated OIDC Configuration](#migration-from-deprecated-oidc-configuration) section for migration instructions. @@ -16,20 +16,20 @@ The deprecated `OIDC_*` configuration parameters (such as `OIDC_URL`, `OIDC_CLIE ### Required AUTH_* Parameters -The enhanced authentication system uses `AUTH_*` configuration parameters that replace the deprecated `OIDC_*` parameters: +The authentication system uses `AUTH_*` configuration parameters that replace the deprecated `OIDC_*` parameters: | Parameter | Description | Example | Required | |-----------|-------------|---------|----------| -| `AUTH_IDP_TYPE` | Identity Provider type | `"oidc"` | Yes | | `AUTH_OIDC_URL` | OIDC issuer URL (replaces `OIDC_URL`) | `"https://auth.example.com"` | Yes (for OIDC) | | `AUTH_OIDC_CLIENT_ID` | OIDC client identifier (replaces `OIDC_CLIENT_NAME`) | `"mlspace-client"` | Yes (for OIDC) | -| `AUTH_SESSION_TTL_HOURS` | Session duration in hours | `24` | No (default: 24) | ### Optional AUTH_* Parameters | Parameter | Description | Example | Default | |-----------|-------------|---------|---------| -| `AUTH_SYNC_DOMAINS` | Comma-separated list of additional domains for cookie sync | `"notebooks.mlspace.com,admin.mlspace.com"` | None | +| `AUTH_IDP_TYPE` | Identity Provider type | `"oidc"` | No | +| `AUTH_SESSION_TTL_HOURS` | Session duration in hours | `24` | No (default: 24) | +| `AUTH_SYNC_DOMAINS` | **Not currently needed.** Reserved for future multi-domain cookie sync functionality | `"notebooks.mlspace.com,admin.mlspace.com"` | None | | `AUTH_OIDC_CLIENT_SECRET_NAME` | Secrets Manager secret name for OIDC client secret | `"mlspace/auth/oidc-client-secret"` | `"mlspace/auth/oidc-client-secret"` | | `AUTH_OIDC_CLIENT_SECRET_VALUE` | Optional OIDC client secret value for deployment-time configuration | `"your-secret-here"` | None | | `AUTH_OIDC_USE_PKCE` | Whether to use PKCE flow (recommended) | `true` | `true` | @@ -41,7 +41,7 @@ The enhanced authentication system uses `AUTH_*` configuration parameters that r ## Configuration Setup -### Update lib/config.json +### Update lib/config.json or lib/constants.ts Update your environment-specific configuration file: @@ -60,4 +60,4 @@ Update your environment-specific configuration file: ## Migration from Deprecated OIDC Configuration -If you're migrating from the deprecated `OIDC_*` parameters, see the [Enhanced Authentication Migration Guide](./bff-authentication-migration.md) for detailed step-by-step instructions. \ No newline at end of file +If you're migrating from the deprecated `OIDC_*` parameters, see the [Authentication Migration Guide](./bff-authentication-migration.md) for detailed step-by-step instructions. \ No newline at end of file diff --git a/frontend/docs/admin-guide/configure-cognito.md b/frontend/docs/admin-guide/configure-cognito.md index aea7c44e..785e1161 100644 --- a/frontend/docs/admin-guide/configure-cognito.md +++ b/frontend/docs/admin-guide/configure-cognito.md @@ -70,7 +70,7 @@ export const AUTH_OIDC_URL = 'https://cognito-idp.us-east-2.amazonaws.com/us-eas ``` ::: warning DEPRECATED OIDC_* PARAMETERS NOT SUPPORTED -The deprecated `OIDC_URL` and `OIDC_CLIENT_NAME` parameters are no longer supported. You must use the new `AUTH_OIDC_URL` and `AUTH_OIDC_CLIENT_ID` parameters instead. See the [Enhanced Authentication Configuration Guide](./bff-authentication.md) for complete details on all AUTH_* parameters. +The deprecated `OIDC_URL` and `OIDC_CLIENT_NAME` parameters are no longer supported. You must use the new `AUTH_OIDC_URL` and `AUTH_OIDC_CLIENT_ID` parameters instead. See the [Authentication Configuration Guide](./bff-authentication.md) for complete details on all AUTH_* parameters. ::: Once both values have been updated, you can build and deploy {{ $params.APPLICATION_NAME }}, and it will use Cognito as the IdP. Once {{ $params.APPLICATION_NAME }} is deployed, you will have to update your Cognito app client to add the {{ $params.APPLICATION_NAME }} API Gateway endpoint to the list of "Allowed callback URLs". You can do this by navigating to the App Client details page, scrolling down to the hosted UI, and clicking the edit button. From there, you will need to add your custom domain or the {{ $params.APPLICATION_NAME }} API Gateway endpoint with the `/auth/callback` path to the URL list. If you aren't using a custom domain, that value should be something similar to `https://.execute-api..amazonaws.com/Prod/auth/callback`. diff --git a/frontend/docs/admin-guide/custom-domain.md b/frontend/docs/admin-guide/custom-domain.md index 40d29fd1..4d73472c 100644 --- a/frontend/docs/admin-guide/custom-domain.md +++ b/frontend/docs/admin-guide/custom-domain.md @@ -286,4 +286,4 @@ https://mlspace.mycompany.com - [AWS API Gateway Custom Domain Names](https://docs.aws.amazon.com/apigateway/latest/developerguide/how-to-custom-domains.html) - [AWS Certificate Manager User Guide](https://docs.aws.amazon.com/acm/latest/userguide/acm-overview.html) - [Route 53 Developer Guide](https://docs.aws.amazon.com/route53/index.html) -- [{{ $params.APPLICATION_NAME }} Enhanced Authentication Configuration](./bff-authentication.md) +- [{{ $params.APPLICATION_NAME }} Authentication Configuration](./bff-authentication.md) diff --git a/frontend/docs/admin-guide/install.md b/frontend/docs/admin-guide/install.md index 6f229fc9..0b4ab603 100644 --- a/frontend/docs/admin-guide/install.md +++ b/frontend/docs/admin-guide/install.md @@ -1610,9 +1610,7 @@ Use the MLSpace Config Wizard by running `npm run config` and select "Advanced C #### Required Parameters ::: warning Authentication Configuration -{{ $params.APPLICATION_NAME }} uses enhanced authentication for improved security and enterprise IdP integration. See the [Enhanced Authentication Configuration Guide](./bff-authentication.md) for detailed setup instructions. - -**Note:** The deprecated `OIDC_URL` and `OIDC_CLIENT_NAME` parameters are no longer supported and will cause deployment errors if used. Use the `AUTH_*` parameters below instead. +**Note:** The deprecated `OIDC_*` and `IDP_ENDPOINT_SSM_PARAM` parameters are no longer supported and will cause deployment errors if you try to use them. Use the `AUTH_*` parameters below instead. See the [Authentication Configuration Guide](./bff-authentication.md) for detailed setup instructions. ::: | Variable | Description | Default | @@ -1632,17 +1630,17 @@ Use the MLSpace Config Wizard by running `npm run config` and select "Advanced C | Variable | Description | Default | |--------------------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------:| -| `IDP_ENDPOINT_SSM_PARAM` | **Deprecated:** If set, {{ $params.APPLICATION_NAME }} will use the value of this parameter as the `OIDC_URL`. For enhanced authentication, client secrets are stored in `mlspace/auth/oidc-client-secret`. See [Enhanced Authentication Guide](./bff-authentication.md) | - | -| `OIDC_REDIRECT_URL` | **Deprecated:** The redirect URL after OIDC authentication. Enhanced authentication uses `/auth/callback` automatically. See [Enhanced Authentication Guide](./bff-authentication.md) | `undefined` | -| `OIDC_VERIFY_SSL` | **Deprecated:** Whether to validate OIDC server certificates. Enhanced authentication handles SSL validation internally | `True` | -| `OIDC_VERIFY_SIGNATURE` | **Deprecated:** Whether to verify JWT token signatures. Enhanced authentication handles token validation server-side | `True` | +| `IDP_ENDPOINT_SSM_PARAM` | **Deprecated:** If set, {{ $params.APPLICATION_NAME }} will use the value of this parameter as the `OIDC_URL`. Client secrets are now stored in `mlspace/auth/oidc-client-secret`. See [Authentication Guide](./bff-authentication.md) | - | +| `OIDC_REDIRECT_URL` | **Deprecated:** The redirect URL after OIDC authentication. Now uses `/auth/callback` automatically. See [Authentication Guide](./bff-authentication.md) | `undefined` | +| `OIDC_VERIFY_SSL` | **Deprecated:** Whether to validate OIDC server certificates. SSL validation is now handled internally | `True` | +| `OIDC_VERIFY_SIGNATURE` | **Deprecated:** Whether to verify JWT token signatures. Token validation is now handled server-side | `True` | | `AUTH_OIDC_CLIENT_SECRET_NAME` | The name of the AWS Secrets Manager secret where the OIDC client secret is stored (required for confidential clients only) | `mlspace/auth/oidc-client-secret` | | `AUTH_OIDC_CLIENT_SECRET_VALUE` | Optional OIDC client secret value for deployment-time configuration if using a confidential client. | - | -| `AUTH_OIDC_USE_PKCE` | Whether to use PKCE (Proof Key for Code Exchange) flow for enhanced security. Recommended for public clients and optional for confidential clients | `true` | +| `AUTH_OIDC_USE_PKCE` | Whether to use PKCE (Proof Key for Code Exchange) flow. Recommended for public clients and optional for confidential clients | `true` | | `AUTH_OIDC_VERIFY_SSL` | Whether to verify SSL certificates when making requests to the OIDC provider. Should be `true` in production | `true` | | `AUTH_OIDC_VERIFY_SIGNATURE` | Whether to verify OIDC token signatures. Should be `true` in production | `true` | | `AUTH_SESSION_TTL_HOURS` | The time-to-live (TTL) in hours for authentication sessions | `24` | -| `AUTH_SYNC_DOMAINS` | Comma-separated list of domains to sync authentication state across (for multi-domain deployments) | - | +| `AUTH_SYNC_DOMAINS` | **Not currently needed.** Reserved for future multi-domain cookie sync functionality | - | | `ADDITIONAL_LAMBDA_ENVIRONMENT_VARS` | A map of key-value pairs which will be set as environment variables on every {{ $params.APPLICATION_NAME }} lambda | `{}` | | `RESOURCE_TERMINATION_INTERVAL` | Interval (in minutes) to run the resource termination cleanup lambda | `60` | | `DATASETS_TABLE_NAME` | DynamoDB table to hold dataset-related metadata | `mlspace-datasets` | From 4c3846d364a0823e5fce4bef84727a926eda2429 Mon Sep 17 00:00:00 2001 From: Dustin Sweigart Date: Fri, 23 Jan 2026 16:28:12 +0000 Subject: [PATCH 29/32] updated documentation --- frontend/docs/.vitepress/config.mts | 6 +++--- frontend/docs/admin-guide/auth-configuration-reference.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/docs/.vitepress/config.mts b/frontend/docs/.vitepress/config.mts index fcae5cde..bb3184ff 100644 --- a/frontend/docs/.vitepress/config.mts +++ b/frontend/docs/.vitepress/config.mts @@ -9,8 +9,8 @@ const docItems = [ { text: 'Install Guide', link: '/admin-guide/install' }, { text: 'Getting Started', link: '/admin-guide/getting-started' }, { text: 'Setting Initial Admin', link: '/admin-guide/initial-admin' }, - { text: 'Enhanced Authentication Configuration', link: '/admin-guide/bff-authentication' }, - { text: 'Enhanced Authentication Migration', link: '/admin-guide/bff-authentication-migration' }, + { text: 'Authentication Configuration', link: '/admin-guide/bff-authentication' }, + { text: 'Authentication Migration', link: '/admin-guide/bff-authentication-migration' }, { text: `Configure AWS Cognito for ${APPLICATION_NAME}`, link: '/admin-guide/configure-cognito' }, { text: `Create a Ground Truth Workforce using Keycloak`, link: '/admin-guide/gt-workforce-keycloak' }, { text: `Security`, link: '/admin-guide/security/intro', items: [ @@ -24,7 +24,7 @@ const docItems = [ { text: 'Advanced Configuration', items: [ - { text: 'AUTH_* Configuration Reference', link: '/admin-guide/auth-configuration-reference' }, + { text: 'Authentication Configuration Reference', link: '/admin-guide/auth-configuration-reference' }, { text: 'Custom Domain Configuration', link: '/admin-guide/custom-domain' }, { text: `Enabling Access To S3 Buckets In ${APPLICATION_NAME}`, link: '/admin-guide/manual-s3-permissions' }, { text: `Custom Algorithm Containers In ${APPLICATION_NAME}`, link: '/admin-guide/byom-permissions' }, diff --git a/frontend/docs/admin-guide/auth-configuration-reference.md b/frontend/docs/admin-guide/auth-configuration-reference.md index 0076aa34..75477ca0 100644 --- a/frontend/docs/admin-guide/auth-configuration-reference.md +++ b/frontend/docs/admin-guide/auth-configuration-reference.md @@ -2,7 +2,7 @@ outline: deep --- -# AUTH_* Configuration Reference +# Authentication Configuration Reference ## Quick Reference From bded8460989e912b95358099a5a424f963ed11f0 Mon Sep 17 00:00:00 2001 From: Dustin Sweigart Date: Fri, 23 Jan 2026 16:35:55 +0000 Subject: [PATCH 30/32] Updated demo deployment configuration --- .github/workflows/code.deploy.demo.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/code.deploy.demo.yml b/.github/workflows/code.deploy.demo.yml index 20448f53..2a580378 100644 --- a/.github/workflows/code.deploy.demo.yml +++ b/.github/workflows/code.deploy.demo.yml @@ -37,8 +37,8 @@ jobs: { "AWS_ACCOUNT": "${{ secrets.AWS_ACCOUNT }}", "AWS_REGION": "${{ secrets.AWS_REGION }}", - "OIDC_URL": "${{ secrets.OIDC_URL }}", - "OIDC_CLIENT_NAME": "${{ secrets.OIDC_CLIENT_NAME }}", + "AUTH_OIDC_CLIENT_ID": "${{ vars.AUTH_OIDC_CLIENT_ID }}", + "AUTH_OIDC_URL": "${{ vars.AUTH_OIDC_URL }}", "KEY_MANAGER_ROLE_NAME": "${{ secrets.KEY_MANAGER_ROLE_NAME }}" } dir: './lib/' From f96fb81471b6b3e3a8f6547dee113f819c3c97a3 Mon Sep 17 00:00:00 2001 From: Dustin Sweigart Date: Fri, 23 Jan 2026 16:53:24 +0000 Subject: [PATCH 31/32] github workflow changes --- .github/workflows/code.hotfix.branch.yml | 4 ++-- .github/workflows/code.merge.main-to-develop.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/code.hotfix.branch.yml b/.github/workflows/code.hotfix.branch.yml index 498e5d98..0e08bf60 100644 --- a/.github/workflows/code.hotfix.branch.yml +++ b/.github/workflows/code.hotfix.branch.yml @@ -24,8 +24,8 @@ jobs: ref: refs/tags/${{ github.event.inputs.source_tag }} - name: Create Hotfix Branch and Update Version run: | - git config --global user.email "evmann@amazon.com" - git config --global user.name "github_actions_mlspace" + git config --global user.name "${GITHUB_ACTOR}" + git config --global user.email "${GITHUB_ACTOR}@users.noreply.github.com" SRC_TAG=${{ github.event.inputs.source_tag }} DST_TAG=${{ github.event.inputs.dest_tag }} git checkout -b hotfix/${{ github.event.inputs.dest_tag }} diff --git a/.github/workflows/code.merge.main-to-develop.yml b/.github/workflows/code.merge.main-to-develop.yml index 97f61f21..2edcd2cc 100644 --- a/.github/workflows/code.merge.main-to-develop.yml +++ b/.github/workflows/code.merge.main-to-develop.yml @@ -18,8 +18,8 @@ jobs: ssh-key: ${{ secrets.DEPLOYMENT_SSH_KEY }} - name: merge main into develop run: | - git config --global user.email "evmann@amazon.com" - git config --global user.name "github_actions_mlspace" + git config --global user.name "${GITHUB_ACTOR}" + git config --global user.email "${GITHUB_ACTOR}@users.noreply.github.com" git fetch --unshallow git checkout develop git pull From 7c3f880499ed81a8412fa1920b2f0b4f05f7c1f4 Mon Sep 17 00:00:00 2001 From: Dustin Sweigart Date: Fri, 23 Jan 2026 18:49:02 -0500 Subject: [PATCH 32/32] Revise release notes for version 1.7 Updated release notes for version 1.7, including new features, documentation updates, and acknowledgements. --- frontend/src/release-notes.md | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/frontend/src/release-notes.md b/frontend/src/release-notes.md index 3769293b..00b84397 100644 --- a/frontend/src/release-notes.md +++ b/frontend/src/release-notes.md @@ -1,23 +1,24 @@ -# v1.6.11 +# v1.7 ## Features -* **Bedrock IAM Policies**: Updated existing IAM policies to address Amazon Bedrock API updates. MLSpace customers can continue to directly call service APIs directly from their notebooks, enabling seamless integration with Bedrock’s foundation models and generative AI capabilities. + +* **Redesigned Authentication System**: MLSpace now supports a wider range of identity providers through a redesigned authentication flow. The new backend-driven approach enables integration with identity providers that require secure server-side authentication. This change is fully backward compatible—existing login methods continue to work with only configuration changes. While the user experience remains the same, the authentication process now happens server-side rather than in the browser, providing better compatibility with a wider variety of identify providers. +* **Custom Domain Support**: MLSpace now includes initial support for using a custom domain instead of the default API Gateway domain. +* **Enhanced Configuration Helper:** The configuration helper has been updated to support the new authentication parameters and custom domain configuration, making it easier to set up and deploy MLSpace with these new capabilities. +* Updated dependencies with the latest security patches. ## Documentation -* Updated Bedrock policy documentation to reflect updated IAM policies. -* Added an updated detailed architecture diagram to showcase MLSpace’s infrastructure and component relationships. -* Expanded documentation to cover how MLSpace stores auditable logs, providing greater transparency into logging mechanisms. - ## Upcoming -* **Bedrock VPC Endpoints**: Addition of a VPC endpoint for Amazon Bedrock to ensure traffic remains private within the customer's AWS network for enhanced security. -* **Bedrock Configuration Parameter**: New configuration parameter to allow customers to disable Bedrock capabilities in notebooks if desired. -* **GroundTruth Label Verification**: Addition of GroundTruth Label Verification jobs to the UI, enabling users to review and validate labeled data directly from the MLSpace interface. +* Added comprehensive documentation for configuring the new authentication system with various examples. +* Added instructions for setting up and configuring a custom domain for MLSpace deployments. + +## Special Thanks + +* 🎉 Special thanks to [@emacthecav](https://github.com/awslabs/mlspace/pull/345) for contributing their first PR! ## Acknowledgements -* @bedanley * @dustins * @estohlmann -* @jmharold -**Full Changelog**: [v1.6.10...v1.6.11](https://github.com/awslabs/mlspace/compare/v1.6.10...v1.6.11) \ No newline at end of file +**Full Changelog**: [v1.6.11...v1.7](https://github.com/awslabs/mlspace/compare/v1.6.11...v1.7)