From 4d74d264dde70528a1b144f0657c88247ee15d34 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Tue, 7 May 2024 10:42:39 -0700 Subject: [PATCH] Finish preliminary backend endpoints for GCP IAM Auth method --- backend/src/@types/fastify.d.ts | 2 + backend/src/@types/knex.d.ts | 8 + .../20240507153350_identity-gcp-iam-auth.ts | 28 ++ .../src/db/schemas/identity-gcp-iam-auths.ts | 25 ++ backend/src/db/schemas/index.ts | 1 + backend/src/db/schemas/models.ts | 4 +- .../ee/services/audit-log/audit-log-types.ts | 50 +++ backend/src/server/routes/index.ts | 13 + .../routes/v1/identity-gcp-iam-auth-router.ts | 259 +++++++++++++++ backend/src/server/routes/v1/index.ts | 2 + .../identity-gcp-iam-auth-dal.ts | 10 + .../identity-gcp-iam-auth-fns.ts | 17 + .../identity-gcp-iam-auth-service.ts | 304 ++++++++++++++++++ .../identity-gcp-iam-auth-types.ts | 46 +++ 14 files changed, 768 insertions(+), 1 deletion(-) create mode 100644 backend/src/db/migrations/20240507153350_identity-gcp-iam-auth.ts create mode 100644 backend/src/db/schemas/identity-gcp-iam-auths.ts create mode 100644 backend/src/server/routes/v1/identity-gcp-iam-auth-router.ts create mode 100644 backend/src/services/identity-gcp-iam-auth/identity-gcp-iam-auth-dal.ts create mode 100644 backend/src/services/identity-gcp-iam-auth/identity-gcp-iam-auth-fns.ts create mode 100644 backend/src/services/identity-gcp-iam-auth/identity-gcp-iam-auth-service.ts create mode 100644 backend/src/services/identity-gcp-iam-auth/identity-gcp-iam-auth-types.ts diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index b3e9d9952..923f7cc4b 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -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; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index a7d76e944..acf4934aa 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -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, diff --git a/backend/src/db/migrations/20240507153350_identity-gcp-iam-auth.ts b/backend/src/db/migrations/20240507153350_identity-gcp-iam-auth.ts new file mode 100644 index 000000000..3d1a3feb2 --- /dev/null +++ b/backend/src/db/migrations/20240507153350_identity-gcp-iam-auth.ts @@ -0,0 +1,28 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + 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 { + await knex.schema.dropTableIfExists(TableName.IdentityGcpIamAuth); + await dropOnUpdateTrigger(knex, TableName.IdentityGcpIamAuth); +} diff --git a/backend/src/db/schemas/identity-gcp-iam-auths.ts b/backend/src/db/schemas/identity-gcp-iam-auths.ts new file mode 100644 index 000000000..e5b7d090e --- /dev/null +++ b/backend/src/db/schemas/identity-gcp-iam-auths.ts @@ -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; +export type TIdentityGcpIamAuthsInsert = Omit, TImmutableDBKeys>; +export type TIdentityGcpIamAuthsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 0eb9b1986..7a365b18c 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -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"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 3baa7f40d..b1006f51d 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -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" } diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 220c25002..196eab9ef 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -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; + }; +} + +interface UpdateIdentityGcpIamAuthEvent { + type: EventType.UPDATE_IDENTITY_GCP_IAM_AUTH; + metadata: { + identityId: string; + allowedServiceAccounts?: string; + allowedProjects?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: Array; + }; +} + +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 diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index aeb66d93f..c64d22129 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -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, diff --git a/backend/src/server/routes/v1/identity-gcp-iam-auth-router.ts b/backend/src/server/routes/v1/identity-gcp-iam-auth-router.ts new file mode 100644 index 000000000..e38767b0e --- /dev/null +++ b/backend/src/server/routes/v1/identity-gcp-iam-auth-router.ts @@ -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 }; + } + }); +}; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index fbc68d974..3d0ac6b1e 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -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" } diff --git a/backend/src/services/identity-gcp-iam-auth/identity-gcp-iam-auth-dal.ts b/backend/src/services/identity-gcp-iam-auth/identity-gcp-iam-auth-dal.ts new file mode 100644 index 000000000..78db67968 --- /dev/null +++ b/backend/src/services/identity-gcp-iam-auth/identity-gcp-iam-auth-dal.ts @@ -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; + +export const identityGcpIamAuthDALFactory = (db: TDbClient) => { + const gcpIamAuthOrm = ormify(db, TableName.IdentityGcpIamAuth); + return gcpIamAuthOrm; +}; diff --git a/backend/src/services/identity-gcp-iam-auth/identity-gcp-iam-auth-fns.ts b/backend/src/services/identity-gcp-iam-auth/identity-gcp-iam-auth-fns.ts new file mode 100644 index 000000000..f9d054b1b --- /dev/null +++ b/backend/src/services/identity-gcp-iam-auth/identity-gcp-iam-auth-fns.ts @@ -0,0 +1,17 @@ +/** + * Extracts the GCP service account email into the name and project ID parts where + * the email is in the format: @.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 }; +}; diff --git a/backend/src/services/identity-gcp-iam-auth/identity-gcp-iam-auth-service.ts b/backend/src/services/identity-gcp-iam-auth/identity-gcp-iam-auth-service.ts new file mode 100644 index 000000000..380fffd68 --- /dev/null +++ b/backend/src/services/identity-gcp-iam-auth/identity-gcp-iam-auth-service.ts @@ -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; + identityOrgMembershipDAL: Pick; + identityAccessTokenDAL: Pick; + identityDAL: Pick; + permissionService: Pick; + licenseService: Pick; +}; + +export type TIdentityGcpIamAuthServiceFactory = ReturnType; + +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 + }; +}; diff --git a/backend/src/services/identity-gcp-iam-auth/identity-gcp-iam-auth-types.ts b/backend/src/services/identity-gcp-iam-auth/identity-gcp-iam-auth-types.ts new file mode 100644 index 000000000..168a5e237 --- /dev/null +++ b/backend/src/services/identity-gcp-iam-auth/identity-gcp-iam-auth-types.ts @@ -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; + +export type TUpdateGcpIamAuthDTO = { + identityId: string; + allowedServiceAccounts?: string; + allowedProjects?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { ipAddress: string }[]; +} & Omit; + +export type TGetGcpIamAuthDTO = { + identityId: string; +} & Omit; + +export type TDecodedGcpIamAuthJwt = { + header: { + alg: string; + kid: string; + typ: string; + }; + payload: { + sub: string; + aud: string; + }; + signature: string; + metadata: { + [key: string]: string; + }; +};