Finish preliminary backend endpoints for GCP IAM Auth method

This commit is contained in:
Tuan Dang
2024-05-07 10:42:39 -07:00
parent 4d8965eb82
commit 4d74d264dd
14 changed files with 768 additions and 1 deletions

View File

@@ -30,6 +30,7 @@ import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-se
import { TGroupProjectServiceFactory } from "@app/services/group-project/group-project-service";
import { TIdentityServiceFactory } from "@app/services/identity/identity-service";
import { TIdentityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service";
import { TIdentityGcpIamAuthServiceFactory } from "@app/services/identity-gcp-iam-auth/identity-gcp-iam-auth-service";
import { TIdentityProjectServiceFactory } from "@app/services/identity-project/identity-project-service";
import { TIdentityUaServiceFactory } from "@app/services/identity-ua/identity-ua-service";
import { TIntegrationServiceFactory } from "@app/services/integration/integration-service";
@@ -113,6 +114,7 @@ declare module "fastify" {
identityAccessToken: TIdentityAccessTokenServiceFactory;
identityProject: TIdentityProjectServiceFactory;
identityUa: TIdentityUaServiceFactory;
identityGcpIamAuth: TIdentityGcpIamAuthServiceFactory;
secretApprovalPolicy: TSecretApprovalPolicyServiceFactory;
secretApprovalRequest: TSecretApprovalRequestServiceFactory;
secretRotation: TSecretRotationServiceFactory;

View File

@@ -47,6 +47,9 @@ import {
TIdentityAccessTokens,
TIdentityAccessTokensInsert,
TIdentityAccessTokensUpdate,
TIdentityGcpIamAuths,
TIdentityGcpIamAuthsInsert,
TIdentityGcpIamAuthsUpdate,
TIdentityOrgMemberships,
TIdentityOrgMembershipsInsert,
TIdentityOrgMembershipsUpdate,
@@ -314,6 +317,11 @@ declare module "knex/types/tables" {
TIdentityUniversalAuthsInsert,
TIdentityUniversalAuthsUpdate
>;
[TableName.IdentityGcpIamAuth]: Knex.CompositeTableType<
TIdentityGcpIamAuths,
TIdentityGcpIamAuthsInsert,
TIdentityGcpIamAuthsUpdate
>;
[TableName.IdentityUaClientSecret]: Knex.CompositeTableType<
TIdentityUaClientSecrets,
TIdentityUaClientSecretsInsert,

View File

@@ -0,0 +1,28 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasTable(TableName.IdentityGcpIamAuth))) {
await knex.schema.createTable(TableName.IdentityGcpIamAuth, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.bigInteger("accessTokenTTL").defaultTo(7200).notNullable();
t.bigInteger("accessTokenMaxTTL").defaultTo(7200).notNullable();
t.bigInteger("accessTokenNumUsesLimit").defaultTo(0).notNullable();
t.jsonb("accessTokenTrustedIps").notNullable();
t.timestamps(true, true, true);
t.uuid("identityId").notNullable().unique();
t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE");
t.string("allowedServiceAccounts").notNullable();
t.string("allowedProjects").notNullable();
});
}
await createOnUpdateTrigger(knex, TableName.IdentityGcpIamAuth);
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.IdentityGcpIamAuth);
await dropOnUpdateTrigger(knex, TableName.IdentityGcpIamAuth);
}

View File

@@ -0,0 +1,25 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const IdentityGcpIamAuthsSchema = z.object({
id: z.string().uuid(),
accessTokenTTL: z.coerce.number().default(7200),
accessTokenMaxTTL: z.coerce.number().default(7200),
accessTokenNumUsesLimit: z.coerce.number().default(0),
accessTokenTrustedIps: z.unknown(),
createdAt: z.date(),
updatedAt: z.date(),
identityId: z.string().uuid(),
allowedServiceAccounts: z.string(),
allowedProjects: z.string()
});
export type TIdentityGcpIamAuths = z.infer<typeof IdentityGcpIamAuthsSchema>;
export type TIdentityGcpIamAuthsInsert = Omit<z.input<typeof IdentityGcpIamAuthsSchema>, TImmutableDBKeys>;
export type TIdentityGcpIamAuthsUpdate = Partial<Omit<z.input<typeof IdentityGcpIamAuthsSchema>, TImmutableDBKeys>>;

View File

@@ -13,6 +13,7 @@ export * from "./group-project-memberships";
export * from "./groups";
export * from "./identities";
export * from "./identity-access-tokens";
export * from "./identity-gcp-iam-auths";
export * from "./identity-org-memberships";
export * from "./identity-project-additional-privilege";
export * from "./identity-project-membership-role";

View File

@@ -44,6 +44,7 @@ export enum TableName {
Identity = "identities",
IdentityAccessToken = "identity_access_tokens",
IdentityUniversalAuth = "identity_universal_auths",
IdentityGcpIamAuth = "identity_gcp_iam_auths",
IdentityUaClientSecret = "identity_ua_client_secrets",
IdentityOrgMembership = "identity_org_memberships",
IdentityProjectMembership = "identity_project_memberships",
@@ -138,5 +139,6 @@ export enum ProjectUpgradeStatus {
}
export enum IdentityAuthMethod {
Univeral = "universal-auth"
Univeral = "universal-auth",
GCP_IAM_AUTH = "gcp-iam-auth"
}

View File

@@ -66,6 +66,10 @@ export enum EventType {
CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "create-identity-universal-auth-client-secret",
REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "revoke-identity-universal-auth-client-secret",
GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS = "get-identity-universal-auth-client-secret",
LOGIN_IDENTITY_GCP_IAM_AUTH = "login-identity-gcp-iam-auth",
ADD_IDENTITY_GCP_IAM_AUTH = "add-identity-gcp-iam -auth",
UPDATE_IDENTITY_GCP_IAM_AUTH = "update-identity-gcp-iam-auth",
GET_IDENTITY_GCP_IAM_AUTH = "get-identity-gcp-iam-auth",
CREATE_ENVIRONMENT = "create-environment",
UPDATE_ENVIRONMENT = "update-environment",
DELETE_ENVIRONMENT = "delete-environment",
@@ -406,6 +410,48 @@ interface RevokeIdentityUniversalAuthClientSecretEvent {
};
}
interface LoginIdentityGcpIamAuthEvent {
type: EventType.LOGIN_IDENTITY_GCP_IAM_AUTH;
metadata: {
identityId: string;
identityGcpIamAuthId: string;
identityAccessTokenId: string;
};
}
interface AddIdentityGcpIamAuthEvent {
type: EventType.ADD_IDENTITY_GCP_IAM_AUTH;
metadata: {
identityId: string;
allowedServiceAccounts: string;
allowedProjects: string;
accessTokenTTL: number;
accessTokenMaxTTL: number;
accessTokenNumUsesLimit: number;
accessTokenTrustedIps: Array<TIdentityTrustedIp>;
};
}
interface UpdateIdentityGcpIamAuthEvent {
type: EventType.UPDATE_IDENTITY_GCP_IAM_AUTH;
metadata: {
identityId: string;
allowedServiceAccounts?: string;
allowedProjects?: string;
accessTokenTTL?: number;
accessTokenMaxTTL?: number;
accessTokenNumUsesLimit?: number;
accessTokenTrustedIps?: Array<TIdentityTrustedIp>;
};
}
interface GetIdentityGcpIamAuthEvent {
type: EventType.GET_IDENTITY_GCP_IAM_AUTH;
metadata: {
identityId: string;
};
}
interface CreateEnvironmentEvent {
type: EventType.CREATE_ENVIRONMENT;
metadata: {
@@ -660,6 +706,10 @@ export type Event =
| CreateIdentityUniversalAuthClientSecretEvent
| GetIdentityUniversalAuthClientSecretsEvent
| RevokeIdentityUniversalAuthClientSecretEvent
| LoginIdentityGcpIamAuthEvent
| AddIdentityGcpIamAuthEvent
| UpdateIdentityGcpIamAuthEvent
| GetIdentityGcpIamAuthEvent
| CreateEnvironmentEvent
| UpdateEnvironmentEvent
| DeleteEnvironmentEvent

View File

@@ -72,6 +72,8 @@ import { identityOrgDALFactory } from "@app/services/identity/identity-org-dal";
import { identityServiceFactory } from "@app/services/identity/identity-service";
import { identityAccessTokenDALFactory } from "@app/services/identity-access-token/identity-access-token-dal";
import { identityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service";
import { identityGcpIamAuthDALFactory } from "@app/services/identity-gcp-iam-auth/identity-gcp-iam-auth-dal";
import { identityGcpIamAuthServiceFactory } from "@app/services/identity-gcp-iam-auth/identity-gcp-iam-auth-service";
import { identityProjectDALFactory } from "@app/services/identity-project/identity-project-dal";
import { identityProjectMembershipRoleDALFactory } from "@app/services/identity-project/identity-project-membership-role-dal";
import { identityProjectServiceFactory } from "@app/services/identity-project/identity-project-service";
@@ -196,6 +198,8 @@ export const registerRoutes = async (
const identityUaDAL = identityUaDALFactory(db);
const identityUaClientSecretDAL = identityUaClientSecretDALFactory(db);
const identityGcpIamAuthDAL = identityGcpIamAuthDALFactory(db);
const auditLogDAL = auditLogDALFactory(db);
const auditLogStreamDAL = auditLogStreamDALFactory(db);
const trustedIpDAL = trustedIpDALFactory(db);
@@ -662,6 +666,14 @@ export const registerRoutes = async (
identityUaDAL,
licenseService
});
const identityGcpIamAuthService = identityGcpIamAuthServiceFactory({
identityGcpIamAuthDAL,
identityOrgMembershipDAL,
identityAccessTokenDAL,
identityDAL,
permissionService,
licenseService
});
const dynamicSecretProviders = buildDynamicSecretProviders();
const dynamicSecretQueueService = dynamicSecretLeaseQueueServiceFactory({
@@ -731,6 +743,7 @@ export const registerRoutes = async (
identityAccessToken: identityAccessTokenService,
identityProject: identityProjectService,
identityUa: identityUaService,
identityGcpIamAuth: identityGcpIamAuthService,
secretApprovalPolicy: sapService,
secretApprovalRequest: sarService,
secretRotation: secretRotationService,

View File

@@ -0,0 +1,259 @@
import { z } from "zod";
import { IdentityGcpIamAuthsSchema } from "@app/db/schemas";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
import { TIdentityTrustedIp } from "@app/services/identity/identity-types";
export const registerIdentityGcpIamAuthRouter = async (server: FastifyZodProvider) => {
server.route({
method: "POST",
url: "/gcp-iam-auth/login",
config: {
rateLimit: writeLimit
},
schema: {
description: "Login with GCP IAM Auth",
body: z.object({
identityId: z.string(),
jwt: z.string()
}),
response: {
200: z.object({
accessToken: z.string(),
expiresIn: z.coerce.number(),
accessTokenMaxTTL: z.coerce.number(),
tokenType: z.literal("Bearer")
})
}
},
handler: async (req) => {
const { identityGcpIamAuth, accessToken, identityAccessToken, identityMembershipOrg } =
await server.services.identityGcpIamAuth.login(req.body);
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: identityMembershipOrg?.orgId,
event: {
type: EventType.LOGIN_IDENTITY_GCP_IAM_AUTH,
metadata: {
identityId: identityGcpIamAuth.identityId,
identityAccessTokenId: identityAccessToken.id,
identityGcpIamAuthId: identityGcpIamAuth.id
}
}
});
return {
accessToken,
tokenType: "Bearer" as const,
expiresIn: identityGcpIamAuth.accessTokenTTL,
accessTokenMaxTTL: identityGcpIamAuth.accessTokenMaxTTL
};
}
});
server.route({
method: "POST",
url: "/gcp-iam-auth/identities/:identityId",
config: {
rateLimit: writeLimit
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
schema: {
description: "Attach GCP IAM Auth configuration onto identity",
security: [
{
bearerAuth: []
}
],
params: z.object({
identityId: z.string().trim()
}),
body: z.object({
allowedServiceAccounts: z.string(), // TODO: better validation
allowedProjects: z.string(), // TODO: better validation
accessTokenTrustedIps: z
.object({
ipAddress: z.string().trim()
})
.array()
.min(1)
.default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]),
accessTokenTTL: z
.number()
.int()
.min(1)
.refine((value) => value !== 0, {
message: "accessTokenTTL must have a non zero number"
})
.default(2592000),
accessTokenMaxTTL: z
.number()
.int()
.refine((value) => value !== 0, {
message: "accessTokenMaxTTL must have a non zero number"
})
.default(2592000),
accessTokenNumUsesLimit: z.number().int().min(0).default(0)
}),
response: {
200: z.object({
identityGcpIamAuth: IdentityGcpIamAuthsSchema
})
}
},
handler: async (req) => {
const identityGcpIamAuth = await server.services.identityGcpIamAuth.attachGcpIamAuth({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
...req.body,
identityId: req.params.identityId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: identityGcpIamAuth.orgId,
event: {
type: EventType.ADD_IDENTITY_GCP_IAM_AUTH,
metadata: {
identityId: identityGcpIamAuth.identityId,
allowedServiceAccounts: identityGcpIamAuth.allowedServiceAccounts,
allowedProjects: identityGcpIamAuth.allowedProjects,
accessTokenTTL: identityGcpIamAuth.accessTokenTTL,
accessTokenMaxTTL: identityGcpIamAuth.accessTokenMaxTTL,
accessTokenTrustedIps: identityGcpIamAuth.accessTokenTrustedIps as TIdentityTrustedIp[],
accessTokenNumUsesLimit: identityGcpIamAuth.accessTokenNumUsesLimit
}
}
});
return { identityGcpIamAuth };
}
});
server.route({
method: "PATCH",
url: "/gcp-iam-auth/identities/:identityId",
config: {
rateLimit: writeLimit
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
schema: {
description: "Update GCP IAM Auth configuration on identity",
security: [
{
bearerAuth: []
}
],
params: z.object({
identityId: z.string().trim()
}),
body: z.object({
allowedServiceAccounts: z.string().trim().optional(),
allowedProjects: z.string().trim().optional(),
accessTokenTrustedIps: z
.object({
ipAddress: z.string().trim()
})
.array()
.min(1)
.optional(),
accessTokenTTL: z.number().int().min(0).optional(),
accessTokenNumUsesLimit: z.number().int().min(0).optional(),
accessTokenMaxTTL: z
.number()
.int()
.refine((value) => value !== 0, {
message: "accessTokenMaxTTL must have a non zero number"
})
.optional()
}),
response: {
200: z.object({
identityGcpIamAuth: IdentityGcpIamAuthsSchema
})
}
},
handler: async (req) => {
const identityGcpIamAuth = await server.services.identityGcpIamAuth.updateGcpIamAuth({
actor: req.permission.type,
actorId: req.permission.id,
actorOrgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod,
...req.body,
identityId: req.params.identityId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: identityGcpIamAuth.orgId,
event: {
type: EventType.UPDATE_IDENTITY_GCP_IAM_AUTH,
metadata: {
identityId: identityGcpIamAuth.identityId,
allowedServiceAccounts: identityGcpIamAuth.allowedServiceAccounts,
allowedProjects: identityGcpIamAuth.allowedProjects,
accessTokenTTL: identityGcpIamAuth.accessTokenTTL,
accessTokenMaxTTL: identityGcpIamAuth.accessTokenMaxTTL,
accessTokenTrustedIps: identityGcpIamAuth.accessTokenTrustedIps as TIdentityTrustedIp[],
accessTokenNumUsesLimit: identityGcpIamAuth.accessTokenNumUsesLimit
}
}
});
return { identityGcpIamAuth };
}
});
server.route({
method: "GET",
url: "/gcp-iam-auth/identities/:identityId",
config: {
rateLimit: readLimit
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
schema: {
description: "Retrieve GCP IAM Auth configuration on identity",
security: [
{
bearerAuth: []
}
],
params: z.object({
identityId: z.string()
}),
response: {
200: z.object({
identityGcpIamAuth: IdentityGcpIamAuthsSchema
})
}
},
handler: async (req) => {
const identityGcpIamAuth = await server.services.identityGcpIamAuth.getGcpIamAuth({
identityId: req.params.identityId,
actor: req.permission.type,
actorId: req.permission.id,
actorOrgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: identityGcpIamAuth.orgId,
event: {
type: EventType.GET_IDENTITY_GCP_IAM_AUTH,
metadata: {
identityId: identityGcpIamAuth.identityId
}
}
});
return { identityGcpIamAuth };
}
});
};

View File

@@ -2,6 +2,7 @@ import { registerAdminRouter } from "./admin-router";
import { registerAuthRoutes } from "./auth-router";
import { registerProjectBotRouter } from "./bot-router";
import { registerIdentityAccessTokenRouter } from "./identity-access-token-router";
import { registerIdentityGcpIamAuthRouter } from "./identity-gcp-iam-auth-router";
import { registerIdentityRouter } from "./identity-router";
import { registerIdentityUaRouter } from "./identity-ua";
import { registerIntegrationAuthRouter } from "./integration-auth-router";
@@ -27,6 +28,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => {
async (authRouter) => {
await authRouter.register(registerAuthRoutes);
await authRouter.register(registerIdentityUaRouter);
await authRouter.register(registerIdentityGcpIamAuthRouter);
await authRouter.register(registerIdentityAccessTokenRouter);
},
{ prefix: "/auth" }

View File

@@ -0,0 +1,10 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TIdentityGcpIamAuthDALFactory = ReturnType<typeof identityGcpIamAuthDALFactory>;
export const identityGcpIamAuthDALFactory = (db: TDbClient) => {
const gcpIamAuthOrm = ormify(db, TableName.IdentityGcpIamAuth);
return gcpIamAuthOrm;
};

View File

@@ -0,0 +1,17 @@
/**
* Extracts the GCP service account email into the name and project ID parts where
* the email is in the format: <service-account-name>@<project-id>.iam.gserviceaccount.com
*/
export const extractGcpServiceAccountEmail = (email: string) => {
const regex = /^(.+)@(.+)\.iam\.gserviceaccount\.com$/;
const match = email.match(regex);
if (!match) {
throw new Error("Invalid GCP service account email format.");
}
const name = match[1];
const projectId = match[2];
return { name, projectId };
};

View File

@@ -0,0 +1,304 @@
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 { TIdentityGcpIamAuthDALFactory } from "./identity-gcp-iam-auth-dal";
import { extractGcpServiceAccountEmail } from "./identity-gcp-iam-auth-fns";
import {
TAttachGcpIamAuthDTO,
TDecodedGcpIamAuthJwt,
TGetGcpIamAuthDTO,
TLoginGcpIamAuthDTO,
TUpdateGcpIamAuthDTO
} from "./identity-gcp-iam-auth-types";
type TIdentityGcpIamAuthServiceFactoryDep = {
identityGcpIamAuthDAL: Pick<TIdentityGcpIamAuthDALFactory, "findOne" | "transaction" | "create" | "updateById">;
identityOrgMembershipDAL: Pick<TIdentityOrgDALFactory, "findOne">;
identityAccessTokenDAL: Pick<TIdentityAccessTokenDALFactory, "create">;
identityDAL: Pick<TIdentityDALFactory, "updateById">;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
};
export type TIdentityGcpIamAuthServiceFactory = ReturnType<typeof identityGcpIamAuthServiceFactory>;
export const identityGcpIamAuthServiceFactory = ({
identityGcpIamAuthDAL,
identityOrgMembershipDAL,
identityAccessTokenDAL,
identityDAL,
permissionService,
licenseService
}: TIdentityGcpIamAuthServiceFactoryDep) => {
const login = async ({ identityId, jwt: serviceAccountJwt }: TLoginGcpIamAuthDTO) => {
const identityGcpIamAuth = await identityGcpIamAuthDAL.findOne({ identityId });
if (!identityGcpIamAuth) throw new UnauthorizedError();
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityGcpIamAuth.identityId });
const decodedJwt = jwt.decode(serviceAccountJwt, { complete: true }) as TDecodedGcpIamAuthJwt;
const { sub, aud } = decodedJwt.payload;
const {
data
}: {
data: {
[key: string]: string;
};
} = await axios.get(`https://www.googleapis.com/service_accounts/v1/metadata/x509/${sub}`);
const publicKey = data[decodedJwt.header.kid];
jwt.verify(serviceAccountJwt, publicKey, {
algorithms: ["RS256"]
});
if (aud !== identityId) throw new UnauthorizedError();
const { name, projectId } = extractGcpServiceAccountEmail(sub);
if (identityGcpIamAuth.allowedServiceAccounts) {
// validate if the service account is in the list of allowed service accounts
const isServiceAccountAllowed = identityGcpIamAuth.allowedServiceAccounts
.split(",")
.map((serviceAccount) => serviceAccount.trim())
.some((serviceAccount) => serviceAccount === name);
if (!isServiceAccountAllowed) throw new UnauthorizedError();
}
if (identityGcpIamAuth.allowedProjects) {
// validate if the project that the service account belongs to is in the list of allowed projects
const isProjectAllowed = identityGcpIamAuth.allowedProjects
.split(",")
.map((project) => project.trim())
.some((project) => project === projectId);
if (!isProjectAllowed) throw new UnauthorizedError();
}
const identityAccessToken = await identityGcpIamAuthDAL.transaction(async (tx) => {
const newToken = await identityAccessTokenDAL.create(
{
identityId: identityGcpIamAuth.identityId,
isAccessTokenRevoked: false,
accessTokenTTL: identityGcpIamAuth.accessTokenTTL,
accessTokenMaxTTL: identityGcpIamAuth.accessTokenMaxTTL,
accessTokenNumUses: 0,
accessTokenNumUsesLimit: identityGcpIamAuth.accessTokenNumUsesLimit
},
tx
);
return newToken;
});
const appCfg = getConfig();
const accessToken = jwt.sign(
{
identityId: identityGcpIamAuth.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, identityGcpIamAuth, identityAccessToken, identityMembershipOrg };
};
const attachGcpIamAuth = async ({
identityId,
allowedServiceAccounts,
allowedProjects,
accessTokenTTL,
accessTokenMaxTTL,
accessTokenNumUsesLimit,
accessTokenTrustedIps,
actorId,
actorAuthMethod,
actor,
actorOrgId
}: TAttachGcpIamAuthDTO) => {
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 IAM 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 identityAwsIamAuth = await identityGcpIamAuthDAL.transaction(async (tx) => {
const doc = await identityGcpIamAuthDAL.create(
{
identityId: identityMembershipOrg.identityId,
allowedServiceAccounts,
allowedProjects,
accessTokenMaxTTL,
accessTokenTTL,
accessTokenNumUsesLimit,
accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps)
},
tx
);
await identityDAL.updateById(
identityMembershipOrg.identityId,
{
authMethod: IdentityAuthMethod.GCP_IAM_AUTH
},
tx
);
return doc;
});
return { ...identityAwsIamAuth, orgId: identityMembershipOrg.orgId };
};
const updateGcpIamAuth = async ({
identityId,
allowedServiceAccounts,
allowedProjects,
accessTokenTTL,
accessTokenMaxTTL,
accessTokenNumUsesLimit,
accessTokenTrustedIps,
actorId,
actorAuthMethod,
actor,
actorOrgId
}: TUpdateGcpIamAuthDTO) => {
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" });
if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.GCP_IAM_AUTH)
throw new BadRequestError({
message: "Failed to update GCP IAM Auth"
});
const identityGcpIamAuth = await identityGcpIamAuthDAL.findOne({ identityId });
if (
(accessTokenMaxTTL || identityGcpIamAuth.accessTokenMaxTTL) > 0 &&
(accessTokenTTL || identityGcpIamAuth.accessTokenMaxTTL) >
(accessTokenMaxTTL || identityGcpIamAuth.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 updatedGcpIamAuth = await identityGcpIamAuthDAL.updateById(identityGcpIamAuth.id, {
allowedServiceAccounts,
allowedProjects,
accessTokenMaxTTL,
accessTokenTTL,
accessTokenNumUsesLimit,
accessTokenTrustedIps: reformattedAccessTokenTrustedIps
? JSON.stringify(reformattedAccessTokenTrustedIps)
: undefined
});
return { ...updatedGcpIamAuth, orgId: identityMembershipOrg.orgId };
};
const getGcpIamAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetGcpIamAuthDTO) => {
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" });
if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.GCP_IAM_AUTH)
throw new BadRequestError({
message: "The identity does not have GCP IAM Auth attached"
});
const gcpIamIdentityAuth = await identityGcpIamAuthDAL.findOne({ identityId });
const { permission } = await permissionService.getOrgPermission(
actor,
actorId,
identityMembershipOrg.orgId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity);
return { ...gcpIamIdentityAuth, orgId: identityMembershipOrg.orgId };
};
return {
login,
attachGcpIamAuth,
updateGcpIamAuth,
getGcpIamAuth
};
};

View File

@@ -0,0 +1,46 @@
import { TProjectPermission } from "@app/lib/types";
export type TLoginGcpIamAuthDTO = {
identityId: string;
jwt: string;
};
export type TAttachGcpIamAuthDTO = {
identityId: string;
allowedServiceAccounts: string;
allowedProjects: string;
accessTokenTTL: number;
accessTokenMaxTTL: number;
accessTokenNumUsesLimit: number;
accessTokenTrustedIps: { ipAddress: string }[];
} & Omit<TProjectPermission, "projectId">;
export type TUpdateGcpIamAuthDTO = {
identityId: string;
allowedServiceAccounts?: string;
allowedProjects?: string;
accessTokenTTL?: number;
accessTokenMaxTTL?: number;
accessTokenNumUsesLimit?: number;
accessTokenTrustedIps?: { ipAddress: string }[];
} & Omit<TProjectPermission, "projectId">;
export type TGetGcpIamAuthDTO = {
identityId: string;
} & Omit<TProjectPermission, "projectId">;
export type TDecodedGcpIamAuthJwt = {
header: {
alg: string;
kid: string;
typ: string;
};
payload: {
sub: string;
aud: string;
};
signature: string;
metadata: {
[key: string]: string;
};
};