Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Seatool indexes and api authorization pattern #46

Merged
merged 8 commits into from
Jun 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 27 additions & 27 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,33 +85,33 @@ jobs:
- name: Test
run: yarn test-ci

e2e:
timeout-minutes: 5
runs-on: ubuntu-20.04
needs:
- deploy
env:
baseurl: ${{ needs.deploy.outputs.app-url }}
if: ${{ github.ref != 'refs/heads/production' }}
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup
uses: ./.github/actions/setup
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v2
with:
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_TO_ASSUME }}
aws-region: us-east-1
role-duration-seconds: 10800
- name: Run e2e tests
run: run e2e
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: src/services/ui/playwright-report/
retention-days: 30
# e2e:
# timeout-minutes: 5
# runs-on: ubuntu-20.04
# needs:
# - deploy
# env:
# baseurl: ${{ needs.deploy.outputs.app-url }}
# if: ${{ github.ref != 'refs/heads/production' }}
# steps:
# - name: Checkout
# uses: actions/checkout@v3
# - name: Setup
# uses: ./.github/actions/setup
# - name: Configure AWS credentials
# uses: aws-actions/configure-aws-credentials@v2
# with:
# role-to-assume: ${{ secrets.AWS_OIDC_ROLE_TO_ASSUME }}
# aws-region: us-east-1
# role-duration-seconds: 10800
# - name: Run e2e tests
# run: run e2e
# - uses: actions/upload-artifact@v3
# if: always()
# with:
# name: playwright-report
# path: src/services/ui/playwright-report/
# retention-days: 30

cfn-nag:
runs-on: ubuntu-20.04
Expand Down
3 changes: 3 additions & 0 deletions src/packages/shared-types/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export type ReactQueryApiError = {
response: { data: { message: string } };
};
2 changes: 2 additions & 0 deletions src/packages/shared-types/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export * from "./issue";
export * from "./user";
export * from "./seatoolData";
export * from "./errors";
6 changes: 6 additions & 0 deletions src/packages/shared-types/seatoolData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export type SeatoolData = {
ID: string;
SUBMISSION_DATE: number;
PLAN_TYPE: string; // should be enum
STATE_CODE: string; // should be enum
};
4 changes: 2 additions & 2 deletions src/packages/shared-types/user.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
export interface CognitoUserAttributes {
export type CognitoUserAttributes = {
sub: string;
"custom:roles": string[];
email_verified: boolean;
"custom:state_codes": string[];
given_name: string;
family_name: string;
email: string;
}
};
50 changes: 47 additions & 3 deletions src/services/api/handlers/getSeatoolData.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,68 @@
import { response } from "../libs/handler";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { SeatoolService } from "../services/seatoolService";
import { APIGatewayEvent } from "aws-lambda";
import { getAuthDetails, lookupUserAttributes } from "../libs/auth/user";

// Create an instance of DynamoDB client
const dynamoInstance = new DynamoDBClient({ region: process.env.region });

export const getSeatoolData = async () => {
// Handler function to get Seatool data
export const getSeatoolData = async (event: APIGatewayEvent) => {
try {
// Retrieve authentication details of the user
const authDetails = getAuthDetails(event);

// Look up user attributes from Cognito
const userAttributes = await lookupUserAttributes(
authDetails.userId,
authDetails.poolId
);

// Retrieve the state code from the path parameters
const stateCode = event.pathParameters?.stateCode;

// Check if stateCode is provided
if (!stateCode) {
return response({
statusCode: 400,
body: { message: "State code is missing" },
});
}

// Check if user is authorized to access the resource based on their attributes
if (
!userAttributes ||
!userAttributes["custom:state_codes"]?.includes(stateCode)
) {
return response({
statusCode: 403,
body: { message: "User is not authorized to access this resource" },
});
}

// Retrieve Seatool data using the SeatoolService
const seaData = await new SeatoolService(dynamoInstance).getIssues({
tableName: process.env.tableName,
stateCode: stateCode,
});

if (!seaData) {
return response({
statusCode: 404,
body: { message: "No Seatool data found for the provided state code" },
});
}

return response<unknown>({
statusCode: 200,
body: seaData,
});
} catch (error) {
console.error({ error });
return response({
statusCode: 404,
body: { message: JSON.stringify(error) },
statusCode: 500,
body: { message: "Internal server error" },
});
}
};
Expand Down
96 changes: 96 additions & 0 deletions src/services/api/libs/auth/user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import {
CognitoIdentityProviderClient,
ListUsersCommand,
UserType as CognitoUserType,
} from "@aws-sdk/client-cognito-identity-provider";
import { CognitoUserAttributes } from "shared-types";
import { APIGatewayEvent } from "aws-lambda";

// Retrieve user authentication details from the APIGatewayEvent
export function getAuthDetails(event: APIGatewayEvent) {
const authProvider =
event.requestContext.identity.cognitoAuthenticationProvider;
const parts = authProvider.split(":");
const userPoolIdParts = parts[parts.length - 3].split("/");
const userPoolId = userPoolIdParts[userPoolIdParts.length - 1];
const userPoolUserId = parts[parts.length - 1];

return { userId: userPoolUserId, poolId: userPoolId };
}

// Convert Cognito user attributes to a dictionary format
function userAttrDict(cognitoUser: CognitoUserType): CognitoUserAttributes {
const attributes = {};

if (cognitoUser.Attributes) {
cognitoUser.Attributes.forEach((attribute) => {
if (attribute.Value && attribute.Name) {
attributes[attribute.Name] = attribute.Value;
}
});
}

return attributes as CognitoUserAttributes;
}

// Parse object values as JSON if possible
export const getParsedObject = (obj: CognitoUserAttributes) =>
Object.fromEntries(
Object.entries(obj).map(([key, value]) => {
try {
return [key, JSON.parse(value as string)];
} catch (error) {
return [key, value];
}
})
);

// Retrieve and parse user attributes from Cognito using the provided userId and poolId
export async function lookupUserAttributes(
userId: string,
poolId: string
): Promise<CognitoUserAttributes> {
const fetchResult = await fetchUserFromCognito(userId, poolId);

if (fetchResult instanceof Error) {
throw fetchResult;
}

const currentUser = fetchResult as CognitoUserType;
const attributes = userAttrDict(currentUser);

return getParsedObject(attributes) as CognitoUserAttributes;
}

// Fetch user data from Cognito based on the provided userId and poolId
async function fetchUserFromCognito(
userID: string,
poolID: string
): Promise<CognitoUserType | Error> {
const cognitoClient = new CognitoIdentityProviderClient({
region: process.env.region,
});

const subFilter = `sub = "${userID}"`;

const commandListUsers = new ListUsersCommand({
UserPoolId: poolID,
Filter: subFilter,
});

try {
const listUsersResponse = await cognitoClient.send(commandListUsers);

if (
listUsersResponse.Users === undefined ||
listUsersResponse.Users.length !== 1
) {
throw new Error("No user found with this sub");
}

const currentUser = listUsersResponse.Users[0];
return currentUser;
} catch (error) {
throw new Error("Error fetching user from Cognito");
}
}
7 changes: 6 additions & 1 deletion src/services/api/serverless.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ provider:
- dynamodb:BatchWrite*
- dynamodb:BatchGet*
Resource: "*"
- Effect: Allow
Action:
- cognito-idp:GetUser
- cognito-idp:ListUsers
Resource: "*"

custom:
project: ${env:PROJECT}
Expand Down Expand Up @@ -69,7 +74,7 @@ functions:
region: ${self:provider.region}
events:
- http:
path: /seatool
path: /seatool/{stateCode}
method: get
cors: true
authorizer: aws_iam
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@benjaminpaige I thought what you demo'd was using an authorizer, which would replace the aws_iam protection. Did I misunderstand? Either way this is great

Expand Down
21 changes: 18 additions & 3 deletions src/services/api/services/seatoolService.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { DynamoDBClient, ScanCommand } from "@aws-sdk/client-dynamodb";
import { DynamoDBClient, QueryCommand } from "@aws-sdk/client-dynamodb";
import { unmarshall } from "@aws-sdk/util-dynamodb";

export class SeatoolService {
Expand All @@ -8,10 +8,25 @@ export class SeatoolService {
this.#dynamoInstance = dynamoInstance;
}

async getIssues({ tableName }: { tableName: string }) {
async getIssues({
tableName,
stateCode,
}: {
tableName: string;
stateCode: string;
}) {
const data = await this.#dynamoInstance.send(
new ScanCommand({
new QueryCommand({
TableName: tableName,
IndexName: "STATE_CODE-SUBMISSION_DATE-index",
KeyConditionExpression: "STATE_CODE = :state",

ExpressionAttributeValues: {
":state": { S: stateCode },
},

ScanIndexForward: false,
Limit: 300,
})
);

Expand Down
36 changes: 30 additions & 6 deletions src/services/seatool/handlers/sink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,35 @@ export const handler: Handler = async (event) => {
({ key, value }: { key: string; value: string }) => {
if (!value) {
records.push({
id: JSON.parse(decode(key)),
ID: JSON.parse(decode(key)),
isTombstone: true,
});
} else {
records.push({
id: JSON.parse(decode(key)),
...JSON.parse(decode(value)),
});
const jsonRecord = { ...JSON.parse(decode(value)) };

const STATE_CODE = jsonRecord?.["STATES"]?.[0]?.["STATE_CODE"];
const PLAN_TYPE = jsonRecord?.["PLAN_TYPES"]?.[0]?.["PLAN_TYPE_NAME"];
const SUBMISSION_DATE =
jsonRecord?.["STATE_PLAN"]?.["SUBMISSION_DATE"];

const record = {
ID: JSON.parse(decode(key)),
...jsonRecord,
};

if (STATE_CODE) {
record.STATE_CODE = STATE_CODE;
}

if (PLAN_TYPE) {
record.PLAN_TYPE = PLAN_TYPE;
}

if (SUBMISSION_DATE) {
record.SUBMISSION_DATE = SUBMISSION_DATE;
}

records.push(record);
}
}
);
Expand All @@ -28,7 +49,10 @@ export const handler: Handler = async (event) => {

for (const item of records) {
if (item.isTombstone) {
await deleteItem({ tableName: process.env.tableName, key: { id: item.id } });
await deleteItem({
tableName: process.env.tableName,
key: { ID: item.ID },
});
} else {
await putItem({
tableName: process.env.tableName,
Expand Down
Loading