From c276c44c081d7cd5da49d5fb8f14c9de57342d15 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Sun, 5 May 2024 19:14:49 -0700 Subject: [PATCH 01/16] Finish preliminary backend endpoints / db structure for k8s auth --- backend/src/@types/fastify.d.ts | 2 + backend/src/@types/knex.d.ts | 8 + .../20240505154140_kubernetes-auth.ts | 36 ++ .../db/schemas/identity-kubernetes-auths.ts | 35 ++ backend/src/db/schemas/index.ts | 1 + backend/src/db/schemas/models.ts | 4 +- .../ee/services/audit-log/audit-log-types.ts | 52 ++ backend/src/server/routes/index.ts | 13 + .../v1/identity-kubernetes-auth-router.ts | 283 ++++++++++ backend/src/server/routes/v1/index.ts | 2 + .../identity-kubernetes-auth-dal.ts | 10 + .../identity-kubernetes-auth-fns.ts | 15 + .../identity-kubernetes-auth-service.ts | 517 ++++++++++++++++++ .../identity-kubernetes-auth-types.ts | 61 +++ 14 files changed, 1038 insertions(+), 1 deletion(-) create mode 100644 backend/src/db/migrations/20240505154140_kubernetes-auth.ts create mode 100644 backend/src/db/schemas/identity-kubernetes-auths.ts create mode 100644 backend/src/server/routes/v1/identity-kubernetes-auth-router.ts create mode 100644 backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-dal.ts create mode 100644 backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-fns.ts create mode 100644 backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts create mode 100644 backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index b3e9d99521..c4c8250398 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 { TIdentityKubernetesAuthServiceFactory } from "@app/services/identity-kubernetes-auth/identity-kubernetes-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; + identityKubernetesAuth: TIdentityKubernetesAuthServiceFactory; secretApprovalPolicy: TSecretApprovalPolicyServiceFactory; secretApprovalRequest: TSecretApprovalRequestServiceFactory; secretRotation: TSecretRotationServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index a7d76e9449..0e75c23674 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -47,6 +47,9 @@ import { TIdentityAccessTokens, TIdentityAccessTokensInsert, TIdentityAccessTokensUpdate, + TIdentityKubernetesAuths, + TIdentityKubernetesAuthsInsert, + TIdentityKubernetesAuthsUpdate, TIdentityOrgMemberships, TIdentityOrgMembershipsInsert, TIdentityOrgMembershipsUpdate, @@ -314,6 +317,11 @@ declare module "knex/types/tables" { TIdentityUniversalAuthsInsert, TIdentityUniversalAuthsUpdate >; + [TableName.IdentityKubernetesAuth]: Knex.CompositeTableType< + TIdentityKubernetesAuths, + TIdentityKubernetesAuthsInsert, + TIdentityKubernetesAuthsUpdate + >; [TableName.IdentityUaClientSecret]: Knex.CompositeTableType< TIdentityUaClientSecrets, TIdentityUaClientSecretsInsert, diff --git a/backend/src/db/migrations/20240505154140_kubernetes-auth.ts b/backend/src/db/migrations/20240505154140_kubernetes-auth.ts new file mode 100644 index 0000000000..f5f79c3a35 --- /dev/null +++ b/backend/src/db/migrations/20240505154140_kubernetes-auth.ts @@ -0,0 +1,36 @@ +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.IdentityKubernetesAuth))) { + await knex.schema.createTable(TableName.IdentityKubernetesAuth, (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("kubernetesHost").notNullable(); + t.string("encryptedCaCert").notNullable(); + t.string("caCertIV").notNullable(); + t.string("caCertTag").notNullable(); + t.string("encryptedTokenReviewerJwt").notNullable(); + t.string("tokenReviewerJwtIV").notNullable(); + t.string("tokenReviewerJwtTag").notNullable(); + t.string("allowedNamespaces").notNullable(); + t.string("allowedNames").notNullable(); + t.string("allowedAudience").notNullable(); + }); + } + + await createOnUpdateTrigger(knex, TableName.IdentityKubernetesAuth); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.IdentityKubernetesAuth); + await dropOnUpdateTrigger(knex, TableName.IdentityKubernetesAuth); +} diff --git a/backend/src/db/schemas/identity-kubernetes-auths.ts b/backend/src/db/schemas/identity-kubernetes-auths.ts new file mode 100644 index 0000000000..ed99dec86b --- /dev/null +++ b/backend/src/db/schemas/identity-kubernetes-auths.ts @@ -0,0 +1,35 @@ +// 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 IdentityKubernetesAuthsSchema = 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(), + kubernetesHost: z.string(), + encryptedCaCert: z.string(), + caCertIV: z.string(), + caCertTag: z.string(), + encryptedTokenReviewerJwt: z.string(), + tokenReviewerJwtIV: z.string(), + tokenReviewerJwtTag: z.string(), + allowedNamespaces: z.string(), + allowedNames: z.string(), + allowedAudience: z.string() +}); + +export type TIdentityKubernetesAuths = z.infer; +export type TIdentityKubernetesAuthsInsert = Omit, TImmutableDBKeys>; +export type TIdentityKubernetesAuthsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 0eb9b19868..28a571db7a 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-kubernetes-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 3baa7f40d2..295b49e1dd 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", + IdentityKubernetesAuth = "identity_kubernetes_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", + Kubernetes_Auth = "kubernetes-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 220c250027..95026e8893 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -63,6 +63,10 @@ export enum EventType { ADD_IDENTITY_UNIVERSAL_AUTH = "add-identity-universal-auth", UPDATE_IDENTITY_UNIVERSAL_AUTH = "update-identity-universal-auth", GET_IDENTITY_UNIVERSAL_AUTH = "get-identity-universal-auth", + LOGIN_IDENTITY_KUBERNETES_AUTH = "login-identity-kubernetes-auth", + ADD_IDENTITY_KUBERNETES_AUTH = "add-identity-kubernetes-auth", + UPDATE_IDENTITY_KUBENETES_AUTH = "update-identity-kubernetes-auth", + GET_IDENTITY_KUBERNETES_AUTH = "get-identity-kubernetes-auth", 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", @@ -383,6 +387,50 @@ interface GetIdentityUniversalAuthEvent { }; } +interface LoginIdentityKubernetesAuthEvent { + type: EventType.LOGIN_IDENTITY_KUBERNETES_AUTH; + metadata: { + identityId: string; + identityKubernetesAuthId: string; + identityAccessTokenId: string; + }; +} + +interface AddIdentityKubernetesAuthEvent { + type: EventType.ADD_IDENTITY_KUBERNETES_AUTH; + metadata: { + identityId: string; + kubernetesHost: string; + allowedNamespaces: string; + allowedNames: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: Array; + }; +} + +interface UpdateIdentityKubernetesAuthEvent { + type: EventType.UPDATE_IDENTITY_KUBENETES_AUTH; + metadata: { + identityId: string; + kubernetesHost?: string; + allowedNamespaces?: string; + allowedNames?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: Array; + }; +} + +interface GetIdentityKubernetesAuthEvent { + type: EventType.GET_IDENTITY_KUBERNETES_AUTH; + metadata: { + identityId: string; + }; +} + interface CreateIdentityUniversalAuthClientSecretEvent { type: EventType.CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET; metadata: { @@ -657,6 +705,10 @@ export type Event = | AddIdentityUniversalAuthEvent | UpdateIdentityUniversalAuthEvent | GetIdentityUniversalAuthEvent + | LoginIdentityKubernetesAuthEvent + | AddIdentityKubernetesAuthEvent + | UpdateIdentityKubernetesAuthEvent + | GetIdentityKubernetesAuthEvent | CreateIdentityUniversalAuthClientSecretEvent | GetIdentityUniversalAuthClientSecretsEvent | RevokeIdentityUniversalAuthClientSecretEvent diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 25e807cc27..53015cf09f 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 { identityKubernetesAuthDALFactory } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-dal"; +import { identityKubernetesAuthServiceFactory } from "@app/services/identity-kubernetes-auth/identity-kubernetes-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"; @@ -192,6 +194,7 @@ export const registerRoutes = async ( const identityProjectAdditionalPrivilegeDAL = identityProjectAdditionalPrivilegeDALFactory(db); const identityUaDAL = identityUaDALFactory(db); + const identityKubernetesAuthDAL = identityKubernetesAuthDALFactory(db); const identityUaClientSecretDAL = identityUaClientSecretDALFactory(db); const auditLogDAL = auditLogDALFactory(db); @@ -646,6 +649,15 @@ export const registerRoutes = async ( identityUaDAL, licenseService }); + const identityKubernetesAuthService = identityKubernetesAuthServiceFactory({ + identityKubernetesAuthDAL, + identityOrgMembershipDAL, + identityAccessTokenDAL, + identityDAL, + orgBotDAL, + permissionService, + licenseService + }); const dynamicSecretProviders = buildDynamicSecretProviders(); const dynamicSecretQueueService = dynamicSecretLeaseQueueServiceFactory({ @@ -715,6 +727,7 @@ export const registerRoutes = async ( identityAccessToken: identityAccessTokenService, identityProject: identityProjectService, identityUa: identityUaService, + identityKubernetesAuth: identityKubernetesAuthService, secretApprovalPolicy: sapService, secretApprovalRequest: sarService, secretRotation: secretRotationService, diff --git a/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts new file mode 100644 index 0000000000..32249aac52 --- /dev/null +++ b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts @@ -0,0 +1,283 @@ +import { z } from "zod"; + +import { IdentityKubernetesAuthsSchema } 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"; + +const IdentityKubernetesAuthResponseSchema = IdentityKubernetesAuthsSchema.omit({ + encryptedCaCert: true, + caCertIV: true, + caCertTag: true, + encryptedTokenReviewerJwt: true, + tokenReviewerJwtIV: true, + tokenReviewerJwtTag: true +}).extend({ + caCert: z.string(), + tokenReviewerJwt: z.string() +}); + +export const registerIdentityKubernetesRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/kubernetes/login", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Login with Kubernetes Auth", + body: z.object({ + identityId: z.string().trim(), + jwt: z.string().trim() + }), + response: { + 200: z.object({ + accessToken: z.string(), + expiresIn: z.coerce.number(), + accessTokenMaxTTL: z.coerce.number(), + tokenType: z.literal("Bearer") + }) + } + }, + handler: async (req) => { + const { identityKubernetesAuth, accessToken, identityAccessToken, identityMembershipOrg } = + await server.services.identityKubernetesAuth.login({ + identityId: req.body.identityId, + jwt: req.body.jwt + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityMembershipOrg?.orgId, + event: { + type: EventType.LOGIN_IDENTITY_KUBERNETES_AUTH, + metadata: { + identityId: identityKubernetesAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + identityKubernetesAuthId: identityKubernetesAuth.id + } + } + }); + return { + accessToken, + tokenType: "Bearer" as const, + expiresIn: identityKubernetesAuth.accessTokenTTL, + accessTokenMaxTTL: identityKubernetesAuth.accessTokenMaxTTL + }; + } + }); + + server.route({ + method: "POST", + url: "/kubernetes-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Attach Kubernetes Auth configuration onto identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim() + }), + body: z.object({ + kubernetesHost: z.string().trim().min(1), + caCert: z.string().trim().min(1), + tokenReviewerJwt: z.string().trim().min(1), + allowedNamespaces: z.string(), // TODO: validation + allowedNames: z.string(), + allowedAudience: z.string(), + 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({ + identityKubernetesAuth: IdentityKubernetesAuthResponseSchema + }) + } + }, + handler: async (req) => { + const identityKubernetesAuth = await server.services.identityKubernetesAuth.attachKubernetesAuth({ + 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: identityKubernetesAuth.orgId, + event: { + type: EventType.ADD_IDENTITY_KUBERNETES_AUTH, + metadata: { + identityId: identityKubernetesAuth.identityId, + kubernetesHost: identityKubernetesAuth.kubernetesHost, + allowedNamespaces: identityKubernetesAuth.allowedNamespaces, + allowedNames: identityKubernetesAuth.allowedNames, + accessTokenTTL: identityKubernetesAuth.accessTokenTTL, + accessTokenMaxTTL: identityKubernetesAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityKubernetesAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityKubernetesAuth.accessTokenNumUsesLimit + } + } + }); + + return { identityKubernetesAuth: IdentityKubernetesAuthResponseSchema.parse(identityKubernetesAuth) }; + } + }); + + server.route({ + method: "PATCH", + url: "/kubernetes-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update Kubernetes Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string() + }), + body: z.object({ + kubernetesHost: z.string().trim().min(1).optional(), + kubernetesCaCert: z.string().trim().min(1).optional(), + tokenReviewerJwt: z.string().trim().min(1).optional(), + allowedNamespaces: z.string().optional(), // TODO: validation + allowedNames: z.string().optional(), + allowedAudience: z.string().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({ + identityKubernetesAuth: IdentityKubernetesAuthsSchema + }) + } + }, + handler: async (req) => { + const identityKubernetesAuth = await server.services.identityKubernetesAuth.updateKubernetesAuth({ + 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: identityKubernetesAuth.orgId, + event: { + type: EventType.UPDATE_IDENTITY_KUBENETES_AUTH, + metadata: { + identityId: identityKubernetesAuth.identityId, + kubernetesHost: identityKubernetesAuth.kubernetesHost, + allowedNamespaces: identityKubernetesAuth.allowedNamespaces, + allowedNames: identityKubernetesAuth.allowedNames, + accessTokenTTL: identityKubernetesAuth.accessTokenTTL, + accessTokenMaxTTL: identityKubernetesAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityKubernetesAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityKubernetesAuth.accessTokenNumUsesLimit + } + } + }); + + return { identityKubernetesAuth }; + } + }); + + server.route({ + method: "GET", + url: "/kubernetes-auth/identities/:identityId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Retrieve Kubernetes Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string() + }), + response: { + 200: z.object({ + identityKubernetesAuth: IdentityKubernetesAuthResponseSchema + }) + } + }, + handler: async (req) => { + const identityKubernetesAuth = await server.services.identityKubernetesAuth.getKubernetesAuth({ + 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: identityKubernetesAuth.orgId, + event: { + type: EventType.GET_IDENTITY_KUBERNETES_AUTH, + metadata: { + identityId: identityKubernetesAuth.identityId + } + } + }); + + return { identityKubernetesAuth: IdentityKubernetesAuthResponseSchema.parse(identityKubernetesAuth) }; + } + }); +}; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index fbc68d974a..c00e2fbb67 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 { registerIdentityKubernetesRouter } from "./identity-kubernetes-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(registerIdentityKubernetesRouter); await authRouter.register(registerIdentityAccessTokenRouter); }, { prefix: "/auth" } diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-dal.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-dal.ts new file mode 100644 index 0000000000..df59191010 --- /dev/null +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-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 TIdentityKubernetesAuthDALFactory = ReturnType; + +export const identityKubernetesAuthDALFactory = (db: TDbClient) => { + const kubernetesAuthOrm = ormify(db, TableName.IdentityKubernetesAuth); + return kubernetesAuthOrm; +}; diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-fns.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-fns.ts new file mode 100644 index 0000000000..194e69b3c3 --- /dev/null +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-fns.ts @@ -0,0 +1,15 @@ +/** + * Extracts the K8s service account name and namespace + * from the username in this format: system:serviceaccount:default:infisical-auth + */ +export const extractK8sUsername = (username: string) => { + const parts = username.split(":"); + // Ensure that the username format is correct + if (parts.length === 4 && parts[0] === "system" && parts[1] === "serviceaccount") { + return { + namespace: parts[2], + name: parts[3] + }; + } + throw new Error("Invalid username format"); +}; diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts new file mode 100644 index 0000000000..e6056ad72f --- /dev/null +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts @@ -0,0 +1,517 @@ +import { ForbiddenError } from "@casl/ability"; +import axios from "axios"; +import https from "https"; +import jwt from "jsonwebtoken"; + +import { IdentityAuthMethod, SecretKeyEncoding, TIdentityKubernetesAuthsUpdate } 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 { + decryptSymmetric, + encryptSymmetric, + generateAsymmetricKeyPair, + generateSymmetricKey, + infisicalSymmetricDecrypt, + infisicalSymmetricEncypt +} from "@app/lib/crypto/encryption"; +import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; +import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; +import { TOrgBotDALFactory } from "@app/services/org/org-bot-dal"; + +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 { TIdentityKubernetesAuthDALFactory } from "./identity-kubernetes-auth-dal"; +import { extractK8sUsername } from "./identity-kubernetes-auth-fns"; +import { + TAttachKubernetesAuthDTO, + TCreateTokenReviewResponse, + TGetKubernetesAuthDTO, + TLoginKubernetesAuthDTO, + TUpdateKubernetesAuthDTO +} from "./identity-kubernetes-auth-types"; + +type TIdentityKubernetesAuthServiceFactoryDep = { + identityKubernetesAuthDAL: Pick< + TIdentityKubernetesAuthDALFactory, + "create" | "findOne" | "transaction" | "updateById" + >; + identityAccessTokenDAL: Pick; + identityOrgMembershipDAL: Pick; + identityDAL: Pick; + orgBotDAL: Pick; + permissionService: Pick; + licenseService: Pick; +}; + +export type TIdentityKubernetesAuthServiceFactory = ReturnType; + +export const identityKubernetesAuthServiceFactory = ({ + identityKubernetesAuthDAL, + identityOrgMembershipDAL, + identityAccessTokenDAL, + identityDAL, + orgBotDAL, + permissionService, + licenseService +}: TIdentityKubernetesAuthServiceFactoryDep) => { + const login = async ({ identityId, jwt: serviceAccountJwt }: TLoginKubernetesAuthDTO) => { + const identityKubernetesAuth = await identityKubernetesAuthDAL.findOne({ identityId }); + if (!identityKubernetesAuth) throw new UnauthorizedError(); + + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ + identityId: identityKubernetesAuth.identityId + }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + + const orgBot = await orgBotDAL.findOne({ orgId: identityMembershipOrg.orgId }); + if (!orgBot) throw new BadRequestError({ message: "Org bot not found", name: "OrgBotNotFound" }); + + const key = infisicalSymmetricDecrypt({ + ciphertext: orgBot.encryptedSymmetricKey, + iv: orgBot.symmetricKeyIV, + tag: orgBot.symmetricKeyTag, + keyEncoding: orgBot.symmetricKeyKeyEncoding as SecretKeyEncoding + }); + + const { encryptedCaCert, caCertIV, caCertTag, encryptedTokenReviewerJwt, tokenReviewerJwtIV, tokenReviewerJwtTag } = + identityKubernetesAuth; + + let caCert = ""; + if (encryptedCaCert && caCertIV && caCertTag) { + caCert = decryptSymmetric({ + ciphertext: encryptedCaCert, + iv: caCertIV, + tag: caCertTag, + key + }); + } + + let tokenReviewerJwt = ""; + if (encryptedTokenReviewerJwt && tokenReviewerJwtIV && tokenReviewerJwtTag) { + tokenReviewerJwt = decryptSymmetric({ + ciphertext: encryptedTokenReviewerJwt, + iv: tokenReviewerJwtIV, + tag: tokenReviewerJwtTag, + key + }); + } + + const { data }: { data: TCreateTokenReviewResponse } = await axios.post( + `${identityKubernetesAuth.kubernetesHost}/apis/authentication.k8s.io/v1/tokenreviews`, + { + apiVersion: "authentication.k8s.io/v1", + kind: "TokenReview", + spec: { + token: serviceAccountJwt + } + }, + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${tokenReviewerJwt}` + }, + ...(caCert && { + httpsAgent: new https.Agent({ + ca: caCert, + rejectUnauthorized: true + }) + }) + } + ); + + if ("error" in data.status) throw new UnauthorizedError({ message: data.status.error }); + + // check the response to determine if the token is valid + if (!(data.status && data.status.authenticated)) throw new UnauthorizedError(); + + const { namespace: targetNamespace, name: targetName } = extractK8sUsername(data.status.user.username); + + if (identityKubernetesAuth.allowedNamespaces) { + // validate if [targetNamespace] is in the list of allowed namespaces + + const isNamespaceAllowed = identityKubernetesAuth.allowedNamespaces + .split(",") + .map((namespace) => namespace.trim()) + .some((namespace) => namespace === targetNamespace); + + if (!isNamespaceAllowed) throw new UnauthorizedError(); + } + + if (identityKubernetesAuth.allowedNames) { + // validate if [targetName] is in the list of allowed names + + const isNameAllowed = identityKubernetesAuth.allowedNames + .split(",") + .map((name) => name.trim()) + .some((name) => name === targetName); + + if (!isNameAllowed) throw new UnauthorizedError(); + } + + if (identityKubernetesAuth.allowedAudience) { + // validate if [audience] is in the list of allowed audiences + const isAudienceAllowed = data.status.audiences.some( + (audience) => audience === identityKubernetesAuth.allowedAudience + ); + + if (!isAudienceAllowed) throw new UnauthorizedError(); + } + + const identityAccessToken = await identityKubernetesAuthDAL.transaction(async (tx) => { + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityKubernetesAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityKubernetesAuth.accessTokenTTL, + accessTokenMaxTTL: identityKubernetesAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityKubernetesAuth.accessTokenNumUsesLimit + }, + tx + ); + return newToken; + }); + + const appCfg = getConfig(); + const accessToken = jwt.sign( + { + identityId: identityKubernetesAuth.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, identityKubernetesAuth, identityAccessToken, identityMembershipOrg }; + }; + + const attachKubernetesAuth = async ({ + identityId, + kubernetesHost, + caCert, + tokenReviewerJwt, + allowedNamespaces, + allowedNames, + allowedAudience, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TAttachKubernetesAuthDTO) => { + 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 Kubernetes 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 orgBot = await orgBotDAL.transaction(async (tx) => { + const doc = await orgBotDAL.findOne({ orgId: identityMembershipOrg.orgId }, tx); + if (doc) return doc; + + const { privateKey, publicKey } = generateAsymmetricKeyPair(); + const key = generateSymmetricKey(); + const { + ciphertext: encryptedPrivateKey, + iv: privateKeyIV, + tag: privateKeyTag, + encoding: privateKeyKeyEncoding, + algorithm: privateKeyAlgorithm + } = infisicalSymmetricEncypt(privateKey); + const { + ciphertext: encryptedSymmetricKey, + iv: symmetricKeyIV, + tag: symmetricKeyTag, + encoding: symmetricKeyKeyEncoding, + algorithm: symmetricKeyAlgorithm + } = infisicalSymmetricEncypt(key); + + return orgBotDAL.create( + { + name: "Infisical org bot", + publicKey, + privateKeyIV, + encryptedPrivateKey, + symmetricKeyIV, + symmetricKeyTag, + encryptedSymmetricKey, + symmetricKeyAlgorithm, + orgId: identityMembershipOrg.orgId, + privateKeyTag, + privateKeyAlgorithm, + privateKeyKeyEncoding, + symmetricKeyKeyEncoding + }, + tx + ); + }); + + const key = infisicalSymmetricDecrypt({ + ciphertext: orgBot.encryptedSymmetricKey, + iv: orgBot.symmetricKeyIV, + tag: orgBot.symmetricKeyTag, + keyEncoding: orgBot.symmetricKeyKeyEncoding as SecretKeyEncoding + }); + + const { ciphertext: encryptedCaCert, iv: caCertIV, tag: caCertTag } = encryptSymmetric(caCert, key); + const { + ciphertext: encryptedTokenReviewerJwt, + iv: tokenReviewerJwtIV, + tag: tokenReviewerJwtTag + } = encryptSymmetric(tokenReviewerJwt, key); + + const identityKubernetesAuth = await identityKubernetesAuthDAL.transaction(async (tx) => { + const doc = await identityKubernetesAuthDAL.create( + { + identityId: identityMembershipOrg.identityId, + kubernetesHost, + encryptedCaCert, + caCertIV, + caCertTag, + encryptedTokenReviewerJwt, + tokenReviewerJwtIV, + tokenReviewerJwtTag, + allowedNamespaces, + allowedNames, + allowedAudience, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps) + }, + tx + ); + await identityDAL.updateById( + identityMembershipOrg.identityId, + { + authMethod: IdentityAuthMethod.Kubernetes_Auth + }, + tx + ); + return doc; + }); + + return { ...identityKubernetesAuth, caCert, tokenReviewerJwt, orgId: identityMembershipOrg.orgId }; + }; + + const updateKubernetesAuth = async ({ + identityId, + kubernetesHost, + caCert, + tokenReviewerJwt, + allowedNamespaces, + allowedNames, + allowedAudience, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdateKubernetesAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.Kubernetes_Auth) + throw new BadRequestError({ + message: "Failed to update Kubernetes Auth" + }); + + const identityKubernetesAuth = await identityKubernetesAuthDAL.findOne({ identityId }); + + if ( + (accessTokenMaxTTL || identityKubernetesAuth.accessTokenMaxTTL) > 0 && + (accessTokenTTL || identityKubernetesAuth.accessTokenMaxTTL) > + (accessTokenMaxTTL || identityKubernetesAuth.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 updateQuery: TIdentityKubernetesAuthsUpdate = { + kubernetesHost, + allowedNamespaces, + allowedNames, + allowedAudience, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: reformattedAccessTokenTrustedIps + ? JSON.stringify(reformattedAccessTokenTrustedIps) + : undefined + }; + + const orgBot = await orgBotDAL.findOne({ orgId: identityMembershipOrg.orgId }); + if (!orgBot) throw new BadRequestError({ message: "Org bot not found", name: "OrgBotNotFound" }); + + const key = infisicalSymmetricDecrypt({ + ciphertext: orgBot.encryptedSymmetricKey, + iv: orgBot.symmetricKeyIV, + tag: orgBot.symmetricKeyTag, + keyEncoding: orgBot.symmetricKeyKeyEncoding as SecretKeyEncoding + }); + + if (caCert !== undefined) { + const { ciphertext: encryptedCACert, iv: caCertIV, tag: caCertTag } = encryptSymmetric(caCert, key); + updateQuery.encryptedCaCert = encryptedCACert; + updateQuery.caCertIV = caCertIV; + updateQuery.caCertTag = caCertTag; + } + + if (tokenReviewerJwt !== undefined) { + const { + ciphertext: encryptedTokenReviewerJwt, + iv: tokenReviewerJwtIV, + tag: tokenReviewerJwtTag + } = encryptSymmetric(tokenReviewerJwt, key); + updateQuery.encryptedTokenReviewerJwt = encryptedTokenReviewerJwt; + updateQuery.tokenReviewerJwtIV = tokenReviewerJwtIV; + updateQuery.tokenReviewerJwtTag = tokenReviewerJwtTag; + } + + const updatedKubernetesAuth = await identityKubernetesAuthDAL.updateById(identityKubernetesAuth.id, updateQuery); + + return { ...updatedKubernetesAuth, orgId: identityMembershipOrg.orgId }; + }; + + const getKubernetesAuth = async ({ + identityId, + actorId, + actor, + actorAuthMethod, + actorOrgId + }: TGetKubernetesAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.Kubernetes_Auth) + throw new BadRequestError({ + message: "The identity does not have Kubernetes Auth attached" + }); + + const identityKubernetesAuth = await identityKubernetesAuthDAL.findOne({ identityId }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); + + const orgBot = await orgBotDAL.findOne({ orgId: identityMembershipOrg.orgId }); + if (!orgBot) throw new BadRequestError({ message: "Org bot not found", name: "OrgBotNotFound" }); + + const key = infisicalSymmetricDecrypt({ + ciphertext: orgBot.encryptedSymmetricKey, + iv: orgBot.symmetricKeyIV, + tag: orgBot.symmetricKeyTag, + keyEncoding: orgBot.symmetricKeyKeyEncoding as SecretKeyEncoding + }); + + const { encryptedCaCert, caCertIV, caCertTag, encryptedTokenReviewerJwt, tokenReviewerJwtIV, tokenReviewerJwtTag } = + identityKubernetesAuth; + + let caCert = ""; + if (encryptedCaCert && caCertIV && caCertTag) { + caCert = decryptSymmetric({ + ciphertext: encryptedCaCert, + iv: caCertIV, + tag: caCertTag, + key + }); + } + + let tokenReviewerJwt = ""; + if (encryptedTokenReviewerJwt && tokenReviewerJwtIV && tokenReviewerJwtTag) { + tokenReviewerJwt = decryptSymmetric({ + ciphertext: encryptedTokenReviewerJwt, + iv: tokenReviewerJwtIV, + tag: tokenReviewerJwtTag, + key + }); + } + + return { ...identityKubernetesAuth, caCert, tokenReviewerJwt, orgId: identityMembershipOrg.orgId }; + }; + + return { + login, + attachKubernetesAuth, + updateKubernetesAuth, + getKubernetesAuth + }; +}; diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts new file mode 100644 index 0000000000..dbb42dce89 --- /dev/null +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts @@ -0,0 +1,61 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TLoginKubernetesAuthDTO = { + identityId: string; + jwt: string; +}; + +export type TAttachKubernetesAuthDTO = { + identityId: string; + kubernetesHost: string; + caCert: string; + tokenReviewerJwt: string; + allowedNamespaces: string; + allowedNames: string; + allowedAudience: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { ipAddress: string }[]; +} & Omit; + +export type TUpdateKubernetesAuthDTO = { + identityId: string; + kubernetesHost?: string; + caCert?: string; + tokenReviewerJwt?: string; + allowedNamespaces?: string; + allowedNames?: string; + allowedAudience?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { ipAddress: string }[]; +} & Omit; + +export type TGetKubernetesAuthDTO = { + identityId: string; +} & Omit; + +type TCreateTokenReviewSuccessResponse = { + authenticated: true; + user: { + username: string; + uid: string; + groups: string[]; + }; + audiences: string[]; +}; + +type TCreateTokenReviewErrorResponse = { + error: string; +}; + +export type TCreateTokenReviewResponse = { + apiVersion: "authentication.k8s.io/v1"; + kind: "TokenReview"; + spec: { + token: string; + }; + status: TCreateTokenReviewSuccessResponse | TCreateTokenReviewErrorResponse; +}; From cd910a2fac80fa4c6a61e25db24a433b4fabe071 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Tue, 14 May 2024 11:42:26 -0700 Subject: [PATCH 02/16] Update k8s auth impl to be able to test ca, tokenReviewerjwt locally --- ...h.ts => 20240512231707_kubernetes-auth.ts} | 4 +- .../v1/identity-kubernetes-auth-router.ts | 6 +- .../identity-kubernetes-auth-service.ts | 8 +- docker-compose.dev.yml | 4 +- .../src/hooks/api/identities/constants.tsx | 3 +- frontend/src/hooks/api/identities/enums.tsx | 3 +- frontend/src/hooks/api/identities/index.tsx | 6 +- .../src/hooks/api/identities/mutations.tsx | 91 +++- frontend/src/hooks/api/identities/queries.tsx | 25 +- frontend/src/hooks/api/identities/types.ts | 48 +++ .../IdentityAuthMethodModal.tsx | 13 +- .../IdentityKubernetesAuthForm.tsx | 407 ++++++++++++++++++ 12 files changed, 598 insertions(+), 20 deletions(-) rename backend/src/db/migrations/{20240505154140_kubernetes-auth.ts => 20240512231707_kubernetes-auth.ts} (93%) create mode 100644 frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityKubernetesAuthForm.tsx diff --git a/backend/src/db/migrations/20240505154140_kubernetes-auth.ts b/backend/src/db/migrations/20240512231707_kubernetes-auth.ts similarity index 93% rename from backend/src/db/migrations/20240505154140_kubernetes-auth.ts rename to backend/src/db/migrations/20240512231707_kubernetes-auth.ts index f5f79c3a35..dd281a3ada 100644 --- a/backend/src/db/migrations/20240505154140_kubernetes-auth.ts +++ b/backend/src/db/migrations/20240512231707_kubernetes-auth.ts @@ -15,10 +15,10 @@ export async function up(knex: Knex): Promise { t.uuid("identityId").notNullable().unique(); t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); t.string("kubernetesHost").notNullable(); - t.string("encryptedCaCert").notNullable(); + t.text("encryptedCaCert").notNullable(); t.string("caCertIV").notNullable(); t.string("caCertTag").notNullable(); - t.string("encryptedTokenReviewerJwt").notNullable(); + t.text("encryptedTokenReviewerJwt").notNullable(); t.string("tokenReviewerJwtIV").notNullable(); t.string("tokenReviewerJwtTag").notNullable(); t.string("allowedNamespaces").notNullable(); diff --git a/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts index 32249aac52..d20ea0edcb 100644 --- a/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts +++ b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts @@ -22,7 +22,7 @@ const IdentityKubernetesAuthResponseSchema = IdentityKubernetesAuthsSchema.omit( export const registerIdentityKubernetesRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", - url: "/kubernetes/login", + url: "/kubernetes-auth/login", config: { rateLimit: writeLimit }, @@ -88,7 +88,7 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide }), body: z.object({ kubernetesHost: z.string().trim().min(1), - caCert: z.string().trim().min(1), + caCert: z.string().trim().default(""), tokenReviewerJwt: z.string().trim().min(1), allowedNamespaces: z.string(), // TODO: validation allowedNames: z.string(), @@ -174,7 +174,7 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide }), body: z.object({ kubernetesHost: z.string().trim().min(1).optional(), - kubernetesCaCert: z.string().trim().min(1).optional(), + caCert: z.string().trim().optional(), tokenReviewerJwt: z.string().trim().min(1).optional(), allowedNamespaces: z.string().optional(), // TODO: validation allowedNames: z.string().optional(), diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts index e6056ad72f..cba7e87ae0 100644 --- a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts @@ -115,11 +115,9 @@ export const identityKubernetesAuthServiceFactory = ({ "Content-Type": "application/json", Authorization: `Bearer ${tokenReviewerJwt}` }, - ...(caCert && { - httpsAgent: new https.Agent({ - ca: caCert, - rejectUnauthorized: true - }) + httpsAgent: new https.Agent({ + ca: caCert, + rejectUnauthorized: false // TODO: change to [true] }) } ); diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 7647610980..422fe43f3c 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -91,6 +91,8 @@ services: - TELEMETRY_ENABLED=false volumes: - ./backend/src:/app/src + extra_hosts: + - "host.docker.internal:host-gateway" frontend: container_name: infisical-dev-frontend @@ -128,7 +130,7 @@ services: ports: - 1025:1025 # SMTP server - 8025:8025 # Web UI - + openldap: # note: more advanced configuration is available image: osixia/openldap:1.5.0 restart: always diff --git a/frontend/src/hooks/api/identities/constants.tsx b/frontend/src/hooks/api/identities/constants.tsx index 64f9469c6d..d06fbc776c 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.AWS_AUTH]: "AWS Auth" + [IdentityAuthMethod.AWS_AUTH]: "AWS Auth", + [IdentityAuthMethod.KUBERNETES_AUTH]: "Kubernetes Auth" }; diff --git a/frontend/src/hooks/api/identities/enums.tsx b/frontend/src/hooks/api/identities/enums.tsx index dc619f722e..716504ae87 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", - AWS_AUTH = "aws-auth" + AWS_AUTH = "aws-auth", + KUBERNETES_AUTH = "kubernetes-auth" } diff --git a/frontend/src/hooks/api/identities/index.tsx b/frontend/src/hooks/api/identities/index.tsx index cef827d9f9..6b2175543f 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, + useAddIdentityKubernetesAuth, useAddIdentityUniversalAuth, useCreateIdentity, useCreateIdentityUniversalAuthClientSecret, @@ -9,10 +10,11 @@ export { useRevokeIdentityUniversalAuthClientSecret, useUpdateIdentity, useUpdateIdentityAwsAuth, - useUpdateIdentityUniversalAuth -} from "./mutations"; + useUpdateIdentityKubernetesAuth, + useUpdateIdentityUniversalAuth} from "./mutations"; export { useGetIdentityAwsAuth, + useGetIdentityKubernetesAuth, useGetIdentityUniversalAuth, useGetIdentityUniversalAuthClientSecrets } from "./queries"; diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx index 8780f178db..6abf035898 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, + AddIdentityKubernetesAuthDTO, AddIdentityUniversalAuthDTO, ClientSecretData, CreateIdentityDTO, @@ -15,11 +16,12 @@ import { DeleteIdentityUniversalAuthClientSecretDTO, Identity, IdentityAwsAuth, + IdentityKubernetesAuth, IdentityUniversalAuth, UpdateIdentityAwsAuthDTO, UpdateIdentityDTO, - UpdateIdentityUniversalAuthDTO -} from "./types"; + UpdateIdentityKubernetesAuthDTO, + UpdateIdentityUniversalAuthDTO} from "./types"; export const useCreateIdentity = () => { const queryClient = useQueryClient(); @@ -243,3 +245,88 @@ export const useUpdateIdentityAwsAuth = () => { } }); }; + +// --- K8s auth (TODO: add cert and token reviewer JWT fields) + +export const useAddIdentityKubernetesAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + kubernetesHost, + tokenReviewerJwt, + allowedNames, + allowedNamespaces, + allowedAudience, + caCert, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }) => { + const { + data: { identityKubernetesAuth } + } = await apiRequest.post<{ identityKubernetesAuth: IdentityKubernetesAuth }>( + `/api/v1/auth/kubernetes-auth/identities/${identityId}`, + { + kubernetesHost, + tokenReviewerJwt, + allowedNames, + allowedNamespaces, + allowedAudience, + caCert, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + } + ); + + return identityKubernetesAuth; + }, + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + } + }); +}; + +export const useUpdateIdentityKubernetesAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + kubernetesHost, + tokenReviewerJwt, + allowedNamespaces, + allowedNames, + allowedAudience, + caCert, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }) => { + const { + data: { identityKubernetesAuth } + } = await apiRequest.patch<{ identityKubernetesAuth: IdentityKubernetesAuth }>( + `/api/v1/auth/kubernetes-auth/identities/${identityId}`, + { + kubernetesHost, + tokenReviewerJwt, + allowedNames, + allowedNamespaces, + allowedAudience, + caCert, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + } + ); + return identityKubernetesAuth; + }, + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + } + }); +}; diff --git a/frontend/src/hooks/api/identities/queries.tsx b/frontend/src/hooks/api/identities/queries.tsx index 26880d5ea7..e7497b4de6 100644 --- a/frontend/src/hooks/api/identities/queries.tsx +++ b/frontend/src/hooks/api/identities/queries.tsx @@ -2,14 +2,20 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { ClientSecretData, IdentityAwsAuth, IdentityUniversalAuth } from "./types"; +import { + ClientSecretData, + IdentityAwsAuth, + IdentityKubernetesAuth, + 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, - getIdentityAwsAuth: (identityId: string) => [{ identityId }, "identity-aws-auth"] as const + getIdentityAwsAuth: (identityId: string) => [{ identityId }, "identity-aws-auth"] as const, + getIdentityKubernetesAuth: (identityId: string) => + [{ identityId }, "identity-kubernetes-auth"] as const }; export const useGetIdentityUniversalAuth = (identityId: string) => { @@ -56,3 +62,18 @@ export const useGetIdentityAwsAuth = (identityId: string) => { } }); }; + +export const useGetIdentityKubernetesAuth = (identityId: string) => { + return useQuery({ + enabled: Boolean(identityId), + queryKey: identitiesKeys.getIdentityKubernetesAuth(identityId), + queryFn: async () => { + const { + data: { identityKubernetesAuth } + } = await apiRequest.get<{ identityKubernetesAuth: IdentityKubernetesAuth }>( + `/api/v1/auth/kubernetes-auth/identities/${identityId}` + ); + return identityKubernetesAuth; + } + }); +}; diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index 1a7ba263ff..583e6add15 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -153,6 +153,54 @@ export type UpdateIdentityAwsAuthDTO = { }[]; }; +export type IdentityKubernetesAuth = { + identityId: string; + kubernetesHost: string; + tokenReviewerJwt: string; + allowedNamespaces: string; + allowedNames: string; + allowedAudience: string; + caCert: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: IdentityTrustedIp[]; +}; + +export type AddIdentityKubernetesAuthDTO = { + organizationId: string; + identityId: string; + kubernetesHost: string; + tokenReviewerJwt: string; + allowedNamespaces: string; + allowedNames: string; + allowedAudience: string; + caCert: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { + ipAddress: string; + }[]; +}; + +export type UpdateIdentityKubernetesAuthDTO = { + organizationId: string; + identityId: string; + kubernetesHost?: string; + tokenReviewerJwt?: string; + allowedNamespaces?: string; + allowedNames?: string; + allowedAudience?: string; + caCert?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { + ipAddress: string; + }[]; +}; + export type CreateIdentityUniversalAuthClientSecretDTO = { identityId: string; description?: string; 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 22fbfee5e2..e3ecd9414c 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 { IdentityKubernetesAuthForm } from "./IdentityKubernetesAuthForm"; import { IdentityUniversalAuthForm } from "./IdentityUniversalAuthForm"; type Props = { @@ -28,7 +29,8 @@ type Props = { const identityAuthMethods = [ { label: "Universal Auth", value: IdentityAuthMethod.UNIVERSAL_AUTH }, - { label: "AWS Auth", value: IdentityAuthMethod.AWS_AUTH } + { label: "AWS Auth", value: IdentityAuthMethod.AWS_AUTH }, + { label: "Kubernetes Auth", value: IdentityAuthMethod.KUBERNETES_AUTH } ]; const schema = yup @@ -75,6 +77,15 @@ export const IdentityAuthMethodModal = ({ popUp, handlePopUpOpen, handlePopUpTog /> ); } + case IdentityAuthMethod.KUBERNETES_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 IdentityKubernetesAuthForm = ({ + handlePopUpOpen, + handlePopUpToggle, + identityAuthMethodData +}: Props) => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + const { subscription } = useSubscription(); + + const { mutateAsync: addMutateAsync } = useAddIdentityKubernetesAuth(); + const { mutateAsync: updateMutateAsync } = useUpdateIdentityKubernetesAuth(); + + const { data } = useGetIdentityKubernetesAuth(identityAuthMethodData?.identityId ?? ""); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + kubernetesHost: "", // TODO + tokenReviewerJwt: "", + allowedNames: "", // TODO + allowedNamespaces: "", // TODO + allowedAudience: "", // TODO + caCert: "", + accessTokenTTL: "2592000", + accessTokenMaxTTL: "2592000", + accessTokenNumUsesLimit: "0", + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + } + }); + + const { + fields: accessTokenTrustedIpsFields, + append: appendAccessTokenTrustedIp, + remove: removeAccessTokenTrustedIp + } = useFieldArray({ control, name: "accessTokenTrustedIps" }); + + useEffect(() => { + if (data) { + reset({ + kubernetesHost: data.kubernetesHost, + tokenReviewerJwt: data.tokenReviewerJwt, + allowedNames: data.allowedNames, + allowedNamespaces: data.allowedNamespaces, + allowedAudience: data.allowedAudience, + caCert: data.caCert, + 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({ + kubernetesHost: "", // TODO + tokenReviewerJwt: "", + allowedNames: "", + allowedNamespaces: "", + allowedAudience: "", + caCert: "", + accessTokenTTL: "2592000", + accessTokenMaxTTL: "2592000", + accessTokenNumUsesLimit: "0", + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + }); + } + }, [data]); + + const onFormSubmit = async ({ + kubernetesHost, + tokenReviewerJwt, + allowedNames, + allowedNamespaces, + allowedAudience, + caCert, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }: FormData) => { + try { + if (!identityAuthMethodData) return; + + if (data) { + await updateMutateAsync({ + organizationId: orgId, + kubernetesHost, + tokenReviewerJwt, + allowedNames, + allowedNamespaces, + allowedAudience, + caCert, + identityId: identityAuthMethodData.identityId, + accessTokenTTL: Number(accessTokenTTL), + accessTokenMaxTTL: Number(accessTokenMaxTTL), + accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), + accessTokenTrustedIps + }); + } else { + await addMutateAsync({ + organizationId: orgId, + identityId: identityAuthMethodData.identityId, + kubernetesHost: kubernetesHost || "", + tokenReviewerJwt, + allowedNames: allowedNames || "", + allowedNamespaces: allowedNamespaces || "", + allowedAudience: allowedAudience || "", + caCert: caCert || "", + 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 ( +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + +