Init
This commit is contained in:
commit
0029086b3f
148 changed files with 19047 additions and 0 deletions
3
packages/api/.eslintrc.js
Normal file
3
packages/api/.eslintrc.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
module.exports = {
|
||||
extends: ['../../.eslintrc.js'],
|
||||
};
|
55
packages/api/package.json
Normal file
55
packages/api/package.json
Normal file
|
@ -0,0 +1,55 @@
|
|||
{
|
||||
"name": "@baseline/api",
|
||||
"version": "1.0.0",
|
||||
"engines": {
|
||||
"node": "20"
|
||||
},
|
||||
"scripts": {
|
||||
"start:exposed": "pnpm run start --host 0.0.0.0",
|
||||
"start": "./scripts/run-api-local.sh",
|
||||
"debug": "export SLS_DEBUG=* && ./scripts/run-api-local-debug.sh",
|
||||
"deploy:staging": "./scripts/deploy.sh staging",
|
||||
"remove:staging": ". ../../scripts/project-variables.sh && npx serverless remove --stage staging --region $REGION",
|
||||
"deploy:prod": "./scripts/deploy.sh prod",
|
||||
"remove:prod": ". ../../scripts/project-variables.sh && npx serverless remove --stage prod --region $REGION",
|
||||
"add:env:local": "../../scripts/add-env-var.sh local",
|
||||
"add:env:staging": "../../scripts/add-env-var.sh staging",
|
||||
"add:env:prod": "../../scripts/add-env-var.sh prod",
|
||||
"aws:profile": "../../scripts/setup-aws-profile.sh",
|
||||
"add:user:staging": "./scripts/add-cognito-user.sh staging",
|
||||
"add:user:prod": "./scripts/add-cognito-user.sh prod",
|
||||
"install:dynamodb": ". ../../scripts/project-variables.sh && npx serverless dynamodb install --stage staging --region $REGION",
|
||||
"lint": "npx eslint --config '.eslintrc.js' 'src/**/*.{ts,tsx,js}'",
|
||||
"build": ". ../../scripts/project-variables.sh && npx serverless package --stage staging --region $REGION",
|
||||
"pretty": "npx prettier --write 'src/**/*.{ts,tsx,js,json,css,scss,md,yml,yaml,html}' && npx prettier --write '*.{yml,yaml,js}'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-cognito-identity-provider": "3.530.0",
|
||||
"@baseline/types": "workspace:1.0.0",
|
||||
"@baselinejs/dynamodb": "0.2.4",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"aws-lambda": "1.0.7",
|
||||
"compression": "1.7.4",
|
||||
"cors": "2.8.5",
|
||||
"express": "4.21.0",
|
||||
"http-status-codes": "2.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"serverless-http": "3.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/aws-lambda": "8.10.93",
|
||||
"@types/compression": "1.7.2",
|
||||
"@types/cors": "2.8.12",
|
||||
"@types/express": "4.17.21",
|
||||
"@types/node": "20.11.26",
|
||||
"esbuild": "0.20.1",
|
||||
"prettier": "2.4.1",
|
||||
"serverless": "3.38.0",
|
||||
"serverless-dynamodb": "0.2.50",
|
||||
"serverless-esbuild": "1.52.1",
|
||||
"serverless-offline": "13.3.3",
|
||||
"stylelint": "16.2.1",
|
||||
"stylelint-order": "6.0.4",
|
||||
"typescript": "5.4.2"
|
||||
}
|
||||
}
|
115
packages/api/scripts/add-cognito-user.sh
Executable file
115
packages/api/scripts/add-cognito-user.sh
Executable file
|
@ -0,0 +1,115 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
shopt -s failglob
|
||||
|
||||
CURRENT_DIR="$(pwd -P)"
|
||||
PARENT_PATH="$(
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")" || exit
|
||||
pwd -P
|
||||
)/.."
|
||||
cd "$PARENT_PATH" || exit
|
||||
|
||||
STAGE=$1
|
||||
USER_EMAIL=$2
|
||||
USER_PASSWORD=$3
|
||||
|
||||
# Sets REGION, APP_NAME, AWS_REGION, AWS_PROFILE
|
||||
. ../../scripts/project-variables.sh
|
||||
|
||||
TABLE="${APP_NAME}-${STAGE}-admin"
|
||||
|
||||
echo "Getting Cognito User Pool Id from [$STAGE]..."
|
||||
. ../../scripts/get-stack-outputs.sh "$STAGE" >/dev/null
|
||||
COGNITO_USER_POOL_ID="${UserPoolId:-}"
|
||||
if [ "$COGNITO_USER_POOL_ID" == "" ]; then
|
||||
echo "Failed to get Cognito User Pool Id!"
|
||||
echo 'Check your aws credentials are up to date, maybe run "npm run aws:profile"'
|
||||
exit 1
|
||||
else
|
||||
echo "Cognito Pool Id [$COGNITO_USER_POOL_ID]"
|
||||
fi
|
||||
|
||||
if [ -z "$USER_EMAIL" ]; then
|
||||
printf "Email: "
|
||||
read -r USER_EMAIL
|
||||
fi
|
||||
|
||||
if [ "$USER_EMAIL" == "" ]; then
|
||||
echo "Error: No user email set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$USER_PASSWORD" ]; then
|
||||
echo
|
||||
echo "Password Requirements:"
|
||||
echo "- 8 character minimum length"
|
||||
echo "- Contains at least 1 number"
|
||||
echo "- Contains at least 1 lowercase letter"
|
||||
echo "- Contains at least 1 uppercase letter"
|
||||
echo "- Contains at least 1 special character"
|
||||
|
||||
printf "Password: "
|
||||
read -sr USER_PASSWORD
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [ "$USER_PASSWORD" == "" ]; then
|
||||
echo "Error: No user password set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
EXISTING_USER=$(aws cognito-idp admin-get-user \
|
||||
--profile "${AWS_PROFILE}" \
|
||||
--region "${REGION}" \
|
||||
--user-pool-id "${COGNITO_USER_POOL_ID:-}" \
|
||||
--username "${USER_EMAIL}")
|
||||
|
||||
if [ "$EXISTING_USER" ]; then
|
||||
echo "User already exists, will not modify password"
|
||||
echo "Will attempt to add to DynamoDB"
|
||||
else
|
||||
echo "Creating User..."
|
||||
|
||||
aws cognito-idp admin-create-user \
|
||||
--profile "${AWS_PROFILE}" \
|
||||
--region "${REGION}" \
|
||||
--user-pool-id "${COGNITO_USER_POOL_ID:-}" \
|
||||
--username "${USER_EMAIL:-}" \
|
||||
--user-attributes Name=email,Value="${USER_EMAIL:-}" Name=email_verified,Value=true \
|
||||
--message-action SUPPRESS >/dev/null
|
||||
|
||||
echo "Setting Password..."
|
||||
aws cognito-idp admin-set-user-password \
|
||||
--profile "${AWS_PROFILE}" \
|
||||
--region "${REGION}" \
|
||||
--user-pool-id "${COGNITO_USER_POOL_ID:-}" \
|
||||
--username "${USER_EMAIL:-}" \
|
||||
--password "${USER_PASSWORD:-}" \
|
||||
--permanent >/dev/null
|
||||
fi
|
||||
|
||||
USER_SUB=$(aws cognito-idp admin-get-user \
|
||||
--profile "${AWS_PROFILE}" \
|
||||
--region "${REGION}" \
|
||||
--user-pool-id "${COGNITO_USER_POOL_ID:-}" \
|
||||
--username "${USER_EMAIL}" |
|
||||
jq '.["Username"]' |
|
||||
tr -d '"')
|
||||
|
||||
echo "User Sub: [${USER_SUB}]"
|
||||
|
||||
if [ "$USER_SUB" ]; then
|
||||
echo "Found user sub, attempting to create DynamoDB record"
|
||||
aws dynamodb put-item \
|
||||
--table-name "${TABLE}" \
|
||||
--item \
|
||||
"{\"userSub\": {\"S\": \"${USER_SUB}\"}, \"userEmail\": {\"S\": \"${USER_EMAIL}\"}}" \
|
||||
--profile "${AWS_PROFILE}" \
|
||||
--region "${REGION}"
|
||||
else
|
||||
echo "User sub not found, cannot create DynamoDB record"
|
||||
fi
|
||||
|
||||
echo "Done!"
|
||||
|
||||
cd "$CURRENT_DIR" || exit
|
25
packages/api/scripts/deploy.sh
Executable file
25
packages/api/scripts/deploy.sh
Executable file
|
@ -0,0 +1,25 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
CURRENT_DIR="$(pwd -P)"
|
||||
PARENT_PATH="$(
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")" || exit
|
||||
pwd -P
|
||||
)/.."
|
||||
cd "$PARENT_PATH" || exit
|
||||
|
||||
STAGE=$1
|
||||
|
||||
. ../../scripts/project-variables.sh
|
||||
. ../../scripts/get-stack-outputs.sh "$STAGE" >/dev/null
|
||||
npx serverless deploy --verbose --stage "$STAGE" --region "$REGION"
|
||||
|
||||
# check if npx serverless deploy was successful
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Deploy successful"
|
||||
else
|
||||
echo "Deploy failed"
|
||||
cd "$CURRENT_DIR" || exit
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$CURRENT_DIR" || exit
|
38
packages/api/scripts/run-api-local-debug.sh
Executable file
38
packages/api/scripts/run-api-local-debug.sh
Executable file
|
@ -0,0 +1,38 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
CURRENT_DIR="$(pwd -P)"
|
||||
PARENT_PATH="$(
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")" || exit
|
||||
pwd -P
|
||||
)/.."
|
||||
cd "$PARENT_PATH" || exit
|
||||
|
||||
# Sets REGION, APP_NAME, AWS_REGION, AWS_PROFILE
|
||||
. ../../scripts/project-variables.sh
|
||||
|
||||
echo "Testing AWS Keys..."
|
||||
IAM_RESULT=$(aws sts get-caller-identity --query "Account" --output text --profile "$AWS_PROFILE")
|
||||
if [ "$IAM_RESULT" ]; then
|
||||
echo "AWS Credentials work!"
|
||||
else
|
||||
printf "\033[31mAWS Keys did not work!\033[39m\n"
|
||||
exit
|
||||
fi
|
||||
|
||||
# Set the user that will be used for private authorised endpoints - the user that logs in on the client will be ignored.
|
||||
# AUTHORIZER is a value detected by serverless offline https://github.com/dherault/serverless-offline#remote-authorizers
|
||||
# This user is and can be linked in local seed data so that there is user specific relationships.
|
||||
# Restart the API when this is changed.
|
||||
export AUTHORIZER='{"claims":{"email":"example@devika.com", "sub":"ed805890-d66b-4126-a5d9-0b22e70fce80"}}'
|
||||
|
||||
# Required to install/use local DynamoDB
|
||||
pnpm run install:dynamodb
|
||||
|
||||
# Doesn't seem compatible with debug mode
|
||||
# Provides stack trace using source map so the correct file and line numbers are shown
|
||||
# export NODE_OPTIONS=--enable-source-maps
|
||||
|
||||
# Start the API with serverless
|
||||
export SLS_DEBUG="*" && node --inspect ./node_modules/serverless/bin/serverless offline start --stage local --region "$REGION" --httpPort 4000 --verbose "$@"
|
||||
|
||||
cd "$CURRENT_DIR" || exit
|
48
packages/api/scripts/run-api-local.sh
Executable file
48
packages/api/scripts/run-api-local.sh
Executable file
|
@ -0,0 +1,48 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
CURRENT_DIR="$(pwd -P)"
|
||||
PARENT_PATH="$(
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")" || exit
|
||||
pwd -P
|
||||
)/.."
|
||||
cd "$PARENT_PATH" || exit
|
||||
|
||||
# Sets REGION, APP_NAME, AWS_REGION, AWS_PROFILE
|
||||
. ../../scripts/project-variables.sh
|
||||
|
||||
echo "Testing AWS Keys..."
|
||||
IAM_RESULT=$(aws sts get-caller-identity --query "Account" --output text --profile "$AWS_PROFILE")
|
||||
if [ "$IAM_RESULT" ]; then
|
||||
echo "AWS Credentials work!"
|
||||
else
|
||||
printf "\033[31mAWS Keys did not work!\033[39m\n"
|
||||
printf "Would you like to continue anyway (y/N)? "
|
||||
old_stty_cfg=$(stty -g)
|
||||
stty raw -echo
|
||||
answer=$(head -c 1)
|
||||
stty "$old_stty_cfg"
|
||||
if echo "$answer" | grep -iq "^y"; then
|
||||
echo Yes
|
||||
echo "Continuing (be aware things may not work as expected)"
|
||||
else
|
||||
echo No
|
||||
exit
|
||||
fi
|
||||
fi
|
||||
|
||||
# Set the user that will be used for private authorised endpoints - the user that logs in on the client will be ignored.
|
||||
# AUTHORIZER is a value detected by serverless offline https://github.com/dherault/serverless-offline#remote-authorizers
|
||||
# This user is and can be linked in local seed data so that there is user specific relationships.
|
||||
# Restart the API when this is changed.
|
||||
export AUTHORIZER='{"claims":{"email":"example@devika.com", "sub":"ed805890-d66b-4126-a5d9-0b22e70fce80"}}'
|
||||
|
||||
# Required to install/use local DynamoDB
|
||||
pnpm run install:dynamodb
|
||||
|
||||
# Provides stack trace using source map so the correct file and line numbers are shown
|
||||
export NODE_OPTIONS=--enable-source-maps
|
||||
|
||||
# Start the API with serverless
|
||||
npx serverless offline start --stage local --region "$REGION" --httpPort 4000 --verbose "$@"
|
||||
|
||||
cd "$CURRENT_DIR" || exit
|
109
packages/api/serverless.yml
Normal file
109
packages/api/serverless.yml
Normal file
|
@ -0,0 +1,109 @@
|
|||
service: ${env:APP_NAME}-api
|
||||
frameworkVersion: '>=2.0.0 <4.0.0'
|
||||
plugins:
|
||||
- serverless-esbuild
|
||||
- serverless-dynamodb
|
||||
- serverless-offline
|
||||
custom:
|
||||
apiCorsOrigin: '*'
|
||||
deletionPolicy:
|
||||
local: Delete
|
||||
staging: Delete
|
||||
prod: Retain
|
||||
updatePolicy:
|
||||
local: Delete
|
||||
staging: Delete
|
||||
prod: Retain
|
||||
esbuildAWSExclude:
|
||||
local: ''
|
||||
staging: '@aws-sdk'
|
||||
prod: '@aws-sdk'
|
||||
esbuild:
|
||||
minify: false
|
||||
packager: pnpm
|
||||
keepOutputDirectory: true
|
||||
sourcemap: linked
|
||||
exclude:
|
||||
- ${self:custom.esbuildAWSExclude.${opt:stage}}
|
||||
watch:
|
||||
pattern: src/**/*.ts
|
||||
ignore: >-
|
||||
scripts/**/* .build/**/* .dynamodb/**/* .serverless/**/* .esbuild/**/*
|
||||
node_modules/**/*
|
||||
serverless-dynamodb:
|
||||
stages:
|
||||
- local
|
||||
start:
|
||||
port: 8000
|
||||
inMemory: true
|
||||
migrate: true
|
||||
seed: true
|
||||
seed:
|
||||
local:
|
||||
sources:
|
||||
- table: ${env:APP_NAME}-${opt:stage}-admin
|
||||
sources:
|
||||
- ./src/baseblocks/admin/admin.seed.json
|
||||
- table: ${env:APP_NAME}-${opt:stage}-chart
|
||||
sources:
|
||||
- ./src/baseblocks/chart/chart.seed.json
|
||||
package:
|
||||
individually: true
|
||||
provider:
|
||||
name: aws
|
||||
deploymentMethod: direct
|
||||
runtime: nodejs20.x
|
||||
profile: ${env:AWS_PROFILE}
|
||||
stage: ${opt:stage}
|
||||
stackTags:
|
||||
AppName: ${env:APP_NAME}
|
||||
Stage: ${opt:stage}
|
||||
Region: ${opt:region}
|
||||
Product: Baseline
|
||||
timeout: 30
|
||||
architecture: arm64
|
||||
memorySize: 2048
|
||||
logRetentionInDays: 90
|
||||
versionFunctions: false
|
||||
endpointType: REGIONAL
|
||||
environment:
|
||||
APP_NAME: ${env:APP_NAME}
|
||||
NODE_OPTIONS: '--enable-source-maps'
|
||||
NODE_ENV: ${opt:stage}
|
||||
API_REGION: ${opt:region}
|
||||
API_CORS_ORIGIN: ${self:custom.apiCorsOrigin}
|
||||
COGNITO_USER_POOL_ID:
|
||||
Ref: CognitoUserPool
|
||||
apiGateway:
|
||||
minimumCompressionSize: 1024
|
||||
iam:
|
||||
role:
|
||||
statements:
|
||||
- Effect: Allow
|
||||
Action:
|
||||
- dynamodb:Query
|
||||
- dynamodb:Scan
|
||||
- dynamodb:GetItem
|
||||
- dynamodb:PutItem
|
||||
- dynamodb:UpdateItem
|
||||
- dynamodb:DeleteItem
|
||||
- dynamodb:BatchGetItem
|
||||
Resource:
|
||||
- !Sub ${adminTable.Arn}
|
||||
- !Sub ${adminTable.Arn}/index/*
|
||||
- !Sub ${chartTable.Arn}
|
||||
- !Sub ${chartTable.Arn}/index/*
|
||||
- Effect: Allow
|
||||
Action:
|
||||
- cognito-idp:AdminCreateUser
|
||||
- cognito-idp:AdminGetUser
|
||||
- cognito-idp:ListUsers
|
||||
Resource:
|
||||
- !Sub ${CognitoUserPool.Arn}
|
||||
resources:
|
||||
- ${file(./src/baseblocks/cognito/cognito-resources.yml)}
|
||||
- ${file(./src/baseblocks/admin/admin-dynamodb.yml)}
|
||||
- ${file(./src/baseblocks/chart/chart-dynamodb.yml)}
|
||||
functions:
|
||||
- ${file(./src/baseblocks/admin/admin-functions.yml)}
|
||||
- ${file(./src/baseblocks/chart/chart-functions.yml)}
|
181
packages/api/src/baseblocks/admin/admin-api.ts
Normal file
181
packages/api/src/baseblocks/admin/admin-api.ts
Normal file
|
@ -0,0 +1,181 @@
|
|||
import { Response } from 'express';
|
||||
import { AdminMapper } from './admin';
|
||||
import { isAdmin } from '../../middleware/is-admin';
|
||||
import {
|
||||
createUser,
|
||||
getUserAttributesByEmail,
|
||||
} from '../cognito/cognito.service';
|
||||
import { RequestContext } from '../../util/request-context.type';
|
||||
import { Admin } from '@baseline/types/admin';
|
||||
import { getErrorMessage } from '../../util/error-message';
|
||||
import createApp from '../../util/express-app';
|
||||
import createAuthenticatedHandler from '../../util/create-authenticated-handler';
|
||||
import { adminService } from './admin.service';
|
||||
|
||||
const app = createApp();
|
||||
// app.use(isAdmin); // All private endpoints require the user to be an admin
|
||||
export const handler = createAuthenticatedHandler(app);
|
||||
|
||||
app.patch('/admin', [
|
||||
isAdmin,
|
||||
async (req: RequestContext, res: Response) => {
|
||||
try {
|
||||
const { userEmail, userSub } = req.body as Admin;
|
||||
const adminData: Partial<Admin> = {
|
||||
userSub: userSub,
|
||||
userEmail: userEmail.toLowerCase(),
|
||||
};
|
||||
const admin = await adminService.update(adminData);
|
||||
res.json(AdminMapper(admin));
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
console.error(`Failed to update admin: ${message}`);
|
||||
res.status(400).json({
|
||||
error: 'Failed to update admin',
|
||||
});
|
||||
}
|
||||
},
|
||||
]);
|
||||
|
||||
app.post('/admin', [
|
||||
isAdmin,
|
||||
async (req: RequestContext, res: Response) => {
|
||||
try {
|
||||
const { userEmail } = req.body as Admin;
|
||||
|
||||
if (!userEmail) {
|
||||
res.status(400).json({
|
||||
error: 'No email given',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// do not attempt to create user if there is a db record for them already
|
||||
const allAdmins = await adminService.getAll();
|
||||
const existingAdmin = allAdmins.find(
|
||||
(admin) => admin.userEmail === userEmail,
|
||||
);
|
||||
if (existingAdmin) {
|
||||
console.log('Admin user already exists');
|
||||
res.json(AdminMapper(existingAdmin));
|
||||
return;
|
||||
}
|
||||
|
||||
// determine if email is used in cognito already
|
||||
let existingUserSub = '';
|
||||
try {
|
||||
const userAttributes = await getUserAttributesByEmail(userEmail);
|
||||
existingUserSub = userAttributes?.sub || '';
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
|
||||
// when there is an existing cognito user all we need to do is create the db record
|
||||
if (existingUserSub) {
|
||||
console.log('Existing cognito user found, adding to db');
|
||||
const adminData: Partial<Admin> = {
|
||||
userSub: existingUserSub,
|
||||
userEmail: userEmail.toLowerCase(),
|
||||
};
|
||||
const admin = await adminService.create(adminData);
|
||||
res.json(AdminMapper(admin));
|
||||
return;
|
||||
}
|
||||
|
||||
// if there is no existing user create cognito user and db record
|
||||
if (!existingUserSub) {
|
||||
console.log('No existing cognito user, creating one');
|
||||
|
||||
const userAttributes = await createUser(userEmail);
|
||||
if (!userAttributes?.sub) {
|
||||
throw new Error('No user sub after create');
|
||||
}
|
||||
const adminData: Partial<Admin> = {
|
||||
userSub: userAttributes?.sub,
|
||||
userEmail: userEmail,
|
||||
};
|
||||
const admin = await adminService.create(adminData);
|
||||
res.json(AdminMapper(admin));
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`Failed to delete admin`);
|
||||
res.status(400).json({
|
||||
error: 'Failed to create admin',
|
||||
});
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
console.error(`Failed to create admin: ${message}`);
|
||||
res.status(400).json({
|
||||
error:
|
||||
'Failed to create admin, if working offline please edit serverless.yml',
|
||||
});
|
||||
}
|
||||
},
|
||||
]);
|
||||
|
||||
app.delete('/admin/:userSub', [
|
||||
isAdmin,
|
||||
async (req: RequestContext, res: Response) => {
|
||||
try {
|
||||
const userSub = req.params.userSub;
|
||||
await adminService.delete(userSub);
|
||||
res.status(200);
|
||||
res.send();
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
console.error(`Failed to delete admin: ${message}`);
|
||||
res.status(400).json({
|
||||
error: 'Failed to delete admin',
|
||||
});
|
||||
}
|
||||
},
|
||||
]);
|
||||
|
||||
app.get('/admin/list', [
|
||||
isAdmin,
|
||||
async (req: RequestContext, res: Response) => {
|
||||
try {
|
||||
const admins = await adminService.getAll();
|
||||
const formattedAdmins = admins.map((data) => AdminMapper(data));
|
||||
res.json(formattedAdmins);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
console.error(`Failed to get admins: ${message}`);
|
||||
res.status(400).json({
|
||||
error: 'Failed to get admins',
|
||||
});
|
||||
}
|
||||
},
|
||||
]);
|
||||
|
||||
app.get('/admin/:userId', [
|
||||
isAdmin,
|
||||
async (req: RequestContext, res: Response) => {
|
||||
try {
|
||||
const admin = await adminService.get(req.params.userId);
|
||||
res.json(AdminMapper(admin));
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
console.error(`Failed to get admin: ${message}`);
|
||||
res.status(400).json({
|
||||
error: 'Failed to get admin',
|
||||
});
|
||||
}
|
||||
},
|
||||
]);
|
||||
|
||||
app.get('/admin', [
|
||||
async (req: RequestContext, res: Response) => {
|
||||
try {
|
||||
const admin = await adminService.get(req.currentUserSub);
|
||||
res.json(AdminMapper(admin));
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
console.error(`Failed to get admin: ${message}`);
|
||||
res.status(400).json({
|
||||
error: 'Failed to get admin',
|
||||
});
|
||||
}
|
||||
},
|
||||
]);
|
16
packages/api/src/baseblocks/admin/admin-dynamodb.yml
Normal file
16
packages/api/src/baseblocks/admin/admin-dynamodb.yml
Normal file
|
@ -0,0 +1,16 @@
|
|||
Resources:
|
||||
adminTable:
|
||||
Type: AWS::DynamoDB::Table
|
||||
DeletionPolicy: ${self:custom.deletionPolicy.${opt:stage}}
|
||||
UpdateReplacePolicy: ${self:custom.updatePolicy.${opt:stage}}
|
||||
Properties:
|
||||
TableName: ${env:APP_NAME}-${opt:stage}-admin
|
||||
AttributeDefinitions:
|
||||
- AttributeName: userSub
|
||||
AttributeType: S
|
||||
KeySchema:
|
||||
- AttributeName: userSub
|
||||
KeyType: HASH
|
||||
BillingMode: PAY_PER_REQUEST
|
||||
PointInTimeRecoverySpecification:
|
||||
PointInTimeRecoveryEnabled: true
|
37
packages/api/src/baseblocks/admin/admin-functions.yml
Normal file
37
packages/api/src/baseblocks/admin/admin-functions.yml
Normal file
|
@ -0,0 +1,37 @@
|
|||
ApiAdmin:
|
||||
handler: src/baseblocks/admin/admin-api.handler
|
||||
events:
|
||||
- http:
|
||||
path: /admin/{any+}
|
||||
method: ANY
|
||||
authorizer: # https://www.serverless.com/framework/docs/providers/aws/events/apigateway#http-endpoints-with-aws_iam-authorizers
|
||||
type: COGNITO_USER_POOLS
|
||||
authorizerId:
|
||||
Ref: ApiGatewayAuthorizer
|
||||
cors:
|
||||
origin: ${self:custom.apiCorsOrigin}
|
||||
headers:
|
||||
- Content-Type
|
||||
- X-Amz-Date
|
||||
- Authorization
|
||||
- X-Api-Key
|
||||
- X-Amz-Security-Token
|
||||
- X-Amz-User-Agent
|
||||
allowCredentials: false
|
||||
- http:
|
||||
path: /admin
|
||||
method: ANY
|
||||
authorizer: # https://www.serverless.com/framework/docs/providers/aws/events/apigateway#http-endpoints-with-aws_iam-authorizers
|
||||
type: COGNITO_USER_POOLS
|
||||
authorizerId:
|
||||
Ref: ApiGatewayAuthorizer
|
||||
cors:
|
||||
origin: ${self:custom.apiCorsOrigin}
|
||||
headers:
|
||||
- Content-Type
|
||||
- X-Amz-Date
|
||||
- Authorization
|
||||
- X-Api-Key
|
||||
- X-Amz-Security-Token
|
||||
- X-Amz-User-Agent
|
||||
allowCredentials: false
|
14
packages/api/src/baseblocks/admin/admin.seed.json
Normal file
14
packages/api/src/baseblocks/admin/admin.seed.json
Normal file
|
@ -0,0 +1,14 @@
|
|||
[
|
||||
{
|
||||
"userSub": "ed805890-d66b-4126-a5d9-0b22e70fce80",
|
||||
"userEmail": "example@devika.com"
|
||||
},
|
||||
{
|
||||
"userSub": "ed805890-d66b-4126-a5d9-0b22e70fce81",
|
||||
"userEmail": "example+1@devika.com"
|
||||
},
|
||||
{
|
||||
"userSub": "ed805890-d66b-4126-a5d9-0b22e70fce82",
|
||||
"userEmail": "example+2@devika.com"
|
||||
}
|
||||
]
|
27
packages/api/src/baseblocks/admin/admin.service.ts
Normal file
27
packages/api/src/baseblocks/admin/admin.service.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { Admin } from '@baseline/types/admin';
|
||||
import { getErrorMessage } from '../../util/error-message';
|
||||
import { getDynamodbConnection } from '@baselinejs/dynamodb';
|
||||
import { ServiceObject } from '../../util/service-object';
|
||||
|
||||
const dynamoDb = getDynamodbConnection({
|
||||
region: `${process.env.API_REGION}`,
|
||||
});
|
||||
|
||||
export const adminService = new ServiceObject<Admin>({
|
||||
dynamoDb: dynamoDb,
|
||||
objectName: 'Admin',
|
||||
table: `${process.env.APP_NAME}-${process.env.NODE_ENV}-admin`,
|
||||
primaryKey: 'userSub',
|
||||
});
|
||||
|
||||
export const isAdminSub = async (userSub: string): Promise<boolean> => {
|
||||
console.log(`Is ${userSub} Admin`);
|
||||
try {
|
||||
const admin = await adminService.get(userSub);
|
||||
return !!admin?.userSub;
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
console.error(`Failed to check if admin: ${message}`);
|
||||
return false;
|
||||
}
|
||||
};
|
9
packages/api/src/baseblocks/admin/admin.ts
Normal file
9
packages/api/src/baseblocks/admin/admin.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { Admin } from '@baseline/types/admin';
|
||||
|
||||
export const AdminMapper = (data: Admin): Admin => {
|
||||
const admin: Admin = {
|
||||
userSub: data?.userSub,
|
||||
userEmail: data?.userEmail,
|
||||
};
|
||||
return admin;
|
||||
};
|
114
packages/api/src/baseblocks/chart/chart-admin-api.ts
Normal file
114
packages/api/src/baseblocks/chart/chart-admin-api.ts
Normal file
|
@ -0,0 +1,114 @@
|
|||
import { Response } from 'express';
|
||||
import { chartMapper } from './chart';
|
||||
import { isAdmin } from '../../middleware/is-admin';
|
||||
import { RequestContext } from '../../util/request-context.type';
|
||||
import { Chart } from '@baseline/types/chart';
|
||||
import { getErrorMessage } from '../../util/error-message';
|
||||
import createApp from '../../util/express-app';
|
||||
import createAuthenticatedHandler from '../../util/create-authenticated-handler';
|
||||
import { chartService } from './chart.service';
|
||||
|
||||
const app = createApp();
|
||||
export const handler = createAuthenticatedHandler(app);
|
||||
|
||||
app.post('/chart/admin', [
|
||||
isAdmin,
|
||||
async (req: RequestContext, res: Response) => {
|
||||
try {
|
||||
const { md5, sha256, resourceUri, parentChart, name, comment } =
|
||||
req.body as Chart;
|
||||
const chartData: Partial<Chart> = {
|
||||
md5,
|
||||
sha256,
|
||||
resourceUri,
|
||||
parentChart,
|
||||
name,
|
||||
comment,
|
||||
};
|
||||
const chart = await chartService.create(chartData);
|
||||
res.json(chartMapper(chart));
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
console.error(`Failed to create chart ${message}`);
|
||||
res.status(400).json({ error: 'Failed to create chart' });
|
||||
}
|
||||
},
|
||||
]);
|
||||
|
||||
app.patch('/chart/admin', [
|
||||
isAdmin,
|
||||
async (req: RequestContext, res: Response) => {
|
||||
try {
|
||||
const { chartId, md5, sha256, resourceUri, parentChart, name, comment } =
|
||||
req.body as Chart;
|
||||
const chartData: Partial<Chart> = {
|
||||
chartId,
|
||||
md5,
|
||||
sha256,
|
||||
resourceUri,
|
||||
parentChart,
|
||||
name,
|
||||
comment,
|
||||
};
|
||||
const chart = await chartService.update(chartData);
|
||||
res.json(chartMapper(chart));
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
console.error(`Failed to update chart: ${message}`);
|
||||
res.status(400).json({
|
||||
error: 'Failed to update chart',
|
||||
});
|
||||
}
|
||||
},
|
||||
]);
|
||||
|
||||
app.delete('/chart/admin/:chartId', [
|
||||
isAdmin,
|
||||
async (req: RequestContext, res: Response) => {
|
||||
try {
|
||||
const chartId = req.params.chartId;
|
||||
await chartService.delete(chartId);
|
||||
res.status(200);
|
||||
res.send();
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
console.error(`Failed to delete chart: ${message}`);
|
||||
res.status(400).json({
|
||||
error: 'Failed to delete chart',
|
||||
});
|
||||
}
|
||||
},
|
||||
]);
|
||||
|
||||
app.get('/chart/admin/list', [
|
||||
isAdmin,
|
||||
async (req: RequestContext, res: Response) => {
|
||||
try {
|
||||
const charts = await chartService.getAll();
|
||||
const formattedCharts = charts.map(chartMapper);
|
||||
res.json(formattedCharts);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
console.error(`Failed to get charts: ${message}`);
|
||||
res.status(400).json({
|
||||
error: 'Failed to get charts',
|
||||
});
|
||||
}
|
||||
},
|
||||
]);
|
||||
|
||||
app.get('/chart/admin/:chartId', [
|
||||
isAdmin,
|
||||
async (req: RequestContext, res: Response) => {
|
||||
try {
|
||||
const chart = await chartService.get(req.params.chartId);
|
||||
res.json(chartMapper(chart));
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
console.error(`Failed to get chart: ${message}`);
|
||||
res.status(400).json({
|
||||
error: 'Failed to get chart',
|
||||
});
|
||||
}
|
||||
},
|
||||
]);
|
41
packages/api/src/baseblocks/chart/chart-dynamodb.yml
Normal file
41
packages/api/src/baseblocks/chart/chart-dynamodb.yml
Normal file
|
@ -0,0 +1,41 @@
|
|||
Resources:
|
||||
chartTable:
|
||||
Type: AWS::DynamoDB::Table
|
||||
DeletionPolicy: ${self:custom.deletionPolicy.${opt:stage}}
|
||||
UpdateReplacePolicy: ${self:custom.updatePolicy.${opt:stage}}
|
||||
Properties:
|
||||
TableName: ${env:APP_NAME}-${opt:stage}-chart
|
||||
AttributeDefinitions:
|
||||
- AttributeName: chartId
|
||||
AttributeType: S
|
||||
- AttributeName: parentChart
|
||||
AttributeType: S
|
||||
- AttributeName: md5
|
||||
AttributeType: S
|
||||
- AttributeName: sha256
|
||||
AttributeType: S
|
||||
KeySchema:
|
||||
- AttributeName: chartId
|
||||
KeyType: HASH
|
||||
BillingMode: PAY_PER_REQUEST
|
||||
PointInTimeRecoverySpecification:
|
||||
PointInTimeRecoveryEnabled: true
|
||||
GlobalSecondaryIndexes:
|
||||
- IndexName: parentChart-index
|
||||
KeySchema:
|
||||
- AttributeName: parentChart
|
||||
KeyType: HASH
|
||||
Projection:
|
||||
ProjectionType: ALL
|
||||
- IndexName: md5-index
|
||||
KeySchema:
|
||||
- AttributeName: md5
|
||||
KeyType: HASH
|
||||
Projection:
|
||||
ProjectionType: ALL
|
||||
- IndexName: sha256-index
|
||||
KeySchema:
|
||||
- AttributeName: sha256
|
||||
KeyType: HASH
|
||||
Projection:
|
||||
ProjectionType: ALL
|
67
packages/api/src/baseblocks/chart/chart-functions.yml
Normal file
67
packages/api/src/baseblocks/chart/chart-functions.yml
Normal file
|
@ -0,0 +1,67 @@
|
|||
ApiChartAdmin:
|
||||
handler: src/baseblocks/chart/chart-admin-api.handler
|
||||
events:
|
||||
- http:
|
||||
path: /chart/admin/{any+}
|
||||
method: ANY
|
||||
authorizer: # https://www.serverless.com/framework/docs/providers/aws/events/apigateway#http-endpoints-with-aws_iam-authorizers
|
||||
type: COGNITO_USER_POOLS
|
||||
authorizerId:
|
||||
Ref: ApiGatewayAuthorizer
|
||||
cors:
|
||||
origin: ${self:custom.apiCorsOrigin}
|
||||
headers:
|
||||
- Content-Type
|
||||
- X-Amz-Date
|
||||
- Authorization
|
||||
- X-Api-Key
|
||||
- X-Amz-Security-Token
|
||||
- X-Amz-User-Agent
|
||||
allowCredentials: false
|
||||
- http:
|
||||
path: /chart/admin
|
||||
method: ANY
|
||||
authorizer: # https://www.serverless.com/framework/docs/providers/aws/events/apigateway#http-endpoints-with-aws_iam-authorizers
|
||||
type: COGNITO_USER_POOLS
|
||||
authorizerId:
|
||||
Ref: ApiGatewayAuthorizer
|
||||
cors:
|
||||
origin: ${self:custom.apiCorsOrigin}
|
||||
headers:
|
||||
- Content-Type
|
||||
- X-Amz-Date
|
||||
- Authorization
|
||||
- X-Api-Key
|
||||
- X-Amz-Security-Token
|
||||
- X-Amz-User-Agent
|
||||
allowCredentials: false
|
||||
|
||||
ApiChartPublic:
|
||||
handler: src/baseblocks/chart/chart-public-api.handler
|
||||
events:
|
||||
- http:
|
||||
path: /chart/public/{any+}
|
||||
method: ANY
|
||||
cors:
|
||||
origin: ${self:custom.apiCorsOrigin}
|
||||
headers:
|
||||
- Content-Type
|
||||
- X-Amz-Date
|
||||
- Authorization
|
||||
- X-Api-Key
|
||||
- X-Amz-Security-Token
|
||||
- X-Amz-User-Agent
|
||||
allowCredentials: false
|
||||
- http:
|
||||
path: /chart/public
|
||||
method: ANY
|
||||
cors:
|
||||
origin: ${self:custom.apiCorsOrigin}
|
||||
headers:
|
||||
- Content-Type
|
||||
- X-Amz-Date
|
||||
- Authorization
|
||||
- X-Api-Key
|
||||
- X-Amz-Security-Token
|
||||
- X-Amz-User-Agent
|
||||
allowCredentials: false
|
43
packages/api/src/baseblocks/chart/chart-public-api.ts
Normal file
43
packages/api/src/baseblocks/chart/chart-public-api.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
import createApp from '../../util/express-app';
|
||||
import createAuthenticatedHandler from '../../util/create-authenticated-handler';
|
||||
import { RequestContext } from '../../util/request-context.type';
|
||||
import { Response } from 'express';
|
||||
import { getErrorMessage } from '../../util/error-message';
|
||||
import { getChartsByMd5, getChartsBySha256 } from './chart.service';
|
||||
import { flatten, keyBy } from 'lodash-es';
|
||||
|
||||
const app = createApp();
|
||||
export const handler = createAuthenticatedHandler(app);
|
||||
|
||||
app.post('/chart/public', [
|
||||
async (req: RequestContext, res: Response) => {
|
||||
try {
|
||||
const { md5 = [], sha256 = [] } = req.body as {
|
||||
md5: string[];
|
||||
sha256: string[];
|
||||
};
|
||||
|
||||
/** @TODO Ask herman how to batch query */
|
||||
const md5Charts = flatten(
|
||||
await Promise.all(md5.map((md5) => getChartsByMd5(md5))),
|
||||
);
|
||||
const md5ChartsKeyedByMd5 = keyBy(md5Charts, 'md5');
|
||||
|
||||
console.log(md5Charts);
|
||||
|
||||
const sha256Charts = flatten(
|
||||
await Promise.all(sha256.map((sha256) => getChartsBySha256(sha256))),
|
||||
);
|
||||
const sha256ChartsKeyedBySha256 = keyBy(sha256Charts, 'sha256');
|
||||
|
||||
res.json({
|
||||
md5: md5ChartsKeyedByMd5,
|
||||
sha256: sha256ChartsKeyedBySha256,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
console.error(`Failed to get charts ${message}`);
|
||||
res.status(400).json({ error: 'Failed to get charts' });
|
||||
}
|
||||
},
|
||||
]);
|
3
packages/api/src/baseblocks/chart/chart.seed.json
Normal file
3
packages/api/src/baseblocks/chart/chart.seed.json
Normal file
|
@ -0,0 +1,3 @@
|
|||
[
|
||||
|
||||
]
|
65
packages/api/src/baseblocks/chart/chart.service.ts
Normal file
65
packages/api/src/baseblocks/chart/chart.service.ts
Normal file
|
@ -0,0 +1,65 @@
|
|||
import { Chart } from '@baseline/types/chart';
|
||||
import { getDynamodbConnection, queryItems } from '@baselinejs/dynamodb';
|
||||
import { ServiceObject } from '../../util/service-object';
|
||||
import { getErrorMessage } from '../../util/error-message';
|
||||
|
||||
const dynamoDb = getDynamodbConnection({
|
||||
region: `${process.env.API_REGION}`,
|
||||
});
|
||||
|
||||
export const chartService = new ServiceObject<Chart>({
|
||||
dynamoDb: dynamoDb,
|
||||
objectName: 'Chart',
|
||||
table: `${process.env.APP_NAME}-${process.env.NODE_ENV}-chart`,
|
||||
primaryKey: 'chartId',
|
||||
});
|
||||
|
||||
export const getChartsByParentChartId = async (
|
||||
parentChartId: string,
|
||||
): Promise<Chart[]> => {
|
||||
try {
|
||||
return await queryItems<Chart>({
|
||||
dynamoDb: chartService.dynamoDb,
|
||||
table: chartService.table,
|
||||
keyName: 'parentChart',
|
||||
keyValue: parentChartId,
|
||||
indexName: 'parentChart-index',
|
||||
});
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
console.error(`Failed to get ${chartService.objectName}: ${message}`);
|
||||
throw new Error(message);
|
||||
}
|
||||
};
|
||||
|
||||
export const getChartsByMd5 = async (md5: string): Promise<Chart[]> => {
|
||||
try {
|
||||
return await queryItems<Chart>({
|
||||
dynamoDb: chartService.dynamoDb,
|
||||
table: chartService.table,
|
||||
keyName: 'md5',
|
||||
keyValue: md5,
|
||||
indexName: 'md5-index',
|
||||
});
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
console.error(`Failed to get ${chartService.objectName}: ${message}`);
|
||||
throw new Error(message);
|
||||
}
|
||||
};
|
||||
|
||||
export const getChartsBySha256 = async (sha256: string): Promise<Chart[]> => {
|
||||
try {
|
||||
return await queryItems<Chart>({
|
||||
dynamoDb: chartService.dynamoDb,
|
||||
table: chartService.table,
|
||||
keyName: 'sha256',
|
||||
keyValue: sha256,
|
||||
indexName: 'sha256-index',
|
||||
});
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
console.error(`Failed to get ${chartService.objectName}: ${message}`);
|
||||
throw new Error(message);
|
||||
}
|
||||
};
|
14
packages/api/src/baseblocks/chart/chart.ts
Normal file
14
packages/api/src/baseblocks/chart/chart.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { Chart } from '@baseline/types/chart';
|
||||
|
||||
export const chartMapper = (data: Chart): Chart => {
|
||||
const chart: Chart = {
|
||||
chartId: data?.chartId,
|
||||
md5: data?.md5,
|
||||
sha256: data?.sha256,
|
||||
resourceUri: data?.resourceUri,
|
||||
parentChart: data?.parentChart,
|
||||
name: data?.name,
|
||||
comment: data?.comment,
|
||||
};
|
||||
return chart;
|
||||
};
|
105
packages/api/src/baseblocks/cognito/cognito-resources.yml
Normal file
105
packages/api/src/baseblocks/cognito/cognito-resources.yml
Normal file
|
@ -0,0 +1,105 @@
|
|||
Resources:
|
||||
CognitoUserPool:
|
||||
Type: AWS::Cognito::UserPool
|
||||
Properties:
|
||||
# Generate a name based on the stage
|
||||
UserPoolName: ${env:APP_NAME}-${opt:stage}-user-pool
|
||||
# Set email as an alias
|
||||
UsernameAttributes:
|
||||
- email
|
||||
AutoVerifiedAttributes:
|
||||
- email
|
||||
UsernameConfiguration:
|
||||
CaseSensitive: false
|
||||
|
||||
CognitoUserPoolClient:
|
||||
Type: AWS::Cognito::UserPoolClient
|
||||
Properties:
|
||||
# Generate an app client name based on the stage
|
||||
ClientName: ${env:APP_NAME}-${opt:stage}-user-pool-client
|
||||
UserPoolId:
|
||||
Ref: CognitoUserPool
|
||||
ExplicitAuthFlows:
|
||||
- ADMIN_NO_SRP_AUTH
|
||||
- USER_PASSWORD_AUTH
|
||||
GenerateSecret: false
|
||||
PreventUserExistenceErrors: ENABLED
|
||||
|
||||
# The federated identity for our user pool to auth with
|
||||
CognitoIdentityPool:
|
||||
Type: AWS::Cognito::IdentityPool
|
||||
Properties:
|
||||
# Generate a name based on the stage
|
||||
IdentityPoolName: ${env:APP_NAME}-${opt:stage}-identity-pool
|
||||
# Don't allow unathenticated users
|
||||
AllowUnauthenticatedIdentities: false
|
||||
# Link to our User Pool
|
||||
CognitoIdentityProviders:
|
||||
- ClientId:
|
||||
Ref: CognitoUserPoolClient
|
||||
ProviderName:
|
||||
Fn::GetAtt: ['CognitoUserPool', 'ProviderName']
|
||||
# IAM roles
|
||||
CognitoIdentityPoolRoles:
|
||||
Type: AWS::Cognito::IdentityPoolRoleAttachment
|
||||
Properties:
|
||||
IdentityPoolId:
|
||||
Ref: CognitoIdentityPool
|
||||
Roles:
|
||||
authenticated:
|
||||
Fn::GetAtt: [CognitoAuthRole, Arn]
|
||||
|
||||
# IAM role used for authenticated users
|
||||
CognitoAuthRole:
|
||||
Type: AWS::IAM::Role
|
||||
Properties:
|
||||
Path: /
|
||||
AssumeRolePolicyDocument:
|
||||
Version: '2012-10-17'
|
||||
Statement:
|
||||
- Effect: 'Allow'
|
||||
Principal:
|
||||
Federated: 'cognito-identity.amazonaws.com'
|
||||
Action:
|
||||
- 'sts:AssumeRoleWithWebIdentity'
|
||||
Condition:
|
||||
StringEquals:
|
||||
'cognito-identity.amazonaws.com:aud':
|
||||
Ref: CognitoIdentityPool
|
||||
'ForAnyValue:StringLike':
|
||||
'cognito-identity.amazonaws.com:amr': authenticated
|
||||
|
||||
# API Gateway authorizer using Cognito
|
||||
ApiGatewayAuthorizer:
|
||||
Type: AWS::ApiGateway::Authorizer
|
||||
Properties:
|
||||
Name: ${env:APP_NAME}-${opt:stage}-api-gateway-authorizer
|
||||
Type: COGNITO_USER_POOLS
|
||||
IdentitySource: method.request.header.Authorization
|
||||
RestApiId:
|
||||
Ref: ApiGatewayRestApi
|
||||
ProviderARNs:
|
||||
- Fn::GetAtt:
|
||||
- CognitoUserPool
|
||||
- Arn
|
||||
|
||||
Outputs:
|
||||
UserPoolId:
|
||||
Description: 'Cognito UserPoolId'
|
||||
Value:
|
||||
Ref: CognitoUserPool
|
||||
|
||||
UserPoolClientId:
|
||||
Description: 'Cognito UserPoolClientId'
|
||||
Value:
|
||||
Ref: CognitoUserPoolClient
|
||||
|
||||
IdentityPoolId:
|
||||
Description: 'Cognito IdentityPoolId'
|
||||
Value:
|
||||
Ref: CognitoIdentityPool
|
||||
|
||||
CognitoAuthRole:
|
||||
Description: 'Cognito CognitoAuthRole'
|
||||
Value:
|
||||
Ref: CognitoAuthRole
|
151
packages/api/src/baseblocks/cognito/cognito.service.ts
Normal file
151
packages/api/src/baseblocks/cognito/cognito.service.ts
Normal file
|
@ -0,0 +1,151 @@
|
|||
import * as AWS_CognitoIdentityServiceProvider from '@aws-sdk/client-cognito-identity-provider';
|
||||
|
||||
const { CognitoIdentityProvider: CognitoIdentityServiceProvider } =
|
||||
AWS_CognitoIdentityServiceProvider;
|
||||
|
||||
const cognito = new CognitoIdentityServiceProvider({
|
||||
region: process.env.API_REGION || 'ap-southeast-2',
|
||||
});
|
||||
|
||||
export async function getUserAttributesByEmail(userEmail: string) {
|
||||
try {
|
||||
const formattedEmail = userEmail?.toLowerCase();
|
||||
const existingResponse = await cognito.adminGetUser({
|
||||
UserPoolId: `${process.env.COGNITO_USER_POOL_ID}`,
|
||||
Username: `${formattedEmail}`,
|
||||
});
|
||||
console.log(JSON.stringify(existingResponse, null, 2));
|
||||
const attributes = existingResponse?.UserAttributes?.reduce(
|
||||
(prev, attr) => {
|
||||
if (attr?.Name) {
|
||||
prev[attr.Name] = `${attr.Value}`;
|
||||
}
|
||||
return prev;
|
||||
},
|
||||
{} as { [key: string]: string },
|
||||
);
|
||||
|
||||
return attributes;
|
||||
} catch (error) {
|
||||
console.log('No user found: ', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createUser(userEmail: string) {
|
||||
try {
|
||||
const formattedEmail = userEmail?.toLowerCase();
|
||||
const userAttributes = [
|
||||
{
|
||||
Name: 'email',
|
||||
Value: formattedEmail,
|
||||
},
|
||||
{
|
||||
Name: 'email_verified',
|
||||
Value: 'true',
|
||||
},
|
||||
];
|
||||
const cognitoUser = await cognito.adminCreateUser({
|
||||
UserPoolId: process.env.COGNITO_USER_POOL_ID,
|
||||
Username: formattedEmail,
|
||||
UserAttributes: userAttributes,
|
||||
DesiredDeliveryMediums: ['EMAIL'],
|
||||
});
|
||||
|
||||
console.log(JSON.stringify(cognitoUser, null, 2));
|
||||
|
||||
const attributes = cognitoUser.User?.Attributes?.reduce((prev, attr) => {
|
||||
if (attr.Name) {
|
||||
prev[attr.Name] = `${attr.Value}`;
|
||||
}
|
||||
return prev;
|
||||
}, {} as { [key: string]: string });
|
||||
|
||||
return attributes;
|
||||
} catch (error) {
|
||||
console.log('Failed to create cognito user', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUsers(args: {
|
||||
subFilter?: string;
|
||||
usernameFilter?: string;
|
||||
emailFilter?: string;
|
||||
phoneNumberFilter?: string;
|
||||
nameFilter?: string;
|
||||
givenNameFilter?: string;
|
||||
familyNameFilter?: string;
|
||||
pageSize?: number;
|
||||
paginationToken?: string;
|
||||
isExactMatch?: boolean;
|
||||
}): Promise<
|
||||
AWS_CognitoIdentityServiceProvider.ListUsersCommandOutput | undefined
|
||||
> {
|
||||
const {
|
||||
subFilter,
|
||||
usernameFilter,
|
||||
emailFilter,
|
||||
nameFilter,
|
||||
givenNameFilter,
|
||||
familyNameFilter,
|
||||
phoneNumberFilter,
|
||||
pageSize,
|
||||
paginationToken,
|
||||
isExactMatch,
|
||||
} = args;
|
||||
|
||||
try {
|
||||
const requestArgs = {
|
||||
UserPoolId: process.env.COGNITO_USER_POOL_ID,
|
||||
Limit: pageSize || 50,
|
||||
PaginationToken: paginationToken,
|
||||
} as AWS_CognitoIdentityServiceProvider.ListUsersCommandInput;
|
||||
|
||||
let field = '';
|
||||
let value = '';
|
||||
const comparison = `${isExactMatch ? '' : '^'}=`;
|
||||
|
||||
if (subFilter) {
|
||||
field = 'sub';
|
||||
value = subFilter;
|
||||
}
|
||||
|
||||
if (usernameFilter) {
|
||||
field = 'username';
|
||||
value = usernameFilter;
|
||||
}
|
||||
|
||||
if (emailFilter) {
|
||||
field = 'email';
|
||||
value = emailFilter;
|
||||
}
|
||||
|
||||
if (phoneNumberFilter) {
|
||||
field = 'phone_number';
|
||||
value = phoneNumberFilter;
|
||||
}
|
||||
|
||||
if (nameFilter) {
|
||||
field = 'name';
|
||||
value = nameFilter;
|
||||
}
|
||||
|
||||
if (givenNameFilter) {
|
||||
field = 'given_name';
|
||||
value = givenNameFilter;
|
||||
}
|
||||
|
||||
if (familyNameFilter) {
|
||||
field = 'family_name';
|
||||
value = familyNameFilter;
|
||||
}
|
||||
|
||||
if (field) {
|
||||
requestArgs.Filter = `${field} ${comparison} "${value}"`;
|
||||
}
|
||||
|
||||
const response = await cognito.listUsers(requestArgs);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.log('No users found: ', error);
|
||||
}
|
||||
}
|
19
packages/api/src/middleware/is-admin.ts
Normal file
19
packages/api/src/middleware/is-admin.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
import { NextFunction, Response } from 'express';
|
||||
import { isAdminSub } from '../baseblocks/admin/admin.service';
|
||||
import { RequestContext } from '../util/request-context.type';
|
||||
|
||||
export const isAdmin = async (
|
||||
req: RequestContext,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
const userSub = req.currentUserSub;
|
||||
const isAdmin = await isAdminSub(userSub);
|
||||
if (!isAdmin) {
|
||||
res.status(403).json({
|
||||
error: 'User does not have permission',
|
||||
});
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
11
packages/api/src/middleware/log-route.ts
Normal file
11
packages/api/src/middleware/log-route.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { NextFunction, Response } from 'express';
|
||||
import { RequestContext } from '../util/request-context.type';
|
||||
|
||||
export const logRoute = (
|
||||
req: RequestContext,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
console.log(`Request: ${req.method} ${req.originalUrl}`);
|
||||
next();
|
||||
};
|
19
packages/api/src/util/create-authenticated-handler.ts
Normal file
19
packages/api/src/util/create-authenticated-handler.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
import serverless from 'serverless-http';
|
||||
import { APIGatewayProxyEventBase } from 'aws-lambda';
|
||||
import { Authorizer, RequestContext } from './request-context.type';
|
||||
import { Application } from 'express';
|
||||
|
||||
const createAuthenticatedHandler = (app: Application) => {
|
||||
const handler = serverless(app, {
|
||||
request(
|
||||
request: RequestContext,
|
||||
event: APIGatewayProxyEventBase<Authorizer>,
|
||||
) {
|
||||
request.context = event.requestContext;
|
||||
request.currentUserSub = `${request.context?.authorizer?.claims?.sub}`;
|
||||
},
|
||||
});
|
||||
return handler;
|
||||
};
|
||||
|
||||
export default createAuthenticatedHandler;
|
9
packages/api/src/util/error-message.ts
Normal file
9
packages/api/src/util/error-message.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
export function getErrorMessage(error: unknown) {
|
||||
let message: string;
|
||||
if (error instanceof Error) {
|
||||
message = error.message;
|
||||
} else {
|
||||
message = String(error);
|
||||
}
|
||||
return message;
|
||||
}
|
22
packages/api/src/util/express-app.ts
Normal file
22
packages/api/src/util/express-app.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
import compression from 'compression';
|
||||
import cors from 'cors';
|
||||
import express, { Application } from 'express';
|
||||
import { logRoute } from '../middleware/log-route';
|
||||
|
||||
const createApp = (): Application => {
|
||||
const corsOptions = {
|
||||
origin: process.env.API_CORS_ORIGIN,
|
||||
optionsSuccessStatus: 200, // some legacy browsers (IE11, various SmartTVs) choke on 204
|
||||
};
|
||||
|
||||
const app = express();
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json());
|
||||
app.use(compression());
|
||||
app.use(cors(corsOptions));
|
||||
app.options('*', cors(corsOptions));
|
||||
app.use(logRoute);
|
||||
return app;
|
||||
};
|
||||
|
||||
export default createApp;
|
23
packages/api/src/util/paging.ts
Normal file
23
packages/api/src/util/paging.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { PagedResponse } from '@baseline/types/paging';
|
||||
|
||||
export interface PageDataParams<T> {
|
||||
items: T[];
|
||||
limit: string | number;
|
||||
offset: string | number;
|
||||
}
|
||||
|
||||
export function pageData<T>(params: PageDataParams<T>): PagedResponse<T> {
|
||||
const limit = parseInt(`${params.limit}`) || 10;
|
||||
const offset = parseInt(`${params.offset}`) || 0;
|
||||
const items = params.items;
|
||||
return {
|
||||
data: items.slice(offset, offset + limit),
|
||||
pagination: {
|
||||
limit: limit,
|
||||
totalRecords: items.length,
|
||||
nextFrom: offset + limit < items.length ? offset + limit : undefined,
|
||||
pages: parseInt(`${Math.ceil(items.length / limit) - 1}`),
|
||||
currentPage: parseInt(`${offset / limit}`),
|
||||
},
|
||||
};
|
||||
}
|
15
packages/api/src/util/request-context.type.ts
Normal file
15
packages/api/src/util/request-context.type.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import {
|
||||
APIGatewayEventDefaultAuthorizerContext,
|
||||
APIGatewayEventRequestContextWithAuthorizer,
|
||||
} from 'aws-lambda';
|
||||
import { Request } from 'express';
|
||||
|
||||
export type RequestContext = Request & {
|
||||
context: APIGatewayEventRequestContextWithAuthorizer<Authorizer>;
|
||||
} & {
|
||||
currentUserSub: string;
|
||||
};
|
||||
|
||||
export type Authorizer = APIGatewayEventDefaultAuthorizerContext & {
|
||||
claims: { email?: string; sub?: string };
|
||||
};
|
178
packages/api/src/util/service-object.ts
Normal file
178
packages/api/src/util/service-object.ts
Normal file
|
@ -0,0 +1,178 @@
|
|||
import { getErrorMessage } from './error-message';
|
||||
import {
|
||||
batchGetItems,
|
||||
deleteItem,
|
||||
getItem,
|
||||
getAllItems,
|
||||
putItem,
|
||||
updateItem,
|
||||
DynamoDbDocumentClient,
|
||||
} from '@baselinejs/dynamodb';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export class ServiceObject<T extends Record<string, any>> {
|
||||
table: string;
|
||||
dynamoDb: DynamoDbDocumentClient;
|
||||
objectName: string;
|
||||
primaryKey: string;
|
||||
ownerField: string | undefined;
|
||||
|
||||
constructor(params: {
|
||||
table: string;
|
||||
objectName: string;
|
||||
dynamoDb: DynamoDbDocumentClient;
|
||||
primaryKey: string;
|
||||
ownerField?: string;
|
||||
}) {
|
||||
this.dynamoDb = params.dynamoDb;
|
||||
this.table = params.table;
|
||||
this.objectName = params.objectName;
|
||||
this.primaryKey = params.primaryKey;
|
||||
this.ownerField = params.ownerField;
|
||||
}
|
||||
|
||||
async getAll(): Promise<T[]> {
|
||||
console.log(`Get all ${this.objectName} records`);
|
||||
try {
|
||||
if (!this.dynamoDb) {
|
||||
throw new Error('DynamoDB not connected');
|
||||
}
|
||||
return getAllItems<T>({
|
||||
dynamoDb: this.dynamoDb,
|
||||
table: this.table,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
console.error(`Failed to get ${this.objectName}: ${message}`);
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async get(key: string): Promise<T> {
|
||||
console.log(`Get ${this.objectName} by ${this.primaryKey} [${key}]`);
|
||||
try {
|
||||
if (!this.dynamoDb) {
|
||||
throw new Error('DynamoDB not connected');
|
||||
}
|
||||
return await getItem<T>({
|
||||
dynamoDb: this.dynamoDb,
|
||||
table: this.table,
|
||||
key: {
|
||||
[this.primaryKey]: `${key}`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
console.error(`Failed to get ${this.objectName}: ${message}`);
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async create(record: Partial<T>): Promise<T> {
|
||||
console.log(`Create ${this.objectName}`);
|
||||
try {
|
||||
if (!this.dynamoDb) {
|
||||
throw new Error('DynamoDB not connected');
|
||||
}
|
||||
const item: Partial<T> = {
|
||||
[this.primaryKey]: randomUUID(),
|
||||
...record,
|
||||
};
|
||||
return await putItem<T>({
|
||||
dynamoDb: this.dynamoDb,
|
||||
table: this.table,
|
||||
item: item as T,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
console.error(`Failed to create ${this.objectName}: ${message}`);
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async update(record: Partial<T>): Promise<T> {
|
||||
console.log(
|
||||
`Update ${this.objectName} ${this.primaryKey} [${
|
||||
record[this.primaryKey]
|
||||
}]`,
|
||||
);
|
||||
try {
|
||||
if (!this.dynamoDb) {
|
||||
throw new Error('DynamoDB not connected');
|
||||
}
|
||||
if (!record[this.primaryKey]) {
|
||||
throw new Error(`Cannot update without ${this.primaryKey}`);
|
||||
}
|
||||
const partial = {} as Partial<T>;
|
||||
Object.keys(record).forEach((key: keyof T) => {
|
||||
if (key !== this.primaryKey) {
|
||||
partial[key] = record[key];
|
||||
}
|
||||
});
|
||||
return await updateItem<T>({
|
||||
dynamoDb: this.dynamoDb,
|
||||
table: this.table,
|
||||
key: {
|
||||
[this.primaryKey]: `${record[this.primaryKey]}`,
|
||||
},
|
||||
fields: partial,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
console.error(`Failed to update ${this.objectName}: ${message}`);
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async delete(keyValue: string): Promise<boolean> {
|
||||
console.log(`Delete ${this.objectName} ${keyValue}`);
|
||||
try {
|
||||
if (!this.dynamoDb) {
|
||||
throw new Error('DynamoDB not connected');
|
||||
}
|
||||
return await deleteItem({
|
||||
dynamoDb: this.dynamoDb,
|
||||
table: this.table,
|
||||
key: {
|
||||
[this.primaryKey]: keyValue,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
console.error(`Failed to delete ${this.objectName}: ${message}`);
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async batchGet(ids: string[]): Promise<T[]> {
|
||||
console.log(`Getting all ${this.objectName} by ids`);
|
||||
try {
|
||||
if (!this.dynamoDb) {
|
||||
throw new Error('DynamoDB not connected');
|
||||
}
|
||||
const keys = ids.map((id) => {
|
||||
return {
|
||||
[this.primaryKey]: id,
|
||||
};
|
||||
});
|
||||
return await batchGetItems<T>({
|
||||
dynamoDb: this.dynamoDb,
|
||||
keys: keys,
|
||||
table: this.table,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
console.error(`Failed to batch get ${this.objectName}: ${message}`);
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async isOwner(id: string, userSub: string) {
|
||||
if (this.ownerField) {
|
||||
const record = await this.get(id);
|
||||
return record[this.ownerField] === userSub;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
27
packages/api/tsconfig.json
Normal file
27
packages/api/tsconfig.json
Normal file
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"preserveConstEnums": true,
|
||||
"strictNullChecks": true,
|
||||
"sourceMap": true,
|
||||
"allowJs": true,
|
||||
"module": "CommonJS",
|
||||
"target": "es5",
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"lib": ["es2015"],
|
||||
"rootDirs": ["./"],
|
||||
"outDir": ".esbuild"
|
||||
},
|
||||
"include": ["../../shared/types", "src"],
|
||||
"watchOptions": {
|
||||
"excludeDirectories": [
|
||||
"node_modules",
|
||||
".esbuild",
|
||||
".dynamodb",
|
||||
".serverless",
|
||||
"scripts"
|
||||
]
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue