Rename AWS IAM auth to AWS Auth with IAM type

This commit is contained in:
Tuan Dang
2024-05-08 21:22:23 -07:00
parent f74993e850
commit 15ea96815c
32 changed files with 246 additions and 245 deletions
@@ -0,0 +1,11 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TIdentityAwsAuthDALFactory = ReturnType<typeof identityAwsAuthDALFactory>;
export const identityAwsAuthDALFactory = (db: TDbClient) => {
const awsAuthOrm = ormify(db, TableName.IdentityAwsAuth);
return awsAuthOrm;
};
@@ -0,0 +1,67 @@
/**
* Extracts the identity ARN from the GetCallerIdentity response to one of the following formats:
* - arn:aws:iam::123456789012:user/MyUserName
* - arn:aws:iam::123456789012:role/MyRoleName
*/
export const extractPrincipalArn = (arn: string) => {
// split the ARN into parts using ":" as the delimiter
const fullParts = arn.split(":");
if (fullParts.length !== 6) {
throw new Error(`Unrecognized ARN: contains ${fullParts.length} colon-separated parts, expected 6`);
}
const [prefix, partition, service, , accountNumber, resource] = fullParts;
if (prefix !== "arn") {
throw new Error('Unrecognized ARN: does not begin with "arn:"');
}
// structure to hold the parsed data
const entity = {
Partition: partition,
Service: service,
AccountNumber: accountNumber,
Type: "",
Path: "",
FriendlyName: "",
SessionInfo: ""
};
// validate the service is either 'iam' or 'sts'
if (entity.Service !== "iam" && entity.Service !== "sts") {
throw new Error(`Unrecognized service: ${entity.Service}, not one of iam or sts`);
}
// parse the last part of the ARN which describes the resource
const parts = resource.split("/");
if (parts.length < 2) {
throw new Error(`Unrecognized ARN: "${resource}" contains fewer than 2 slash-separated parts`);
}
const [type, ...rest] = parts;
entity.Type = type;
entity.FriendlyName = parts[parts.length - 1];
// handle different types of resources
switch (entity.Type) {
case "assumed-role": {
if (rest.length < 2) {
throw new Error(`Unrecognized ARN: "${resource}" contains fewer than 3 slash-separated parts`);
}
// assumed roles use a special format where the friendly name is the role name
const [roleName, sessionId] = rest;
entity.Type = "role"; // treat assumed role case as role
entity.FriendlyName = roleName;
entity.SessionInfo = sessionId;
break;
}
case "user":
case "role":
case "instance-profile":
// standard cases: just join back the path if there's any
entity.Path = rest.slice(0, -1).join("/");
break;
default:
throw new Error(`Unrecognized principal type: "${entity.Type}"`);
}
return `arn:aws:iam::${entity.AccountNumber}:${entity.Type}/${entity.FriendlyName}`;
};
@@ -0,0 +1,310 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { ForbiddenError } from "@casl/ability";
import axios from "axios";
import jwt from "jsonwebtoken";
import { IdentityAuthMethod } from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError, UnauthorizedError } from "@app/lib/errors";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
import { AuthTokenType } from "../auth/auth-type";
import { TIdentityDALFactory } from "../identity/identity-dal";
import { TIdentityOrgDALFactory } from "../identity/identity-org-dal";
import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal";
import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types";
import { TIdentityAwsAuthDALFactory } from "./identity-aws-auth-dal";
import { extractPrincipalArn } from "./identity-aws-auth-fns";
import {
TAttachAwsAuthDTO,
TAwsGetCallerIdentityHeaders,
TGetAwsAuthDTO,
TGetCallerIdentityResponse,
TLoginAwsAuthDTO,
TUpdateAwsAuthDTO
} from "./identity-aws-auth-types";
type TIdentityAwsAuthServiceFactoryDep = {
identityAccessTokenDAL: Pick<TIdentityAccessTokenDALFactory, "create">;
identityAwsAuthDAL: Pick<TIdentityAwsAuthDALFactory, "findOne" | "transaction" | "create" | "updateById">;
identityOrgMembershipDAL: Pick<TIdentityOrgDALFactory, "findOne">;
identityDAL: Pick<TIdentityDALFactory, "updateById">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
};
export type TIdentityAwsAuthServiceFactory = ReturnType<typeof identityAwsAuthServiceFactory>;
export const identityAwsAuthServiceFactory = ({
identityAccessTokenDAL,
identityAwsAuthDAL,
identityOrgMembershipDAL,
identityDAL,
licenseService,
permissionService
}: TIdentityAwsAuthServiceFactoryDep) => {
const login = async ({ identityId, iamHttpRequestMethod, iamRequestBody, iamRequestHeaders }: TLoginAwsAuthDTO) => {
const identityAwsAuth = await identityAwsAuthDAL.findOne({ identityId });
if (!identityAwsAuth) throw new UnauthorizedError();
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityAwsAuth.identityId });
const headers: TAwsGetCallerIdentityHeaders = JSON.parse(Buffer.from(iamRequestHeaders, "base64").toString());
const body: string = Buffer.from(iamRequestBody, "base64").toString();
const {
data: {
GetCallerIdentityResponse: {
GetCallerIdentityResult: { Account, Arn }
}
}
}: { data: TGetCallerIdentityResponse } = await axios({
method: iamHttpRequestMethod,
url: identityAwsAuth.stsEndpoint,
headers,
data: body
});
if (identityAwsAuth.allowedAccountIds) {
// validate if Account is in the list of allowed Account IDs
const isAccountAllowed = identityAwsAuth.allowedAccountIds
.split(",")
.map((accountId) => accountId.trim())
.some((accountId) => accountId === Account);
if (!isAccountAllowed) throw new UnauthorizedError();
}
if (identityAwsAuth.allowedPrincipalArns) {
// validate if Arn is in the list of allowed Principal ARNs
const isArnAllowed = identityAwsAuth.allowedPrincipalArns
.split(",")
.map((principalArn) => principalArn.trim())
.some((principalArn) => {
// convert wildcard ARN to a regular expression: "arn:aws:iam::123456789012:*" -> "^arn:aws:iam::123456789012:.*$"
// considers exact matches + wildcard matches
const regex = new RegExp(`^${principalArn.replace(/\*/g, ".*")}$`);
return regex.test(extractPrincipalArn(Arn));
});
if (!isArnAllowed) throw new UnauthorizedError();
}
const identityAccessToken = await identityAwsAuthDAL.transaction(async (tx) => {
const newToken = await identityAccessTokenDAL.create(
{
identityId: identityAwsAuth.identityId,
isAccessTokenRevoked: false,
accessTokenTTL: identityAwsAuth.accessTokenTTL,
accessTokenMaxTTL: identityAwsAuth.accessTokenMaxTTL,
accessTokenNumUses: 0,
accessTokenNumUsesLimit: identityAwsAuth.accessTokenNumUsesLimit
},
tx
);
return newToken;
});
const appCfg = getConfig();
const accessToken = jwt.sign(
{
identityId: identityAwsAuth.identityId,
identityAccessTokenId: identityAccessToken.id,
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN
} as TIdentityAccessTokenJwtPayload,
appCfg.AUTH_SECRET,
{
expiresIn:
Number(identityAccessToken.accessTokenMaxTTL) === 0
? undefined
: Number(identityAccessToken.accessTokenMaxTTL)
}
);
return { accessToken, identityAwsAuth, identityAccessToken, identityMembershipOrg };
};
const attachAwsAuth = async ({
identityId,
stsEndpoint,
allowedPrincipalArns,
allowedAccountIds,
accessTokenTTL,
accessTokenMaxTTL,
accessTokenNumUsesLimit,
accessTokenTrustedIps,
actorId,
actorAuthMethod,
actor,
actorOrgId
}: TAttachAwsAuthDTO) => {
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" });
if (identityMembershipOrg.identity.authMethod)
throw new BadRequestError({
message: "Failed to add AWS Auth to already configured identity"
});
if (accessTokenMaxTTL > 0 && accessTokenTTL > accessTokenMaxTTL) {
throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" });
}
const { permission } = await permissionService.getOrgPermission(
actor,
actorId,
identityMembershipOrg.orgId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity);
const plan = await licenseService.getPlan(identityMembershipOrg.orgId);
const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => {
if (
!plan.ipAllowlisting &&
accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" &&
accessTokenTrustedIp.ipAddress !== "::/0"
)
throw new BadRequestError({
message:
"Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range."
});
if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress))
throw new BadRequestError({
message: "The IP is not a valid IPv4, IPv6, or CIDR block"
});
return extractIPDetails(accessTokenTrustedIp.ipAddress);
});
const identityAwsAuth = await identityAwsAuthDAL.transaction(async (tx) => {
const doc = await identityAwsAuthDAL.create(
{
identityId: identityMembershipOrg.identityId,
type: "iam",
stsEndpoint,
allowedPrincipalArns,
allowedAccountIds,
accessTokenMaxTTL,
accessTokenTTL,
accessTokenNumUsesLimit,
accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps)
},
tx
);
await identityDAL.updateById(
identityMembershipOrg.identityId,
{
authMethod: IdentityAuthMethod.AWS_AUTH
},
tx
);
return doc;
});
return { ...identityAwsAuth, orgId: identityMembershipOrg.orgId };
};
const updateAwsAuth = async ({
identityId,
stsEndpoint,
allowedPrincipalArns,
allowedAccountIds,
accessTokenTTL,
accessTokenMaxTTL,
accessTokenNumUsesLimit,
accessTokenTrustedIps,
actorId,
actorAuthMethod,
actor,
actorOrgId
}: TUpdateAwsAuthDTO) => {
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" });
if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.AWS_AUTH)
throw new BadRequestError({
message: "Failed to update AWS Auth"
});
const identityAwsAuth = await identityAwsAuthDAL.findOne({ identityId });
if (
(accessTokenMaxTTL || identityAwsAuth.accessTokenMaxTTL) > 0 &&
(accessTokenTTL || identityAwsAuth.accessTokenMaxTTL) > (accessTokenMaxTTL || identityAwsAuth.accessTokenMaxTTL)
) {
throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" });
}
const { permission } = await permissionService.getOrgPermission(
actor,
actorId,
identityMembershipOrg.orgId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity);
const plan = await licenseService.getPlan(identityMembershipOrg.orgId);
const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => {
if (
!plan.ipAllowlisting &&
accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" &&
accessTokenTrustedIp.ipAddress !== "::/0"
)
throw new BadRequestError({
message:
"Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range."
});
if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress))
throw new BadRequestError({
message: "The IP is not a valid IPv4, IPv6, or CIDR block"
});
return extractIPDetails(accessTokenTrustedIp.ipAddress);
});
const updatedAwsAuth = await identityAwsAuthDAL.updateById(identityAwsAuth.id, {
stsEndpoint,
allowedPrincipalArns,
allowedAccountIds,
accessTokenMaxTTL,
accessTokenTTL,
accessTokenNumUsesLimit,
accessTokenTrustedIps: reformattedAccessTokenTrustedIps
? JSON.stringify(reformattedAccessTokenTrustedIps)
: undefined
});
return { ...updatedAwsAuth, orgId: identityMembershipOrg.orgId };
};
const getAwsAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetAwsAuthDTO) => {
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" });
if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.AWS_AUTH)
throw new BadRequestError({
message: "The identity does not have AWS Auth attached"
});
const awsIdentityAuth = await identityAwsAuthDAL.findOne({ identityId });
const { permission } = await permissionService.getOrgPermission(
actor,
actorId,
identityMembershipOrg.orgId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity);
return { ...awsIdentityAuth, orgId: identityMembershipOrg.orgId };
};
return {
login,
attachAwsAuth,
updateAwsAuth,
getAwsAuth
};
};
@@ -0,0 +1,54 @@
import { TProjectPermission } from "@app/lib/types";
export type TLoginAwsAuthDTO = {
identityId: string;
iamHttpRequestMethod: string;
iamRequestBody: string;
iamRequestHeaders: string;
};
export type TAttachAwsAuthDTO = {
identityId: string;
stsEndpoint: string;
allowedPrincipalArns: string;
allowedAccountIds: string;
accessTokenTTL: number;
accessTokenMaxTTL: number;
accessTokenNumUsesLimit: number;
accessTokenTrustedIps: { ipAddress: string }[];
} & Omit<TProjectPermission, "projectId">;
export type TUpdateAwsAuthDTO = {
identityId: string;
stsEndpoint?: string;
allowedPrincipalArns?: string;
allowedAccountIds?: string;
accessTokenTTL?: number;
accessTokenMaxTTL?: number;
accessTokenNumUsesLimit?: number;
accessTokenTrustedIps?: { ipAddress: string }[];
} & Omit<TProjectPermission, "projectId">;
export type TGetAwsAuthDTO = {
identityId: string;
} & Omit<TProjectPermission, "projectId">;
export type TAwsGetCallerIdentityHeaders = {
"Content-Type": string;
Host: string;
"X-Amz-Date": string;
"Content-Length": number;
"x-amz-security-token": string;
Authorization: string;
};
export type TGetCallerIdentityResponse = {
GetCallerIdentityResponse: {
GetCallerIdentityResult: {
Account: string;
Arn: string;
UserId: string;
};
ResponseMetadata: { RequestId: string };
};
};
@@ -0,0 +1,58 @@
import { z } from "zod";
const twelveDigitRegex = /^\d{12}$/;
const arnRegex = /^arn:aws:iam::\d{12}:(user\/[\w-]+|role\/[\w-]+|\*)$/;
export const validateAccountIds = z
.string()
.trim()
.default("")
// Custom validation to ensure each part is a 12-digit number
.refine(
(data) => {
if (data === "") return true;
// Split the string by commas to check each supposed number
const accountIds = data.split(",").map((id) => id.trim());
// Return true only if every item matches the 12-digit requirement
return accountIds.every((id) => twelveDigitRegex.test(id));
},
{
message: "Each account ID must be a 12-digit number."
}
)
// Transform the string to normalize space after commas
.transform((data) => {
if (data === "") return "";
// Trim each ID and join with ', ' to ensure formatting
return data
.split(",")
.map((id) => id.trim())
.join(", ");
});
export const validatePrincipalArns = z
.string()
.trim()
.default("")
// Custom validation for ARN format
.refine(
(data) => {
// Skip validation if the string is empty
if (data === "") return true;
// Split the string by commas to check each supposed ARN
const arns = data.split(",");
// Return true only if every item matches one of the allowed ARN formats
return arns.every((arn) => arnRegex.test(arn.trim()));
},
{
message:
"Each ARN must be in the format of 'arn:aws:iam::123456789012:user/UserName', 'arn:aws:iam::123456789012:role/RoleName', or 'arn:aws:iam::123456789012:*'."
}
)
// Transform to normalize the spaces around commas
.transform((data) =>
data
.split(",")
.map((arn) => arn.trim())
.join(", ")
);