diff --git a/backend/package-lock.json b/backend/package-lock.json index 6c674474b..071ba6145 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -39,6 +39,8 @@ "dotenv": "^16.4.1", "fastify": "^4.26.0", "fastify-plugin": "^4.5.1", + "google-auth-library": "^9.9.0", + "googleapis": "^137.1.0", "handlebars": "^4.7.8", "ioredis": "^5.3.2", "jmespath": "^0.16.0", @@ -6183,6 +6185,14 @@ "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==" }, + "node_modules/bignumber.js": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz", + "integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==", + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -7759,6 +7769,11 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, "node_modules/extsprintf": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", @@ -8286,6 +8301,88 @@ "node": ">=8" } }, + "node_modules/gaxios": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.5.0.tgz", + "integrity": "sha512-R9QGdv8j4/dlNoQbX3hSaK/S0rkMijqjVvW3YM06CoBdbU/VdKd159j4hePpng0KuE6Lh6JJ7UdmVGJZFcAG1w==", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gaxios/node_modules/agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/gaxios/node_modules/https-proxy-agent": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", + "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gaxios/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/gcp-metadata": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.0.tgz", + "integrity": "sha512-Jh/AIwwgaxan+7ZUUmRLCjtchyDiqh4KjBJ5tW3plBZb5iL/BPcso8A5DlzeD9qlw0duCamnNdpFjxwaT0KyKg==", + "dependencies": { + "gaxios": "^6.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/generate-function": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", @@ -8482,6 +8579,69 @@ "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", "dev": true }, + "node_modules/google-auth-library": { + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.9.0.tgz", + "integrity": "sha512-9l+zO07h1tDJdIHN74SpnWIlNR+OuOemXlWJlLP9pXy6vFtizgpEzMuwJa4lqY9UAdiAv5DVd5ql0Am916I+aA==", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-auth-library/node_modules/jwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/google-auth-library/node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/googleapis": { + "version": "137.1.0", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-137.1.0.tgz", + "integrity": "sha512-2L7SzN0FLHyQtFmyIxrcXhgust77067pkkduqkbIpDuj9JzVnByxsRrcRfUMFQam3rQkWW2B0f1i40IwKDWIVQ==", + "dependencies": { + "google-auth-library": "^9.0.0", + "googleapis-common": "^7.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/googleapis-common": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-7.2.0.tgz", + "integrity": "sha512-/fhDZEJZvOV3X5jmD+fKxMqma5q2Q9nZNSF3kn1F18tpxmA86BcTxAGBQdM0N89Z3bEaIs+HVznSmFJEAmMTjA==", + "dependencies": { + "extend": "^3.0.2", + "gaxios": "^6.0.3", + "google-auth-library": "^9.7.0", + "qs": "^6.7.0", + "url-template": "^2.0.8", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", @@ -8504,6 +8664,37 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/gtoken/node_modules/jwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/gtoken/node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, "node_modules/handlebars": { "version": "4.7.8", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", @@ -9277,6 +9468,14 @@ "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==" }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -13703,6 +13902,11 @@ "querystring": "0.2.0" } }, + "node_modules/url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==" + }, "node_modules/url/node_modules/punycode": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", diff --git a/backend/package.json b/backend/package.json index 113fa4d5b..96a4a7c37 100644 --- a/backend/package.json +++ b/backend/package.json @@ -100,6 +100,8 @@ "dotenv": "^16.4.1", "fastify": "^4.26.0", "fastify-plugin": "^4.5.1", + "google-auth-library": "^9.9.0", + "googleapis": "^137.1.0", "handlebars": "^4.7.8", "ioredis": "^5.3.2", "jmespath": "^0.16.0", diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index b39847f27..994b324f6 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -33,6 +33,7 @@ import { TGroupProjectServiceFactory } from "@app/services/group-project/group-p import { TIdentityServiceFactory } from "@app/services/identity/identity-service"; import { TIdentityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service"; import { TIdentityAwsAuthServiceFactory } from "@app/services/identity-aws-auth/identity-aws-auth-service"; +import { TIdentityGcpAuthServiceFactory } from "@app/services/identity-gcp-auth/identity-gcp-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"; @@ -116,6 +117,7 @@ declare module "fastify" { identityAccessToken: TIdentityAccessTokenServiceFactory; identityProject: TIdentityProjectServiceFactory; identityUa: TIdentityUaServiceFactory; + identityGcpAuth: TIdentityGcpAuthServiceFactory; identityAwsAuth: TIdentityAwsAuthServiceFactory; accessApprovalPolicy: TAccessApprovalPolicyServiceFactory; accessApprovalRequest: TAccessApprovalRequestServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 02d2e3981..3a6aa549d 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -62,6 +62,9 @@ import { TIdentityAwsAuths, TIdentityAwsAuthsInsert, TIdentityAwsAuthsUpdate, + TIdentityGcpAuths, + TIdentityGcpAuthsInsert, + TIdentityGcpAuthsUpdate, TIdentityOrgMemberships, TIdentityOrgMembershipsInsert, TIdentityOrgMembershipsUpdate, @@ -329,6 +332,11 @@ declare module "knex/types/tables" { TIdentityUniversalAuthsInsert, TIdentityUniversalAuthsUpdate >; + [TableName.IdentityGcpAuth]: Knex.CompositeTableType< + TIdentityGcpAuths, + TIdentityGcpAuthsInsert, + TIdentityGcpAuthsUpdate + >; [TableName.IdentityAwsAuth]: Knex.CompositeTableType< TIdentityAwsAuths, TIdentityAwsAuthsInsert, diff --git a/backend/src/db/migrations/20240509235819_identity-gcp-auth.ts b/backend/src/db/migrations/20240509235819_identity-gcp-auth.ts new file mode 100644 index 000000000..8c80fed84 --- /dev/null +++ b/backend/src/db/migrations/20240509235819_identity-gcp-auth.ts @@ -0,0 +1,30 @@ +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.IdentityGcpAuth))) { + await knex.schema.createTable(TableName.IdentityGcpAuth, (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("type").notNullable(); + t.string("allowedServiceAccounts").notNullable(); + t.string("allowedProjects").notNullable(); + t.string("allowedZones").notNullable(); // GCE only (fully qualified zone names) + }); + } + + await createOnUpdateTrigger(knex, TableName.IdentityGcpAuth); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.IdentityGcpAuth); + await dropOnUpdateTrigger(knex, TableName.IdentityGcpAuth); +} diff --git a/backend/src/db/schemas/identity-gcp-auths.ts b/backend/src/db/schemas/identity-gcp-auths.ts new file mode 100644 index 000000000..65c7db837 --- /dev/null +++ b/backend/src/db/schemas/identity-gcp-auths.ts @@ -0,0 +1,27 @@ +// 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 IdentityGcpAuthsSchema = 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(), + type: z.string(), + allowedServiceAccounts: z.string(), + allowedProjects: z.string(), + allowedZones: z.string() +}); + +export type TIdentityGcpAuths = z.infer; +export type TIdentityGcpAuthsInsert = Omit, TImmutableDBKeys>; +export type TIdentityGcpAuthsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 8bd00ff62..4993003f9 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -18,6 +18,7 @@ export * from "./groups"; export * from "./identities"; export * from "./identity-access-tokens"; export * from "./identity-aws-auths"; +export * from "./identity-gcp-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 053c6411d..4951de99e 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", + IdentityGcpAuth = "identity_gcp_auths", IdentityUaClientSecret = "identity_ua_client_secrets", IdentityAwsAuth = "identity_aws_auths", IdentityOrgMembership = "identity_org_memberships", @@ -144,5 +145,6 @@ export enum ProjectUpgradeStatus { export enum IdentityAuthMethod { Univeral = "universal-auth", + GCP_AUTH = "gcp-auth", AWS_AUTH = "aws-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 1bebd96d6..0dc993358 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_AUTH = "login-identity-gcp-auth", + ADD_IDENTITY_GCP_AUTH = "add-identity-gcp-auth", + UPDATE_IDENTITY_GCP_AUTH = "update-identity-gcp-auth", + GET_IDENTITY_GCP_AUTH = "get-identity-gcp-auth", LOGIN_IDENTITY_AWS_AUTH = "login-identity-aws-auth", ADD_IDENTITY_AWS_AUTH = "add-identity-aws-auth", UPDATE_IDENTITY_AWS_AUTH = "update-identity-aws-auth", @@ -410,6 +414,52 @@ interface RevokeIdentityUniversalAuthClientSecretEvent { }; } +interface LoginIdentityGcpAuthEvent { + type: EventType.LOGIN_IDENTITY_GCP_AUTH; + metadata: { + identityId: string; + identityGcpAuthId: string; + identityAccessTokenId: string; + }; +} + +interface AddIdentityGcpAuthEvent { + type: EventType.ADD_IDENTITY_GCP_AUTH; + metadata: { + identityId: string; + type: string; + allowedServiceAccounts: string; + allowedProjects: string; + allowedZones: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: Array; + }; +} + +interface UpdateIdentityGcpAuthEvent { + type: EventType.UPDATE_IDENTITY_GCP_AUTH; + metadata: { + identityId: string; + type?: string; + allowedServiceAccounts?: string; + allowedProjects?: string; + allowedZones?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: Array; + }; +} + +interface GetIdentityGcpAuthEvent { + type: EventType.GET_IDENTITY_GCP_AUTH; + metadata: { + identityId: string; + }; +} + interface LoginIdentityAwsAuthEvent { type: EventType.LOGIN_IDENTITY_AWS_AUTH; metadata: { @@ -708,6 +758,10 @@ export type Event = | CreateIdentityUniversalAuthClientSecretEvent | GetIdentityUniversalAuthClientSecretsEvent | RevokeIdentityUniversalAuthClientSecretEvent + | LoginIdentityGcpAuthEvent + | AddIdentityGcpAuthEvent + | UpdateIdentityGcpAuthEvent + | GetIdentityGcpAuthEvent | LoginIdentityAwsAuthEvent | AddIdentityAwsAuthEvent | UpdateIdentityAwsAuthEvent diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index b887a6e24..3b0758d68 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -80,6 +80,8 @@ import { identityAccessTokenDALFactory } from "@app/services/identity-access-tok import { identityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service"; import { identityAwsAuthDALFactory } from "@app/services/identity-aws-auth/identity-aws-auth-dal"; import { identityAwsAuthServiceFactory } from "@app/services/identity-aws-auth/identity-aws-auth-service"; +import { identityGcpAuthDALFactory } from "@app/services/identity-gcp-auth/identity-gcp-auth-dal"; +import { identityGcpAuthServiceFactory } from "@app/services/identity-gcp-auth/identity-gcp-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"; @@ -208,6 +210,8 @@ export const registerRoutes = async ( const identityUaClientSecretDAL = identityUaClientSecretDALFactory(db); const identityAwsAuthDAL = identityAwsAuthDALFactory(db); + const identityGcpAuthDAL = identityGcpAuthDALFactory(db); + const auditLogDAL = auditLogDALFactory(db); const auditLogStreamDAL = auditLogStreamDALFactory(db); const trustedIpDAL = trustedIpDALFactory(db); @@ -707,6 +711,15 @@ export const registerRoutes = async ( identityUaDAL, licenseService }); + const identityGcpAuthService = identityGcpAuthServiceFactory({ + identityGcpAuthDAL, + identityOrgMembershipDAL, + identityAccessTokenDAL, + identityDAL, + permissionService, + licenseService + }); + const identityAwsAuthService = identityAwsAuthServiceFactory({ identityAccessTokenDAL, identityAwsAuthDAL, @@ -784,6 +797,7 @@ export const registerRoutes = async ( identityAccessToken: identityAccessTokenService, identityProject: identityProjectService, identityUa: identityUaService, + identityGcpAuth: identityGcpAuthService, identityAwsAuth: identityAwsAuthService, secretApprovalPolicy: sapService, accessApprovalPolicy: accessApprovalPolicyService, diff --git a/backend/src/server/routes/v1/identity-gcp-auth-router.ts b/backend/src/server/routes/v1/identity-gcp-auth-router.ts new file mode 100644 index 000000000..58654f220 --- /dev/null +++ b/backend/src/server/routes/v1/identity-gcp-auth-router.ts @@ -0,0 +1,268 @@ +import { z } from "zod"; + +import { IdentityGcpAuthsSchema } 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"; +import { validateGcpAuthField } from "@app/services/identity-gcp-auth/identity-gcp-auth-validators"; + +export const registerIdentityGcpAuthRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/gcp-auth/login", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Login with GCP 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 { identityGcpAuth, accessToken, identityAccessToken, identityMembershipOrg } = + await server.services.identityGcpAuth.login(req.body); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityMembershipOrg?.orgId, + event: { + type: EventType.LOGIN_IDENTITY_GCP_AUTH, + metadata: { + identityId: identityGcpAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + identityGcpAuthId: identityGcpAuth.id + } + } + }); + + return { + accessToken, + tokenType: "Bearer" as const, + expiresIn: identityGcpAuth.accessTokenTTL, + accessTokenMaxTTL: identityGcpAuth.accessTokenMaxTTL + }; + } + }); + + server.route({ + method: "POST", + url: "/gcp-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Attach GCP Auth configuration onto identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim() + }), + body: z.object({ + type: z.enum(["iam", "gce"]), + allowedServiceAccounts: validateGcpAuthField, + allowedProjects: validateGcpAuthField, + allowedZones: validateGcpAuthField, + 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({ + identityGcpAuth: IdentityGcpAuthsSchema + }) + } + }, + handler: async (req) => { + const identityGcpAuth = await server.services.identityGcpAuth.attachGcpAuth({ + 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: identityGcpAuth.orgId, + event: { + type: EventType.ADD_IDENTITY_GCP_AUTH, + metadata: { + identityId: identityGcpAuth.identityId, + type: identityGcpAuth.type, + allowedServiceAccounts: identityGcpAuth.allowedServiceAccounts, + allowedProjects: identityGcpAuth.allowedProjects, + allowedZones: identityGcpAuth.allowedZones, + accessTokenTTL: identityGcpAuth.accessTokenTTL, + accessTokenMaxTTL: identityGcpAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityGcpAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityGcpAuth.accessTokenNumUsesLimit + } + } + }); + + return { identityGcpAuth }; + } + }); + + server.route({ + method: "PATCH", + url: "/gcp-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update GCP Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim() + }), + body: z.object({ + type: z.enum(["iam", "gce"]).optional(), + allowedServiceAccounts: validateGcpAuthField, + allowedProjects: validateGcpAuthField, + allowedZones: validateGcpAuthField, + 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({ + identityGcpAuth: IdentityGcpAuthsSchema + }) + } + }, + handler: async (req) => { + const identityGcpAuth = await server.services.identityGcpAuth.updateGcpAuth({ + 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: identityGcpAuth.orgId, + event: { + type: EventType.UPDATE_IDENTITY_GCP_AUTH, + metadata: { + identityId: identityGcpAuth.identityId, + type: identityGcpAuth.type, + allowedServiceAccounts: identityGcpAuth.allowedServiceAccounts, + allowedProjects: identityGcpAuth.allowedProjects, + allowedZones: identityGcpAuth.allowedZones, + accessTokenTTL: identityGcpAuth.accessTokenTTL, + accessTokenMaxTTL: identityGcpAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityGcpAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityGcpAuth.accessTokenNumUsesLimit + } + } + }); + + return { identityGcpAuth }; + } + }); + + server.route({ + method: "GET", + url: "/gcp-auth/identities/:identityId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Retrieve GCP Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string() + }), + response: { + 200: z.object({ + identityGcpAuth: IdentityGcpAuthsSchema + }) + } + }, + handler: async (req) => { + const identityGcpAuth = await server.services.identityGcpAuth.getGcpAuth({ + 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: identityGcpAuth.orgId, + event: { + type: EventType.GET_IDENTITY_GCP_AUTH, + metadata: { + identityId: identityGcpAuth.identityId + } + } + }); + + return { identityGcpAuth }; + } + }); +}; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 6907fd040..1a02a4cbf 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -3,6 +3,7 @@ import { registerAuthRoutes } from "./auth-router"; import { registerProjectBotRouter } from "./bot-router"; import { registerIdentityAccessTokenRouter } from "./identity-access-token-router"; import { registerIdentityAwsAuthRouter } from "./identity-aws-iam-auth-router"; +import { registerIdentityGcpAuthRouter } from "./identity-gcp-auth-router"; import { registerIdentityRouter } from "./identity-router"; import { registerIdentityUaRouter } from "./identity-ua"; import { registerIntegrationAuthRouter } from "./integration-auth-router"; @@ -28,6 +29,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { async (authRouter) => { await authRouter.register(registerAuthRoutes); await authRouter.register(registerIdentityUaRouter); + await authRouter.register(registerIdentityGcpAuthRouter); await authRouter.register(registerIdentityAccessTokenRouter); await authRouter.register(registerIdentityAwsAuthRouter); }, diff --git a/backend/src/services/identity-gcp-auth/identity-gcp-auth-dal.ts b/backend/src/services/identity-gcp-auth/identity-gcp-auth-dal.ts new file mode 100644 index 000000000..e10250445 --- /dev/null +++ b/backend/src/services/identity-gcp-auth/identity-gcp-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 TIdentityGcpAuthDALFactory = ReturnType; + +export const identityGcpAuthDALFactory = (db: TDbClient) => { + const gcpAuthOrm = ormify(db, TableName.IdentityGcpAuth); + return gcpAuthOrm; +}; diff --git a/backend/src/services/identity-gcp-auth/identity-gcp-auth-fns.ts b/backend/src/services/identity-gcp-auth/identity-gcp-auth-fns.ts new file mode 100644 index 000000000..e1afceada --- /dev/null +++ b/backend/src/services/identity-gcp-auth/identity-gcp-auth-fns.ts @@ -0,0 +1,70 @@ +import axios from "axios"; +import { OAuth2Client } from "google-auth-library"; +import jwt from "jsonwebtoken"; + +import { UnauthorizedError } from "@app/lib/errors"; + +import { TDecodedGcpIamAuthJwt, TGcpIdTokenPayload } from "./identity-gcp-auth-types"; + +/** + * Validates that the identity token [jwt] sent in from a client GCE instance as part of GCP ID Token authentication + * is valid. + * @param {string} identityId - The ID of the identity in Infisical that is being authenticated against (used as audience). + * @param {string} jwt - The identity token to validate. + * @param {string} credentials - The credentials in the GCP Auth configuration for Infisical. + */ +export const validateIdTokenIdentity = async ({ + identityId, + jwt: identityToken +}: { + identityId: string; + jwt: string; +}) => { + const oAuth2Client = new OAuth2Client(); + const response = await oAuth2Client.getFederatedSignonCerts(); + const ticket = await oAuth2Client.verifySignedJwtWithCertsAsync( + identityToken, + response.certs, + identityId, // audience + ["https://accounts.google.com"] + ); + const payload = ticket.getPayload() as TGcpIdTokenPayload; + if (!payload || !payload.email) throw new UnauthorizedError(); + + return { email: payload.email, computeEngineDetails: payload.google?.compute_engine }; +}; + +/** + * Validates that the signed JWT token for a GCP service account is valid as part of GCP IAM authentication. + * @param {string} identityId - The ID of the identity in Infisical that is being authenticated against (used as audience). + * @param {string} jwt - The signed JWT token to validate. + * @param {string} credentials - The credentials in the GCP Auth configuration for Infisical. + * @returns + */ +export const validateIamIdentity = async ({ + identityId, + jwt: serviceAccountJwt +}: { + identityId: string; + jwt: string; +}) => { + 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(); + return { email: sub }; +}; diff --git a/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts b/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts new file mode 100644 index 000000000..5f829cb33 --- /dev/null +++ b/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts @@ -0,0 +1,324 @@ +import { ForbiddenError } from "@casl/ability"; +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 { TIdentityGcpAuthDALFactory } from "./identity-gcp-auth-dal"; +import { validateIamIdentity, validateIdTokenIdentity } from "./identity-gcp-auth-fns"; +import { + TAttachGcpAuthDTO, + TGcpIdentityDetails, + TGetGcpAuthDTO, + TLoginGcpAuthDTO, + TUpdateGcpAuthDTO +} from "./identity-gcp-auth-types"; + +type TIdentityGcpAuthServiceFactoryDep = { + identityGcpAuthDAL: Pick; + identityOrgMembershipDAL: Pick; + identityAccessTokenDAL: Pick; + identityDAL: Pick; + permissionService: Pick; + licenseService: Pick; +}; + +export type TIdentityGcpAuthServiceFactory = ReturnType; + +export const identityGcpAuthServiceFactory = ({ + identityGcpAuthDAL, + identityOrgMembershipDAL, + identityAccessTokenDAL, + identityDAL, + permissionService, + licenseService +}: TIdentityGcpAuthServiceFactoryDep) => { + const login = async ({ identityId, jwt: gcpJwt }: TLoginGcpAuthDTO) => { + const identityGcpAuth = await identityGcpAuthDAL.findOne({ identityId }); + if (!identityGcpAuth) throw new UnauthorizedError(); + + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityGcpAuth.identityId }); + if (!identityMembershipOrg) throw new UnauthorizedError(); + + let gcpIdentityDetails: TGcpIdentityDetails; + switch (identityGcpAuth.type) { + case "gce": { + gcpIdentityDetails = await validateIdTokenIdentity({ + identityId, + jwt: gcpJwt + }); + break; + } + case "iam": { + gcpIdentityDetails = await validateIamIdentity({ + identityId, + jwt: gcpJwt + }); + break; + } + default: { + throw new BadRequestError({ message: "Invalid GCP Auth type" }); + } + } + + if (identityGcpAuth.allowedServiceAccounts) { + // validate if the service account is in the list of allowed service accounts + + const isServiceAccountAllowed = identityGcpAuth.allowedServiceAccounts + .split(",") + .map((serviceAccount) => serviceAccount.trim()) + .some((serviceAccount) => serviceAccount === gcpIdentityDetails.email); + + if (!isServiceAccountAllowed) throw new UnauthorizedError(); + } + + if (identityGcpAuth.type === "gce" && identityGcpAuth.allowedProjects && gcpIdentityDetails.computeEngineDetails) { + // validate if the project that the service account belongs to is in the list of allowed projects + + const isProjectAllowed = identityGcpAuth.allowedProjects + .split(",") + .map((project) => project.trim()) + .some((project) => project === gcpIdentityDetails.computeEngineDetails?.project_id); + + if (!isProjectAllowed) throw new UnauthorizedError(); + } + + if (identityGcpAuth.type === "gce" && identityGcpAuth.allowedZones && gcpIdentityDetails.computeEngineDetails) { + const isZoneAllowed = identityGcpAuth.allowedZones + .split(",") + .map((zone) => zone.trim()) + .some((zone) => zone === gcpIdentityDetails.computeEngineDetails?.zone); + + if (!isZoneAllowed) throw new UnauthorizedError(); + } + + const identityAccessToken = await identityGcpAuthDAL.transaction(async (tx) => { + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityGcpAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityGcpAuth.accessTokenTTL, + accessTokenMaxTTL: identityGcpAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityGcpAuth.accessTokenNumUsesLimit + }, + tx + ); + return newToken; + }); + + const appCfg = getConfig(); + const accessToken = jwt.sign( + { + identityId: identityGcpAuth.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, identityGcpAuth, identityAccessToken, identityMembershipOrg }; + }; + + const attachGcpAuth = async ({ + identityId, + type, + allowedServiceAccounts, + allowedProjects, + allowedZones, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TAttachGcpAuthDTO) => { + 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 GCP 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 identityGcpAuth = await identityGcpAuthDAL.transaction(async (tx) => { + const doc = await identityGcpAuthDAL.create( + { + identityId: identityMembershipOrg.identityId, + type, + allowedServiceAccounts, + allowedProjects, + allowedZones, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps) + }, + tx + ); + await identityDAL.updateById( + identityMembershipOrg.identityId, + { + authMethod: IdentityAuthMethod.GCP_AUTH + }, + tx + ); + return doc; + }); + return { ...identityGcpAuth, orgId: identityMembershipOrg.orgId }; + }; + + const updateGcpAuth = async ({ + identityId, + type, + allowedServiceAccounts, + allowedProjects, + allowedZones, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdateGcpAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.GCP_AUTH) + throw new BadRequestError({ + message: "Failed to update GCP Auth" + }); + + const identityGcpAuth = await identityGcpAuthDAL.findOne({ identityId }); + + if ( + (accessTokenMaxTTL || identityGcpAuth.accessTokenMaxTTL) > 0 && + (accessTokenTTL || identityGcpAuth.accessTokenMaxTTL) > (accessTokenMaxTTL || identityGcpAuth.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 updatedGcpAuth = await identityGcpAuthDAL.updateById(identityGcpAuth.id, { + type, + allowedServiceAccounts, + allowedProjects, + allowedZones, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: reformattedAccessTokenTrustedIps + ? JSON.stringify(reformattedAccessTokenTrustedIps) + : undefined + }); + + return { + ...updatedGcpAuth, + orgId: identityMembershipOrg.orgId + }; + }; + + const getGcpAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetGcpAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.GCP_AUTH) + throw new BadRequestError({ + message: "The identity does not have GCP Auth attached" + }); + + const identityGcpAuth = await identityGcpAuthDAL.findOne({ identityId }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); + + return { ...identityGcpAuth, orgId: identityMembershipOrg.orgId }; + }; + + return { + login, + attachGcpAuth, + updateGcpAuth, + getGcpAuth + }; +}; diff --git a/backend/src/services/identity-gcp-auth/identity-gcp-auth-types.ts b/backend/src/services/identity-gcp-auth/identity-gcp-auth-types.ts new file mode 100644 index 000000000..60ab36b58 --- /dev/null +++ b/backend/src/services/identity-gcp-auth/identity-gcp-auth-types.ts @@ -0,0 +1,78 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TLoginGcpAuthDTO = { + identityId: string; + jwt: string; +}; + +export type TAttachGcpAuthDTO = { + identityId: string; + type: "iam" | "gce"; + allowedServiceAccounts: string; + allowedProjects: string; + allowedZones: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { ipAddress: string }[]; +} & Omit; + +export type TUpdateGcpAuthDTO = { + identityId: string; + type?: "iam" | "gce"; + allowedServiceAccounts?: string; + allowedProjects?: string; + allowedZones?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { ipAddress: string }[]; +} & Omit; + +export type TGetGcpAuthDTO = { + identityId: string; +} & Omit; + +type TComputeEngineDetails = { + instance_creation_timestamp: number; + instance_id: string; + instance_name: string; + project_id: string; + project_number: number; + zone: string; +}; + +export type TGcpIdentityDetails = { + email: string; + computeEngineDetails?: TComputeEngineDetails; +}; + +export type TGcpIdTokenPayload = { + aud: string; + azp: string; + email: string; + email_verified: boolean; + exp: number; + google?: { + compute_engine: TComputeEngineDetails; + }; + iat: number; + iss: string; + sub: string; +}; + +export type TDecodedGcpIamAuthJwt = { + header: { + alg: string; + kid: string; + typ: string; + }; + payload: { + sub: string; + aud: string; + }; + signature: string; + metadata: { + [key: string]: string; + }; +}; diff --git a/backend/src/services/identity-gcp-auth/identity-gcp-auth-validators.ts b/backend/src/services/identity-gcp-auth/identity-gcp-auth-validators.ts new file mode 100644 index 000000000..a49cee417 --- /dev/null +++ b/backend/src/services/identity-gcp-auth/identity-gcp-auth-validators.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +export const validateGcpAuthField = z + .string() + .trim() + .default("") + .transform((data) => { + if (data === "") return ""; + // Trim each ID and join with ', ' to ensure formatting + return data + .split(",") + .map((id) => id.trim()) + .join(", "); + }); diff --git a/docs/documentation/platform/identities/aws-auth.mdx b/docs/documentation/platform/identities/aws-auth.mdx index cf76ffce7..de43fd83c 100644 --- a/docs/documentation/platform/identities/aws-auth.mdx +++ b/docs/documentation/platform/identities/aws-auth.mdx @@ -27,7 +27,7 @@ sequenceDiagram Note over Infis: Step 4: Identity Property Validation Infis->>Client: Return short-lived access token - Note over Client,Infis: Step 4: Access Infisical API with Token + Note over Client,Infis: Step 5: Access Infisical API with Token Client->>Infis: Make authenticated requests using the short-lived access token ``` @@ -36,9 +36,13 @@ sequenceDiagram At a high-level, Infisical authenticates an IAM principal by verifying its identity and checking that it meets specific requirements (e.g. it is an allowed IAM principal ARN) at the `/api/v1/auth/aws-auth/login` endpoint. If successful, then Infisical returns a short-lived access token that can be used to make authenticated requests to the Infisical API. -In AWS Auth, an IAM principal signs a `GetCallerIdentity` query using the [AWS Signature v4 algorithm](https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html); this is done using the credentials from the AWS environment where the IAM principal is running. -The query data including the request method, request body, and request headers are sent to Infisical afterwhich Infisical forwards the signed query to AWS STS API via the [sts:GetCallerIdentity](https://docs.aws.amazon.com/STS/latest/APIReference/API_GetCallerIdentity.html) method to verify and obtain the identity of the IAM principal. -Once obtained, the identity information is verified against specified requirements such as if the associated IAM principal ARN is allowed to authenticate with Infisical. If all is well, Infisical returns a short-lived access token that can be used to make authenticated requests to the Infisical API. +To be more specific: + +1. The client IAM principal signs a `GetCallerIdentity` query using the [AWS Signature v4 algorithm](https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html); this is done using the credentials from the AWS environment where the IAM principal is running. +2. The client sends the signed query data to Infisical including the request method, request body, and request headers. +3. Infisical reconstructs the query and sends it to AWS STS API via the [sts:GetCallerIdentity](https://docs.aws.amazon.com/STS/latest/APIReference/API_GetCallerIdentity.html) method for verification and obtains the identity associated with the IAM principal. +4. Infisical checks the identity's properties against set criteria such **Allowed Principal ARNs**. +5. If all is well, Infisical returns a short-lived access token that the IAM principal can use to make authenticated requests to the Infisical API. We recommend using one of Infisical's clients like SDKs or the Infisical Agent @@ -51,7 +55,7 @@ as part of the AWS Auth workflow. -## Workflow +## Guide In the following steps, we explore how to create and use identities for your workloads and applications on AWS to access the Infisical API using the AWS Auth authentication method. diff --git a/docs/documentation/platform/identities/gcp-auth.mdx b/docs/documentation/platform/identities/gcp-auth.mdx new file mode 100644 index 000000000..42ae819d3 --- /dev/null +++ b/docs/documentation/platform/identities/gcp-auth.mdx @@ -0,0 +1,351 @@ +--- +title: GCP Auth +description: "Learn how to authenticate with Infisical for services on Google Cloud Platform" +--- + +**GCP Auth** is a GCP-native authentication method for GCP resources to access Infisical. It consists of two sub-methods/approaches: + +- GCP ID Token Auth: For GCP services including [Compute Engine](https://cloud.google.com/compute/docs/instances/verifying-instance-identity#request_signature), [App Engine standard environment](https://cloud.google.com/appengine/docs/standard/python3/runtime#metadata_server), [App Engine flexible environment](https://cloud.google.com/appengine/docs/flexible/python/runtime#metadata_server), [Cloud Functions](https://cloud.google.com/functions/docs/securing/function-identity#using_the_metadata_server_to_acquire_tokens), [Cloud Run](https://cloud.google.com/run/docs/container-contract#metadata-server), [Google Kubernetes Engine](https://cloud.google.com/kubernetes-engine/docs/concepts/workload-identity#instance_metadata), and [Cloud Build](https://cloud.google.com/kubernetes-engine/docs/concepts/workload-identity#instance_metadata) to authenticate with Infisical. +- GCP IAM Auth: For Google Cloud Platform (GCP) service accounts to authenticate with Infisical. + + + + + ## Diagram + + The following sequence digram illustrates the GCP ID Token Auth workflow for authenticating GCP resources with Infisical. + +```mermaid +sequenceDiagram + participant GCE as GCP Service + participant Infis as Infisical + participant Google as OAuth2 API + + Note over GCE,Google: Step 1: Instance Identity Token Retrieval + GCE->>Google: Request instance identity metadata token + Google-->>GCE: Return JWT token with RS256 signature + + Note over GCE,Infis: Step 2: Identity Token Login Operation + GCE->>Infis: Send JWT token to /api/v1/auth/gcp-auth/login + Infis->>Google: Request OAuth2 certificates + Google-->>Infis: Return certificates + + Note over Infis: Step 3: Identity Token Verification + Note over Infis: Step 4: Identity Property Validation + Infis->>GCE: Return short-lived access token + + Note over GCE,Infis: Step 4: Access Infisical API with Token + GCE->>Infis: Make authenticated requests using the short-lived access token +``` + + ## Concept + +At a high-level, Infisical authenticates a GCP resource by verifying its identity and checking that it meets specific requirements (e.g. it is an allowed GCE instance) at the `/api/v1/auth/gcp-auth/login` endpoint. If successful, +then Infisical returns a short-lived access token that can be used to make authenticated requests to the Infisical API. + +To be more specific: + +1. The client running on a GCP service obtains an [ID token](https://cloud.google.com/docs/authentication/get-id-token) constituting the identity for a GCP resource such as a GCE instance or Cloud Function; this is a unique JWT token that includes details about the instance as well as Google's [RS256 signature](https://datatracker.ietf.org/doc/html/rfc7518#section-3.3). +2. The client sends the ID token to Infisical. +3. Infisical verifies the token against Google's [public OAuth2 certificates](https://www.googleapis.com/oauth2/v3/certs). +4. Infisical checks if the entity behind the ID token is allowed to authenticate with Infisical based on set criteria such as **Allowed Service Account Emails**. +5. If all is well, Infisical returns a short-lived access token that the client can use to make authenticated requests to the Infisical API. + + +We recommend using one of Infisical's clients like SDKs or the Infisical Agent +to authenticate with Infisical using GCP ID Token Auth as they handle the +authentication process including generating the instance ID token for you. + +Also, note that Infisical needs network-level access to send requests to the Google Cloud API +as part of the GCP Auth workflow. + + + +## Guide + +In the following steps, we explore how to create and use identities for your workloads and applications on GCP to +access the Infisical API using the GCP ID Token authentication method. + + + + To create an identity, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**. + + ![identities organization](/images/platform/identities/identities-org.png) + + When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + + ![identities organization create](/images/platform/identities/identities-org-create.png) + + Now input a few details for your new identity. Here's some guidance for each field: + + - Name (required): A friendly name for the identity. + - Role (required): A role from the **Organization Roles** tab for the identity to assume. The organization role assigned will determine what organization level resources this identity can have access to. + + Once you've created an identity, you'll be prompted to configure the authentication method for it. Here, select **GCP Auth** and set the **Type** to **GCP ID Token Auth**. + + ![identities create gcp auth method](/images/platform/identities/identities-org-create-gcp-gce-auth-method.png) + + Here's some more guidance on each field: + + - Allowed Service Account Emails: A comma-separated list of trusted service account emails corresponding to the GCE resource(s) allowed to authenticate with Infisical; this could be something like `test@project.iam.gserviceaccount.com`, `12345-compute@developer.gserviceaccount.com`, etc. + - Allowed Projects: A comma-separated list of trusted GCP projects that the GCE instance must belong to authenticate with Infisical. Note that this validation property will only work for GCE instances. + - Allowed Zones: A comma-separated list of trusted zones that the GCE instances must belong to authenticate with Infisical; this should be the fully-qualified zone name in the format `-`like `us-central1-a`, `us-west1-b`, etc. Note that this validation property will only work for GCE instances. + - Access Token TTL (default is `2592000` equivalent to 30 days): The lifetime for an acccess token in seconds. This value will be referenced at renewal time. + - Access Token Max TTL (default is `2592000` equivalent to 30 days): The maximum lifetime for an acccess token in seconds. This value will be referenced at renewal time. + - Access Token Max Number of Uses (default is `0`): The maximum number of times that an access token can be used; a value of `0` implies infinite number of uses. + - Access Token Trusted IPs: The IPs or CIDR ranges that access tokens can be used from. By default, each token is given the `0.0.0.0/0`, allowing usage from any network address. + + + + To enable the identity to access project-level resources such as secrets within a specific project, you should add it to that project. + + To do this, head over to the project you want to add the identity to and go to Project Settings > Access Control > Machine Identities and press **Add identity**. + + Next, select the identity you want to add to the project and the project level role you want to allow it to assume. The project role assigned will determine what project level resources this identity can have access to. + + ![identities project](/images/platform/identities/identities-project.png) + + ![identities project create](/images/platform/identities/identities-project-create.png) + + + To access the Infisical API as the identity, you need to generate an [ID token](https://cloud.google.com/docs/authentication/get-id-token) constituting the identity of the present GCE instance and make a request to the `/api/v1/auth/gcp-auth/login` endpoint containing the token in exchange for an access token. + + We provide a few code examples below of how you can authenticate with Infisical to access the [Infisical API](/api-reference/overview/introduction). + + + + Start by making a request from the GCE instance to obtain the ID token. + For more examples of how to obtain the token in Java, Go, Node.js, etc. refer to the [official documentation](https://cloud.google.com/docs/authentication/get-id-token#curl). + + #### Sample request + + ```bash curl + curl -H "Metadata-Flavor: Google" \ + 'http://metadata/computeMetadata/v1/instance/service-accounts/default/identity?audience=' + ``` + + + + Note that you should replace `` with the ID of the identity you created in step 1. + + + Next use send the obtained JWT token along to authenticate with Infisical and obtain an access token. + + #### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v1/auth/gcp-auth/login' \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'identityId=...' \ + --data-urlencode 'jwt=...' + ``` + + #### Sample response + + ```bash Response + { + "accessToken": "...", + "expiresIn": 7200, + "accessTokenMaxTTL": 43244 + "tokenType": "Bearer" + } + ``` + + Next, you can use the access token to access the [Infisical API](/api-reference/overview/introduction) + + + + + We recommend using one of Infisical's clients like SDKs or the Infisical Agent to authenticate with Infisical using GCP IAM Auth as they handle the authentication process including generating the signed JWT token. + + + Each identity access token has a time-to-live (TLL) which you can infer from the response of the login operation; + the default TTL is `7200` seconds which can be adjusted. + If an identity access token expires, it can no longer authenticate with the Infisical API. In this case, + a new access token should be obtained by performing another login operation. + + + + + + + + + ## Diagram + + The following sequence digram illustrates the GCP IAM Auth workflow for authenticating GCP IAM service accounts with Infisical. + +```mermaid +sequenceDiagram + participant GCE as Client + participant Infis as Infisical + participant Google as Cloud IAM + + Note over GCE,Google: Step 1: Signed JWT Token Generation + GCE->>Google: Request to generate signed JWT token + Google-->>GCE: Return signed JWT token + + Note over GCE,Infis: Step 2: JWT Token Login Operation + GCE->>Infis: Send signed JWT token to /api/v1/auth/gcp-auth/login + Infis->>Google: Request public key + Google-->>Infis: Return public key + + Note over Infis: Step 3: JWT Token Verification + Note over Infis: Step 4: JWT Property Validation + Infis->>GCE: Return short-lived access token + + Note over GCE,Infis: Step 5: Access Infisical API with Token + GCE->>Infis: Make authenticated requests using the short-lived access token +``` + +## Concept + +At a high-level, Infisical authenticates an IAM service account by verifying its identity and checking that it meets specific requirements (e.g. it is an allowed service account) at the `/api/v1/auth/gcp-auth/login` endpoint. If successful, +then Infisical returns a short-lived access token that can be used to make authenticated requests to the Infisical API. + +To be more specific: + +1. The client generates a signed JWT token using the `projects.serviceAccounts.signJwt` [API method](https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/signJwt); this is done using the service account credentials associated with the client. +2. The client sends the signed JWT token to Infisical. +3. Infisical verifies the signed JWT token. +4. Infisical checks if the service account behind the JWT token is allowed to authenticate with Infisical based **Allowed Service Account Emails**. +5. If all is well, Infisical returns a short-lived access token that the client can use to make authenticated requests to the Infisical API. + + +We recommend using one of Infisical's clients like SDKs or the Infisical Agent +to authenticate with Infisical using GCP IAM Auth as they handle the +authentication process including generating the signed JWT token. + +Also, note that Infisical needs network-level access to send requests to the Google Cloud API +as part of the GCP Auth workflow. + + + +## Guide + +In the following steps, we explore how to create and use identities for your workloads and applications on GCP to +access the Infisical API using the GCP IAM authentication method. + + + + To create an identity, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**. + + ![identities organization](/images/platform/identities/identities-org.png) + + When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + + ![identities organization create](/images/platform/identities/identities-org-create.png) + + Now input a few details for your new identity. Here's some guidance for each field: + + - Name (required): A friendly name for the identity. + - Role (required): A role from the **Organization Roles** tab for the identity to assume. The organization role assigned will determine what organization level resources this identity can have access to. + + Once you've created an identity, you'll be prompted to configure the authentication method for it. Here, select **GCP IAM Auth** and set the **Type** to **GCP IAM Auth**. + + ![identities create gcp auth method](/images/platform/identities/identities-org-create-gcp-iam-auth-method.png) + + Here's some more guidance on each field: + + - Allowed Service Account Emails: A comma-separated list of trusted IAM service account emails that are allowed to authenticate with Infisical; this could be something like `test@project.iam.gserviceaccount.com`, `12345-compute@developer.gserviceaccount.com`, etc. + - Access Token TTL (default is `2592000` equivalent to 30 days): The lifetime for an acccess token in seconds. This value will be referenced at renewal time. + - Access Token Max TTL (default is `2592000` equivalent to 30 days): The maximum lifetime for an acccess token in seconds. This value will be referenced at renewal time. + - Access Token Max Number of Uses (default is `0`): The maximum number of times that an access token can be used; a value of `0` implies infinite number of uses. + - Access Token Trusted IPs: The IPs or CIDR ranges that access tokens can be used from. By default, each token is given the `0.0.0.0/0`, allowing usage from any network address. + + + + To enable the identity to access project-level resources such as secrets within a specific project, you should add it to that project. + + To do this, head over to the project you want to add the identity to and go to Project Settings > Access Control > Machine Identities and press **Add identity**. + + Next, select the identity you want to add to the project and the project level role you want to allow it to assume. The project role assigned will determine what project level resources this identity can have access to. + + ![identities project](/images/platform/identities/identities-project.png) + + ![identities project create](/images/platform/identities/identities-project-create.png) + + + To access the Infisical API as the identity, you need to generate a signed JWT token using the `projects.serviceAccounts.signJwt` [API method](https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/signJwt) and make a request to the `/api/v1/auth/gcp-auth/login` endpoint containing the signed JWT token in exchange for an access token. + + + Make sure that the service account has the `iam.serviceAccounts.signJwt` permission or the `roles/iam.serviceAccountTokenCreator` role. + + + We provide a few code examples below of how you can authenticate with Infisical to access the [Infisical API](/api-reference/overview/introduction). + + + + The following code provides a generic example of how you can generate a signed JWT token against the `projects.serviceAccounts.signJwt` API method. + + The shown example uses Node.js and the official [google-auth-library](https://github.com/googleapis/google-auth-library-nodejs#readme) package but you can use any language you wish. + + + ```javascript + const { GoogleAuth } = require("google-auth-library"); + + const auth = new GoogleAuth({ + scopes: "https://www.googleapis.com/auth/cloud-platform", + }); + + const credentials = await auth.getCredentials(); + + const identityId = ""; + + const jwtPayload = { + sub: credentials.client_email, + aud: identityId, + }; + + const { data } = await client.request({ + url: `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${credentials.client_email}:signJwt`, + method: "POST", + data: { payload: JSON.stringify(jwtPayload) }, + }); + + const jwt = data.signedJwt // send this jwt to Infisical in the next step + ``` + + #### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v1/auth/gcp-auth/login' \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'identityId=...' \ + --data-urlencode 'jwt=...' + ``` + + #### Sample response + + ```bash Response + { + "accessToken": "...", + "expiresIn": 7200, + "accessTokenMaxTTL": 43244 + "tokenType": "Bearer" + } + ``` + + Next, you can use the access token to access the [Infisical API](/api-reference/overview/introduction) + + + + + We recommend using one of Infisical's clients like SDKs or the Infisical Agent to authenticate with Infisical using GCP IAM Auth as they handle the authentication process including generating the signed JWT token. + + + Each identity access token has a time-to-live (TLL) which you can infer from the response of the login operation; + the default TTL is `7200` seconds which can be adjusted. + If an identity access token expires, it can no longer authenticate with the Infisical API. In this case, + a new access token should be obtained by performing another login operation. + + + + + + + diff --git a/docs/documentation/platform/identities/machine-identities.mdx b/docs/documentation/platform/identities/machine-identities.mdx index e70458c9c..28bde95fe 100644 --- a/docs/documentation/platform/identities/machine-identities.mdx +++ b/docs/documentation/platform/identities/machine-identities.mdx @@ -7,7 +7,7 @@ description: "Learn how to use Machine Identities to programmatically interact w An Infisical machine identity is an entity that represents a workload or application that require access to various resources in Infisical. This is conceptually similar to an IAM user in AWS or service account in Google Cloud Platform (GCP). -Each identity must authenticate using a supported authentication method like [Universal Auth](/documentation/platform/identities/universal-auth) or [AWS Auth](/documentation/platform/identities/aws-auth) to get back a short-lived access token to be used in subsequent requests. +Each identity must authenticate with the Infisical API using a supported authentication method like [Universal Auth](/documentation/platform/identities/universal-auth), [AWS Auth](/documentation/platform/identities/aws-auth), or [GCP Auth](/documentation/platform/identities/gcp-auth) to get back a short-lived access token to be used in subsequent requests. ![organization identities](/images/platform/organization/organization-machine-identities.png) @@ -21,7 +21,7 @@ Key Features: A typical workflow for using identities consists of four steps: 1. Creating the identity with a name and [role](/documentation/platform/role-based-access-controls) in Organization Access Control > Machine Identities. - This step also involves configuring an authentication method for it such as [Universal Auth](/documentation/platform/identities/universal-auth) or [AWS Auth](/documentation/platform/identities/aws-auth). + This step also involves configuring an authentication method for it. 2. Adding the identity to the project(s) you want it to have access to. 3. Authenticating the identity with the Infisical API based on the configured authentication method on it and receiving a short-lived access token back. 4. Authenticating subsequent requests with the Infisical API using the short-lived access token. @@ -39,6 +39,9 @@ To interact with various resources in Infisical, Machine Identities are able to - [Universal Auth](/documentation/platform/identities/universal-auth): A platform-agnostic authentication method that can be configured on an identity suitable to authenticate from any platform/environment. - [AWS Auth](/documentation/platform/identities/aws-auth): An AWS-native authentication method for IAM principals like EC2 instances or Lambda functions to authenticate with Infisical. +- [GCP Auth](/documentation/platform/identities/gcp-auth): A GCP-native authentication method for GCP resources (e.g. Compute Engine, App Engine, Cloud Run, Google Kubernetes Engine, IAM service accounts, etc.) to authenticate with Infisical. + +IAM service accounts and GCE instances to authenticate with Infisical. ## FAQ diff --git a/docs/documentation/platform/identities/universal-auth.mdx b/docs/documentation/platform/identities/universal-auth.mdx index bad96c0e3..d7fb26e71 100644 --- a/docs/documentation/platform/identities/universal-auth.mdx +++ b/docs/documentation/platform/identities/universal-auth.mdx @@ -26,10 +26,14 @@ sequenceDiagram ## Concept -In this method, Infisical authenticates an identity by verifying the credentials issued for it at the `/api/v1/auth/universal-auth/login` endpoint. If successful, +In this method, Infisical authenticates a client by verifying the credentials issued for it at the `/api/v1/auth/universal-auth/login` endpoint. If successful, then Infisical returns a short-lived access token that can be used to make authenticated requests to the Infisical API. -In Universal Auth, an identity is given a **Client ID** and one or more **Client Secret(s)**. Together, a **Client ID** and **Client Secret** can be exchanged for a short-lived access token to authenticate with the Infisical API. +To be more specific: + +1. The client submits a **Client ID** and **Client Secret** to Infisical. +2. Infisical verifies the credential pair. +3. If all is well, Infisical returns a short-lived access token that the client can use to make authenticated requests to the Infisical API. ## Guide @@ -148,7 +152,7 @@ using the Universal Auth authentication method. - The client secret/access token is being used from an untrusted IP. - A identity access token can have a time-to-live (TTL) or incremental lifetime afterwhich it expires. + A identity access token can have a time-to-live (TTL) or incremental lifetime after which it expires. In certain cases, you may want to extend the lifespan of an access token; to do so, you must set a max TTL parameter. diff --git a/docs/images/platform/identities/identities-org-create-gcp-gce-auth-method.png b/docs/images/platform/identities/identities-org-create-gcp-gce-auth-method.png new file mode 100644 index 000000000..899130c42 Binary files /dev/null and b/docs/images/platform/identities/identities-org-create-gcp-gce-auth-method.png differ diff --git a/docs/images/platform/identities/identities-org-create-gcp-iam-auth-method.png b/docs/images/platform/identities/identities-org-create-gcp-iam-auth-method.png new file mode 100644 index 000000000..9dacf9f89 Binary files /dev/null and b/docs/images/platform/identities/identities-org-create-gcp-iam-auth-method.png differ diff --git a/docs/mint.json b/docs/mint.json index d499f3cac..4a106a79c 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -153,6 +153,7 @@ "documentation/platform/auth-methods/email-password", "documentation/platform/token", "documentation/platform/identities/universal-auth", + "documentation/platform/identities/gcp-auth", "documentation/platform/identities/aws-auth", "documentation/platform/mfa", { diff --git a/frontend/src/hooks/api/identities/constants.tsx b/frontend/src/hooks/api/identities/constants.tsx index 64f9469c6..d2669a308 100644 --- a/frontend/src/hooks/api/identities/constants.tsx +++ b/frontend/src/hooks/api/identities/constants.tsx @@ -2,5 +2,6 @@ import { IdentityAuthMethod } from "./enums"; export const identityAuthToNameMap: { [I in IdentityAuthMethod]: string } = { [IdentityAuthMethod.UNIVERSAL_AUTH]: "Universal Auth", + [IdentityAuthMethod.GCP_AUTH]: "GCP Auth", [IdentityAuthMethod.AWS_AUTH]: "AWS Auth" }; diff --git a/frontend/src/hooks/api/identities/enums.tsx b/frontend/src/hooks/api/identities/enums.tsx index dc619f722..a67776c87 100644 --- a/frontend/src/hooks/api/identities/enums.tsx +++ b/frontend/src/hooks/api/identities/enums.tsx @@ -1,4 +1,5 @@ export enum IdentityAuthMethod { UNIVERSAL_AUTH = "universal-auth", + GCP_AUTH = "gcp-auth", AWS_AUTH = "aws-auth" } diff --git a/frontend/src/hooks/api/identities/index.tsx b/frontend/src/hooks/api/identities/index.tsx index cef827d9f..d59e096c0 100644 --- a/frontend/src/hooks/api/identities/index.tsx +++ b/frontend/src/hooks/api/identities/index.tsx @@ -2,6 +2,7 @@ export { identityAuthToNameMap } from "./constants"; export { IdentityAuthMethod } from "./enums"; export { useAddIdentityAwsAuth, + useAddIdentityGcpAuth, useAddIdentityUniversalAuth, useCreateIdentity, useCreateIdentityUniversalAuthClientSecret, @@ -9,10 +10,11 @@ export { useRevokeIdentityUniversalAuthClientSecret, useUpdateIdentity, useUpdateIdentityAwsAuth, - useUpdateIdentityUniversalAuth -} from "./mutations"; + useUpdateIdentityGcpAuth, + useUpdateIdentityUniversalAuth} from "./mutations"; export { useGetIdentityAwsAuth, + useGetIdentityGcpAuth, useGetIdentityUniversalAuth, useGetIdentityUniversalAuthClientSecrets } from "./queries"; diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx index 8780f178d..5c2e20ff0 100644 --- a/frontend/src/hooks/api/identities/mutations.tsx +++ b/frontend/src/hooks/api/identities/mutations.tsx @@ -6,6 +6,7 @@ import { organizationKeys } from "../organization/queries"; import { identitiesKeys } from "./queries"; import { AddIdentityAwsAuthDTO, + AddIdentityGcpAuthDTO, AddIdentityUniversalAuthDTO, ClientSecretData, CreateIdentityDTO, @@ -15,9 +16,11 @@ import { DeleteIdentityUniversalAuthClientSecretDTO, Identity, IdentityAwsAuth, + IdentityGcpAuth, IdentityUniversalAuth, UpdateIdentityAwsAuthDTO, UpdateIdentityDTO, + UpdateIdentityGcpAuthDTO, UpdateIdentityUniversalAuthDTO } from "./types"; @@ -173,6 +176,82 @@ export const useRevokeIdentityUniversalAuthClientSecret = () => { }); }; +export const useAddIdentityGcpAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + type, + allowedServiceAccounts, + allowedProjects, + allowedZones, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }) => { + const { + data: { identityGcpAuth } + } = await apiRequest.post<{ identityGcpAuth: IdentityGcpAuth }>( + `/api/v1/auth/gcp-auth/identities/${identityId}`, + { + type, + allowedServiceAccounts, + allowedProjects, + allowedZones, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + } + ); + + return identityGcpAuth; + }, + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + } + }); +}; + +export const useUpdateIdentityGcpAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + type, + allowedServiceAccounts, + allowedProjects, + allowedZones, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }) => { + const { + data: { identityGcpAuth } + } = await apiRequest.patch<{ identityGcpAuth: IdentityGcpAuth }>( + `/api/v1/auth/gcp-auth/identities/${identityId}`, + { + type, + allowedServiceAccounts, + allowedProjects, + allowedZones, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + } + ); + + return identityGcpAuth; + }, + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + } + }); +}; + export const useAddIdentityAwsAuth = () => { const queryClient = useQueryClient(); return useMutation({ @@ -236,6 +315,7 @@ export const useUpdateIdentityAwsAuth = () => { accessTokenTrustedIps } ); + return identityAwsAuth; }, onSuccess: (_, { organizationId }) => { diff --git a/frontend/src/hooks/api/identities/queries.tsx b/frontend/src/hooks/api/identities/queries.tsx index 26880d5ea..2996f275f 100644 --- a/frontend/src/hooks/api/identities/queries.tsx +++ b/frontend/src/hooks/api/identities/queries.tsx @@ -2,13 +2,14 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { ClientSecretData, IdentityAwsAuth, IdentityUniversalAuth } from "./types"; +import { ClientSecretData, IdentityAwsAuth, IdentityGcpAuth, IdentityUniversalAuth } from "./types"; export const identitiesKeys = { getIdentityUniversalAuth: (identityId: string) => [{ identityId }, "identity-universal-auth"] as const, getIdentityUniversalAuthClientSecrets: (identityId: string) => [{ identityId }, "identity-universal-auth-client-secrets"] as const, + getIdentityGcpAuth: (identityId: string) => [{ identityId }, "identity-gcp-auth"] as const, getIdentityAwsAuth: (identityId: string) => [{ identityId }, "identity-aws-auth"] as const }; @@ -42,6 +43,21 @@ export const useGetIdentityUniversalAuthClientSecrets = (identityId: string) => }); }; +export const useGetIdentityGcpAuth = (identityId: string) => { + return useQuery({ + enabled: Boolean(identityId), + queryKey: identitiesKeys.getIdentityGcpAuth(identityId), + queryFn: async () => { + const { + data: { identityGcpAuth } + } = await apiRequest.get<{ identityGcpAuth: IdentityGcpAuth }>( + `/api/v1/auth/gcp-auth/identities/${identityId}` + ); + return identityGcpAuth; + } + }); +}; + export const useGetIdentityAwsAuth = (identityId: string) => { return useQuery({ enabled: Boolean(identityId), diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index 1a7ba263f..c1d8e09fa 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -113,6 +113,48 @@ export type UpdateIdentityUniversalAuthDTO = { }[]; }; +export type IdentityGcpAuth = { + identityId: string; + type: "iam" | "gce"; + allowedServiceAccounts: string; + allowedProjects: string; + allowedZones: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: IdentityTrustedIp[]; +}; + +export type AddIdentityGcpAuthDTO = { + organizationId: string; + identityId: string; + type: "iam" | "gce"; + allowedServiceAccounts: string; + allowedProjects: string; + allowedZones: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { + ipAddress: string; + }[]; +}; + +export type UpdateIdentityGcpAuthDTO = { + organizationId: string; + identityId: string; + type?: "iam" | "gce"; + allowedServiceAccounts?: string; + allowedProjects?: string; + allowedZones?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { + ipAddress: string; + }[]; +}; + export type IdentityAwsAuth = { identityId: string; type: "iam"; diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModal.tsx index 22fbfee5e..74c4c7f6d 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModal.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModal.tsx @@ -15,6 +15,7 @@ import { IdentityAuthMethod } from "@app/hooks/api/identities"; import { UsePopUpState } from "@app/hooks/usePopUp"; import { IdentityAwsAuthForm } from "./IdentityAwsAuthForm"; +import { IdentityGcpAuthForm } from "./IdentityGcpAuthForm"; import { IdentityUniversalAuthForm } from "./IdentityUniversalAuthForm"; type Props = { @@ -28,6 +29,7 @@ type Props = { const identityAuthMethods = [ { label: "Universal Auth", value: IdentityAuthMethod.UNIVERSAL_AUTH }, + { label: "GCP Auth", value: IdentityAuthMethod.GCP_AUTH }, { label: "AWS Auth", value: IdentityAuthMethod.AWS_AUTH } ]; @@ -75,6 +77,15 @@ export const IdentityAuthMethodModal = ({ popUp, handlePopUpOpen, handlePopUpTog /> ); } + case IdentityAuthMethod.GCP_AUTH: { + return ( + + ); + } case IdentityAuthMethod.UNIVERSAL_AUTH: { return ( ; + +type Props = { + handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["identityAuthMethod"]>, + state?: boolean + ) => void; + identityAuthMethodData: { + identityId: string; + name: string; + authMethod?: IdentityAuthMethod; + }; +}; + +export const IdentityGcpAuthForm = ({ + handlePopUpOpen, + handlePopUpToggle, + identityAuthMethodData +}: Props) => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + const { subscription } = useSubscription(); + + const { mutateAsync: addMutateAsync } = useAddIdentityGcpAuth(); + const { mutateAsync: updateMutateAsync } = useUpdateIdentityGcpAuth(); + + const { data } = useGetIdentityGcpAuth(identityAuthMethodData?.identityId ?? ""); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting }, + watch + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + type: "gce", + allowedServiceAccounts: "", + allowedProjects: "", + allowedZones: "", + accessTokenTTL: "2592000", + accessTokenMaxTTL: "2592000", + accessTokenNumUsesLimit: "0", + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + } + }); + + const watchedType = watch("type"); + + const { + fields: accessTokenTrustedIpsFields, + append: appendAccessTokenTrustedIp, + remove: removeAccessTokenTrustedIp + } = useFieldArray({ control, name: "accessTokenTrustedIps" }); + + useEffect(() => { + if (data) { + reset({ + type: data.type, + allowedServiceAccounts: data.allowedServiceAccounts, + allowedProjects: data.allowedProjects, + allowedZones: data.allowedZones, + accessTokenTTL: String(data.accessTokenTTL), + accessTokenMaxTTL: String(data.accessTokenMaxTTL), + accessTokenNumUsesLimit: String(data.accessTokenNumUsesLimit), + accessTokenTrustedIps: data.accessTokenTrustedIps.map( + ({ ipAddress, prefix }: IdentityTrustedIp) => { + return { + ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}` + }; + } + ) + }); + } else { + reset({ + type: "iam", + allowedServiceAccounts: "", + allowedProjects: "", + allowedZones: "", + accessTokenTTL: "2592000", + accessTokenMaxTTL: "2592000", + accessTokenNumUsesLimit: "0", + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + }); + } + }, [data]); + + const onFormSubmit = async ({ + type, + allowedServiceAccounts, + allowedProjects, + allowedZones, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }: FormData) => { + try { + if (!identityAuthMethodData) return; + + if (data) { + await updateMutateAsync({ + identityId: identityAuthMethodData.identityId, + organizationId: orgId, + type, + allowedServiceAccounts, + allowedProjects, + allowedZones, + accessTokenTTL: Number(accessTokenTTL), + accessTokenMaxTTL: Number(accessTokenMaxTTL), + accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), + accessTokenTrustedIps + }); + } else { + await addMutateAsync({ + identityId: identityAuthMethodData.identityId, + organizationId: orgId, + type, + allowedServiceAccounts: allowedServiceAccounts || "", + allowedProjects: allowedProjects || "", + allowedZones: allowedZones || "", + accessTokenTTL: Number(accessTokenTTL), + accessTokenMaxTTL: Number(accessTokenMaxTTL), + accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), + accessTokenTrustedIps + }); + } + + handlePopUpToggle("identityAuthMethod", false); + + createNotification({ + text: `Successfully ${ + identityAuthMethodData?.authMethod ? "updated" : "configured" + } auth method`, + type: "success" + }); + + reset(); + } catch (err) { + createNotification({ + text: `Failed to ${identityAuthMethodData?.authMethod ? "update" : "configure"} identity`, + type: "error" + }); + } + }; + + return ( +
+ ( + + + + )} + /> + ( + + + + )} + /> + {watchedType === "gce" && ( + ( + + + + )} + /> + )} + {watchedType === "gce" && ( + ( + + + + )} + /> + )} + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + {accessTokenTrustedIpsFields.map(({ id }, index) => ( +
+ { + return ( + + { + if (subscription?.ipAllowlisting) { + field.onChange(e); + return; + } + + handlePopUpOpen("upgradePlan"); + }} + placeholder="123.456.789.0" + /> + + ); + }} + /> + { + if (subscription?.ipAllowlisting) { + removeAccessTokenTrustedIp(index); + return; + } + + handlePopUpOpen("upgradePlan"); + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + className="p-3" + > + + +
+ ))} +
+ +
+
+ + +
+ + ); +};