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

Build dynamic IAM support for groups and users -> roles #25

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
4 changes: 4 additions & 0 deletions cloudformation/iam.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ Resources:
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-events-ticketing-metadata
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-merchstore-metadata/*
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-merchstore-metadata
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-iam-userroles
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-iam-userroles/*
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-iam-grouproles
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-iam-grouproles/*

PolicyName: lambda-dynamo
Outputs:
Expand Down
32 changes: 32 additions & 0 deletions cloudformation/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,38 @@ Resources:
Path: /{proxy+}
Method: ANY

IamGroupRolesTable:
Type: 'AWS::DynamoDB::Table'
DeletionPolicy: "Retain"
Properties:
BillingMode: 'PAY_PER_REQUEST'
TableName: infra-core-api-iam-grouproles
DeletionProtectionEnabled: true
PointInTimeRecoverySpecification:
PointInTimeRecoveryEnabled: false
AttributeDefinitions:
- AttributeName: groupUuid
AttributeType: S
KeySchema:
- AttributeName: groupUuid
KeyType: HASH

IamUserRolesTable:
Type: 'AWS::DynamoDB::Table'
DeletionPolicy: "Retain"
Properties:
BillingMode: 'PAY_PER_REQUEST'
TableName: infra-core-api-iam-userroles
DeletionProtectionEnabled: true
PointInTimeRecoverySpecification:
PointInTimeRecoveryEnabled: false
AttributeDefinitions:
- AttributeName: userEmail
AttributeType: S
KeySchema:
- AttributeName: userEmail
KeyType: HASH

EventRecordsTable:
Type: 'AWS::DynamoDB::Table'
DeletionPolicy: "Retain"
Expand Down
2 changes: 2 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type GenericConfigType = {
TicketPurchasesTableName: string;
TicketMetadataTableName: string;
MerchStoreMetadataTableName: string;
IAMTablePrefix: string;
};

type EnvironmentConfigType = {
Expand All @@ -46,6 +47,7 @@ const genericConfig: GenericConfigType = {
MerchStoreMetadataTableName: "infra-merchstore-metadata",
TicketPurchasesTableName: "infra-events-tickets",
TicketMetadataTableName: "infra-events-ticketing-metadata",
IAMTablePrefix: "infra-core-api-iam",
} as const;

const environmentConfig: EnvironmentConfigType = {
Expand Down
11 changes: 11 additions & 0 deletions src/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ export abstract class BaseError<T extends string> extends Error {
}
}

export class NotImplementedError extends BaseError<"NotImplementedError"> {
constructor({ message }: { message?: string }) {
super({
name: "NotImplementedError",
id: 100,
message: message || "This feature has not been implemented yet.",
httpStatusCode: 500,
});
}
}

export class UnauthorizedError extends BaseError<"UnauthorizedError"> {
constructor({ message }: { message: string }) {
super({ name: "UnauthorizedError", id: 101, message, httpStatusCode: 401 });
Expand Down
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import organizationsPlugin from "./routes/organizations.js";
import icalPlugin from "./routes/ics.js";
import vendingPlugin from "./routes/vending.js";
import * as dotenv from "dotenv";
import ssoManagementRoute from "./routes/sso.js";
import iamRoutes from "./routes/iam.js";
import ticketsPlugin from "./routes/tickets.js";
dotenv.config();

Expand Down Expand Up @@ -73,7 +73,7 @@ async function init() {
api.register(eventsPlugin, { prefix: "/events" });
api.register(organizationsPlugin, { prefix: "/organizations" });
api.register(icalPlugin, { prefix: "/ical" });
api.register(ssoManagementRoute, { prefix: "/sso" });
api.register(iamRoutes, { prefix: "/iam" });
api.register(ticketsPlugin, { prefix: "/tickets" });
if (app.runEnvironment === "dev") {
api.register(vendingPlugin, { prefix: "/vending" });
Expand Down
1 change: 1 addition & 0 deletions src/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export enum AppRoles {
SSO_INVITE_USER = "invite:sso",
TICKETS_SCANNER = "scan:tickets",
TICKETS_MANAGER = "manage:tickets",
IAM_ADMIN = "admin:iam",
}
export const allAppRoles = Object.values(AppRoles).filter(
(value) => typeof value === "string",
Expand Down
206 changes: 206 additions & 0 deletions src/routes/iam.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { FastifyPluginAsync } from "fastify";
import { AppRoles } from "../roles.js";
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
import { addToTenant, getEntraIdToken } from "../functions/entraId.js";
import {
BaseError,
DatabaseFetchError,
DatabaseInsertError,
EntraInvitationError,
InternalServerError,
NotFoundError,
} from "../errors/index.js";
import {
DynamoDBClient,
GetItemCommand,
PutItemCommand,
} from "@aws-sdk/client-dynamodb";
import { genericConfig } from "../config.js";
import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";

const invitePostRequestSchema = z.object({
emails: z.array(z.string()),
});
export type InviteUserPostRequest = z.infer<typeof invitePostRequestSchema>;

const groupMappingCreatePostSchema = z.object({
roles: z
.array(z.nativeEnum(AppRoles))
.min(1)
.refine((items) => new Set(items).size === items.length, {
message: "All roles must be unique, no duplicate values allowed",
}),
});

export type GroupMappingCreatePostRequest = z.infer<
typeof groupMappingCreatePostSchema
>;

const invitePostResponseSchema = zodToJsonSchema(
z.object({
success: z.array(z.object({ email: z.string() })).optional(),
failure: z
.array(z.object({ email: z.string(), message: z.string() }))
.optional(),
}),
);

const dynamoClient = new DynamoDBClient({
region: genericConfig.AwsRegion,
});

const iamRoutes: FastifyPluginAsync = async (fastify, _options) => {
fastify.get<{
Body: undefined;
Querystring: { groupId: string };
}>(
"/groupRoles/:groupId",
{
schema: {
querystring: {
type: "object",
properties: {
groupId: {
type: "string",
},
},
},
},
onRequest: async (request, reply) => {
await fastify.authorize(request, reply, [AppRoles.IAM_ADMIN]);
},
},
async (request, reply) => {
const groupId = (request.params as Record<string, string>).groupId;
try {
const command = new GetItemCommand({
TableName: `${genericConfig.IAMTablePrefix}-grouproles`,
Key: { groupUuid: { S: groupId } },
});
const response = await dynamoClient.send(command);
if (!response.Item) {
throw new NotFoundError({
endpointName: `/api/v1/iam/groupRoles/${groupId}`,
});
}
reply.send(unmarshall(response.Item));
} catch (e: unknown) {
if (e instanceof BaseError) {
throw e;
}

request.log.error(e);
throw new DatabaseFetchError({
message: "An error occurred finding the group role mapping.",
});
}
},
);
fastify.post<{
Body: GroupMappingCreatePostRequest;
Querystring: { groupId: string };
}>(
"/groupRoles/:groupId",
{
schema: {
querystring: {
type: "object",
properties: {
groupId: {
type: "string",
},
},
},
},
preValidation: async (request, reply) => {
await fastify.zodValidateBody(
request,
reply,
groupMappingCreatePostSchema,
);
},
onRequest: async (request, reply) => {
await fastify.authorize(request, reply, [AppRoles.IAM_ADMIN]);
},
},
async (request, reply) => {
const groupId = (request.params as Record<string, string>).groupId;
try {
const timestamp = new Date().toISOString();
const command = new PutItemCommand({
TableName: `${genericConfig.IAMTablePrefix}-grouproles`,
Item: marshall({
groupUuid: groupId,
roles: request.body.roles,
createdAt: timestamp,
}),
});

await dynamoClient.send(command);
} catch (e: unknown) {
if (e instanceof BaseError) {
throw e;
}

request.log.error(e);
throw new DatabaseInsertError({
message: "Could not create group role mapping.",
});
}
reply.send({ message: "OK" });
},
);
fastify.post<{ Body: InviteUserPostRequest }>(
"/inviteUsers",
{
schema: {
response: { 200: invitePostResponseSchema },
},
preValidation: async (request, reply) => {
await fastify.zodValidateBody(request, reply, invitePostRequestSchema);
},
onRequest: async (request, reply) => {
await fastify.authorize(request, reply, [AppRoles.SSO_INVITE_USER]);
},
},
async (request, reply) => {
const emails = request.body.emails;
const entraIdToken = await getEntraIdToken(
fastify.environmentConfig.AadValidClientId,
);
if (!entraIdToken) {
throw new InternalServerError({
message: "Could not get Entra ID token to perform task.",
});
}
const response: Record<string, Record<string, string>[]> = {
success: [],
failure: [],
};
const results = await Promise.allSettled(
emails.map((email) => addToTenant(entraIdToken, email)),
);
for (let i = 0; i < results.length; i++) {
const result = results[i];
if (result.status === "fulfilled") {
response.success.push({ email: emails[i] });
} else {
if (result.reason instanceof EntraInvitationError) {
response.failure.push({
email: emails[i],
message: result.reason.message,
});
}
}
}
let statusCode = 201;
if (response.success.length === 0) {
statusCode = 500;
}
reply.status(statusCode).send(response);
},
);
};

export default iamRoutes;
75 changes: 0 additions & 75 deletions src/routes/sso.ts

This file was deleted.

Loading
Loading