From 328e650339b5b8f63e71ace54ceb23a5a7188eac Mon Sep 17 00:00:00 2001 From: Mike Dial <48921055+mdial89f@users.noreply.github.com> Date: Tue, 24 Oct 2023 13:06:10 -0400 Subject: [PATCH 01/19] feat(attachments infrastructure): Bucket, file scanning, and presigned urls. (#176) * uploads serfvice and presigned url generation * add missing export --- serverless-compose.yml | 6 + src/services/api/handlers/getAttachmentUrl.ts | 61 +++-- src/services/api/handlers/getUploadUrl.ts | 52 ++++ src/services/api/serverless.yml | 22 ++ src/services/data/serverless.yml | 3 +- src/services/uploads/package.json | 11 + src/services/uploads/serverless.yml | 195 ++++++++++++++ src/services/uploads/src/antivirus.ts | 140 ++++++++++ src/services/uploads/src/clamav.ts | 244 ++++++++++++++++++ src/services/uploads/src/constants.ts | 46 ++++ .../uploads/src/download-definitions.ts | 19 ++ .../uploads/src/triggerInitialDownload.ts | 27 ++ src/services/uploads/src/utils.ts | 91 +++++++ src/services/uploads/tsconfig.json | 15 ++ 14 files changed, 903 insertions(+), 29 deletions(-) create mode 100644 src/services/api/handlers/getUploadUrl.ts create mode 100644 src/services/uploads/package.json create mode 100644 src/services/uploads/serverless.yml create mode 100644 src/services/uploads/src/antivirus.ts create mode 100644 src/services/uploads/src/clamav.ts create mode 100644 src/services/uploads/src/constants.ts create mode 100644 src/services/uploads/src/download-definitions.ts create mode 100644 src/services/uploads/src/triggerInitialDownload.ts create mode 100644 src/services/uploads/src/utils.ts create mode 100644 src/services/uploads/tsconfig.json diff --git a/serverless-compose.yml b/serverless-compose.yml index 7d1e4400cd..4e8c972e6e 100644 --- a/serverless-compose.yml +++ b/serverless-compose.yml @@ -9,6 +9,8 @@ services: path: src/services/data params: ECSFailureTopicArn: ${alerts.ECSFailureTopicArn} + uploads: + path: src/services/uploads ui-infra: path: src/services/ui-infra api: @@ -17,6 +19,10 @@ services: ECSFailureTopicArn: ${alerts.ECSFailureTopicArn} osDomainArn: ${data.OpenSearchDomainArn} osDomain: ${data.OpenSearchDomainEndpoint} + topicName: ${data.TopicName} + attachmentsBucketName: ${uploads.AttachmentsBucketName} + attachmentsBucketRegion: ${uploads.AttachmentsBucketRegion} + attachmentsBucketArn: ${uploads.AttachmentsBucketArn} auth: path: src/services/auth params: diff --git a/src/services/api/handlers/getAttachmentUrl.ts b/src/services/api/handlers/getAttachmentUrl.ts index b1d3f10465..8747890cc5 100644 --- a/src/services/api/handlers/getAttachmentUrl.ts +++ b/src/services/api/handlers/getAttachmentUrl.ts @@ -69,7 +69,7 @@ export const handler = async (event: APIGatewayEvent) => { } // Now we can generate the presigned url - const url = await generatePresignedS3Url(body.bucket, body.key, 60); + const url = await generateLegacyPresignedS3Url(body.bucket, body.key, 60); return response({ statusCode: 200, @@ -84,32 +84,37 @@ export const handler = async (event: APIGatewayEvent) => { } }; -async function generatePresignedS3Url(bucket, key, expirationInSeconds) { - // Create an S3 client - const roleToAssumeArn = process.env.onemacLegacyS3AccessRoleArn; - - // Create an STS client to make the AssumeRole API call - const stsClient = new STSClient({}); - - // Assume the role - const assumedRoleResponse = await stsClient.send( - new AssumeRoleCommand({ - RoleArn: roleToAssumeArn, - RoleSessionName: "AssumedRoleSession", - }) - ); - - // Extract the assumed role credentials - const assumedCredentials = assumedRoleResponse.Credentials; - - // Create S3 client using the assumed role's credentials - const assumedS3Client = new S3Client({ - credentials: { - accessKeyId: assumedCredentials.AccessKeyId, - secretAccessKey: assumedCredentials.SecretAccessKey, - sessionToken: assumedCredentials.SessionToken, - }, - }); +async function getClient(bucket) { + if (bucket.startsWith("uploads")) { + const stsClient = new STSClient({}); + + // Assume the role + const assumedRoleResponse = await stsClient.send( + new AssumeRoleCommand({ + RoleArn: process.env.onemacLegacyS3AccessRoleArn, + RoleSessionName: "AssumedRoleSession", + }) + ); + + // Extract the assumed role credentials + const assumedCredentials = assumedRoleResponse.Credentials; + + // Create S3 client using the assumed role's credentials + return new S3Client({ + credentials: { + accessKeyId: assumedCredentials.AccessKeyId, + secretAccessKey: assumedCredentials.SecretAccessKey, + sessionToken: assumedCredentials.SessionToken, + }, + }); + } else { + return new S3Client({}); + } +} + +async function generateLegacyPresignedS3Url(bucket, key, expirationInSeconds) { + // Get an S3 client + const client = await getClient(bucket); // Create a command to get the object (you can adjust this according to your use case) const getObjectCommand = new GetObjectCommand({ @@ -118,7 +123,7 @@ async function generatePresignedS3Url(bucket, key, expirationInSeconds) { }); // Generate a presigned URL - const presignedUrl = await getSignedUrl(assumedS3Client, getObjectCommand, { + const presignedUrl = await getSignedUrl(client, getObjectCommand, { expiresIn: expirationInSeconds, }); diff --git a/src/services/api/handlers/getUploadUrl.ts b/src/services/api/handlers/getUploadUrl.ts new file mode 100644 index 0000000000..276ad47e6a --- /dev/null +++ b/src/services/api/handlers/getUploadUrl.ts @@ -0,0 +1,52 @@ +import { response } from "../libs/handler"; +import { APIGatewayEvent } from "aws-lambda"; +import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { v4 as uuidv4 } from "uuid"; + +checkEnvVariables(["attachmentsBucketName", "attachmentsBucketRegion"]); + +const s3 = new S3Client({ + region: process.env.attachmentsBucketRegion, +}); + +export const handler = async (event: APIGatewayEvent) => { + try { + const body = JSON.parse(event.body); + const bucket = process.env.attachmentsBucketName; + const key = uuidv4(); + const url = await getSignedUrl( + s3, + new PutObjectCommand({ + Bucket: bucket, + Key: key, + }), + { + expiresIn: 60, + } + ); + + return response({ + statusCode: 200, + body: { url, bucket, key }, + }); + } catch (error) { + console.error({ error }); + return response({ + statusCode: 500, + body: { message: "Internal server error" }, + }); + } +}; + +function checkEnvVariables(requiredEnvVariables) { + const missingVariables = requiredEnvVariables.filter( + (envVar) => !process.env[envVar] + ); + + if (missingVariables.length > 0) { + throw new Error( + `Missing required environment variables: ${missingVariables.join(", ")}` + ); + } +} diff --git a/src/services/api/serverless.yml b/src/services/api/serverless.yml index a97494897c..e4d902e37f 100644 --- a/src/services/api/serverless.yml +++ b/src/services/api/serverless.yml @@ -39,6 +39,14 @@ provider: - sts:AssumeRole Resource: - ${self:custom.onemacLegacyS3AccessRoleArn} + - Effect: Allow + Action: + - s3:PutObject + - s3:PutObjectTagging + - s3:GetObject + - s3:GetObjectTagging + Resource: + - ${param:attachmentsBucketArn}/* custom: project: ${env:PROJECT} @@ -52,6 +60,7 @@ custom: vpc: ${ssm:/aws/reference/secretsmanager/${self:custom.project}/${sls:stage}/vpc, ssm:/aws/reference/secretsmanager/${self:custom.project}/default/vpc} onemacLegacyS3AccessRoleArn: ${ssm:/aws/reference/secretsmanager/${self:custom.project}/${sls:stage}/onemacLegacyS3AccessRoleArn, ssm:/aws/reference/secretsmanager/${self:custom.project}/default/onemacLegacyS3AccessRoleArn} dbInfo: ${ssm:/aws/reference/secretsmanager/${self:custom.project}/${sls:stage}/seatool/dbInfo, ssm:/aws/reference/secretsmanager/${self:custom.project}/default/seatool/dbInfo} + brokerString: ${ssm:/aws/reference/secretsmanager/${self:custom.project}/${sls:stage}/brokerString, ssm:/aws/reference/secretsmanager/${self:custom.project}/default/brokerString} scriptable: hooks: package:compileEvents: ./handlers/repack.js @@ -115,6 +124,17 @@ functions: subnetIds: >- ${self:custom.vpc.privateSubnets} provisionedConcurrency: ${param:getAttachmentUrlProvisionedConcurrency} + getUploadUrl: + handler: handlers/getUploadUrl.handler + environment: + attachmentsBucketName: ${param:attachmentsBucketName} + attachmentsBucketRegion: ${param:attachmentsBucketRegion} + events: + - http: + path: /getUploadUrl + method: post + cors: true + authorizer: aws_iam item: handler: handlers/item.handler maximumRetryAttempts: 0 @@ -142,6 +162,8 @@ functions: dbPort: ${self:custom.dbInfo.port} dbUser: ${self:custom.dbInfo.user} dbPassword: ${self:custom.dbInfo.password} + topicName: ${param:topicName} + brokerString: ${self:custom.brokerString} events: - http: path: /submit diff --git a/src/services/data/serverless.yml b/src/services/data/serverless.yml index 0e4d748823..5950a795f7 100644 --- a/src/services/data/serverless.yml +++ b/src/services/data/serverless.yml @@ -78,7 +78,6 @@ custom: then aws lambda invoke --region ${self:provider.region} --function-name ${self:service}-${sls:stage}-bootstrapUsers --invocation-type RequestResponse /dev/null fi - stepFunctions: stateMachines: reindex: @@ -681,3 +680,5 @@ resources: Value: !Sub https://${OpenSearch.DomainEndpoint} OpenSearchDashboardEndpoint: Value: !Sub https://${OpenSearch.DomainEndpoint}/_dashboards + TopicName: + Value: ${param:topicNamespace}aws.onemac.migration.cdc diff --git a/src/services/uploads/package.json b/src/services/uploads/package.json new file mode 100644 index 0000000000..c23c026318 --- /dev/null +++ b/src/services/uploads/package.json @@ -0,0 +1,11 @@ +{ + "name": "uploads", + "description": "", + "private": true, + "version": "0.0.0", + "main": "index.js", + "scripts": {}, + "author": "", + "license": "CC0-1.0", + "devDependencies": {} +} diff --git a/src/services/uploads/serverless.yml b/src/services/uploads/serverless.yml new file mode 100644 index 0000000000..280c152962 --- /dev/null +++ b/src/services/uploads/serverless.yml @@ -0,0 +1,195 @@ +service: ${self:custom.project}-uploads +frameworkVersion: "3" + +plugins: + - serverless-esbuild + - serverless-stack-termination-protection + - "@stratiformdigital/serverless-iam-helper" + - "@stratiformdigital/serverless-s3-security-helper" + - serverless-scriptable-plugin +provider: + name: aws + runtime: nodejs18.x + region: ${env:REGION_A} + stackTags: + PROJECT: ${self:custom.project} + SERVICE: ${self:service} + iam: + role: + path: /delegatedadmin/developer/ + permissionsBoundary: arn:aws:iam::${aws:accountId}:policy/cms-cloud-admin/developer-boundary-policy + statements: + - Effect: "Allow" + Action: + - s3:GetObject + - s3:GetObjectTagging + - s3:PutObject + - s3:PutObjectAcl + - s3:PutObjectTagging + - s3:PutObjectVersionTagging + - s3:DeleteObject + - s3:ListBucket + Resource: + - !Sub arn:aws:s3:::${self:service}-${sls:stage}-attachments-${AWS::AccountId}/* + - !Sub arn:aws:s3:::${self:service}-${sls:stage}-avscan-${AWS::AccountId}/* + - Effect: "Allow" + Action: + - s3:ListBucket + Resource: + - !Sub arn:aws:s3:::${self:service}-${sls:stage}-attachments-${AWS::AccountId} + - !Sub arn:aws:s3:::${self:service}-${sls:stage}-avscan-${AWS::AccountId} + - Effect: "Allow" + Action: + - lambda:InvokeFunction + Resource: + - !Sub arn:aws:lambda:${self:provider.region}:${AWS::AccountId}:function:${self:service}-${sls:stage}-avDownloadDefinitions + +custom: + project: ${env:PROJECT} + accountId: !Sub "${AWS::AccountId}" + stage: ${opt:stage, self:provider.stage} + serverlessTerminationProtection: + stages: # Apply CloudFormation termination protection for these stages + - master + - val + - production + scriptable: + hooks: + package:initialize: | + set -e + curl -s -L --output lambda_layer.zip https://github.com/CMSgov/lambda-clamav-layer/releases/download/0.7/lambda_layer.zip + deploy:finalize: | + rm lambda_layer.zip + +layers: + clamDefs: + name: clamDefs-${self:service}-${sls:stage} + package: + artifact: lambda_layer.zip + +functions: + avScan: + handler: src/antivirus.lambdaHandleEvent + name: ${self:service}-${sls:stage}-avScan + timeout: 300 # 300 seconds = 5 minutes. Average scan is 25 seconds. + memorySize: 3008 + layers: + - !Ref ClamDefsLambdaLayer + environment: + CLAMAV_BUCKET_NAME: !Ref ClamDefsBucket + PATH_TO_AV_DEFINITIONS: "lambda/s3-antivirus/av-definitions" + avDownloadDefinitions: + handler: src/download-definitions.lambdaHandleEvent + events: + - schedule: cron(0 10 */1 * ? *) + timeout: 300 # 300 seconds = 5 minutes + memorySize: 1024 + layers: + - !Ref ClamDefsLambdaLayer + environment: + CLAMAV_BUCKET_NAME: !Ref ClamDefsBucket + PATH_TO_AV_DEFINITIONS: "lambda/s3-antivirus/av-definitions" + triggerInitialDownload: + handler: src/triggerInitialDownload.handler + timeout: 300 # 300 seconds = 5 minutes + memorySize: 1024 +resources: + Resources: + AttachmentsBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: !Sub ${self:service}-${sls:stage}-attachments-${AWS::AccountId} + # Set the CORS policy + CorsConfiguration: + CorsRules: + - AllowedOrigins: + - "*" + AllowedHeaders: + - "*" + AllowedMethods: + - GET + - PUT + - POST + - DELETE + - HEAD + ExposedHeaders: + - ETag + MaxAge: 3000 + NotificationConfiguration: + LambdaConfigurations: + - Event: s3:ObjectCreated:* + Function: !GetAtt AvScanLambdaFunction.Arn + DependsOn: LambdaInvokePermission + S3CMSReadBucketPolicy: + Type: AWS::S3::BucketPolicy + Properties: + Bucket: + Ref: AttachmentsBucket + PolicyDocument: + Statement: + - Effect: Deny + Principal: "*" + Action: "s3:*" + Resource: + - !Sub ${AttachmentsBucket.Arn}/* + - !Sub ${AttachmentsBucket.Arn} + Condition: + Bool: + "aws:SecureTransport": "false" + - Effect: "Deny" + Principal: "*" + Action: + - "s3:GetObject" + Resource: + - !Sub ${AttachmentsBucket.Arn}/* + Condition: + StringNotEquals: + s3:ExistingObjectTag/virusScanStatus: + - "CLEAN" + aws:PrincipalArn: !GetAtt IamRoleLambdaExecution.Arn + LambdaInvokePermission: + Type: AWS::Lambda::Permission + Properties: + FunctionName: !GetAtt AvScanLambdaFunction.Arn + Action: lambda:InvokeFunction + Principal: s3.amazonaws.com + SourceAccount: !Sub ${AWS::AccountId} + SourceArn: !Sub arn:aws:s3:::${self:service}-${sls:stage}-attachments-${AWS::AccountId} + ClamDefsBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: !Sub ${self:service}-${sls:stage}-avscan-${AWS::AccountId} + AccessControl: Private + BucketEncryption: + ServerSideEncryptionConfiguration: + - ServerSideEncryptionByDefault: + SSEAlgorithm: AES256 + ClamDefsBucketPolicy: + Type: AWS::S3::BucketPolicy + Properties: + Bucket: + Ref: ClamDefsBucket + PolicyDocument: + Statement: + - Effect: Deny + Principal: "*" + Action: "s3:*" + Resource: + - !Sub ${ClamDefsBucket.Arn}/* + - !Sub ${ClamDefsBucket.Arn} + Condition: + Bool: + "aws:SecureTransport": "false" + TriggerInitialDownload: + Type: Custom::TriggerInitialDownload + Properties: + ServiceToken: !GetAtt TriggerInitialDownloadLambdaFunction.Arn + FunctionName: !GetAtt AvDownloadDefinitionsLambdaFunction.Arn + Outputs: + AttachmentsBucketName: + Value: + Ref: AttachmentsBucket + AttachmentsBucketArn: + Value: !GetAtt AttachmentsBucket.Arn + AttachmentsBucketRegion: + Value: ${self:provider.region} diff --git a/src/services/uploads/src/antivirus.ts b/src/services/uploads/src/antivirus.ts new file mode 100644 index 0000000000..2e0a2d8118 --- /dev/null +++ b/src/services/uploads/src/antivirus.ts @@ -0,0 +1,140 @@ +import { + S3Client, + HeadObjectCommand, + GetObjectCommand, + PutObjectTaggingCommand, +} from "@aws-sdk/client-s3"; +import { randomUUID } from "crypto"; +import fs from "fs"; +import asyncfs from "fs/promises"; + +import { downloadAVDefinitions, scanLocalFile } from "./clamav"; +import * as utils from "./utils"; +import * as constants from "./constants"; + +const s3Client: S3Client = new S3Client(); + +export async function isS3FileTooBig( + key: string, + bucket: string +): Promise { + try { + const res: HeadObjectCommandOutput = await s3Client.send( + new HeadObjectCommand({ Key: key, Bucket: bucket }) + ); + return res.ContentLength > constants.MAX_FILE_SIZE; + } catch (e) { + utils.generateSystemMessage( + `Error finding size of S3 Object: s3://${bucket}/${key}` + ); + return false; + } +} + +async function downloadFileFromS3( + s3ObjectKey: string, + s3ObjectBucket: string +): Promise { + if (!fs.existsSync(constants.TMP_DOWNLOAD_PATH)) { + fs.mkdirSync(constants.TMP_DOWNLOAD_PATH); + } + + const localPath: string = `${constants.TMP_DOWNLOAD_PATH}${randomUUID()}.tmp`; + const writeStream: fs.WriteStream = fs.createWriteStream(localPath); + + utils.generateSystemMessage( + `Downloading file s3://${s3ObjectBucket}/${s3ObjectKey}` + ); + + const options = { + Bucket: s3ObjectBucket, + Key: s3ObjectKey, + }; + + try { + const { Body } = await s3Client.send(new GetObjectCommand(options)); + await asyncfs.writeFile(localPath, Body); + utils.generateSystemMessage( + `Finished downloading new object ${s3ObjectKey}` + ); + return localPath; + } catch (err) { + console.error(err); + throw err; + } +} + +const scanAndTagS3Object = async ( + s3ObjectKey: string, + s3ObjectBucket: string +): Promise => { + utils.generateSystemMessage( + `S3 Bucket and Key\n ${s3ObjectBucket}\n${s3ObjectKey}` + ); + + let virusScanStatus: string; + + if (await isS3FileTooBig(s3ObjectKey, s3ObjectBucket)) { + virusScanStatus = constants.STATUS_SKIPPED_FILE; + utils.generateSystemMessage( + `S3 File is too big. virusScanStatus=${virusScanStatus}` + ); + } else { + utils.generateSystemMessage("Download AV Definitions"); + await downloadAVDefinitions(); + utils.generateSystemMessage("Download File from S3"); + const fileLoc: string = await downloadFileFromS3( + s3ObjectKey, + s3ObjectBucket + ); + utils.generateSystemMessage("Set virusScanStatus"); + virusScanStatus = scanLocalFile(fileLoc); + utils.generateSystemMessage(`virusScanStatus=${virusScanStatus}`); + } + + const taggingParams = { + Bucket: s3ObjectBucket, + Key: s3ObjectKey, + Tagging: utils.generateTagSet(virusScanStatus), + }; + + try { + await s3Client.send(new PutObjectTaggingCommand(taggingParams)); + utils.generateSystemMessage("Tagging successful"); + } catch (err) { + console.error(err); + } + + return virusScanStatus; +}; + +export async function lambdaHandleEvent(event: any): Promise { + utils.generateSystemMessage( + `Start avScan with event ${JSON.stringify(event, null, 2)}` + ); + + let s3ObjectKey: string, s3ObjectBucket: string; + + if (event.s3ObjectKey && event.s3ObjectBucket) { + s3ObjectKey = event.s3ObjectKey; + s3ObjectBucket = event.s3ObjectBucket; + } else if ( + event.Records && + Array.isArray(event.Records) && + event.Records[0]?.eventSource === "aws:s3" + ) { + s3ObjectKey = utils.extractKeyFromS3Event(event); + s3ObjectBucket = utils.extractBucketFromS3Event(event); + } else { + utils.generateSystemMessage( + `Event missing s3ObjectKey or s3ObjectBucket: ${JSON.stringify( + event, + null, + 2 + )}` + ); + return constants.STATUS_ERROR_PROCESSING_FILE; + } + + return await scanAndTagS3Object(s3ObjectKey, s3ObjectBucket); +} diff --git a/src/services/uploads/src/clamav.ts b/src/services/uploads/src/clamav.ts new file mode 100644 index 0000000000..19c6d5d05d --- /dev/null +++ b/src/services/uploads/src/clamav.ts @@ -0,0 +1,244 @@ +import { + S3Client, + ListObjectsV2Command, + GetObjectCommand, + PutObjectCommand, + DeleteObjectsCommand, +} from "@aws-sdk/client-s3"; +import { spawnSync, SpawnSyncReturns } from "child_process"; +import path from "path"; +import fs from "fs"; +import asyncfs from "fs/promises"; +import * as constants from "./constants"; +import * as utils from "./utils"; + +const s3Client: S3Client = new S3Client(); + +export async function listBucketFiles(bucketName: string): Promise { + try { + const listFilesResult = await s3Client.send( + new ListObjectsV2Command({ Bucket: bucketName }) + ); + if (listFilesResult.Contents) { + const keys: string[] = listFilesResult.Contents.map((c) => c.Key); + return keys; + } else { + return []; + } + } catch (err) { + utils.generateSystemMessage("Error listing files"); + console.error(err); + throw err; + } +} + +export const updateAVDefinitonsWithFreshclam = (): boolean => { + try { + const { stdout, stderr, error }: SpawnSyncReturns = spawnSync( + `${constants.PATH_TO_FRESHCLAM}`, + [ + `--config-file=${constants.FRESHCLAM_CONFIG}`, + `--datadir=${constants.FRESHCLAM_WORK_DIR}`, + ] + ); + utils.generateSystemMessage("Update message"); + console.log(stdout.toString()); + + console.log("Downloaded:", fs.readdirSync(constants.FRESHCLAM_WORK_DIR)); + + if (stderr) { + utils.generateSystemMessage("stderr"); + console.log(stderr.toString()); + } + + return true; + } catch (err) { + console.log("in the catch"); + console.log(err); + return false; + } +}; + +/** + * Download the Antivirus definition from S3. + * The definitions are stored on the local disk, ensure there's enough space. + */ +export const downloadAVDefinitions = async (): Promise => { + // list all the files in that bucket + utils.generateSystemMessage("Downloading Definitions"); + const allFileKeys: string[] = await listBucketFiles( + constants.CLAMAV_BUCKET_NAME + ); + + const definitionFileKeys: string[] = allFileKeys + .filter((key) => key.startsWith(constants.PATH_TO_AV_DEFINITIONS)) + .map((fullPath) => path.basename(fullPath)); + + // download each file in the bucket. + const downloadPromises: Promise[] = definitionFileKeys.map( + (filenameToDownload) => { + return new Promise(async (resolve, reject) => { + const destinationFile: string = path.join( + constants.FRESHCLAM_WORK_DIR, + filenameToDownload + ); + + utils.generateSystemMessage( + `Downloading ${filenameToDownload} from S3 to ${destinationFile}` + ); + + const localFileWriteStream = fs.createWriteStream(destinationFile); + + const options = { + Bucket: constants.CLAMAV_BUCKET_NAME, + Key: `${constants.PATH_TO_AV_DEFINITIONS}/${filenameToDownload}`, + }; + + try { + const { Body } = await s3Client.send(new GetObjectCommand(options)); + await asyncfs.writeFile(destinationFile, Body); + utils.generateSystemMessage( + `Finished download ${filenameToDownload}` + ); + resolve(); + } catch (err) { + utils.generateSystemMessage( + `Error downloading definition file ${filenameToDownload}` + ); + console.log(err); + reject(); + } + }); + } + ); + + return await Promise.all(downloadPromises); +}; + +/** + * Uploads the AV definitions to the S3 bucket. + */ +export const uploadAVDefinitions = async (): Promise => { + // delete all the definitions currently in the bucket. + // first list them. + utils.generateSystemMessage("Uploading Definitions"); + const s3AllFullKeys: string[] = await listBucketFiles( + constants.CLAMAV_BUCKET_NAME + ); + const s3DefinitionFileFullKeys: string[] = s3AllFullKeys.filter((key) => + key.startsWith(constants.PATH_TO_AV_DEFINITIONS) + ); + + // If there are any s3 Definition files in the s3 bucket, delete them. + if (s3DefinitionFileFullKeys && s3DefinitionFileFullKeys.length !== 0) { + try { + await s3Client.send( + new DeleteObjectsCommand({ + Bucket: constants.CLAMAV_BUCKET_NAME, + Delete: { + Objects: s3DefinitionFileFullKeys.map((k) => { + return { Key: k }; + }), + }, + }) + ); + + utils.generateSystemMessage( + `Deleted extant definitions: ${s3DefinitionFileFullKeys}` + ); + } catch (err) { + utils.generateSystemMessage( + `Error deleting current definition files: ${s3DefinitionFileFullKeys}` + ); + console.log(err); + throw err; + } + } + + // list all the files in the work dir for upload + const definitionFiles: string[] = fs.readdirSync( + constants.FRESHCLAM_WORK_DIR + ); + + const uploadPromises: Promise[] = definitionFiles.map( + (filenameToUpload) => { + return new Promise(async (resolve, reject) => { + utils.generateSystemMessage( + `Uploading updated definitions for file ${filenameToUpload} ---` + ); + + const options = { + Bucket: constants.CLAMAV_BUCKET_NAME, + Key: `${constants.PATH_TO_AV_DEFINITIONS}/${filenameToUpload}`, + Body: fs.readFileSync( + path.join(constants.FRESHCLAM_WORK_DIR, filenameToUpload) + ), + }; + + try { + await s3Client.send(new PutObjectCommand(options)); + resolve(); + utils.generateSystemMessage( + `--- Finished uploading ${filenameToUpload} ---` + ); + } catch (err) { + utils.generateSystemMessage( + `--- Error uploading ${filenameToUpload} ---` + ); + console.log(err); + reject(); + } + }); + } + ); + + return await Promise.all(uploadPromises); +}; + +/** + * Function to scan the given file. This function requires ClamAV and the definitions to be available. + * This function does not download the file so the file should also be accessible. + * + * Three possible case can happen: + * - The file is clean, the clamAV command returns 0 and the function return "CLEAN" + * - The file is infected, the clamAV command returns 1 and this function will return "INFECTED" + * - Any other error and the function will return null; (falsey) + * + * @param pathToFile Path in the filesystem where the file is stored. + */ +export const scanLocalFile = (pathToFile: string): string | null => { + try { + const avResult: SpawnSyncReturns = spawnSync( + constants.PATH_TO_CLAMAV, + [ + "--stdout", + "-v", + "-a", + "-d", + constants.FRESHCLAM_WORK_DIR, + pathToFile, + ] + ); + + // status 1 means that the file is infected. + if (avResult.status === 1) { + utils.generateSystemMessage("SUCCESSFUL SCAN, FILE INFECTED"); + return constants.STATUS_INFECTED_FILE; + } else if (avResult.status !== 0) { + utils.generateSystemMessage("-- SCAN FAILED WITH ERROR --"); + console.error("stderror", avResult.stderr.toString()); + console.error("stdout", avResult.stdout.toString()); + console.error("err", avResult.error); + return constants.STATUS_ERROR_PROCESSING_FILE; + } + + utils.generateSystemMessage("SUCCESSFUL SCAN, FILE CLEAN"); + console.log(avResult.stdout.toString()); + + return constants.STATUS_CLEAN_FILE; + } catch (err) { + utils.generateSystemMessage("-- SCAN FAILED --"); + console.log(err); + return constants.STATUS_ERROR_PROCESSING_FILE; + } +}; \ No newline at end of file diff --git a/src/services/uploads/src/constants.ts b/src/services/uploads/src/constants.ts new file mode 100644 index 0000000000..61325d3bc6 --- /dev/null +++ b/src/services/uploads/src/constants.ts @@ -0,0 +1,46 @@ +/** + * Exposes the constants used throughout the program. + * + * The following variables have to be set: + * + * CLAMAV_BUCKET_NAME: Name of the bucket where ClamAV and its definitions are stored + * PATH_TO_AV_DEFINITIONS: Path in S3 where the definitions are stored. + * + * The following variables can be overridden: + * + * STATUS_CLEAN_FILE: (default 'CLEAN') Tag that will be added to files that are clean. + * STATUS_INFECTED_FILE: (default 'INFECTED') Tag that will be added to files that are infected. + * STATUS_ERROR_PROCESSING_FILE: (default 'ERROR') Tag that will be added to files where the scan was not successful. + * VIRUS_SCAN_STATUS_KEY: (default 'virusScanStatus') Name of the tag that indicates the status of the virus scan. + * VIRUS_SCAN_TIMESTAMP_KEY: (default 'virusScanTimestamp') Name of the tag that indicates the time of the virus scan. + */ + +import process from "process"; + +// Various paths and application names on S3 +export const ATTACHMENTS_BUCKET: string | undefined = + process.env.ATTACHMENTS_BUCKET; +export const CLAMAV_BUCKET_NAME: string | undefined = + process.env.CLAMAV_BUCKET_NAME; +export const PATH_TO_AV_DEFINITIONS: string | undefined = + process.env.PATH_TO_AV_DEFINITIONS; +export const PATH_TO_FRESHCLAM: string = "/opt/bin/freshclam"; +export const PATH_TO_CLAMAV: string = "/opt/bin/clamscan"; +export const FRESHCLAM_CONFIG: string = "/opt/bin/freshclam.conf"; +export const FRESHCLAM_WORK_DIR: string = "/tmp/"; +export const TMP_DOWNLOAD_PATH: string = "/tmp/download/"; + +// Constants for tagging file after a virus scan. +export const STATUS_CLEAN_FILE: string = + process.env.STATUS_CLEAN_FILE || "CLEAN"; +export const STATUS_INFECTED_FILE: string = + process.env.STATUS_INFECTED_FILE || "INFECTED"; +export const STATUS_ERROR_PROCESSING_FILE: string = + process.env.STATUS_ERROR_PROCESSING_FILE || "ERROR"; +export const STATUS_SKIPPED_FILE: string = + process.env.STATUS_SKIPPED_FILE || "SKIPPED"; +export const VIRUS_SCAN_STATUS_KEY: string = + process.env.VIRUS_SCAN_STATUS_KEY || "virusScanStatus"; +export const VIRUS_SCAN_TIMESTAMP_KEY: string = + process.env.VIRUS_SCAN_TIMESTAMP_KEY || "virusScanTimestamp"; +export const MAX_FILE_SIZE: string = process.env.MAX_FILE_SIZE || "314572800"; diff --git a/src/services/uploads/src/download-definitions.ts b/src/services/uploads/src/download-definitions.ts new file mode 100644 index 0000000000..151a4dd710 --- /dev/null +++ b/src/services/uploads/src/download-definitions.ts @@ -0,0 +1,19 @@ +import * as clamav from "./clamav"; +import { generateSystemMessage, cleanupFolder } from "./utils"; +import { FRESHCLAM_WORK_DIR } from "./constants"; + +export async function lambdaHandleEvent(): Promise { + generateSystemMessage(`AV definition update start time: ${new Date()}`); + + await cleanupFolder(FRESHCLAM_WORK_DIR); + if (await clamav.updateAVDefinitonsWithFreshclam()) { + generateSystemMessage("Folder content after freshclam "); + await clamav.uploadAVDefinitions(); + + generateSystemMessage(`AV definition update end time: ${new Date()}`); + + return "DEFINITION UPDATE SUCCESS"; + } else { + return "DEFINITION UPDATE FAILED"; + } +} \ No newline at end of file diff --git a/src/services/uploads/src/triggerInitialDownload.ts b/src/services/uploads/src/triggerInitialDownload.ts new file mode 100644 index 0000000000..a9ff085c0d --- /dev/null +++ b/src/services/uploads/src/triggerInitialDownload.ts @@ -0,0 +1,27 @@ +import { Handler } from "aws-lambda"; +import { send, SUCCESS, FAILED } from "cfn-response-async"; +type ResponseStatus = typeof SUCCESS | typeof FAILED; +import { LambdaClient, InvokeCommand } from "@aws-sdk/client-lambda"; + +const lambdaClient = new LambdaClient({}); + +export const handler: Handler = async (event, context) => { + console.log("request:", JSON.stringify(event, undefined, 2)); + const responseData = {}; + let responseStatus: ResponseStatus = SUCCESS; + try { + if (event.RequestType == "Create" || event.RequestType == "Update") { + const invokeCommand = new InvokeCommand({ + FunctionName: event.ResourceProperties.FunctionName, + InvocationType: "RequestResponse", + }); + await lambdaClient.send(invokeCommand); + } + } catch (error) { + console.log(error); + responseStatus = FAILED; + } finally { + console.log("finally"); + await send(event, context, responseStatus, responseData); + } +}; diff --git a/src/services/uploads/src/utils.ts b/src/services/uploads/src/utils.ts new file mode 100644 index 0000000000..90ca9c4dbc --- /dev/null +++ b/src/services/uploads/src/utils.ts @@ -0,0 +1,91 @@ +import { execSync } from "child_process"; + +import * as constants from "./constants"; + +interface TagSet { + TagSet: Tag[]; +} + +interface Tag { + Key: string; + Value: string; +} + +/** + * Generates the set of tags that will be used to tag the files of S3. + * @param virusScanStatus String representing the status. + * @return {{TagSet: *[]}} TagSet ready to be attached to an S3 file. + */ +export function generateTagSet(virusScanStatus: string): TagSet { + return { + TagSet: [ + { + Key: constants.VIRUS_SCAN_STATUS_KEY, + Value: virusScanStatus, + }, + { + Key: constants.VIRUS_SCAN_TIMESTAMP_KEY, + Value: new Date().getTime().toString(), + }, + ], + }; +} + +/** + * Cleanup the specific S3 folder by removing all of its content. + * We need that to cleanup the /tmp/ folder after the download of the definitions. + */ +export function cleanupFolder(folderToClean: string): void { + let result: Buffer = execSync(`ls -l ${folderToClean}`); + + console.log("-- Folder before cleanup--"); + console.log(result.toString()); + + execSync(`rm -rf ${folderToClean}*`); + + result = execSync(`ls -l ${folderToClean}`); + + console.log("-- Folder after cleanup --"); + console.log(result.toString()); +} + +/** + * Extract the key from an S3 event. + * @param s3Event Inbound S3 event. + * @return {string} decoded key. + */ +export function extractKeyFromS3Event(s3Event: any): string { + const key: string = s3Event["Records"][0]["s3"]["object"]["key"]; + + if (!key) { + throw new Error("Unable to retrieve key information from the event"); + } + + return decodeURIComponent(key).replace(/\+/g, " "); +} + +/** + * Extract the bucket from an S3 event. + * @param s3Event Inbound S3 event. + * @return {string} Bucket + */ +export function extractBucketFromS3Event(s3Event: any): string { + const bucketName: string = s3Event["Records"][0]["s3"]["bucket"]["name"]; + + if (!bucketName) { + throw new Error("Unable to retrieve bucket information from the event"); + } + + return bucketName; +} + +/** + * Generates & logs a system message (simple --- the message here ---) + * @param systemMessage Inbound message to log and generate. + * @return {string} Formatted message. + */ +export function generateSystemMessage(systemMessage: string): string { + const finalMessage: string = `--- ${systemMessage} ---`; + console.log(finalMessage); + return finalMessage; +} diff --git a/src/services/uploads/tsconfig.json b/src/services/uploads/tsconfig.json new file mode 100644 index 0000000000..8b94d5c8d7 --- /dev/null +++ b/src/services/uploads/tsconfig.json @@ -0,0 +1,15 @@ +{ + "exclude": [ + "./docs/**/*", + "./src/services/**/*", + "./src/libs/**/*", + "./src/tests/**/**" + ], + "compilerOptions": { + "resolveJsonModule": true, + "target": "es5", + "module": "commonjs", + "strict": true, + "allowSyntheticDefaultImports": true + } +} From f3e8e429b8b31da9ebf3001e9449e307e248a4ea Mon Sep 17 00:00:00 2001 From: "Gavin St. Ours" Date: Mon, 30 Oct 2023 10:07:45 -0400 Subject: [PATCH 02/19] Create useGetForm --- src/services/ui/src/api/useGetForm.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/services/ui/src/api/useGetForm.ts diff --git a/src/services/ui/src/api/useGetForm.ts b/src/services/ui/src/api/useGetForm.ts new file mode 100644 index 0000000000..d56c1bdb4b --- /dev/null +++ b/src/services/ui/src/api/useGetForm.ts @@ -0,0 +1,21 @@ +import { useQuery, useQueryOptions } from "@tanstack/react-query"; +import { API } from "aws-amplify"; +import { OsHit, OsMainSourceItem, ReactQueryApiError } from "shared-types"; + +export const getForm = async ( + formId: string, + formVersion?: number +): Promise> => { + const form = await API.post("os", "/forms", { + body: { formId, formVersion }, + }); + + return form; +}; + +export const useGetForm = (formId: string, formVersion?: number) => { + return useQuery, ReactQueryApiError>( + ["form", formId], + () => getForm(formId, formVersion) + ); +}; From fe2178402595c09f45f723e2431c516c7313f1d5 Mon Sep 17 00:00:00 2001 From: "Gavin St. Ours" Date: Mon, 30 Oct 2023 10:23:27 -0400 Subject: [PATCH 03/19] Test useGetForm --- src/services/ui/src/api/useGetForm.ts | 8 ++++---- src/services/ui/src/pages/form/index.tsx | 7 ++++++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/services/ui/src/api/useGetForm.ts b/src/services/ui/src/api/useGetForm.ts index d56c1bdb4b..2a437fdd9e 100644 --- a/src/services/ui/src/api/useGetForm.ts +++ b/src/services/ui/src/api/useGetForm.ts @@ -1,4 +1,4 @@ -import { useQuery, useQueryOptions } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { API } from "aws-amplify"; import { OsHit, OsMainSourceItem, ReactQueryApiError } from "shared-types"; @@ -13,9 +13,9 @@ export const getForm = async ( return form; }; -export const useGetForm = (formId: string, formVersion?: number) => { +export const useGetForm = (id: string, formVersion?: number) => { return useQuery, ReactQueryApiError>( - ["form", formId], - () => getForm(formId, formVersion) + ["formID", id], + () => getForm(id, formVersion) ); }; diff --git a/src/services/ui/src/pages/form/index.tsx b/src/services/ui/src/pages/form/index.tsx index 2d1cc83ac2..c00f143d9d 100644 --- a/src/services/ui/src/pages/form/index.tsx +++ b/src/services/ui/src/pages/form/index.tsx @@ -1,6 +1,6 @@ import { useForm } from "react-hook-form"; import { Button, Form } from "@/components/Inputs"; - +import { useGetForm } from "@/api/useGetForm"; import { RHFDocument } from "@/components/RHF"; import { ABP1 } from "./proto"; import { documentInitializer } from "@/components/RHF"; @@ -21,8 +21,13 @@ export function ExampleForm() { } ); + const { data, isLoading, error } = useGetForm("testform"); + console.info(data, isLoading, error); + return (
+ {isLoading} +
From 53ca0ec50a9bd581ccbb3da2cc956eaa4a64de3e Mon Sep 17 00:00:00 2001 From: "Gavin St. Ours" Date: Mon, 30 Oct 2023 10:36:56 -0400 Subject: [PATCH 04/19] Export useGetForm --- src/services/ui/src/api/index.ts | 1 + src/services/ui/src/api/useGetForm.ts | 15 ++++++--------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/services/ui/src/api/index.ts b/src/services/ui/src/api/index.ts index 8165896841..1413d6a571 100644 --- a/src/services/ui/src/api/index.ts +++ b/src/services/ui/src/api/index.ts @@ -1,3 +1,4 @@ export * from "./useSearch"; +export * from "./useGetForm"; export * from "./useGetItem"; export * from "./getAttachmentUrl"; diff --git a/src/services/ui/src/api/useGetForm.ts b/src/services/ui/src/api/useGetForm.ts index 2a437fdd9e..c3b1acf7e7 100644 --- a/src/services/ui/src/api/useGetForm.ts +++ b/src/services/ui/src/api/useGetForm.ts @@ -2,20 +2,17 @@ import { useQuery } from "@tanstack/react-query"; import { API } from "aws-amplify"; import { OsHit, OsMainSourceItem, ReactQueryApiError } from "shared-types"; -export const getForm = async ( - formId: string, - formVersion?: number -): Promise> => { - const form = await API.post("os", "/forms", { - body: { formId, formVersion }, - }); +export const getForm = async (id: string): Promise> => { + const form = await API.post("os", "/forms", { body: { id } }); + + console.info("form", form); return form; }; -export const useGetForm = (id: string, formVersion?: number) => { +export const useGetForm = (id: string) => { return useQuery, ReactQueryApiError>( ["formID", id], - () => getForm(id, formVersion) + () => getForm(id) ); }; From 0705c3fc6d6348be521765f448f55a76098df773 Mon Sep 17 00:00:00 2001 From: Mike Dial <48921055+mdial89f@users.noreply.github.com> Date: Mon, 30 Oct 2023 11:06:15 -0400 Subject: [PATCH 05/19] fix(minor things): Some clean up and standardization (#177) * Correct syntax to avoid warnings and such * make this match deploy * Fixes for cleanup function * lint * lint * lint * lint --- .github/workflows/destroy.yml | 8 ++++---- src/services/api/handlers/getUploadUrl.ts | 4 +--- .../dashboard/tests/createDashboardTemplateWidget.test.ts | 2 +- .../dashboard/tests/templatizeCloudWatchDashboard.test.ts | 2 +- src/services/data/handlers/cleanupKafka.ts | 6 +++--- src/services/data/libs/topics-lib.js | 4 +++- src/services/data/serverless.yml | 1 + src/services/ui/src/api/useSearch.ts | 1 - 8 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/destroy.yml b/.github/workflows/destroy.yml index 2410fa3fa7..ed62b9b9c0 100644 --- a/.github/workflows/destroy.yml +++ b/.github/workflows/destroy.yml @@ -13,12 +13,12 @@ jobs: if: | ( github.event.ref_type == 'branch' && - !startsWith(github.event.ref, 'skipci') && - !contains(fromJson('["master", "staging", "production"]'), github.event.ref) + (!startsWith(github.event.ref, 'skipci')) && + (!contains(fromJson('["master", "staging", "production"]'), github.event.ref)) ) || ( inputs.environment != '' && - !contains(fromJson('["master", "staging", "production"]'), inputs.environment) + (!contains(fromJson('["master", "staging", "production"]'), inputs.environment)) ) runs-on: ubuntu-20.04 environment: @@ -51,7 +51,7 @@ jobs: SLACK_COLOR: ${{job.status}} SLACK_ICON: https://github.com/Enterprise-CMCS.png?size=48 SLACK_TITLE: Failure - SLACK_USERNAME: ${{ github.repository }} ${{job.status}} + SLACK_USERNAME: ${{ github.repository }} - ${{job.status}} SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} delete_environment: diff --git a/src/services/api/handlers/getUploadUrl.ts b/src/services/api/handlers/getUploadUrl.ts index 276ad47e6a..3254d039c6 100644 --- a/src/services/api/handlers/getUploadUrl.ts +++ b/src/services/api/handlers/getUploadUrl.ts @@ -1,5 +1,4 @@ import { response } from "../libs/handler"; -import { APIGatewayEvent } from "aws-lambda"; import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { v4 as uuidv4 } from "uuid"; @@ -10,9 +9,8 @@ const s3 = new S3Client({ region: process.env.attachmentsBucketRegion, }); -export const handler = async (event: APIGatewayEvent) => { +export const handler = async () => { try { - const body = JSON.parse(event.body); const bucket = process.env.attachmentsBucketName; const key = uuidv4(); const url = await getSignedUrl( diff --git a/src/services/dashboard/tests/createDashboardTemplateWidget.test.ts b/src/services/dashboard/tests/createDashboardTemplateWidget.test.ts index 136f9656a8..f7ef425f26 100644 --- a/src/services/dashboard/tests/createDashboardTemplateWidget.test.ts +++ b/src/services/dashboard/tests/createDashboardTemplateWidget.test.ts @@ -1,4 +1,4 @@ -import { it, describe, expect, beforeEach } from "vitest"; +import { it, describe, expect } from "vitest"; import { handler } from "../handlers/createDashboardTemplateWidget"; import type { APIGatewayEvent, diff --git a/src/services/dashboard/tests/templatizeCloudWatchDashboard.test.ts b/src/services/dashboard/tests/templatizeCloudWatchDashboard.test.ts index c7449e182b..92a197a3ee 100644 --- a/src/services/dashboard/tests/templatizeCloudWatchDashboard.test.ts +++ b/src/services/dashboard/tests/templatizeCloudWatchDashboard.test.ts @@ -1,4 +1,4 @@ -import { it, describe, expect, beforeEach, afterEach, vi } from "vitest"; +import { it, describe, expect, afterEach, vi } from "vitest"; import { CloudWatch } from "@aws-sdk/client-cloudwatch"; import { handler } from "../handlers/templatizeCloudWatchDashboard"; import type { diff --git a/src/services/data/handlers/cleanupKafka.ts b/src/services/data/handlers/cleanupKafka.ts index 830b5ef560..a2ca2afea0 100644 --- a/src/services/data/handlers/cleanupKafka.ts +++ b/src/services/data/handlers/cleanupKafka.ts @@ -11,12 +11,12 @@ export const handler = async function ( const responseData: any = {}; let responseStatus: ResponseStatus = SUCCESS; try { - const BrokerString: string = event.ResourceProperties.BrokerString; - const TopicPatternsToDelete: string[] = - event.ResourceProperties.TopicPatternsToDelete; if (event.RequestType === "Create" || event.RequestType == "Update") { console.log("This resource does nothing on Create and Update events."); } else if (event.RequestType === "Delete") { + const BrokerString: string = event.ResourceProperties.BrokerString; + const TopicPatternsToDelete: string[] = + event.ResourceProperties.TopicPatternsToDelete; console.log( `Attempting a delete for each of the following patterns: ${TopicPatternsToDelete}` ); diff --git a/src/services/data/libs/topics-lib.js b/src/services/data/libs/topics-lib.js index 1df2261c6f..86c84fe383 100644 --- a/src/services/data/libs/topics-lib.js +++ b/src/services/data/libs/topics-lib.js @@ -108,7 +108,9 @@ export async function deleteTopics(brokerString, topicList) { const kafka = new Kafka({ clientId: "admin", brokers: brokers, - ssl: true, + ssl: { + rejectUnauthorized: false, + }, requestTimeout: 295000, // 5s short of the lambda function's timeout }); var admin = kafka.admin(); diff --git a/src/services/data/serverless.yml b/src/services/data/serverless.yml index 5950a795f7..c030bec139 100644 --- a/src/services/data/serverless.yml +++ b/src/services/data/serverless.yml @@ -243,6 +243,7 @@ functions: timeout: 300 cleanupKafka: handler: handlers/cleanupKafka.handler + maximumRetryAttempts: 0 vpc: securityGroupIds: - Ref: SecurityGroup diff --git a/src/services/ui/src/api/useSearch.ts b/src/services/ui/src/api/useSearch.ts index a16254a48d..5e5f9504b9 100644 --- a/src/services/ui/src/api/useSearch.ts +++ b/src/services/ui/src/api/useSearch.ts @@ -12,7 +12,6 @@ import type { OsFilterable, OsAggQuery, OsMainSearchResponse, - OsHits, OsMainSourceItem, } from "shared-types"; From b947838664ffe021b325acfa0d9b0b6be672c04e Mon Sep 17 00:00:00 2001 From: "Gavin St. Ours" Date: Tue, 31 Oct 2023 14:26:20 -0400 Subject: [PATCH 06/19] Temporarily disable authorizer --- src/services/api/serverless.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/api/serverless.yml b/src/services/api/serverless.yml index a77c6d0161..98e80fa5b4 100644 --- a/src/services/api/serverless.yml +++ b/src/services/api/serverless.yml @@ -171,7 +171,7 @@ functions: path: /forms method: get cors: true - authorizer: aws_iam + # authorizer: aws_iam vpc: securityGroupIds: - Ref: SecurityGroup From 4f73705ce228489b1cfb557bbc25d93aeaabe42b Mon Sep 17 00:00:00 2001 From: "Gavin St. Ours" Date: Tue, 31 Oct 2023 14:37:48 -0400 Subject: [PATCH 07/19] Temporarily hard-code formId --- src/services/api/handlers/forms.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/services/api/handlers/forms.ts b/src/services/api/handlers/forms.ts index 57c93de7ad..c9070cd3d4 100644 --- a/src/services/api/handlers/forms.ts +++ b/src/services/api/handlers/forms.ts @@ -7,17 +7,17 @@ export const forms = async (event: APIGatewayEvent) => { const formId = body.formId; const formVersion = body.formVersion; - if (!formId) { - return { - statusCode: 400, - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ error: "File ID was not provided" }), - }; - } + // if (!formId) { + // return { + // statusCode: 400, + // headers: { + // "Content-Type": "application/json", + // }, + // body: JSON.stringify({ error: "File ID was not provided" }), + // }; + // } - const filePath = getFilepathForIdAndVersion(formId, formVersion); + const filePath = getFilepathForIdAndVersion("testform", formVersion); const jsonData = await fs.promises.readFile(filePath, "utf-8"); if (!jsonData) { From bd1194a3905bbe24aad92a06c92b4df60d6a5c28 Mon Sep 17 00:00:00 2001 From: "Gavin St. Ours" Date: Tue, 31 Oct 2023 14:48:06 -0400 Subject: [PATCH 08/19] Wrap returns in response --- src/services/api/handlers/forms.ts | 35 ++++++++++++------------------ 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/src/services/api/handlers/forms.ts b/src/services/api/handlers/forms.ts index c9070cd3d4..15d795f52c 100644 --- a/src/services/api/handlers/forms.ts +++ b/src/services/api/handlers/forms.ts @@ -1,3 +1,5 @@ +import { response } from "../libs/handler"; + import * as fs from "fs"; import { APIGatewayEvent } from "aws-lambda"; @@ -7,46 +9,37 @@ export const forms = async (event: APIGatewayEvent) => { const formId = body.formId; const formVersion = body.formVersion; - // if (!formId) { - // return { - // statusCode: 400, - // headers: { - // "Content-Type": "application/json", - // }, - // body: JSON.stringify({ error: "File ID was not provided" }), - // }; - // } + if (!formId) { + return response({ + statusCode: 400, + body: JSON.stringify({ error: "File ID was not provided" }), + }); + } const filePath = getFilepathForIdAndVersion("testform", formVersion); const jsonData = await fs.promises.readFile(filePath, "utf-8"); if (!jsonData) { - return { + return response({ statusCode: 404, - headers: { - "Content-Type": "application/json", - }, body: JSON.stringify({ error: "No file was found with provided formId and formVersion", }), - }; + }); } console.log(jsonData); - return { + return response({ statusCode: 200, - headers: { - "Content-Type": "application/json", - }, body: jsonData, - }; + }); } catch (error) { console.error("Error:", error); - return { + return response({ statusCode: 500, body: JSON.stringify({ error: error.message ? error.message : "Internal server error", }), - }; + }); } }; From 84f10514a7aea6eb2bc43e3f83977279d4f4ccde Mon Sep 17 00:00:00 2001 From: "Gavin St. Ours" Date: Tue, 31 Oct 2023 14:59:00 -0400 Subject: [PATCH 09/19] Temp. disable 400 response --- src/services/api/handlers/forms.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/services/api/handlers/forms.ts b/src/services/api/handlers/forms.ts index 15d795f52c..d8227d0889 100644 --- a/src/services/api/handlers/forms.ts +++ b/src/services/api/handlers/forms.ts @@ -9,12 +9,12 @@ export const forms = async (event: APIGatewayEvent) => { const formId = body.formId; const formVersion = body.formVersion; - if (!formId) { - return response({ - statusCode: 400, - body: JSON.stringify({ error: "File ID was not provided" }), - }); - } + // if (!formId) { + // return response({ + // statusCode: 400, + // body: JSON.stringify({ error: "File ID was not provided" }), + // }); + // } const filePath = getFilepathForIdAndVersion("testform", formVersion); const jsonData = await fs.promises.readFile(filePath, "utf-8"); From d0f29109efe83d4af329ecd369a9f1106fae4d39 Mon Sep 17 00:00:00 2001 From: "Gavin St. Ours" Date: Tue, 31 Oct 2023 15:51:39 -0400 Subject: [PATCH 10/19] Re-enable error 400 response --- src/services/api/handlers/forms.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/services/api/handlers/forms.ts b/src/services/api/handlers/forms.ts index d8227d0889..15d795f52c 100644 --- a/src/services/api/handlers/forms.ts +++ b/src/services/api/handlers/forms.ts @@ -9,12 +9,12 @@ export const forms = async (event: APIGatewayEvent) => { const formId = body.formId; const formVersion = body.formVersion; - // if (!formId) { - // return response({ - // statusCode: 400, - // body: JSON.stringify({ error: "File ID was not provided" }), - // }); - // } + if (!formId) { + return response({ + statusCode: 400, + body: JSON.stringify({ error: "File ID was not provided" }), + }); + } const filePath = getFilepathForIdAndVersion("testform", formVersion); const jsonData = await fs.promises.readFile(filePath, "utf-8"); From 03643672fa8c2f85abe3e894b6154c67a0d2165b Mon Sep 17 00:00:00 2001 From: "Gavin St. Ours" Date: Wed, 1 Nov 2023 10:27:06 -0400 Subject: [PATCH 11/19] WIP: Receive params instead of a body for GET req --- src/services/api/handlers/forms.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/services/api/handlers/forms.ts b/src/services/api/handlers/forms.ts index 15d795f52c..bee973ca8c 100644 --- a/src/services/api/handlers/forms.ts +++ b/src/services/api/handlers/forms.ts @@ -5,8 +5,10 @@ import { APIGatewayEvent } from "aws-lambda"; export const forms = async (event: APIGatewayEvent) => { try { + console.info("*** event", event); const body = event.body ? JSON.parse(event.body) : {}; - const formId = body.formId; + // const formId = body.formId; + const formId = "testForm"; const formVersion = body.formVersion; if (!formId) { @@ -16,7 +18,7 @@ export const forms = async (event: APIGatewayEvent) => { }); } - const filePath = getFilepathForIdAndVersion("testform", formVersion); + const filePath = getFilepathForIdAndVersion(formId, formVersion); const jsonData = await fs.promises.readFile(filePath, "utf-8"); if (!jsonData) { From 2c38067924435e2017a18c16f6aa738784c2caa6 Mon Sep 17 00:00:00 2001 From: "Gavin St. Ours" Date: Wed, 1 Nov 2023 10:39:24 -0400 Subject: [PATCH 12/19] WIP: Use query params --- src/services/api/handlers/forms.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/services/api/handlers/forms.ts b/src/services/api/handlers/forms.ts index bee973ca8c..71b5dbab9a 100644 --- a/src/services/api/handlers/forms.ts +++ b/src/services/api/handlers/forms.ts @@ -5,11 +5,10 @@ import { APIGatewayEvent } from "aws-lambda"; export const forms = async (event: APIGatewayEvent) => { try { - console.info("*** event", event); const body = event.body ? JSON.parse(event.body) : {}; // const formId = body.formId; - const formId = "testForm"; - const formVersion = body.formVersion; + const formId = event.queryStringParameters.formId; + const formVersion = event.queryStringParameters.formVersion; if (!formId) { return response({ From ec532e2a6588ee74cd285b35ec0ff5dbf536e967 Mon Sep 17 00:00:00 2001 From: "Gavin St. Ours" Date: Wed, 1 Nov 2023 13:28:17 -0400 Subject: [PATCH 13/19] Cleanup --- src/services/api/handlers/forms.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/services/api/handlers/forms.ts b/src/services/api/handlers/forms.ts index 71b5dbab9a..9e1bee72fa 100644 --- a/src/services/api/handlers/forms.ts +++ b/src/services/api/handlers/forms.ts @@ -5,8 +5,6 @@ import { APIGatewayEvent } from "aws-lambda"; export const forms = async (event: APIGatewayEvent) => { try { - const body = event.body ? JSON.parse(event.body) : {}; - // const formId = body.formId; const formId = event.queryStringParameters.formId; const formVersion = event.queryStringParameters.formVersion; From 54f4c554fab58f01159500c17842902b4ce8dc2f Mon Sep 17 00:00:00 2001 From: "Gavin St. Ours" Date: Wed, 1 Nov 2023 13:28:41 -0400 Subject: [PATCH 14/19] Cleanup --- src/services/ui/src/api/useGetForm.ts | 20 +++++++++++--------- src/services/ui/src/pages/form/index.tsx | 6 ------ 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/src/services/ui/src/api/useGetForm.ts b/src/services/ui/src/api/useGetForm.ts index c3b1acf7e7..058efef9e2 100644 --- a/src/services/ui/src/api/useGetForm.ts +++ b/src/services/ui/src/api/useGetForm.ts @@ -1,18 +1,20 @@ import { useQuery } from "@tanstack/react-query"; import { API } from "aws-amplify"; -import { OsHit, OsMainSourceItem, ReactQueryApiError } from "shared-types"; +import { ReactQueryApiError } from "shared-types"; -export const getForm = async (id: string): Promise> => { - const form = await API.post("os", "/forms", { body: { id } }); - - console.info("form", form); +export const getForm = async ( + formId: string, + formVersion?: string +): Promise => { + const form = await API.get("os", "/forms", { + queryStringParameters: { formId, formVersion }, + }); return form; }; -export const useGetForm = (id: string) => { - return useQuery, ReactQueryApiError>( - ["formID", id], - () => getForm(id) +export const useGetForm = (formId: string, formVersion?: string) => { + return useQuery(["formID"], () => + getForm(formId, formVersion) ); }; diff --git a/src/services/ui/src/pages/form/index.tsx b/src/services/ui/src/pages/form/index.tsx index c00f143d9d..f5c81a1a30 100644 --- a/src/services/ui/src/pages/form/index.tsx +++ b/src/services/ui/src/pages/form/index.tsx @@ -1,6 +1,5 @@ import { useForm } from "react-hook-form"; import { Button, Form } from "@/components/Inputs"; -import { useGetForm } from "@/api/useGetForm"; import { RHFDocument } from "@/components/RHF"; import { ABP1 } from "./proto"; import { documentInitializer } from "@/components/RHF"; @@ -21,13 +20,8 @@ export function ExampleForm() { } ); - const { data, isLoading, error } = useGetForm("testform"); - console.info(data, isLoading, error); - return (
- {isLoading} - From dce9e577be3869123673deb0fef56a5c0b47dd42 Mon Sep 17 00:00:00 2001 From: "Gavin St. Ours" Date: Wed, 1 Nov 2023 13:31:37 -0400 Subject: [PATCH 15/19] Reenable AWS IAM roles for forms endpoint --- src/services/api/serverless.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/api/serverless.yml b/src/services/api/serverless.yml index 98e80fa5b4..a77c6d0161 100644 --- a/src/services/api/serverless.yml +++ b/src/services/api/serverless.yml @@ -171,7 +171,7 @@ functions: path: /forms method: get cors: true - # authorizer: aws_iam + authorizer: aws_iam vpc: securityGroupIds: - Ref: SecurityGroup From 3bbb5ed980edd5353fe9625be944bdfa7d312c04 Mon Sep 17 00:00:00 2001 From: "Gavin St. Ours" Date: Thu, 2 Nov 2023 13:21:53 -0400 Subject: [PATCH 16/19] Use formID variable as query key --- src/services/ui/src/api/useGetForm.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/ui/src/api/useGetForm.ts b/src/services/ui/src/api/useGetForm.ts index 058efef9e2..c72ec4943c 100644 --- a/src/services/ui/src/api/useGetForm.ts +++ b/src/services/ui/src/api/useGetForm.ts @@ -14,7 +14,7 @@ export const getForm = async ( }; export const useGetForm = (formId: string, formVersion?: string) => { - return useQuery(["formID"], () => + return useQuery([formId], () => getForm(formId, formVersion) ); }; From 3329ee51dd21cb40f4a453cce96e200efdd3714e Mon Sep 17 00:00:00 2001 From: "Gavin St. Ours" Date: Thu, 2 Nov 2023 13:22:48 -0400 Subject: [PATCH 17/19] Add a TODO reminder to use the Document type --- src/services/ui/src/api/useGetForm.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/services/ui/src/api/useGetForm.ts b/src/services/ui/src/api/useGetForm.ts index c72ec4943c..5cb2f9eeca 100644 --- a/src/services/ui/src/api/useGetForm.ts +++ b/src/services/ui/src/api/useGetForm.ts @@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { API } from "aws-amplify"; import { ReactQueryApiError } from "shared-types"; +// TODO: Use the Document type here once it is in a shared location. export const getForm = async ( formId: string, formVersion?: string From 103c04e5a34f058723339bfd8da15cac9b98b573 Mon Sep 17 00:00:00 2001 From: Kevin Haube Date: Thu, 2 Nov 2023 14:50:44 -0400 Subject: [PATCH 18/19] feat(package actions): Set up API to return actions; UI to read and render actions (#182) * get item now gives actions * remove rai timestamp condition for manual testing * Set up front-end to read and render actions * Map action link added * Add routing tooling for action forms * Update routing * Remove need for mapping routes * Testing previous commit * Make getPackageActions endpoint * Make getPackageActions endpoint * Add api hook for ui * Use hook for UI * Add endpoint to yaml * Fix ui-api connection * Fix box placement * Add ID into path * Remove actions from ItemResult type * Add default text for no actions --- src/packages/shared-types/actions.ts | 3 + src/packages/shared-types/index.ts | 1 + src/packages/shared-types/opensearch.ts | 4 + .../api/handlers/getPackageActions.ts | 66 +++++++++++++++ src/services/api/handlers/item.ts | 11 +-- src/services/api/libs/package/getPackage.ts | 5 ++ src/services/api/serverless.yml | 19 +++++ src/services/ui/src/api/index.ts | 1 + src/services/ui/src/api/useGetItem.ts | 8 +- .../ui/src/api/useGetPackageActions.ts | 19 +++++ .../components/Cards/CardWithTopBorder.tsx | 5 +- .../actions/EnableRaiResponseWithdraw.tsx | 15 ++++ src/services/ui/src/pages/actions/index.tsx | 16 ++++ src/services/ui/src/pages/detail/index.tsx | 82 ++++++++++++++----- src/services/ui/src/pages/index.ts | 1 + src/services/ui/src/router.tsx | 1 + src/services/ui/src/routes.ts | 3 +- .../ui/src/utils/actionLabelMapper.ts | 8 ++ src/services/ui/src/utils/index.ts | 1 + 19 files changed, 234 insertions(+), 35 deletions(-) create mode 100644 src/packages/shared-types/actions.ts create mode 100644 src/services/api/handlers/getPackageActions.ts create mode 100644 src/services/api/libs/package/getPackage.ts create mode 100644 src/services/ui/src/api/useGetPackageActions.ts create mode 100644 src/services/ui/src/pages/actions/EnableRaiResponseWithdraw.tsx create mode 100644 src/services/ui/src/pages/actions/index.tsx create mode 100644 src/services/ui/src/utils/actionLabelMapper.ts diff --git a/src/packages/shared-types/actions.ts b/src/packages/shared-types/actions.ts new file mode 100644 index 0000000000..373e22cc70 --- /dev/null +++ b/src/packages/shared-types/actions.ts @@ -0,0 +1,3 @@ +export enum Action { + ENABLE_RAI_WITHDRAW = "enable-rai-withdraw", +} diff --git a/src/packages/shared-types/index.ts b/src/packages/shared-types/index.ts index a7f2153021..5a470581d1 100644 --- a/src/packages/shared-types/index.ts +++ b/src/packages/shared-types/index.ts @@ -4,3 +4,4 @@ export * from "./errors"; export * from "./seatool"; export * from "./onemac"; export * from "./opensearch"; +export * from "./actions"; diff --git a/src/packages/shared-types/opensearch.ts b/src/packages/shared-types/opensearch.ts index e4e0b4e168..a392a6eda1 100644 --- a/src/packages/shared-types/opensearch.ts +++ b/src/packages/shared-types/opensearch.ts @@ -1,5 +1,6 @@ import { SeaToolTransform } from "./seatool"; import { OneMacTransform } from "./onemac"; +import { Action } from "./actions"; export type OsHit = { _index: string; @@ -34,6 +35,9 @@ export type OsResponse = { export type OsMainSourceItem = OneMacTransform & SeaToolTransform; export type OsMainSearchResponse = OsResponse; export type SearchData = OsHits; +export type ItemResult = OsHit & { + found: boolean; +}; export type OsFilterType = | "term" diff --git a/src/services/api/handlers/getPackageActions.ts b/src/services/api/handlers/getPackageActions.ts new file mode 100644 index 0000000000..db458a60d9 --- /dev/null +++ b/src/services/api/handlers/getPackageActions.ts @@ -0,0 +1,66 @@ +import { APIGatewayEvent } from "aws-lambda"; +import { Action, CognitoUserAttributes, ItemResult } from "shared-types"; +import { isCmsUser } from "shared-utils"; +import { getPackage } from "../libs/package/getPackage"; +import { + getAuthDetails, + isAuthorized, + lookupUserAttributes, +} from "../libs/auth/user"; +import { response } from "../libs/handler"; + +type GetPackageActionsBody = { + id: string; +}; + +/** Generates an array of allowed actions from a combination of user attributes + * and OS result data */ +const packageActionsForResult = ( + user: CognitoUserAttributes, + result: ItemResult +): Action[] => { + const actions = []; + if (isCmsUser(user)) { + actions.push(Action.ENABLE_RAI_WITHDRAW); + } + return actions; +}; +export const getPackageActions = async (event: APIGatewayEvent) => { + const body = JSON.parse(event.body) as GetPackageActionsBody; + try { + console.log(body); + const result = await getPackage(body.id); + const passedStateAuth = await isAuthorized(event, result._source.state); + if (!passedStateAuth) + return response({ + statusCode: 401, + body: { message: "Not authorized to view resources from this state" }, + }); + if (!result.found) + return response({ + statusCode: 404, + body: { message: "No record found for the given id" }, + }); + + const authDetails = getAuthDetails(event); + const userAttr = await lookupUserAttributes( + authDetails.userId, + authDetails.poolId + ); + + return response({ + statusCode: 200, + body: { + actions: packageActionsForResult(userAttr, result), + }, + }); + } catch (err) { + console.error({ err }); + return response({ + statusCode: 500, + body: { message: "Internal server error" }, + }); + } +}; + +export const handler = getPackageActions; diff --git a/src/services/api/handlers/item.ts b/src/services/api/handlers/item.ts index 25977bf772..7774d05cf3 100644 --- a/src/services/api/handlers/item.ts +++ b/src/services/api/handlers/item.ts @@ -1,8 +1,7 @@ import { response } from "../libs/handler"; import { APIGatewayEvent } from "aws-lambda"; -import * as os from "../../../libs/opensearch-lib"; import { getStateFilter } from "../libs/auth/user"; -import { OsHit, OsMainSourceItem } from "shared-types"; +import { getPackage } from "../libs/package/getPackage"; if (!process.env.osDomain) { throw "ERROR: osDomain env variable is required,"; @@ -11,14 +10,8 @@ if (!process.env.osDomain) { export const getItemData = async (event: APIGatewayEvent) => { try { const body = JSON.parse(event.body); - const stateFilter = await getStateFilter(event); - - const result = (await os.getItem( - process.env.osDomain, - "main", - body.id - )) as OsHit & { found: boolean }; + const result = await getPackage(body.id); if ( stateFilter && diff --git a/src/services/api/libs/package/getPackage.ts b/src/services/api/libs/package/getPackage.ts new file mode 100644 index 0000000000..5084e08336 --- /dev/null +++ b/src/services/api/libs/package/getPackage.ts @@ -0,0 +1,5 @@ +import * as os from "../../../../libs/opensearch-lib"; +import { ItemResult } from "shared-types"; + +export const getPackage = async (id: string) => + (await os.getItem(process.env.osDomain, "main", id)) as ItemResult; diff --git a/src/services/api/serverless.yml b/src/services/api/serverless.yml index e4d902e37f..a45935386b 100644 --- a/src/services/api/serverless.yml +++ b/src/services/api/serverless.yml @@ -105,6 +105,25 @@ functions: subnetIds: >- ${self:custom.vpc.privateSubnets} provisionedConcurrency: ${param:searchProvisionedConcurrency} + getPackageActions: + handler: handlers/getPackageActions.handler + maximumRetryAttempts: 0 + environment: + region: ${self:provider.region} + osDomain: ${param:osDomain} + onemacLegacyS3AccessRoleArn: ${self:custom.onemacLegacyS3AccessRoleArn} + events: + - http: + path: /getPackageActions + method: post + cors: true + authorizer: aws_iam + vpc: + securityGroupIds: + - Ref: SecurityGroup + subnetIds: >- + ${self:custom.vpc.privateSubnets} + provisionedConcurrency: ${param:getAttachmentUrlProvisionedConcurrency} getAttachmentUrl: handler: handlers/getAttachmentUrl.handler maximumRetryAttempts: 0 diff --git a/src/services/ui/src/api/index.ts b/src/services/ui/src/api/index.ts index 8165896841..7778f91a6c 100644 --- a/src/services/ui/src/api/index.ts +++ b/src/services/ui/src/api/index.ts @@ -1,3 +1,4 @@ export * from "./useSearch"; export * from "./useGetItem"; export * from "./getAttachmentUrl"; +export * from "./useGetPackageActions"; diff --git a/src/services/ui/src/api/useGetItem.ts b/src/services/ui/src/api/useGetItem.ts index e44d376445..4f0ff0f2dd 100644 --- a/src/services/ui/src/api/useGetItem.ts +++ b/src/services/ui/src/api/useGetItem.ts @@ -1,8 +1,8 @@ import { useQuery, UseQueryOptions } from "@tanstack/react-query"; import { API } from "aws-amplify"; -import { OsHit, OsMainSourceItem, ReactQueryApiError } from "shared-types"; +import { ItemResult, ReactQueryApiError } from "shared-types"; -export const getItem = async (id: string): Promise> => { +export const getItem = async (id: string): Promise => { const record = await API.post("os", "/item", { body: { id } }); return record; @@ -10,9 +10,9 @@ export const getItem = async (id: string): Promise> => { export const useGetItem = ( id: string, - options?: UseQueryOptions, ReactQueryApiError> + options?: UseQueryOptions ) => { - return useQuery, ReactQueryApiError>( + return useQuery( ["record", id], () => getItem(id), options diff --git a/src/services/ui/src/api/useGetPackageActions.ts b/src/services/ui/src/api/useGetPackageActions.ts new file mode 100644 index 0000000000..389c59e2d9 --- /dev/null +++ b/src/services/ui/src/api/useGetPackageActions.ts @@ -0,0 +1,19 @@ +import { Action, ReactQueryApiError } from "shared-types"; +import { API } from "aws-amplify"; +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; +type PackageActionsResponse = { + actions: Action[]; +}; +const getPackageActions = async (id: string): Promise => + await API.post("os", "/getPackageActions", { body: { id } }); + +export const useGetPackageActions = ( + id: string, + options?: UseQueryOptions +) => { + return useQuery( + ["actions", id], + () => getPackageActions(id), + options + ); +}; diff --git a/src/services/ui/src/components/Cards/CardWithTopBorder.tsx b/src/services/ui/src/components/Cards/CardWithTopBorder.tsx index 10a99ebb71..461898403a 100644 --- a/src/services/ui/src/components/Cards/CardWithTopBorder.tsx +++ b/src/services/ui/src/components/Cards/CardWithTopBorder.tsx @@ -1,13 +1,16 @@ import { FC, ReactNode } from "react"; +import { cn } from "@/lib"; interface CardWithTopBorderProps { children: ReactNode; + className?: string; } export const CardWithTopBorder: FC = ({ children, + className, }: CardWithTopBorderProps) => { return ( -
+
{ + const { id, type } = useParams<{ + id: string; + type: string; + }>(); + + return ( +
+ ID: {id} + Type: {type} +
+ ); +}; diff --git a/src/services/ui/src/pages/actions/index.tsx b/src/services/ui/src/pages/actions/index.tsx new file mode 100644 index 0000000000..cd177a09c6 --- /dev/null +++ b/src/services/ui/src/pages/actions/index.tsx @@ -0,0 +1,16 @@ +import { Navigate, useParams } from "react-router-dom"; +import { ROUTES } from "@/routes"; +import { EnableRaiResponseWithdraw } from "@/pages/actions/EnableRaiResponseWithdraw"; +import { Action } from "shared-types"; + +export const ActionFormIndex = () => { + const { type } = useParams<{ type: Action }>(); + switch (type) { + case Action.ENABLE_RAI_WITHDRAW: + return ; + default: + // TODO: Better error communication instead of navigate? + // "Hey, this action doesn't exist. Click to go back to the Dashboard." + return ; + } +}; diff --git a/src/services/ui/src/pages/detail/index.tsx b/src/services/ui/src/pages/detail/index.tsx index 7082385207..e7f3c00a55 100644 --- a/src/services/ui/src/pages/detail/index.tsx +++ b/src/services/ui/src/pages/detail/index.tsx @@ -10,18 +10,67 @@ import { SubmissionInfo, } from "@/components"; import { useGetUser } from "@/api/useGetUser"; -import { OsHit, OsMainSourceItem } from "shared-types"; +import { CognitoUserAttributes, ItemResult } from "shared-types"; import { useQuery } from "@/hooks"; import { useGetItem } from "@/api"; -import { DetailNav } from "./detailNav"; import { BreadCrumbs } from "@/components/BreadCrumb"; import { BREAD_CRUMB_CONFIG_PACKAGE_DETAILS } from "@/components/BreadCrumb/bread-crumb-config"; +import { mapActionLabel } from "@/utils"; +import { Link } from "react-router-dom"; +import { useGetPackageActions } from "@/api/useGetPackageActions"; +import { PropsWithChildren } from "react"; -export const DetailsContent = ({ - data, -}: { - data?: OsHit; -}) => { +const DetailCardWrapper = ({ + title, + children, +}: PropsWithChildren<{ + title: string; +}>) => ( + +
+

{title}

+ {children} +
+
+); +const StatusCard = ({ isCms, data }: { isCms: boolean; data: ItemResult }) => ( + +
+

+ {isCms ? data._source.cmsStatus : data._source.stateStatus} +

+
+
+); +const PackageActionsCard = ({ id }: { id: string }) => { + const { data, error } = useGetPackageActions(id); + if (!data?.actions || error) return ; + return ( + +
+ {!data.actions.length ? ( + + No actions are currently available for this submission. + + ) : ( +
    + {data.actions.map((action, idx) => ( + +
  • {mapActionLabel(action)}
  • + + ))} +
+ )} +
+
+ ); +}; + +export const DetailsContent = ({ data }: { data?: ItemResult }) => { const { data: user } = useGetUser(); if (!data?._source) return ; return ( @@ -46,19 +95,12 @@ export const DetailsContent = ({ ))}
-
- -
-

Status

-
-

- {user?.isCms - ? data._source.cmsStatus - : data._source.stateStatus} -

-
-
-
+
+ +
diff --git a/src/services/ui/src/pages/index.ts b/src/services/ui/src/pages/index.ts index e8746153ba..e0cb29759d 100644 --- a/src/services/ui/src/pages/index.ts +++ b/src/services/ui/src/pages/index.ts @@ -4,3 +4,4 @@ export * from "./welcome"; export * from "./detail"; export * from "./faq"; export * from "./form"; +export * from "./actions"; diff --git a/src/services/ui/src/router.tsx b/src/services/ui/src/router.tsx index ae151451a5..44fe1205b5 100644 --- a/src/services/ui/src/router.tsx +++ b/src/services/ui/src/router.tsx @@ -66,6 +66,7 @@ export const router = createBrowserRouter([ element: , }, { path: ROUTES.CREATE, element: }, + { path: ROUTES.ACTION, element: }, // TODO: Remove "/form" and ExampleForm if there's no usage; the Create page // is our current SEATool integration test form. { path: "/form", element: }, diff --git a/src/services/ui/src/routes.ts b/src/services/ui/src/routes.ts index 50b914a9ea..7cae94e460 100644 --- a/src/services/ui/src/routes.ts +++ b/src/services/ui/src/routes.ts @@ -17,6 +17,7 @@ export enum ROUTES { MEDICAID_ABP_LANDING = "/new-submission/spa/medicaid/landing/medicaid-abp", MEDICAID_ELIGIBILITY_LANDING = "/new-submission/spa/medicaid/landing/medicaid-eligibility", CHIP_ELIGIBILITY_LANDING = "/new-submission/spa/chip/landing/chip-eligibility", + ACTION = "/action/:id/:type", CREATE = "/create", } @@ -24,4 +25,4 @@ export enum FAQ_SECTION { SYSTEM = "system", } -export const FAQ_TARGET = "faq-target" as const; \ No newline at end of file +export const FAQ_TARGET = "faq-target" as const; diff --git a/src/services/ui/src/utils/actionLabelMapper.ts b/src/services/ui/src/utils/actionLabelMapper.ts new file mode 100644 index 0000000000..e7fbdfea28 --- /dev/null +++ b/src/services/ui/src/utils/actionLabelMapper.ts @@ -0,0 +1,8 @@ +import { Action } from "shared-types"; + +export const mapActionLabel = (a: Action) => { + switch (a) { + case Action.ENABLE_RAI_WITHDRAW: + return "Enable RAI Response Withdraw"; + } +}; diff --git a/src/services/ui/src/utils/index.ts b/src/services/ui/src/utils/index.ts index 416cf7fef2..588f199980 100644 --- a/src/services/ui/src/utils/index.ts +++ b/src/services/ui/src/utils/index.ts @@ -2,3 +2,4 @@ export * from "./user"; export * from "./date"; export * from "./textHelpers"; export * from "./createContextProvider"; +export * from "./actionLabelMapper"; From fa0ac18570e7aefb1e1ce9114fc2d6b4c2bc5241 Mon Sep 17 00:00:00 2001 From: Walesango2 <110047743+Walesango2@users.noreply.github.com> Date: Thu, 2 Nov 2023 15:02:29 -0400 Subject: [PATCH 19/19] feat(role based view): implemented a role based view for user groups (#175) * feat(Role based users view): configure role based user view * feat(role based view): inplimanting a role based view for user groups * feat(role based view): inplimanting a role based view for user groups * feat(Role based users view): configure role based user view * feat(Role based users view): configure role based user view * feat(Role based users view): configure role based user view * feat(Role based users view): configure role based user view * feat(Role based users view): configure role based user view * feat(Role based users view): configure role based user view * feat(Role based users view): configure role based user view * feat(Role based users view): configure role based user view --------- Co-authored-by: Adewale Sangobiyi Co-authored-by: Adewale Sangobiyi --- src/services/auth/libs/users.json | 23 ++- src/services/ui/src/api/useGetUser.ts | 10 +- .../ui/src/components/Context/userContext.tsx | 14 ++ .../ui/src/components/Layout/index.tsx | 16 +- .../ui/src/components/UsaBanner/index.tsx | 159 +++++++++++------- src/services/ui/src/main.tsx | 5 +- .../src/pages/dashboard/Lists/spas/consts.tsx | 15 +- .../src/pages/dashboard/Lists/spas/index.tsx | 6 +- .../pages/dashboard/Lists/waivers/consts.tsx | 13 +- .../pages/dashboard/Lists/waivers/index.tsx | 2 +- src/services/ui/src/pages/dashboard/index.tsx | 19 ++- 11 files changed, 192 insertions(+), 90 deletions(-) create mode 100644 src/services/ui/src/components/Context/userContext.tsx diff --git a/src/services/auth/libs/users.json b/src/services/auth/libs/users.json index 93e4ea4bbc..b810bc9e50 100644 --- a/src/services/auth/libs/users.json +++ b/src/services/auth/libs/users.json @@ -87,7 +87,7 @@ ] }, { - "username": "onemac-micro-helpdesk@example.com", + "username": "helpdesk@example.com", "attributes": [ { "Name": "email", @@ -172,5 +172,26 @@ "Value": "onemac-micro-statesubmitter" } ] + }, + { + "username": "badfootball@example.com", + "attributes": [ + { + "Name": "email", + "Value": "badfootball@example.com" + }, + { + "Name": "given_name", + "Value": "bad" + }, + { + "Name": "family_name", + "Value": "football" + }, + { + "Name": "email_verified", + "Value": "true" + } + ] } ] diff --git a/src/services/ui/src/api/useGetUser.ts b/src/services/ui/src/api/useGetUser.ts index da813f54a5..ae79cf67b3 100644 --- a/src/services/ui/src/api/useGetUser.ts +++ b/src/services/ui/src/api/useGetUser.ts @@ -4,7 +4,9 @@ import { Auth } from "aws-amplify"; import { CognitoUserAttributes } from "shared-types"; import { isCmsUser } from "shared-utils"; -export const getUser = async () => { +export type OneMacUser = { isCms?: boolean, user: CognitoUserAttributes | null } + +export const getUser = async (): Promise => { try { const authenticatedUser = await Auth.currentAuthenticatedUser(); const attributes = await Auth.userAttributes(authenticatedUser); @@ -14,14 +16,14 @@ export const getUser = async () => { }, {}) as unknown as CognitoUserAttributes; if (user["custom:cms-roles"]) { const isCms = isCmsUser(user); - return { user, isCms }; + return { user, isCms } satisfies OneMacUser; } else { user["custom:cms-roles"] = ""; - return { user, isCms: false }; + return { user, isCms: false } satisfies OneMacUser; } } catch (e) { console.log({ e }); - return { user: null }; + return { user: null } satisfies OneMacUser; } }; diff --git a/src/services/ui/src/components/Context/userContext.tsx b/src/services/ui/src/components/Context/userContext.tsx new file mode 100644 index 0000000000..44a2b94bb8 --- /dev/null +++ b/src/services/ui/src/components/Context/userContext.tsx @@ -0,0 +1,14 @@ +import { OneMacUser, useGetUser } from "@/api/useGetUser"; +import { PropsWithChildren, createContext, useContext } from "react"; + +const initialState = { user: null }; + +export const UserContext = createContext(initialState); +export const UserContextProvider = ({ children }: PropsWithChildren) => { + const { data: userData } = useGetUser(); + return ( + {children} + ); +}; + +export const useUserContext = () => useContext(UserContext); diff --git a/src/services/ui/src/components/Layout/index.tsx b/src/services/ui/src/components/Layout/index.tsx index 0033bd8b7f..dd9dd10e73 100644 --- a/src/services/ui/src/components/Layout/index.tsx +++ b/src/services/ui/src/components/Layout/index.tsx @@ -2,16 +2,17 @@ import { Link, NavLink, NavLinkProps, Outlet } from "react-router-dom"; import oneMacLogo from "@/assets/onemac_logo.svg"; import { useMediaQuery } from "@/hooks"; import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/outline"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useGetUser } from "@/api/useGetUser"; import { Auth } from "aws-amplify"; import { AwsCognitoOAuthOpts } from "@aws-amplify/auth/lib-esm/types"; import { Footer } from "../Footer"; import { UsaBanner } from "../UsaBanner"; import { FAQ_TARGET } from "@/routes"; +import { useUserContext } from "../Context/userContext"; -const getLinks = (isAuthenticated: boolean) => { - if (isAuthenticated) { +const getLinks = (isAuthenticated: boolean, role?: boolean) => { + if (isAuthenticated && role) { return [ { name: "Home", @@ -83,6 +84,10 @@ const ResponsiveNav = ({ isDesktop }: ResponsiveNavProps) => { const [prevMediaQuery, setPrevMediaQuery] = useState(isDesktop); const [isOpen, setIsOpen] = useState(false); const { isLoading, isError, data } = useGetUser(); + const userContext = useUserContext(); + const role = useMemo(() => { + return userContext?.user?.["custom:cms-roles"] ? true : false; + }, []); const handleLogin = () => { const authConfig = Auth.configure(); @@ -90,7 +95,6 @@ const ResponsiveNav = ({ isDesktop }: ResponsiveNavProps) => { authConfig.oauth as AwsCognitoOAuthOpts; const clientId = authConfig.userPoolWebClientId; const url = `https://${domain}/oauth2/authorize?redirect_uri=${redirectSignIn}&response_type=${responseType}&client_id=${clientId}`; - window.location.assign(url); }; @@ -111,7 +115,7 @@ const ResponsiveNav = ({ isDesktop }: ResponsiveNavProps) => { if (isDesktop) { return ( <> - {getLinks(!!data.user).map((link) => ( + {getLinks(!!data.user, role).map((link) => ( { {isOpen && (
    - {getLinks(!!data.user).map((link) => ( + {getLinks(!!data.user, role).map((link) => (
  • { const [isOpen, setIsOpen] = useState(false); const isDesktop = useMediaQuery("(min-width: 640px)"); + const userContext = useUserContext(); + const role = useMemo(() => { + return userContext?.user?.["custom:cms-roles"] ? false : true; + }, []); + const hasRole = useMemo(() => { + if (role && userContext?.user) { + return true; + } else { + return false; + } + }, []); return ( -
    - {/* Display for Desktop */} - {isDesktop && ( - <> -
    + <> +
    + {/* Display for Desktop */} + {isDesktop && ( + <> +
    + A United States Flag icon +

    An official website of the United States government

    + +
    + + )} + {/* Display for Mobile */} + {!isDesktop && ( + -
    - - )} - {/* Display for Mobile */} - {!isDesktop && ( - - )} - {isOpen && ( -
    -
    - -

    - Official websites use .govA - .gov website belongs to an official government - organization in the United States. + + )} + {hasRole && ( +

    +

    + You do not have access to view the application + + Please visit IDM + {" "} + to request the appropriate user role(s) - FAIL

    -
    - -

    - Secure .gov websites use HTTPSA - lock () or https:// means you've - safely connected to the .gov website. Share sensitive information - only on official, secure websites. -

    + )} + + {isOpen && ( +
    +
    + +

    + Official websites use .govA + .gov website belongs to an official government + organization in the United States. +

    +
    +
    + +

    + + Secure .gov websites use HTTPS + + A lock () or https:// means + you've safely connected to the .gov website. Share + sensitive information only on official, secure websites. +

    +
    -
    - )} -
    + )} +
    + ); }; diff --git a/src/services/ui/src/main.tsx b/src/services/ui/src/main.tsx index 48dea5293f..81fa53c4a5 100644 --- a/src/services/ui/src/main.tsx +++ b/src/services/ui/src/main.tsx @@ -5,11 +5,14 @@ import "./index.css"; // this one second import { queryClient, router } from "./router"; import { QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { UserContextProvider } from "./components/Context/userContext"; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - + + + diff --git a/src/services/ui/src/pages/dashboard/Lists/spas/consts.tsx b/src/services/ui/src/pages/dashboard/Lists/spas/consts.tsx index 8f0c23b161..95abf69592 100644 --- a/src/services/ui/src/pages/dashboard/Lists/spas/consts.tsx +++ b/src/services/ui/src/pages/dashboard/Lists/spas/consts.tsx @@ -4,8 +4,12 @@ import { removeUnderscoresAndCapitalize } from "@/utils"; import { OsTableColumn } from "@/components/Opensearch/Table/types"; import { LABELS } from "@/lib"; import { BLANK_VALUE } from "@/consts"; +import { CognitoUserAttributes, UserRoles } from "shared-types"; -export const TABLE_COLUMNS = (props?: { isCms?: boolean }): OsTableColumn[] => [ +export const TABLE_COLUMNS = (props?: { + isCms?: boolean; + user?: CognitoUserAttributes | null | undefined; +}): OsTableColumn[] => [ { props: { className: "w-[150px]" }, field: "id.keyword", @@ -26,7 +30,7 @@ export const TABLE_COLUMNS = (props?: { isCms?: boolean }): OsTableColumn[] => [ { field: "state.keyword", label: "State", - visible: false, + visible: true, cell: (data) => data.state, }, { @@ -43,9 +47,12 @@ export const TABLE_COLUMNS = (props?: { isCms?: boolean }): OsTableColumn[] => [ : BLANK_VALUE, }, { - field: props?.isCms ? "cmsStatus.keyword" : "stateStatus.keyword", + field: props?.isCms ? "cmsStatus" : "stateStatus.keyword", label: "Status", - cell: (data) => (props?.isCms ? data.cmsStatus : data.stateStatus), + cell: (data) => + props?.isCms && !(props.user?.["custom:cms-roles"] === UserRoles.HELPDESK) + ? data.cmsStatus + : data.stateStatus, }, { field: "submissionDate", diff --git a/src/services/ui/src/pages/dashboard/Lists/spas/index.tsx b/src/services/ui/src/pages/dashboard/Lists/spas/index.tsx index e4e971582d..61458a4b26 100644 --- a/src/services/ui/src/pages/dashboard/Lists/spas/index.tsx +++ b/src/services/ui/src/pages/dashboard/Lists/spas/index.tsx @@ -1,7 +1,7 @@ import { useGetUser } from "@/api/useGetUser"; import { ErrorAlert, LoadingSpinner } from "@/components"; - import { Pagination } from "@/components/Pagination"; + import { OsTable, OsFiltering, @@ -14,10 +14,10 @@ export const SpasList = () => { const { data: user } = useGetUser(); const context = useOsContext(); const params = useOsParams(); - if (context.error) return ; + console.log(user, "user from spas"); - const columns = TABLE_COLUMNS({ isCms: user?.isCms }); + const columns = TABLE_COLUMNS({ isCms: user?.isCms, user: user?.user }); return (
    diff --git a/src/services/ui/src/pages/dashboard/Lists/waivers/consts.tsx b/src/services/ui/src/pages/dashboard/Lists/waivers/consts.tsx index 85939a1adc..f71cc0a11f 100644 --- a/src/services/ui/src/pages/dashboard/Lists/waivers/consts.tsx +++ b/src/services/ui/src/pages/dashboard/Lists/waivers/consts.tsx @@ -5,8 +5,12 @@ import { removeUnderscoresAndCapitalize } from "@/utils"; import { OsTableColumn } from "@/components/Opensearch/Table/types"; import { LABELS } from "@/lib"; import { BLANK_VALUE } from "@/consts"; +import { CognitoUserAttributes, UserRoles } from "shared-types"; -export const TABLE_COLUMNS = (props?: { isCms?: boolean }): OsTableColumn[] => [ +export const TABLE_COLUMNS = (props?: { + isCms?: boolean; + user?: CognitoUserAttributes | null | undefined; +}): OsTableColumn[] => [ { props: { className: "w-[150px]" }, field: "id.keyword", @@ -27,7 +31,7 @@ export const TABLE_COLUMNS = (props?: { isCms?: boolean }): OsTableColumn[] => [ { field: "state.keyword", label: "State", - visible: false, + visible: true, cell: (data) => data.state, }, { @@ -46,7 +50,10 @@ export const TABLE_COLUMNS = (props?: { isCms?: boolean }): OsTableColumn[] => [ { field: props?.isCms ? "cmsStatus.keyword" : "stateStatus.keyword", label: "Status", - cell: (data) => (props?.isCms ? data.cmsStatus : data.stateStatus), + cell: (data) => + props?.isCms && !(props.user?.["custom:cms-roles"] === UserRoles.HELPDESK) + ? data.cmsStatus + : data.stateStatus, }, { field: "submissionDate", diff --git a/src/services/ui/src/pages/dashboard/Lists/waivers/index.tsx b/src/services/ui/src/pages/dashboard/Lists/waivers/index.tsx index eda8c2c102..b517b88a81 100644 --- a/src/services/ui/src/pages/dashboard/Lists/waivers/index.tsx +++ b/src/services/ui/src/pages/dashboard/Lists/waivers/index.tsx @@ -17,7 +17,7 @@ export const WaiversList = () => { if (context.error) return ; - const columns = TABLE_COLUMNS({ isCms: user?.isCms }); + const columns = TABLE_COLUMNS({ isCms: user?.isCms, user: user?.user }); return (
    diff --git a/src/services/ui/src/pages/dashboard/index.tsx b/src/services/ui/src/pages/dashboard/index.tsx index be2ac8644d..28fe78dc76 100644 --- a/src/services/ui/src/pages/dashboard/index.tsx +++ b/src/services/ui/src/pages/dashboard/index.tsx @@ -1,6 +1,6 @@ -import { Link, redirect } from "react-router-dom"; +import { Link, Navigate, redirect } from "react-router-dom"; import { QueryClient } from "@tanstack/react-query"; -import { getUser, useGetUser } from "@/api/useGetUser"; +import { getUser } from "@/api/useGetUser"; import { WaiversList } from "./Lists/waivers"; import { SpasList } from "./Lists/spas"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/Tabs"; @@ -13,6 +13,8 @@ import { } from "@/components/Opensearch"; import { Button } from "@/components/Inputs"; import { ROUTES } from "@/routes"; +import { useUserContext } from "@/components/Context/userContext"; +import { useMemo } from "react"; const loader = (queryClient: QueryClient) => { return async () => { @@ -33,12 +35,21 @@ const loader = (queryClient: QueryClient) => { return isUser; }; }; + export const dashboardLoader = loader; export const Dashboard = () => { - const { data: user } = useGetUser(); + const userContext = useUserContext(); const query = useOsQuery(); + const role = useMemo(() => { + return userContext?.user?.["custom:cms-roles"] ? true : false; + }, []); + + if (!role) { + return ; + } + return ( {

    Dashboard

    - {!user?.isCms && ( + {!userContext?.isCms && (