diff --git a/backend/scripts/generate-schema-types.ts b/backend/scripts/generate-schema-types.ts index c0e18a763..111f42520 100644 --- a/backend/scripts/generate-schema-types.ts +++ b/backend/scripts/generate-schema-types.ts @@ -99,6 +99,7 @@ const main = async () => { (el) => !el.tableName.includes("_migrations") && !el.tableName.includes("audit_logs_") && + !el.tableName.includes("active_locks") && el.tableName !== "intermediate_audit_logs" ); diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 15e967948..c53e362fe 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -66,6 +66,7 @@ import { THsmServiceFactory } from "@app/services/hsm/hsm-service"; import { TIdentityServiceFactory } from "@app/services/identity/identity-service"; import { TIdentityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service"; import { TIdentityAliCloudAuthServiceFactory } from "@app/services/identity-alicloud-auth/identity-alicloud-auth-service"; +import { TIdentityAuthTemplateServiceFactory } from "@app/services/identity-auth-template"; import { TIdentityAwsAuthServiceFactory } from "@app/services/identity-aws-auth/identity-aws-auth-service"; import { TIdentityAzureAuthServiceFactory } from "@app/services/identity-azure-auth/identity-azure-auth-service"; import { TIdentityGcpAuthServiceFactory } from "@app/services/identity-gcp-auth/identity-gcp-auth-service"; @@ -300,6 +301,7 @@ declare module "fastify" { reminder: TReminderServiceFactory; bus: TEventBusService; sse: TServerSentEventsService; + identityAuthTemplate: TIdentityAuthTemplateServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 185a32356..f645cb8f2 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -494,6 +494,11 @@ import { TAccessApprovalPoliciesEnvironmentsInsert, TAccessApprovalPoliciesEnvironmentsUpdate } from "@app/db/schemas/access-approval-policies-environments"; +import { + TIdentityAuthTemplates, + TIdentityAuthTemplatesInsert, + TIdentityAuthTemplatesUpdate +} from "@app/db/schemas/identity-auth-templates"; import { TIdentityLdapAuths, TIdentityLdapAuthsInsert, @@ -878,6 +883,11 @@ declare module "knex/types/tables" { TIdentityProjectAdditionalPrivilegeInsert, TIdentityProjectAdditionalPrivilegeUpdate >; + [TableName.IdentityAuthTemplate]: KnexOriginal.CompositeTableType< + TIdentityAuthTemplates, + TIdentityAuthTemplatesInsert, + TIdentityAuthTemplatesUpdate + >; [TableName.AccessApprovalPolicy]: KnexOriginal.CompositeTableType< TAccessApprovalPolicies, diff --git a/backend/src/db/migrations/20250801170240_add-identity-auth-template.ts b/backend/src/db/migrations/20250801170240_add-identity-auth-template.ts new file mode 100644 index 000000000..fff5bc40b --- /dev/null +++ b/backend/src/db/migrations/20250801170240_add-identity-auth-template.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.IdentityAuthTemplate))) { + await knex.schema.createTable(TableName.IdentityAuthTemplate, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.binary("templateFields").notNullable(); + t.uuid("orgId").notNullable(); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + t.string("name", 64).notNullable(); + t.string("authMethod").notNullable(); + t.timestamps(true, true, true); + }); + await createOnUpdateTrigger(knex, TableName.IdentityAuthTemplate); + } + if (!(await knex.schema.hasColumn(TableName.IdentityLdapAuth, "templateId"))) { + await knex.schema.alterTable(TableName.IdentityLdapAuth, (t) => { + t.uuid("templateId").nullable(); + t.foreign("templateId").references("id").inTable(TableName.IdentityAuthTemplate).onDelete("SET NULL"); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.IdentityLdapAuth, "template")) { + await knex.schema.alterTable(TableName.IdentityLdapAuth, (t) => { + t.dropForeign("templateId"); + t.dropColumn("templateId"); + }); + } + await knex.schema.dropTableIfExists(TableName.IdentityAuthTemplate); + await dropOnUpdateTrigger(knex, TableName.IdentityAuthTemplate); +} diff --git a/backend/src/db/schemas/identity-auth-templates.ts b/backend/src/db/schemas/identity-auth-templates.ts new file mode 100644 index 000000000..efe8ccb8c --- /dev/null +++ b/backend/src/db/schemas/identity-auth-templates.ts @@ -0,0 +1,24 @@ +// 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 { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const IdentityAuthTemplatesSchema = z.object({ + id: z.string().uuid(), + templateFields: zodBuffer, + orgId: z.string().uuid(), + name: z.string(), + authMethod: z.string(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TIdentityAuthTemplates = z.infer; +export type TIdentityAuthTemplatesInsert = Omit, TImmutableDBKeys>; +export type TIdentityAuthTemplatesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/identity-ldap-auths.ts b/backend/src/db/schemas/identity-ldap-auths.ts index e8d0658d5..3e4d88649 100644 --- a/backend/src/db/schemas/identity-ldap-auths.ts +++ b/backend/src/db/schemas/identity-ldap-auths.ts @@ -25,7 +25,8 @@ export const IdentityLdapAuthsSchema = z.object({ allowedFields: z.unknown().nullable().optional(), createdAt: z.date(), updatedAt: z.date(), - accessTokenPeriod: z.coerce.number().default(0) + accessTokenPeriod: z.coerce.number().default(0), + templateId: z.string().uuid().nullable().optional() }); export type TIdentityLdapAuths = z.infer; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 55ec12faa..855934b28 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -91,6 +91,7 @@ export enum TableName { IdentityProjectMembership = "identity_project_memberships", IdentityProjectMembershipRole = "identity_project_membership_role", IdentityProjectAdditionalPrivilege = "identity_project_additional_privilege", + IdentityAuthTemplate = "identity_auth_templates", // used by both identity and users IdentityMetadata = "identity_metadata", ResourceMetadata = "resource_metadata", 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 818bb1f99..a1617d57e 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -161,6 +161,9 @@ export enum EventType { CREATE_IDENTITY = "create-identity", UPDATE_IDENTITY = "update-identity", DELETE_IDENTITY = "delete-identity", + MACHINE_IDENTITY_AUTH_TEMPLATE_CREATE = "machine-identity-auth-template-create", + MACHINE_IDENTITY_AUTH_TEMPLATE_UPDATE = "machine-identity-auth-template-update", + MACHINE_IDENTITY_AUTH_TEMPLATE_DELETE = "machine-identity-auth-template-delete", LOGIN_IDENTITY_UNIVERSAL_AUTH = "login-identity-universal-auth", ADD_IDENTITY_UNIVERSAL_AUTH = "add-identity-universal-auth", UPDATE_IDENTITY_UNIVERSAL_AUTH = "update-identity-universal-auth", @@ -830,6 +833,30 @@ interface LoginIdentityUniversalAuthEvent { }; } +interface MachineIdentityAuthTemplateCreateEvent { + type: EventType.MACHINE_IDENTITY_AUTH_TEMPLATE_CREATE; + metadata: { + templateId: string; + name: string; + }; +} + +interface MachineIdentityAuthTemplateUpdateEvent { + type: EventType.MACHINE_IDENTITY_AUTH_TEMPLATE_UPDATE; + metadata: { + templateId: string; + name: string; + }; +} + +interface MachineIdentityAuthTemplateDeleteEvent { + type: EventType.MACHINE_IDENTITY_AUTH_TEMPLATE_DELETE; + metadata: { + templateId: string; + name: string; + }; +} + interface AddIdentityUniversalAuthEvent { type: EventType.ADD_IDENTITY_UNIVERSAL_AUTH; metadata: { @@ -3439,6 +3466,9 @@ export type Event = | UpdateIdentityEvent | DeleteIdentityEvent | LoginIdentityUniversalAuthEvent + | MachineIdentityAuthTemplateCreateEvent + | MachineIdentityAuthTemplateUpdateEvent + | MachineIdentityAuthTemplateDeleteEvent | AddIdentityUniversalAuthEvent | UpdateIdentityUniversalAuthEvent | DeleteIdentityUniversalAuthEvent diff --git a/backend/src/ee/services/license/__mocks__/license-fns.ts b/backend/src/ee/services/license/__mocks__/license-fns.ts index 5259d4616..4c42ad7a2 100644 --- a/backend/src/ee/services/license/__mocks__/license-fns.ts +++ b/backend/src/ee/services/license/__mocks__/license-fns.ts @@ -31,7 +31,8 @@ export const getDefaultOnPremFeatures = () => { caCrl: false, sshHostGroups: false, enterpriseSecretSyncs: false, - enterpriseAppConnections: false + enterpriseAppConnections: false, + machineIdentityAuthTemplates: false }; }; diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index fecba7ba7..bd3949a7e 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -60,7 +60,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ enterpriseSecretSyncs: false, enterpriseAppConnections: false, fips: false, - eventSubscriptions: false + eventSubscriptions: false, + machineIdentityAuthTemplates: false }); export const setupLicenseRequestWithStore = ( diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 84a652c46..098d00feb 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -75,6 +75,7 @@ export type TFeatureSet = { secretScanning: false; enterpriseSecretSyncs: false; enterpriseAppConnections: false; + machineIdentityAuthTemplates: false; fips: false; eventSubscriptions: false; }; diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index f0fe73d71..5c818f1f1 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -28,6 +28,14 @@ export enum OrgPermissionKmipActions { Setup = "setup" } +export enum OrgPermissionMachineIdentityAuthTemplateActions { + ListTemplates = "list-templates", + EditTemplates = "edit-templates", + CreateTemplates = "create-templates", + DeleteTemplates = "delete-templates", + UnlinkTemplates = "unlink-templates" +} + export enum OrgPermissionAdminConsoleAction { AccessAllProjects = "access-all-projects" } @@ -88,6 +96,7 @@ export enum OrgPermissionSubjects { Identity = "identity", Kms = "kms", AdminConsole = "organization-admin-console", + MachineIdentityAuthTemplate = "machine-identity-auth-template", AuditLogs = "audit-logs", ProjectTemplates = "project-templates", AppConnections = "app-connections", @@ -126,6 +135,7 @@ export type OrgPermissionSet = ) ] | [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole] + | [OrgPermissionMachineIdentityAuthTemplateActions, OrgPermissionSubjects.MachineIdentityAuthTemplate] | [OrgPermissionKmipActions, OrgPermissionSubjects.Kmip] | [OrgPermissionSecretShareAction, OrgPermissionSubjects.SecretShare]; @@ -237,6 +247,14 @@ export const OrgPermissionSchema = z.discriminatedUnion("subject", [ "Describe what action an entity can take." ) }), + z.object({ + subject: z + .literal(OrgPermissionSubjects.MachineIdentityAuthTemplate) + .describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionMachineIdentityAuthTemplateActions).describe( + "Describe what action an entity can take." + ) + }), z.object({ subject: z.literal(OrgPermissionSubjects.Gateway).describe("The entity this permission pertains to."), action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionGatewayActions).describe( @@ -350,6 +368,21 @@ const buildAdminPermission = () => { // the proxy assignment is temporary in order to prevent "more privilege" error during role assignment to MI can(OrgPermissionKmipActions.Proxy, OrgPermissionSubjects.Kmip); + can(OrgPermissionMachineIdentityAuthTemplateActions.ListTemplates, OrgPermissionSubjects.MachineIdentityAuthTemplate); + can(OrgPermissionMachineIdentityAuthTemplateActions.EditTemplates, OrgPermissionSubjects.MachineIdentityAuthTemplate); + can( + OrgPermissionMachineIdentityAuthTemplateActions.CreateTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + can( + OrgPermissionMachineIdentityAuthTemplateActions.DeleteTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + can( + OrgPermissionMachineIdentityAuthTemplateActions.UnlinkTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + can(OrgPermissionSecretShareAction.ManageSettings, OrgPermissionSubjects.SecretShare); return rules; @@ -385,6 +418,21 @@ const buildMemberPermission = () => { can(OrgPermissionGatewayActions.CreateGateways, OrgPermissionSubjects.Gateway); can(OrgPermissionGatewayActions.AttachGateways, OrgPermissionSubjects.Gateway); + can(OrgPermissionMachineIdentityAuthTemplateActions.ListTemplates, OrgPermissionSubjects.MachineIdentityAuthTemplate); + can(OrgPermissionMachineIdentityAuthTemplateActions.EditTemplates, OrgPermissionSubjects.MachineIdentityAuthTemplate); + can( + OrgPermissionMachineIdentityAuthTemplateActions.CreateTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + can( + OrgPermissionMachineIdentityAuthTemplateActions.DeleteTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + can( + OrgPermissionMachineIdentityAuthTemplateActions.UnlinkTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + return rules; }; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 72b283a0f..a0a7920fa 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -18,6 +18,7 @@ import { SECRET_SYNC_CONNECTION_MAP, SECRET_SYNC_NAME_MAP } from "@app/services/ export enum ApiDocsTags { Identities = "Identities", + IdentityTemplates = "Identity Templates", TokenAuth = "Token Auth", UniversalAuth = "Universal Auth", GcpAuth = "GCP Auth", @@ -147,6 +148,42 @@ export const IDENTITIES = { } } as const; +export const IDENTITY_TEMPLATES = { + CREATE: { + name: "The name of the identity template to create.", + organizationId: "The organization ID to which the identity template belongs.", + authMethod: "The auth method of the template.", + credentials: "Set of credentials to reuse by this template" + }, + UPDATE: { + identityTemplateId: "The ID of the identity to update.", + name: "The new name of the identity template.", + authMethod: "The auth method of the template." + }, + DELETE: { + identityTemplateId: "The ID of the identity template to delete." + }, + GET_BY_ID: { + identityTemplateId: "The ID of the identity template to get details.", + organizationId: "The organization ID to which the identity template belongs.", + authMethod: "The auth method of the template.", + credentials: "Set of credentials used by this template" + }, + LIST: { + orgId: "The ID of the organization to list identity templates." + }, + SEARCH: { + search: { + desc: "The filters to apply to the search.", + name: "The name of the identity template to filter by." + }, + offset: "The offset to start from. If you enter 10, it will start from the 10th identity.", + limit: "The number of identity templates to return.", + orderBy: "The column to order identity templates by.", + orderDirection: "The direction to order identity templates in." + } +}; + export const UNIVERSAL_AUTH = { LOGIN: { clientId: "Your Machine Identity Client ID.", @@ -214,6 +251,7 @@ export const LDAP_AUTH = { password: "The password of the LDAP user to login." }, ATTACH: { + templateId: "The ID of the template to attach the configuration onto.", identityId: "The ID of the identity to attach the configuration onto.", url: "The URL of the LDAP server.", allowedFields: @@ -240,7 +278,8 @@ export const LDAP_AUTH = { accessTokenTTL: "The new lifetime for an access token in seconds.", accessTokenMaxTTL: "The new maximum lifetime for an access token in seconds.", accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used.", - accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from." + accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from.", + templateId: "The ID of the template to update the configuration for." }, RETRIEVE: { identityId: "The ID of the identity to retrieve the configuration for." diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index ee0b2e0f3..c93a12ab6 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -179,6 +179,8 @@ import { identityAccessTokenDALFactory } from "@app/services/identity-access-tok import { identityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service"; import { identityAliCloudAuthDALFactory } from "@app/services/identity-alicloud-auth/identity-alicloud-auth-dal"; import { identityAliCloudAuthServiceFactory } from "@app/services/identity-alicloud-auth/identity-alicloud-auth-service"; +import { identityAuthTemplateDALFactory } from "@app/services/identity-auth-template/identity-auth-template-dal"; +import { identityAuthTemplateServiceFactory } from "@app/services/identity-auth-template/identity-auth-template-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 { identityAzureAuthDALFactory } from "@app/services/identity-azure-auth/identity-azure-auth-dal"; @@ -394,6 +396,7 @@ export const registerRoutes = async ( const identityProjectDAL = identityProjectDALFactory(db); const identityProjectMembershipRoleDAL = identityProjectMembershipRoleDALFactory(db); const identityProjectAdditionalPrivilegeDAL = identityProjectAdditionalPrivilegeDALFactory(db); + const identityAuthTemplateDAL = identityAuthTemplateDALFactory(db); const identityTokenAuthDAL = identityTokenAuthDALFactory(db); const identityUaDAL = identityUaDALFactory(db); @@ -1461,6 +1464,14 @@ export const registerRoutes = async ( identityMetadataDAL }); + const identityAuthTemplateService = identityAuthTemplateServiceFactory({ + identityAuthTemplateDAL, + identityLdapAuthDAL, + permissionService, + kmsService, + licenseService + }); + const identityAccessTokenService = identityAccessTokenServiceFactory({ identityAccessTokenDAL, identityOrgMembershipDAL, @@ -1604,7 +1615,8 @@ export const registerRoutes = async ( identityAccessTokenDAL, identityOrgMembershipDAL, licenseService, - identityDAL + identityDAL, + identityAuthTemplateDAL }); const dynamicSecretProviders = buildDynamicSecretProviders({ @@ -2008,6 +2020,7 @@ export const registerRoutes = async ( webhook: webhookService, serviceToken: serviceTokenService, identity: identityService, + identityAuthTemplate: identityAuthTemplateService, identityAccessToken: identityAccessTokenService, identityProject: identityProjectService, identityTokenAuth: identityTokenAuthService, diff --git a/backend/src/server/routes/v1/identity-ldap-auth-router.ts b/backend/src/server/routes/v1/identity-ldap-auth-router.ts index 3da8a425b..c9bae9fc4 100644 --- a/backend/src/server/routes/v1/identity-ldap-auth-router.ts +++ b/backend/src/server/routes/v1/identity-ldap-auth-router.ts @@ -202,10 +202,11 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) }), body: z .object({ - url: z.string().trim().min(1).describe(LDAP_AUTH.ATTACH.url), - bindDN: z.string().trim().min(1).describe(LDAP_AUTH.ATTACH.bindDN), - bindPass: z.string().trim().min(1).describe(LDAP_AUTH.ATTACH.bindPass), - searchBase: z.string().trim().min(1).describe(LDAP_AUTH.ATTACH.searchBase), + templateId: z.string().trim().optional().describe(LDAP_AUTH.ATTACH.templateId), + url: z.string().trim().optional().describe(LDAP_AUTH.ATTACH.url), + bindDN: z.string().trim().optional().describe(LDAP_AUTH.ATTACH.bindDN), + bindPass: z.string().trim().optional().describe(LDAP_AUTH.ATTACH.bindPass), + searchBase: z.string().trim().optional().describe(LDAP_AUTH.ATTACH.searchBase), searchFilter: z .string() .trim() @@ -242,7 +243,12 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) .refine( (val) => val.accessTokenTTL <= val.accessTokenMaxTTL, "Access Token TTL cannot be greater than Access Token Max TTL." - ), + ) + .refine((val) => { + const hasTemplateId = !!val.templateId; + const hasManualConfig = !!(val.url && val.bindDN && val.bindPass && val.searchBase); + return hasTemplateId || hasManualConfig; + }, "Either templateId must be provided, or all of url, bindDN, bindPass, and searchBase must be provided."), response: { 200: z.object({ identityLdapAuth: IdentityLdapAuthsSchema.omit({ @@ -309,6 +315,7 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) bindDN: z.string().trim().min(1).optional().describe(LDAP_AUTH.UPDATE.bindDN), bindPass: z.string().trim().min(1).optional().describe(LDAP_AUTH.UPDATE.bindPass), searchBase: z.string().trim().min(1).optional().describe(LDAP_AUTH.UPDATE.searchBase), + templateId: z.string().trim().optional().describe(LDAP_AUTH.UPDATE.templateId), searchFilter: z .string() .trim() @@ -411,9 +418,10 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) encryptedBindPass: true, encryptedLdapCaCertificate: true }).extend({ - bindDN: z.string(), - bindPass: z.string(), - ldapCaCertificate: z.string().optional() + bindDN: z.string().optional(), + bindPass: z.string().optional(), + ldapCaCertificate: z.string().optional(), + templateId: z.string().optional().nullable() }) }) } diff --git a/backend/src/server/routes/v1/identity-template-router.ts b/backend/src/server/routes/v1/identity-template-router.ts new file mode 100644 index 000000000..1846e1f53 --- /dev/null +++ b/backend/src/server/routes/v1/identity-template-router.ts @@ -0,0 +1,381 @@ +import { z } from "zod"; + +import { IdentityAuthTemplatesSchema } from "@app/db/schemas/identity-auth-templates"; +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 { + IdentityAuthTemplateMethod, + TEMPLATE_SUCCESS_MESSAGES, + TEMPLATE_VALIDATION_MESSAGES +} from "@app/services/identity-auth-template/identity-auth-template-enums"; + +const ldapTemplateFieldsSchema = z.object({ + url: z.string().min(1, TEMPLATE_VALIDATION_MESSAGES.LDAP_URL_REQUIRED), + bindDN: z.string().min(1, TEMPLATE_VALIDATION_MESSAGES.BIND_DN_REQUIRED), + bindPass: z.string().min(1, TEMPLATE_VALIDATION_MESSAGES.BIND_PASSWORD_REQUIRED), + searchBase: z.string().min(1, TEMPLATE_VALIDATION_MESSAGES.SEARCH_BASE_REQUIRED) +}); + +export const registerIdentityTemplateRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + description: "Create identity auth template", + security: [ + { + bearerAuth: [] + } + ], + body: z.object({ + name: z.string().trim().min(1, TEMPLATE_VALIDATION_MESSAGES.TEMPLATE_NAME_REQUIRED), + authMethod: z.nativeEnum(IdentityAuthTemplateMethod), + templateFields: ldapTemplateFieldsSchema + }), + response: { + 200: z.object({ + message: z.string() + }) + } + }, + handler: async (req) => { + const template = await server.services.identityAuthTemplate.createTemplate({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + name: req.body.name, + authMethod: req.body.authMethod, + templateFields: req.body.templateFields + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.MACHINE_IDENTITY_AUTH_TEMPLATE_CREATE, + metadata: { + templateId: template.id, + name: template.name + } + } + }); + + return { message: TEMPLATE_SUCCESS_MESSAGES.CREATED }; + } + }); + + server.route({ + method: "PATCH", + url: "/:templateId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + description: "Update identity auth template", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + templateId: z.string().min(1, TEMPLATE_VALIDATION_MESSAGES.TEMPLATE_ID_REQUIRED) + }), + body: z.object({ + name: z.string().trim().optional(), + templateFields: ldapTemplateFieldsSchema.partial().optional() + }), + response: { + 200: z.object({ + message: z.string() + }) + } + }, + handler: async (req) => { + const template = await server.services.identityAuthTemplate.updateTemplate({ + templateId: req.params.templateId, + name: req.body.name, + templateFields: req.body.templateFields, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.MACHINE_IDENTITY_AUTH_TEMPLATE_UPDATE, + metadata: { + templateId: template.id, + name: template.name + } + } + }); + + return { message: TEMPLATE_SUCCESS_MESSAGES.UPDATED }; + } + }); + + server.route({ + method: "DELETE", + url: "/:templateId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + description: "Delete identity auth template", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + templateId: z.string().min(1, TEMPLATE_VALIDATION_MESSAGES.TEMPLATE_ID_REQUIRED) + }), + response: { + 200: z.object({ + message: z.string() + }) + } + }, + handler: async (req) => { + const template = await server.services.identityAuthTemplate.deleteTemplate({ + templateId: req.params.templateId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.MACHINE_IDENTITY_AUTH_TEMPLATE_DELETE, + metadata: { + templateId: template.id, + name: template.name + } + } + }); + + return { message: TEMPLATE_SUCCESS_MESSAGES.DELETED }; + } + }); + + server.route({ + method: "GET", + url: "/:templateId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + description: "Get identity auth template by ID", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + templateId: z.string().min(1, TEMPLATE_VALIDATION_MESSAGES.TEMPLATE_ID_REQUIRED) + }), + response: { + 200: z.object({ + template: IdentityAuthTemplatesSchema.extend({ + templateFields: ldapTemplateFieldsSchema + }) + }) + } + }, + handler: async (req) => { + const template = await server.services.identityAuthTemplate.getTemplate({ + templateId: req.params.templateId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return { template }; + } + }); + + server.route({ + method: "GET", + url: "/search", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + description: "List identity auth templates", + security: [ + { + bearerAuth: [] + } + ], + querystring: z.object({ + limit: z.coerce.number().positive().max(100).default(5).optional(), + offset: z.coerce.number().min(0).default(0).optional() + }), + response: { + 200: z.object({ + templates: IdentityAuthTemplatesSchema.extend({ + templateFields: ldapTemplateFieldsSchema + }).array(), + totalCount: z.number() + }) + } + }, + handler: async (req) => { + const { templates, totalCount } = await server.services.identityAuthTemplate.listTemplates({ + limit: req.query.limit, + offset: req.query.offset, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return { templates, totalCount }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + description: "Get identity auth templates by authentication method", + security: [ + { + bearerAuth: [] + } + ], + querystring: z.object({ + authMethod: z.nativeEnum(IdentityAuthTemplateMethod) + }), + response: { + 200: IdentityAuthTemplatesSchema.extend({ + templateFields: ldapTemplateFieldsSchema + }).array() + } + }, + handler: async (req) => { + const templates = await server.services.identityAuthTemplate.getTemplatesByAuthMethod({ + authMethod: req.query.authMethod, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return templates; + } + }); + + server.route({ + method: "GET", + url: "/:templateId/usage", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + description: "Get identity auth templates by authentication method", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + templateId: z.string() + }), + response: { + 200: z + .object({ + identityId: z.string(), + identityName: z.string() + }) + .array() + } + }, + handler: async (req) => { + const templates = await server.services.identityAuthTemplate.findTemplateUsages({ + templateId: req.params.templateId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return templates; + } + }); + + server.route({ + method: "POST", + url: "/:templateId/usage", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + description: "Unlink identity auth template usage", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + templateId: z.string() + }), + body: z.object({ + identityIds: z.string().array() + }), + response: { + 200: z + .object({ + authId: z.string(), + identityId: z.string(), + identityName: z.string() + }) + .array() + } + }, + handler: async (req) => { + const templates = await server.services.identityAuthTemplate.unlinkTemplateUsage({ + templateId: req.params.templateId, + identityIds: req.body.identityIds, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return templates; + } + }); +}; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 458efd194..3d0e721c4 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -26,6 +26,7 @@ import { registerIdentityLdapAuthRouter } from "./identity-ldap-auth-router"; import { registerIdentityOciAuthRouter } from "./identity-oci-auth-router"; import { registerIdentityOidcAuthRouter } from "./identity-oidc-auth-router"; import { registerIdentityRouter } from "./identity-router"; +import { registerIdentityTemplateRouter } from "./identity-template-router"; import { registerIdentityTlsCertAuthRouter } from "./identity-tls-cert-auth-router"; import { registerIdentityTokenAuthRouter } from "./identity-token-auth-router"; import { registerIdentityUaRouter } from "./identity-universal-auth-router"; @@ -134,7 +135,13 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerIntegrationRouter, { prefix: "/integration" }); await server.register(registerIntegrationAuthRouter, { prefix: "/integration-auth" }); await server.register(registerWebhookRouter, { prefix: "/webhooks" }); - await server.register(registerIdentityRouter, { prefix: "/identities" }); + await server.register( + async (identitiesRouter) => { + await identitiesRouter.register(registerIdentityRouter); + await identitiesRouter.register(registerIdentityTemplateRouter, { prefix: "/templates" }); + }, + { prefix: "/identities" } + ); await server.register( async (secretSharingRouter) => { diff --git a/backend/src/services/identity-auth-template/identity-auth-template-dal.ts b/backend/src/services/identity-auth-template/identity-auth-template-dal.ts new file mode 100644 index 000000000..cbcd1581b --- /dev/null +++ b/backend/src/services/identity-auth-template/identity-auth-template-dal.ts @@ -0,0 +1,69 @@ +/* eslint-disable no-case-declarations */ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { buildFindFilter, ormify } from "@app/lib/knex"; + +import { IdentityAuthTemplateMethod } from "./identity-auth-template-enums"; + +export type TIdentityAuthTemplateDALFactory = ReturnType; + +export const identityAuthTemplateDALFactory = (db: TDbClient) => { + const identityAuthTemplateOrm = ormify(db, TableName.IdentityAuthTemplate); + + const findByOrgId = async ( + orgId: string, + { limit, offset, tx }: { limit?: number; offset?: number; tx?: Knex } = {} + ) => { + let query = (tx || db.replicaNode())(TableName.IdentityAuthTemplate).where({ orgId }).orderBy("createdAt", "desc"); + + if (limit !== undefined) { + query = query.limit(limit); + } + if (offset !== undefined) { + query = query.offset(offset); + } + + const docs = await query; + + const [{ count }] = (await (tx || db.replicaNode())(TableName.IdentityAuthTemplate) + .where({ orgId }) + .count("* as count")) as [{ count: string | number }]; + + return { docs, totalCount: Number(count) }; + }; + + const findByAuthMethod = async (authMethod: string, orgId: string, tx?: Knex) => { + const query = (tx || db.replicaNode())(TableName.IdentityAuthTemplate) + .where({ authMethod, orgId }) + .orderBy("createdAt", "desc"); + const docs = await query; + return docs; + }; + + const findTemplateUsages = async (templateId: string, authMethod: string, tx?: Knex) => { + switch (authMethod) { + case IdentityAuthTemplateMethod.LDAP: + const query = (tx || db.replicaNode())(TableName.IdentityLdapAuth) + .join(TableName.Identity, `${TableName.IdentityLdapAuth}.identityId`, `${TableName.Identity}.id`) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .where(buildFindFilter({ templateId }, TableName.IdentityLdapAuth)) + .select( + db.ref("identityId").withSchema(TableName.IdentityLdapAuth), + db.ref("name").withSchema(TableName.Identity).as("identityName") + ); + const docs = await query; + return docs; + default: + return []; + } + }; + + return { + ...identityAuthTemplateOrm, + findByOrgId, + findByAuthMethod, + findTemplateUsages + }; +}; diff --git a/backend/src/services/identity-auth-template/identity-auth-template-enums.ts b/backend/src/services/identity-auth-template/identity-auth-template-enums.ts new file mode 100644 index 000000000..163cd8fd1 --- /dev/null +++ b/backend/src/services/identity-auth-template/identity-auth-template-enums.ts @@ -0,0 +1,19 @@ +export enum IdentityAuthTemplateMethod { + LDAP = "ldap" +} + +export const TEMPLATE_VALIDATION_MESSAGES = { + TEMPLATE_NAME_REQUIRED: "Template name is required", + AUTH_METHOD_REQUIRED: "Auth method is required", + TEMPLATE_ID_REQUIRED: "Template ID is required", + LDAP_URL_REQUIRED: "LDAP URL is required", + BIND_DN_REQUIRED: "Bind DN is required", + BIND_PASSWORD_REQUIRED: "Bind password is required", + SEARCH_BASE_REQUIRED: "Search base is required" +} as const; + +export const TEMPLATE_SUCCESS_MESSAGES = { + CREATED: "Template created successfully", + UPDATED: "Template updated successfully", + DELETED: "Template deleted successfully" +} as const; diff --git a/backend/src/services/identity-auth-template/identity-auth-template-service.ts b/backend/src/services/identity-auth-template/identity-auth-template-service.ts new file mode 100644 index 000000000..0873efc96 --- /dev/null +++ b/backend/src/services/identity-auth-template/identity-auth-template-service.ts @@ -0,0 +1,406 @@ +import { ForbiddenError } from "@casl/ability"; + +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { + OrgPermissionMachineIdentityAuthTemplateActions, + OrgPermissionSubjects +} from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { TOrgPermission } from "@app/lib/types"; + +import { TIdentityLdapAuthDALFactory } from "../identity-ldap-auth/identity-ldap-auth-dal"; +import { TKmsServiceFactory } from "../kms/kms-service"; +import { KmsDataKey } from "../kms/kms-types"; +import { TIdentityAuthTemplateDALFactory } from "./identity-auth-template-dal"; +import { IdentityAuthTemplateMethod } from "./identity-auth-template-enums"; +import { + TDeleteIdentityAuthTemplateDTO, + TFindTemplateUsagesDTO, + TGetIdentityAuthTemplateDTO, + TGetTemplatesByAuthMethodDTO, + TLdapTemplateFields, + TListIdentityAuthTemplatesDTO, + TUnlinkTemplateUsageDTO +} from "./identity-auth-template-types"; + +type TIdentityAuthTemplateServiceFactoryDep = { + identityAuthTemplateDAL: TIdentityAuthTemplateDALFactory; + identityLdapAuthDAL: TIdentityLdapAuthDALFactory; + permissionService: Pick; + kmsService: Pick; + licenseService: Pick; +}; + +export type TIdentityAuthTemplateServiceFactory = ReturnType; + +export const identityAuthTemplateServiceFactory = ({ + identityAuthTemplateDAL, + identityLdapAuthDAL, + permissionService, + kmsService, + licenseService +}: TIdentityAuthTemplateServiceFactoryDep) => { + // Plan check + const $checkPlan = async (orgId: string) => { + const plan = await licenseService.getPlan(orgId); + if (!plan.machineIdentityAuthTemplates) + throw new BadRequestError({ + message: + "Failed to use identity auth template due to plan restriction. Upgrade plan to access machine identity auth templates." + }); + }; + const createTemplate = async ({ + name, + authMethod, + templateFields, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: { + name: string; + authMethod: string; + templateFields: Record; + } & Omit) => { + await $checkPlan(actorOrgId); + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionMachineIdentityAuthTemplateActions.CreateTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: actorOrgId + }); + const template = await identityAuthTemplateDAL.create({ + name, + authMethod, + templateFields: encryptor({ plainText: Buffer.from(JSON.stringify(templateFields)) }).cipherTextBlob, + orgId: actorOrgId + }); + + return template; + }; + + const updateTemplate = async ({ + templateId, + name, + templateFields, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: { + templateId: string; + name?: string; + templateFields?: Record; + } & Omit) => { + await $checkPlan(actorOrgId); + const template = await identityAuthTemplateDAL.findById(templateId); + if (!template) { + throw new NotFoundError({ message: "Template not found" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + template.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionMachineIdentityAuthTemplateActions.EditTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: template.orgId + }); + + const updatedTemplate = await identityAuthTemplateDAL.transaction(async (tx) => { + const authTemplate = await identityAuthTemplateDAL.updateById( + templateId, + { + name, + ...(templateFields && { + templateFields: encryptor({ plainText: Buffer.from(JSON.stringify(templateFields)) }).cipherTextBlob + }) + }, + tx + ); + + if (templateFields && template.authMethod === IdentityAuthTemplateMethod.LDAP) { + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: template.orgId + }); + + const currentTemplateFields = JSON.parse( + decryptor({ cipherTextBlob: template.templateFields }).toString() + ) as TLdapTemplateFields; + + const mergedTemplateFields: TLdapTemplateFields = { ...currentTemplateFields, ...templateFields }; + + const ldapUpdateData: { + url?: string; + searchBase?: string; + encryptedBindDN?: Buffer; + encryptedBindPass?: Buffer; + } = {}; + + if ("url" in templateFields) { + ldapUpdateData.url = mergedTemplateFields.url; + } + if ("searchBase" in templateFields) { + ldapUpdateData.searchBase = mergedTemplateFields.searchBase; + } + if ("bindDN" in templateFields) { + ldapUpdateData.encryptedBindDN = encryptor({ + plainText: Buffer.from(mergedTemplateFields.bindDN) + }).cipherTextBlob; + } + if ("bindPass" in templateFields) { + ldapUpdateData.encryptedBindPass = encryptor({ + plainText: Buffer.from(mergedTemplateFields.bindPass) + }).cipherTextBlob; + } + + if (Object.keys(ldapUpdateData).length > 0) { + await identityLdapAuthDAL.update({ templateId }, ldapUpdateData, tx); + } + } + + return authTemplate; + }); + + return updatedTemplate; + }; + + const deleteTemplate = async ({ + templateId, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TDeleteIdentityAuthTemplateDTO) => { + await $checkPlan(actorOrgId); + const template = await identityAuthTemplateDAL.findById(templateId); + if (!template) { + throw new NotFoundError({ message: "Template not found" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + template.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionMachineIdentityAuthTemplateActions.DeleteTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + + const deletedTemplate = await identityAuthTemplateDAL.transaction(async (tx) => { + // Remove template reference from identityLdapAuth records + await identityLdapAuthDAL.update({ templateId }, { templateId: null }, tx); + + // Delete the template + const [deletedTpl] = await identityAuthTemplateDAL.delete({ id: templateId }, tx); + return deletedTpl; + }); + + return deletedTemplate; + }; + + const getTemplate = async ({ + templateId, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TGetIdentityAuthTemplateDTO) => { + await $checkPlan(actorOrgId); + const template = await identityAuthTemplateDAL.findById(templateId); + if (!template) { + throw new NotFoundError({ message: "Template not found" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + template.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionMachineIdentityAuthTemplateActions.ListTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: actorOrgId + }); + const decryptedTemplateFields = decryptor({ cipherTextBlob: template.templateFields }).toString(); + return { + ...template, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + templateFields: JSON.parse(decryptedTemplateFields.toString()) + }; + }; + + const listTemplates = async ({ + limit, + offset, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TListIdentityAuthTemplatesDTO) => { + await $checkPlan(actorOrgId); + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionMachineIdentityAuthTemplateActions.ListTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + + const { docs, totalCount } = await identityAuthTemplateDAL.findByOrgId(actorOrgId, { limit, offset }); + + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: actorOrgId + }); + return { + totalCount, + templates: docs.map((doc) => ({ + ...doc, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + templateFields: JSON.parse(decryptor({ cipherTextBlob: doc.templateFields }).toString()) + })) + }; + }; + + const getTemplatesByAuthMethod = async ({ + authMethod, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TGetTemplatesByAuthMethodDTO) => { + await $checkPlan(actorOrgId); + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionMachineIdentityAuthTemplateActions.ListTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + + const docs = await identityAuthTemplateDAL.findByAuthMethod(authMethod, actorOrgId); + + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: actorOrgId + }); + return docs.map((doc) => ({ + ...doc, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + templateFields: JSON.parse(decryptor({ cipherTextBlob: doc.templateFields }).toString()) + })); + }; + + const findTemplateUsages = async ({ + templateId, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TFindTemplateUsagesDTO) => { + await $checkPlan(actorOrgId); + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionMachineIdentityAuthTemplateActions.ListTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + + const template = await identityAuthTemplateDAL.findById(templateId); + if (!template) { + throw new NotFoundError({ message: "Template not found" }); + } + + const docs = await identityAuthTemplateDAL.findTemplateUsages(templateId, template.authMethod); + return docs; + }; + + const unlinkTemplateUsage = async ({ + templateId, + identityIds, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUnlinkTemplateUsageDTO) => { + await $checkPlan(actorOrgId); + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionMachineIdentityAuthTemplateActions.ListTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + + const template = await identityAuthTemplateDAL.findById(templateId); + if (!template) { + throw new NotFoundError({ message: "Template not found" }); + } + + switch (template.authMethod) { + case IdentityAuthTemplateMethod.LDAP: + await identityLdapAuthDAL.update({ $in: { identityId: identityIds }, templateId }, { templateId: null }); + break; + default: + break; + } + }; + + return { + createTemplate, + updateTemplate, + deleteTemplate, + getTemplate, + listTemplates, + getTemplatesByAuthMethod, + findTemplateUsages, + unlinkTemplateUsage + }; +}; diff --git a/backend/src/services/identity-auth-template/identity-auth-template-types.ts b/backend/src/services/identity-auth-template/identity-auth-template-types.ts new file mode 100644 index 000000000..2472edec6 --- /dev/null +++ b/backend/src/services/identity-auth-template/identity-auth-template-types.ts @@ -0,0 +1,59 @@ +import { TProjectPermission } from "@app/lib/types"; + +import { IdentityAuthTemplateMethod } from "./identity-auth-template-enums"; + +// Method-specific template field types +export type TLdapTemplateFields = { + url: string; + bindDN: string; + bindPass: string; + searchBase: string; +}; + +// Union type for all template field types +export type TTemplateFieldsByMethod = { + [IdentityAuthTemplateMethod.LDAP]: TLdapTemplateFields; +}; + +// Generic base types that use conditional types for type safety +export type TCreateIdentityAuthTemplateDTO = { + name: string; + authMethod: T; + templateFields: TTemplateFieldsByMethod[T]; +} & Omit; + +export type TUpdateIdentityAuthTemplateDTO = { + templateId: string; + name?: string; + templateFields?: Partial; +} & Omit; + +export type TDeleteIdentityAuthTemplateDTO = { + templateId: string; +} & Omit; + +export type TGetIdentityAuthTemplateDTO = { + templateId: string; +} & Omit; + +export type TListIdentityAuthTemplatesDTO = { + limit?: number; + offset?: number; +} & Omit; + +export type TGetTemplatesByAuthMethodDTO = { + authMethod: string; +} & Omit; + +export type TFindTemplateUsagesDTO = { + templateId: string; +} & Omit; + +export type TUnlinkTemplateUsageDTO = { + templateId: string; + identityIds: string[]; +} & Omit; + +// Specific LDAP types for convenience +export type TCreateLdapTemplateDTO = TCreateIdentityAuthTemplateDTO; +export type TUpdateLdapTemplateDTO = TUpdateIdentityAuthTemplateDTO; diff --git a/backend/src/services/identity-auth-template/index.ts b/backend/src/services/identity-auth-template/index.ts new file mode 100644 index 000000000..4358d1a27 --- /dev/null +++ b/backend/src/services/identity-auth-template/index.ts @@ -0,0 +1,6 @@ +export type { TIdentityAuthTemplateDALFactory } from "./identity-auth-template-dal"; +export { identityAuthTemplateDALFactory } from "./identity-auth-template-dal"; +export * from "./identity-auth-template-enums"; +export type { TIdentityAuthTemplateServiceFactory } from "./identity-auth-template-service"; +export { identityAuthTemplateServiceFactory } from "./identity-auth-template-service"; +export type * from "./identity-auth-template-types"; diff --git a/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts b/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts index 399ef7da9..ddb4a0e66 100644 --- a/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts +++ b/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts @@ -20,6 +20,7 @@ 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 { TIdentityAuthTemplateDALFactory } from "../identity-auth-template"; import { TKmsServiceFactory } from "../kms/kms-service"; import { KmsDataKey } from "../kms/kms-types"; import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns"; @@ -44,6 +45,7 @@ type TIdentityLdapAuthServiceFactoryDep = { permissionService: Pick; kmsService: TKmsServiceFactory; identityDAL: TIdentityDALFactory; + identityAuthTemplateDAL: TIdentityAuthTemplateDALFactory; }; export type TIdentityLdapAuthServiceFactory = ReturnType; @@ -55,7 +57,8 @@ export const identityLdapAuthServiceFactory = ({ identityOrgMembershipDAL, licenseService, permissionService, - kmsService + kmsService, + identityAuthTemplateDAL }: TIdentityLdapAuthServiceFactoryDep) => { const getLdapConfig = async (identityId: string) => { const identity = await identityDAL.findOne({ id: identityId }); @@ -173,6 +176,7 @@ export const identityLdapAuthServiceFactory = ({ const attachLdapAuth = async ({ identityId, + templateId, url, searchBase, searchFilter, @@ -241,13 +245,36 @@ export const identityLdapAuthServiceFactory = ({ if (allowedFields) AllowedFieldsSchema.array().parse(allowedFields); const identityLdapAuth = await identityLdapAuthDAL.transaction(async (tx) => { - const { encryptor } = await kmsService.createCipherPairWithDataKey({ + const { encryptor, decryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, orgId: identityMembershipOrg.orgId }); + const template = templateId ? await identityAuthTemplateDAL.findById(templateId) : undefined; + + let ldapConfig: { bindDN: string; bindPass: string; searchBase: string; url: string }; + if (template) { + ldapConfig = JSON.parse(decryptor({ cipherTextBlob: template.templateFields }).toString()); + } else { + if (!bindDN || !bindPass || !searchBase || !url) { + throw new BadRequestError({ + message: "Invalid request. Missing bind DN, bind pass, search base, or URL." + }); + } + ldapConfig = { + bindDN, + bindPass, + searchBase, + url + }; + } + const { cipherTextBlob: encryptedBindPass } = encryptor({ - plainText: Buffer.from(bindPass) + plainText: Buffer.from(ldapConfig.bindPass) + }); + + const { cipherTextBlob: encryptedBindDN } = encryptor({ + plainText: Buffer.from(ldapConfig.bindDN) }); let encryptedLdapCaCertificate: Buffer | undefined; @@ -259,15 +286,11 @@ export const identityLdapAuthServiceFactory = ({ encryptedLdapCaCertificate = encryptedCertificate; } - const { cipherTextBlob: encryptedBindDN } = encryptor({ - plainText: Buffer.from(bindDN) - }); - const isConnected = await testLDAPConfig({ - bindDN, - bindPass, + bindDN: ldapConfig.bindDN, + bindPass: ldapConfig.bindPass, caCert: ldapCaCertificate || "", - url + url: ldapConfig.url }); if (!isConnected) { @@ -282,15 +305,16 @@ export const identityLdapAuthServiceFactory = ({ identityId: identityMembershipOrg.identityId, encryptedBindDN, encryptedBindPass, - searchBase, + searchBase: ldapConfig.searchBase, searchFilter, - url, + url: ldapConfig.url, encryptedLdapCaCertificate, accessTokenMaxTTL, accessTokenTTL, accessTokenNumUsesLimit, accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps), - allowedFields: allowedFields ? JSON.stringify(allowedFields) : undefined + allowedFields: allowedFields ? JSON.stringify(allowedFields) : undefined, + templateId }, tx ); @@ -301,6 +325,7 @@ export const identityLdapAuthServiceFactory = ({ const updateLdapAuth = async ({ identityId, + templateId, url, searchBase, searchFilter, @@ -371,15 +396,34 @@ export const identityLdapAuthServiceFactory = ({ if (allowedFields) AllowedFieldsSchema.array().parse(allowedFields); - const { encryptor } = await kmsService.createCipherPairWithDataKey({ + const { encryptor, decryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, orgId: identityMembershipOrg.orgId }); + const template = templateId ? await identityAuthTemplateDAL.findById(templateId) : undefined; + let config: { + bindDN?: string; + bindPass?: string; + searchBase?: string; + url?: string; + }; + + if (template) { + config = JSON.parse(decryptor({ cipherTextBlob: template.templateFields }).toString()); + } else { + config = { + bindDN, + bindPass, + searchBase, + url + }; + } + let encryptedBindPass: Buffer | undefined; - if (bindPass) { + if (config.bindPass) { const { cipherTextBlob: bindPassCiphertext } = encryptor({ - plainText: Buffer.from(bindPass) + plainText: Buffer.from(config.bindPass) }); encryptedBindPass = bindPassCiphertext; @@ -395,9 +439,9 @@ export const identityLdapAuthServiceFactory = ({ } let encryptedBindDN: Buffer | undefined; - if (bindDN) { + if (config.bindDN) { const { cipherTextBlob: bindDNCiphertext } = encryptor({ - plainText: Buffer.from(bindDN) + plainText: Buffer.from(config.bindDN) }); encryptedBindDN = bindDNCiphertext; @@ -406,10 +450,10 @@ export const identityLdapAuthServiceFactory = ({ const { ldapConfig } = await getLdapConfig(identityId); const isConnected = await testLDAPConfig({ - bindDN: bindDN || ldapConfig.bindDN, - bindPass: bindPass || ldapConfig.bindPass, + bindDN: config.bindDN || ldapConfig.bindDN, + bindPass: config.bindPass || ldapConfig.bindPass, caCert: ldapCaCertificate || ldapConfig.caCert, - url: url || ldapConfig.url + url: config.url || ldapConfig.url }); if (!isConnected) { @@ -420,14 +464,15 @@ export const identityLdapAuthServiceFactory = ({ } const updatedLdapAuth = await identityLdapAuthDAL.updateById(identityLdapAuth.id, { - url, - searchBase, + url: config.url, + searchBase: config.searchBase, searchFilter, encryptedBindDN, encryptedBindPass, encryptedLdapCaCertificate, allowedFields: allowedFields ? JSON.stringify(allowedFields) : undefined, accessTokenMaxTTL, + templateId: template?.id || null, accessTokenTTL, accessTokenNumUsesLimit, accessTokenTrustedIps: reformattedAccessTokenTrustedIps diff --git a/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts b/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts index 0e6feb5fb..8629763bb 100644 --- a/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts +++ b/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts @@ -14,11 +14,12 @@ export type TAllowedFields = z.infer; export type TAttachLdapAuthDTO = { identityId: string; - url: string; - searchBase: string; + templateId?: string; + url?: string; + searchBase?: string; searchFilter: string; - bindDN: string; - bindPass: string; + bindDN?: string; + bindPass?: string; ldapCaCertificate?: string; allowedFields?: TAllowedFields[]; accessTokenTTL: number; @@ -30,6 +31,7 @@ export type TAttachLdapAuthDTO = { export type TUpdateLdapAuthDTO = { identityId: string; + templateId?: string; url?: string; searchBase?: string; searchFilter?: string; diff --git a/docs/docs.json b/docs/docs.json index 4459a67de..3d6d1daaf 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -291,6 +291,7 @@ { "group": "Machine Identities", "pages": [ + "documentation/platform/identities/auth-templates", "documentation/platform/identities/alicloud-auth", "documentation/platform/identities/aws-auth", "documentation/platform/identities/azure-auth", diff --git a/docs/documentation/platform/identities/auth-templates.mdx b/docs/documentation/platform/identities/auth-templates.mdx new file mode 100644 index 000000000..0052e524a --- /dev/null +++ b/docs/documentation/platform/identities/auth-templates.mdx @@ -0,0 +1,98 @@ +--- +title: "Machine Identity Auth Templates" +description: "Learn how to use auth templates to standardize authentication configurations for machine identities." +--- + +## Concept + +Machine Identity Auth Templates allow you to create reusable authentication configurations that can be applied across multiple machine identities. This feature helps standardize authentication setups, reduces configuration drift, and simplifies identity management at scale. + +Instead of manually configuring authentication settings for each identity, you can create templates with predefined authentication parameters and apply them to multiple identities. This ensures consistency and reduces the likelihood of configuration errors. + +Key Benefits: + +- **Standardization**: Ensure consistent authentication configurations across identities +- **Efficiency**: Reduce time spent configuring individual identities +- **Governance**: Centrally manage and update authentication parameters +- **Scalability**: Easily apply proven configurations to new identities + +## Managing Auth Templates + +Auth templates are managed in **Organization Settings > Access Control > Identities** under the **Identity Auth Templates** section. + +![Identity Auth Templates Section](/images/platform/identities/auth-templates/templates-section.png) + +### Creating a Template + + + + In your organization settings, go to **Access Control > Identities** and scroll down to the **Identity Auth Templates** section. + + + + Click **Create Template** to open the template creation modal. + + ![Create Template Button](/images/platform/identities/auth-templates/create-template-button.png) + + Select the authentication method you want to create a template for (currently supports LDAP Auth). + + + + Fill in the template configuration based on your chosen authentication method. + + + + **For LDAP Auth templates**, configure the following fields: + + ![LDAP Auth Template](/images/platform/identities/auth-templates/ldap-template.png) + + - **Template Name**: A descriptive name for your template + - **URL**: The LDAP server to connect to such as `ldap://ldap.your-org.com`, `ldaps://ldap.myorg.com:636` _(for connection over SSL/TLS)_, etc. + - **Bind DN**: The DN to bind to the LDAP server with. + - **Bind Pass**: The password to bind to the LDAP server with. + - **Search Base / DN**: Base DN under which to perform user search such as `ou=Users,dc=acme,dc=com`. + - **User Search Filter**: Template used to construct the LDAP user search filter such as `(uid={{username}})`; use literal `{{username}}` to have the given username used in the search. The default is `(uid={{username}})` which is compatible with several common directory schemas. + - **Required Attributes**: A key/value pair of attributes that must be present in the LDAP user entry for them to be authenticated. As an example, if you set key `uid` to value `user1,user2,user3`, then only users with `uid` of `user1`, `user2`, or `user3` will be able to login with this identity. Each value is a comma separated list of attributes. + - **CA Certificate**: The CA certificate to use when verifying the LDAP server certificate. This field is optional but recommended. + + + You can read more about LDAP Auth configuration in the [LDAP Auth documentation](/documentation/platform/identities/ldap-auth/general). + + + + + + +### Using Templates + +Once created, templates can be applied when configuring authentication methods for machine identities. When adding an auth method to an identity, you'll have the option to select from available templates or configure manually. + +### Managing Template Usage + +You can view which identities are using a specific template by clicking **View Usages** in the template's dropdown menu. This allows you to: + +![Template Usages](/images/platform/identities/auth-templates/template-usages.png) +![Template Usages Modal](/images/platform/identities/auth-templates/template-usages-modal.png) + +- See all identities currently using the template +- Unlink identities from the template + +## FAQ + + + + Yes, you can edit existing templates. After editing a template, changes to templates will automatically update identities that are already using them. + + + + If you delete a template that's currently being used by identities, those identities will continue to function with their existing configuration. + + + + Yes, click **View Usages** in the template's dropdown menu to see all identities currently using that template. You can also unlink identities from templates from this view. + + + + Currently, auth templates support LDAP Auth. Support for additional authentication methods will be added in future releases. + + \ No newline at end of file diff --git a/docs/images/platform/identities/auth-templates/create-template-button.png b/docs/images/platform/identities/auth-templates/create-template-button.png new file mode 100644 index 000000000..859031ea3 Binary files /dev/null and b/docs/images/platform/identities/auth-templates/create-template-button.png differ diff --git a/docs/images/platform/identities/auth-templates/ldap-template.png b/docs/images/platform/identities/auth-templates/ldap-template.png new file mode 100644 index 000000000..04e9c5a1a Binary files /dev/null and b/docs/images/platform/identities/auth-templates/ldap-template.png differ diff --git a/docs/images/platform/identities/auth-templates/template-usages-modal.png b/docs/images/platform/identities/auth-templates/template-usages-modal.png new file mode 100644 index 000000000..1048593b0 Binary files /dev/null and b/docs/images/platform/identities/auth-templates/template-usages-modal.png differ diff --git a/docs/images/platform/identities/auth-templates/template-usages.png b/docs/images/platform/identities/auth-templates/template-usages.png new file mode 100644 index 000000000..67e71b53a Binary files /dev/null and b/docs/images/platform/identities/auth-templates/template-usages.png differ diff --git a/docs/images/platform/identities/auth-templates/templates-section.png b/docs/images/platform/identities/auth-templates/templates-section.png new file mode 100644 index 000000000..a26ebd820 Binary files /dev/null and b/docs/images/platform/identities/auth-templates/templates-section.png differ diff --git a/frontend/src/context/OrgPermissionContext/types.ts b/frontend/src/context/OrgPermissionContext/types.ts index 59446eb07..95d65273b 100644 --- a/frontend/src/context/OrgPermissionContext/types.ts +++ b/frontend/src/context/OrgPermissionContext/types.ts @@ -21,6 +21,14 @@ export enum OrgGatewayPermissionActions { AttachGateways = "attach-gateways" } +export enum OrgPermissionMachineIdentityAuthTemplateActions { + ListTemplates = "list-templates", + CreateTemplates = "create-templates", + EditTemplates = "edit-templates", + DeleteTemplates = "delete-templates", + UnlinkTemplates = "unlink-templates" +} + export enum OrgPermissionSubjects { Workspace = "workspace", Role = "role", @@ -42,7 +50,8 @@ export enum OrgPermissionSubjects { Kmip = "kmip", Gateway = "gateway", SecretShare = "secret-share", - GithubOrgSync = "github-org-sync" + GithubOrgSync = "github-org-sync", + MachineIdentityAuthTemplate = "machine-identity-auth-template" } export enum OrgPermissionAdminConsoleAction { @@ -113,6 +122,10 @@ export type OrgPermissionSet = | [OrgPermissionAppConnectionActions, OrgPermissionSubjects.AppConnections] | [OrgPermissionIdentityActions, OrgPermissionSubjects.Identity] | [OrgPermissionKmipActions, OrgPermissionSubjects.Kmip] + | [ + OrgPermissionMachineIdentityAuthTemplateActions, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ] | [OrgGatewayPermissionActions, OrgPermissionSubjects.Gateway] | [OrgPermissionSecretShareAction, OrgPermissionSubjects.SecretShare]; // TODO(scott): add back once org UI refactored diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx index 165c94395..c01f1aa85 100644 --- a/frontend/src/hooks/api/identities/mutations.tsx +++ b/frontend/src/hooks/api/identities/mutations.tsx @@ -1385,6 +1385,7 @@ export const useAddIdentityLdapAuth = () => { return useMutation({ mutationFn: async ({ identityId, + templateId, url, bindDN, bindPass, @@ -1400,6 +1401,7 @@ export const useAddIdentityLdapAuth = () => { const { data } = await apiRequest.post<{ identityLdapAuth: IdentityLdapAuth }>( `/api/v1/auth/ldap-auth/identities/${identityId}`, { + templateId, url, bindDN, bindPass, @@ -1432,6 +1434,7 @@ export const useUpdateIdentityLdapAuth = () => { return useMutation({ mutationFn: async ({ identityId, + templateId, url, bindDN, bindPass, @@ -1447,6 +1450,7 @@ export const useUpdateIdentityLdapAuth = () => { const { data } = await apiRequest.patch<{ identityLdapAuth: IdentityLdapAuth }>( `/api/v1/auth/ldap-auth/identities/${identityId}`, { + templateId, url, bindDN, bindPass, diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index 1af1cc24c..c0e49e987 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -567,10 +567,11 @@ export type IdentityTokenAuth = { export type AddIdentityLdapAuthDTO = { organizationId: string; identityId: string; - url: string; - bindDN: string; - bindPass: string; - searchBase: string; + templateId?: string; + url?: string; + bindDN?: string; + bindPass?: string; + searchBase?: string; searchFilter: string; ldapCaCertificate?: string; allowedFields?: { @@ -588,6 +589,7 @@ export type AddIdentityLdapAuthDTO = { export type UpdateIdentityLdapAuthDTO = { identityId: string; organizationId: string; + templateId?: string; url?: string; bindDN?: string; bindPass?: string; @@ -612,10 +614,11 @@ export type DeleteIdentityLdapAuthDTO = { }; export type IdentityLdapAuth = { - url: string; - bindDN: string; - bindPass: string; - searchBase: string; + url?: string; + bindDN?: string; + templateId?: string; + bindPass?: string; + searchBase?: string; searchFilter: string; ldapCaCertificate?: string; allowedFields?: { diff --git a/frontend/src/hooks/api/identityAuthTemplates/index.tsx b/frontend/src/hooks/api/identityAuthTemplates/index.tsx new file mode 100644 index 000000000..177955438 --- /dev/null +++ b/frontend/src/hooks/api/identityAuthTemplates/index.tsx @@ -0,0 +1,3 @@ +export * from "./mutations"; +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/identityAuthTemplates/mutations.tsx b/frontend/src/hooks/api/identityAuthTemplates/mutations.tsx new file mode 100644 index 000000000..690cb4de7 --- /dev/null +++ b/frontend/src/hooks/api/identityAuthTemplates/mutations.tsx @@ -0,0 +1,97 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { identityAuthTemplatesKeys } from "./queries"; +import { + CreateIdentityAuthTemplateDTO, + DeleteIdentityAuthTemplateDTO, + IdentityAuthTemplate, + MachineAuthTemplateUsage, + UnlinkTemplateUsageDTO, + UpdateIdentityAuthTemplateDTO +} from "./types"; + +export const useCreateIdentityAuthTemplate = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (dto: CreateIdentityAuthTemplateDTO) => { + const { data } = await apiRequest.post<{ template: IdentityAuthTemplate }>( + "/api/v1/identities/templates", + dto + ); + return data.template; + }, + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries({ + queryKey: identityAuthTemplatesKeys.getTemplates({ organizationId }) + }); + } + }); +}; + +export const useUpdateIdentityAuthTemplate = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (dto: UpdateIdentityAuthTemplateDTO) => { + const { data } = await apiRequest.patch<{ template: IdentityAuthTemplate }>( + `/api/v1/identities/templates/${dto.templateId}`, + dto + ); + return data.template; + }, + onSuccess: (_, { organizationId, templateId }) => { + queryClient.invalidateQueries({ + queryKey: identityAuthTemplatesKeys.getTemplates({ organizationId }) + }); + queryClient.invalidateQueries({ + queryKey: identityAuthTemplatesKeys.getTemplate(templateId) + }); + } + }); +}; + +export const useDeleteIdentityAuthTemplate = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (dto: DeleteIdentityAuthTemplateDTO) => { + await apiRequest.delete(`/api/v1/identities/templates/${dto.templateId}`, { + params: { organizationId: dto.organizationId } + }); + }, + onSuccess: (_, { organizationId, templateId }) => { + queryClient.invalidateQueries({ + queryKey: identityAuthTemplatesKeys.getTemplates({ organizationId }) + }); + queryClient.removeQueries({ + queryKey: identityAuthTemplatesKeys.getTemplate(templateId) + }); + } + }); +}; + +export const useUnlinkTemplateUsage = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (dto: UnlinkTemplateUsageDTO) => { + const { data } = await apiRequest.post( + `/api/v1/identities/templates/${dto.templateId}/usage`, + { identityIds: dto.identityIds }, + { params: { organizationId: dto.organizationId } } + ); + return data; + }, + onSuccess: (_, { templateId, organizationId }) => { + queryClient.invalidateQueries({ + queryKey: identityAuthTemplatesKeys.getTemplateUsages(templateId) + }); + queryClient.invalidateQueries({ + queryKey: identityAuthTemplatesKeys.getTemplates({ organizationId }) + }); + } + }); +}; diff --git a/frontend/src/hooks/api/identityAuthTemplates/queries.tsx b/frontend/src/hooks/api/identityAuthTemplates/queries.tsx new file mode 100644 index 000000000..b63dce79d --- /dev/null +++ b/frontend/src/hooks/api/identityAuthTemplates/queries.tsx @@ -0,0 +1,90 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { + GetIdentityAuthTemplatesDTO, + GetTemplateUsagesDTO, + IdentityAuthTemplate, + MachineAuthTemplateUsage +} from "./types"; + +export const identityAuthTemplatesKeys = { + all: ["identity-auth-templates"] as const, + getTemplates: (dto: GetIdentityAuthTemplatesDTO) => + [...identityAuthTemplatesKeys.all, "list", dto] as const, + getTemplate: (templateId: string) => + [...identityAuthTemplatesKeys.all, "single", templateId] as const, + getTemplatesByOrgId: (authMethod: string) => + [...identityAuthTemplatesKeys.all, "list", authMethod] as const, + getTemplateUsages: (templateId: string) => + [...identityAuthTemplatesKeys.all, "usages", templateId] as const +}; + +export const useGetIdentityAuthTemplates = (dto: GetIdentityAuthTemplatesDTO) => { + return useQuery({ + queryKey: identityAuthTemplatesKeys.getTemplates(dto), + queryFn: async () => { + const { data } = await apiRequest.get<{ + templates: IdentityAuthTemplate[]; + totalCount: number; + }>("/api/v1/identities/templates/search", { + params: { + organizationId: dto.organizationId, + limit: dto.limit || 50, + offset: dto.offset || 0 + } + }); + return data; + }, + enabled: Boolean(dto.organizationId) + }); +}; + +export const useGetIdentityAuthTemplate = (templateId: string, organizationId: string) => { + return useQuery({ + queryKey: identityAuthTemplatesKeys.getTemplate(templateId), + queryFn: async () => { + const { data } = await apiRequest.get<{ template: IdentityAuthTemplate }>( + `/api/v1/identities/templates/${templateId}`, + { + params: { organizationId } + } + ); + return data.template; + }, + enabled: Boolean(templateId) && Boolean(organizationId) + }); +}; + +export const useGetIdentityAuthTemplatesByOrgId = (authMethod: string) => { + return useQuery({ + queryKey: identityAuthTemplatesKeys.getTemplatesByOrgId(authMethod), + queryFn: async () => { + const { data } = await apiRequest.get( + "/api/v1/identities/templates", + { + params: { authMethod } + } + ); + return data; + }, + enabled: Boolean(authMethod) + }); +}; + +export const useGetTemplateUsages = (dto: GetTemplateUsagesDTO) => { + return useQuery({ + queryKey: identityAuthTemplatesKeys.getTemplateUsages(dto.templateId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/identities/templates/${dto.templateId}/usage`, + { + params: { organizationId: dto.organizationId } + } + ); + return data; + }, + enabled: Boolean(dto.templateId) && Boolean(dto.organizationId) + }); +}; diff --git a/frontend/src/hooks/api/identityAuthTemplates/types.ts b/frontend/src/hooks/api/identityAuthTemplates/types.ts new file mode 100644 index 000000000..4b52187a9 --- /dev/null +++ b/frontend/src/hooks/api/identityAuthTemplates/types.ts @@ -0,0 +1,76 @@ +export enum MachineIdentityAuthMethod { + LDAP = "ldap" +} + +export interface LdapTemplateFields { + url: string; + bindDN: string; + bindPass: string; + searchBase: string; +} + +export interface IdentityAuthTemplate { + id: string; + name: string; + authMethod: MachineIdentityAuthMethod; + organizationId: string; + templateFields: LdapTemplateFields; + createdAt: string; + updatedAt: string; +} + +export interface CreateIdentityAuthTemplateDTO { + organizationId: string; + name: string; + authMethod: MachineIdentityAuthMethod; + templateFields: LdapTemplateFields; +} + +export interface UpdateIdentityAuthTemplateDTO { + templateId: string; + organizationId: string; + name?: string; + templateFields?: Partial; +} + +export interface DeleteIdentityAuthTemplateDTO { + templateId: string; + organizationId: string; +} + +export interface GetIdentityAuthTemplatesDTO { + organizationId: string; + limit?: number; + offset?: number; +} + +export interface MachineAuthTemplateUsage { + identityId: string; + identityName: string; +} + +export interface GetTemplateUsagesDTO { + templateId: string; + organizationId: string; +} + +export interface UnlinkTemplateUsageDTO { + templateId: string; + identityIds: string[]; + organizationId: string; +} + +export const TEMPLATE_ERROR_MESSAGES = { + UNLINK_SUCCESS: "Successfully unlinked template usages", + UNLINK_FAILED: "Failed to unlink template usages", + SINGLE_UNLINK_SUCCESS: "Successfully unlinked template usage", + SINGLE_UNLINK_FAILED: "Failed to unlink template usage" +} as const; + +export const TEMPLATE_UI_LABELS = { + VIEW_USAGES: "View Usages", + EDIT_TEMPLATE: "Edit Template", + DELETE_TEMPLATE: "Delete Template", + UNLINK: "Unlink", + UNSELECT_ALL: "Unselect All" +} as const; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 4b4967f16..78dcd3b79 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -15,6 +15,7 @@ export * from "./gateways"; export * from "./githubOrgSyncConfig"; export * from "./groups"; export * from "./identities"; +export * from "./identityAuthTemplates"; export * from "./identityProjectAdditionalPrivilege"; export * from "./incidentContacts"; export * from "./integrationAuth"; diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index 87a02231f..4ded71cfe 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -53,4 +53,5 @@ export type SubscriptionPlan = { secretScanning: boolean; enterpriseSecretSyncs: boolean; enterpriseAppConnections: boolean; + machineIdentityAuthTemplates: boolean; }; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthTemplateModal.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthTemplateModal.tsx new file mode 100644 index 000000000..1c19ca122 --- /dev/null +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthTemplateModal.tsx @@ -0,0 +1,291 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { + MachineIdentityAuthMethod, + useCreateIdentityAuthTemplate, + useUpdateIdentityAuthTemplate +} from "@app/hooks/api/identityAuthTemplates"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const authMethods = [{ label: "LDAP Auth", value: MachineIdentityAuthMethod.LDAP }]; + +const schema = z.object({ + name: z.string().min(1, "Template name is required"), + method: z.nativeEnum(MachineIdentityAuthMethod), + url: z.string().min(1, "LDAP URL is required"), + bindDN: z.string().min(1, "Bind DN is required"), + bindPass: z.string().min(1, "Bind Pass is required"), + searchBase: z.string().min(1, "Search Base / DN is required") +}); + +export type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["createTemplate", "editTemplate"]>; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["createTemplate", "editTemplate"]>, + state?: boolean + ) => void; +}; + +export const IdentityAuthTemplateModal = ({ popUp, handlePopUpToggle }: Props) => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + + const { mutateAsync: createTemplate } = useCreateIdentityAuthTemplate(); + const { mutateAsync: updateTemplate } = useUpdateIdentityAuthTemplate(); + + const isEdit = popUp.editTemplate.isOpen; + const template = popUp.editTemplate?.data?.template; + + const { + control, + handleSubmit, + reset, + watch, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + name: "", + method: MachineIdentityAuthMethod.LDAP, + url: "", + bindDN: "", + bindPass: "", + searchBase: "" + } + }); + + useEffect(() => { + if (isEdit && template) { + reset({ + name: template.name || "", + method: MachineIdentityAuthMethod.LDAP, + url: template.templateFields?.url || "", + bindDN: template.templateFields?.bindDN || "", + bindPass: template.templateFields?.bindPass || "", + searchBase: template.templateFields?.searchBase || "" + }); + } else { + reset({ + name: "", + method: MachineIdentityAuthMethod.LDAP, + url: "", + bindDN: "", + bindPass: "", + searchBase: "" + }); + } + }, [isEdit, template, reset]); + + const selectedMethod = watch("method"); + + const onFormSubmit = async (data: FormData) => { + try { + if (isEdit && template) { + await updateTemplate({ + templateId: template.id, + organizationId: orgId, + name: data.name, + templateFields: { + url: data.url, + bindDN: data.bindDN, + bindPass: data.bindPass, + searchBase: data.searchBase + } + }); + createNotification({ + text: "Successfully updated auth template", + type: "success" + }); + } else { + await createTemplate({ + organizationId: orgId, + name: data.name, + authMethod: data.method, + templateFields: { + url: data.url, + bindDN: data.bindDN, + bindPass: data.bindPass, + searchBase: data.searchBase + } + }); + createNotification({ + text: "Successfully created auth template", + type: "success" + }); + } + + handlePopUpToggle(isEdit ? "editTemplate" : "createTemplate", false); + reset(); + } catch (err) { + console.error(err); + const error = err as any; + const text = + error?.response?.data?.message ?? `Failed to ${isEdit ? "update" : "create"} auth template`; + + createNotification({ + text, + type: "error" + }); + } + }; + + const handleClose = () => { + handlePopUpToggle(isEdit ? "editTemplate" : "createTemplate", false); + reset(); + }; + + return ( + + +
+ ( + + + + )} + /> + + ( + + + + )} + /> + + {/* LDAP Configuration Fields */} + {selectedMethod === "ldap" && ( + <> + ( + + + + )} + /> + + ( + + + + )} + /> + + ( + + + + )} + /> + + ( + + + + )} + /> + + )} + +
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthTemplatesTable.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthTemplatesTable.tsx new file mode 100644 index 000000000..d556424a2 --- /dev/null +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthTemplatesTable.tsx @@ -0,0 +1,284 @@ +import { + faArrowDown, + faArrowUp, + faEdit, + faEllipsisV, + faEye, + faMagnifyingGlass, + faServer, + faTrash +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { OrgPermissionCan } from "@app/components/permissions"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + IconButton, + Input, + Pagination, + Spinner, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { OrgPermissionSubjects, useOrganization } from "@app/context"; +import { OrgPermissionMachineIdentityAuthTemplateActions } from "@app/context/OrgPermissionContext/types"; +import { + getUserTablePreference, + PreferenceKey, + setUserTablePreference +} from "@app/helpers/userTablePreferences"; +import { usePagination, useResetPageHelper } from "@app/hooks"; +import { OrderByDirection } from "@app/hooks/api/generic/types"; +import { + TEMPLATE_UI_LABELS, + useGetIdentityAuthTemplates +} from "@app/hooks/api/identityAuthTemplates"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +enum TemplatesOrderBy { + Name = "name", + AuthMethod = "authMethod" +} + +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState< + ["deleteTemplate", "createTemplate", "editTemplate", "viewUsages"] + >, + data?: any + ) => void; +}; + +export const IdentityAuthTemplatesTable = ({ handlePopUpOpen }: Props) => { + const { currentOrg } = useOrganization(); + + const { + offset, + limit, + orderBy, + setOrderBy, + orderDirection, + setOrderDirection, + search, + debouncedSearch, + setPage, + setSearch, + perPage, + page, + setPerPage + } = usePagination(TemplatesOrderBy.Name, { + initPerPage: getUserTablePreference("templatesTable", PreferenceKey.PerPage, 20) + }); + + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + setUserTablePreference("templatesTable", PreferenceKey.PerPage, newPerPage); + }; + + const organizationId = currentOrg?.id || ""; + + const { data, isPending, isFetching } = useGetIdentityAuthTemplates({ + organizationId, + limit, + offset + }); + + const { templates = [], totalCount = 0 } = data ?? {}; + useResetPageHelper({ + totalCount, + offset, + setPage + }); + + const filteredTemplates = templates.filter((template) => + template.name.toLowerCase().includes(debouncedSearch.toLowerCase()) + ); + + const handleSort = (column: TemplatesOrderBy) => { + if (column === orderBy) { + setOrderDirection((prev) => + prev === OrderByDirection.ASC ? OrderByDirection.DESC : OrderByDirection.ASC + ); + return; + } + + setOrderBy(column); + setOrderDirection(OrderByDirection.ASC); + }; + + return ( +
+
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search templates by name..." + /> +
+ + + + + + + + + + + + {isPending && } + {!isPending && + filteredTemplates?.map((template) => ( + + + + + + + ))} + +
+
+ Name + handleSort(TemplatesOrderBy.Name)} + > + + +
+
+
+ Method + handleSort(TemplatesOrderBy.AuthMethod)} + > + + +
+
URL{isFetching ? : null}
{template.name} +
+ {template.authMethod} +
+
+ + {template.templateFields.url} + + + + + + + + + + } + onClick={(e) => { + e.stopPropagation(); + handlePopUpOpen("viewUsages", { template }); + }} + > + {TEMPLATE_UI_LABELS.VIEW_USAGES} + + + {(isAllowed) => ( + } + onClick={(e) => { + e.stopPropagation(); + handlePopUpOpen("editTemplate", { template }); + }} + isDisabled={!isAllowed} + > + {TEMPLATE_UI_LABELS.EDIT_TEMPLATE} + + )} + + + {(isAllowed) => ( + { + e.stopPropagation(); + handlePopUpOpen("deleteTemplate", { + templateId: template.id, + name: template.name + }); + }} + isDisabled={!isAllowed} + icon={} + > + {TEMPLATE_UI_LABELS.DELETE_TEMPLATE} + + )} + + + +
+ {!isPending && data && totalCount > 0 && ( + setPage(newPage)} + onChangePerPage={handlePerPageChange} + /> + )} + {!isPending && data && filteredTemplates.length === 0 && ( + 0 + ? "No templates match search filter" + : "No identity auth templates have been created" + } + icon={faServer} + /> + )} +
+
+ ); +}; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLdapAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLdapAuthForm.tsx index 62080bfd1..48c0e00b0 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLdapAuthForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLdapAuthForm.tsx @@ -11,6 +11,8 @@ import { FormControl, IconButton, Input, + Select, + SelectItem, Tab, TabList, TabPanel, @@ -25,16 +27,19 @@ import { useUpdateIdentityLdapAuth } from "@app/hooks/api"; import { IdentityTrustedIp } from "@app/hooks/api/identities/types"; +import { useGetIdentityAuthTemplatesByOrgId } from "@app/hooks/api/identityAuthTemplates/queries"; import { UsePopUpState } from "@app/hooks/usePopUp"; import { IdentityFormTab } from "./types"; const schema = z .object({ - url: z.string().min(1), - bindDN: z.string(), - bindPass: z.string(), - searchBase: z.string(), + scope: z.enum(["template", "custom"]), + templateId: z.string().optional(), + url: z.string().optional(), + bindDN: z.string().optional(), + bindPass: z.string().optional(), + searchBase: z.string().optional(), searchFilter: z.string(), // defaults to (uid={{username}}) ldapCaCertificate: z .string() @@ -66,7 +71,50 @@ const schema = z ) .min(1) }) - .required(); + .superRefine((data, ctx) => { + // Validation based on scope + if (data.scope === "template") { + if (!data.templateId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Template is required when using template scope", + path: ["templateId"] + }); + } + return; + } + + if (data.scope === "custom") { + if (!data.url) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "LDAP URL is required when using custom scope", + path: ["url"] + }); + } + if (!data.bindDN) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Bind DN is required when using custom scope", + path: ["bindDN"] + }); + } + if (!data.bindPass) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Bind Pass is required when using custom scope", + path: ["bindPass"] + }); + } + if (!data.searchBase) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Search Base is required when using custom scope", + path: ["searchBase"] + }); + } + } + }); export type FormData = z.infer; @@ -93,6 +141,7 @@ export const IdentityLdapAuthForm = ({ const { mutateAsync: addMutateAsync } = useAddIdentityLdapAuth(); const { mutateAsync: updateMutateAsync } = useUpdateIdentityLdapAuth(); const [tabValue, setTabValue] = useState(IdentityFormTab.Configuration); + const { data: templates } = useGetIdentityAuthTemplatesByOrgId("ldap"); const { data } = useGetIdentityLdapAuth(identityId ?? "", { enabled: isUpdate @@ -102,11 +151,13 @@ export const IdentityLdapAuthForm = ({ control, handleSubmit, reset, - + watch, formState: { isSubmitting } } = useForm({ resolver: zodResolver(schema), defaultValues: { + scope: "template", + templateId: "", url: "", bindDN: "", bindPass: "", @@ -119,6 +170,8 @@ export const IdentityLdapAuthForm = ({ } }); + const scope = watch("scope"); + const { fields: accessTokenTrustedIpsFields, append: appendAccessTokenTrustedIp, @@ -131,16 +184,34 @@ export const IdentityLdapAuthForm = ({ remove: removeAllowedField } = useFieldArray({ control, name: "allowedFields" }); + // Helper function to determine scope based on existing data + const determineScope = (authData: any) => { + // If templateId exists in the data, it's template scope + // If url, bindDN, bindPass, searchBase exist, it's custom scope + if (authData.templateId) { + return "template"; + } + if (authData.url || authData.bindDN || authData.bindPass || authData.searchBase) { + return "custom"; + } + // Default to template if we can't determine + return "template"; + }; + useEffect(() => { if (data) { + const detectedScope = determineScope(data); + reset({ - url: data.url, - bindDN: data.bindDN, - bindPass: data.bindPass, - searchBase: data.searchBase, + scope: detectedScope, + templateId: data.templateId || "", + url: data.url || "", + bindDN: data.bindDN || "", + bindPass: data.bindPass || "", + searchBase: data.searchBase || "", searchFilter: data.searchFilter, ldapCaCertificate: data.ldapCaCertificate || undefined, - allowedFields: data.allowedFields, + allowedFields: data.allowedFields || [], accessTokenTTL: String(data.accessTokenTTL), accessTokenMaxTTL: String(data.accessTokenMaxTTL), accessTokenNumUsesLimit: String(data.accessTokenNumUsesLimit), @@ -152,78 +223,81 @@ export const IdentityLdapAuthForm = ({ } ) }); - } else { - reset({ - url: "", - bindDN: "", - bindPass: "", - searchBase: "", - searchFilter: "(uid={{username}})", - ldapCaCertificate: undefined, - allowedFields: [], - accessTokenTTL: "2592000", - accessTokenMaxTTL: "2592000", - accessTokenNumUsesLimit: "0", - accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] - }); + return; } - }, [data]); + + reset({ + scope: "template", + templateId: "", + url: "", + bindDN: "", + bindPass: "", + searchBase: "", + searchFilter: "(uid={{username}})", + ldapCaCertificate: undefined, + allowedFields: [], + accessTokenTTL: "2592000", + accessTokenMaxTTL: "2592000", + accessTokenNumUsesLimit: "0", + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + }); + }, [data, reset]); useEffect(() => { if (!subscription?.ldap) { handlePopUpOpen("upgradePlan"); handlePopUpToggle("identityAuthMethod", false); } - }, [subscription]); + }, [subscription, handlePopUpOpen, handlePopUpToggle]); - const onFormSubmit = async ({ - url, - bindDN, - bindPass, - searchBase, - searchFilter, - ldapCaCertificate, - allowedFields, - accessTokenTTL, - accessTokenMaxTTL, - accessTokenNumUsesLimit, - accessTokenTrustedIps - }: FormData) => { + const onFormSubmit = async (formData: FormData) => { try { if (!identityId) return; + const { + scope: submissionScope, + templateId: submissionTemplateId, + url: submissionUrl, + bindDN: submissionBindDN, + bindPass: submissionBindPass, + searchBase: submissionSearchBase, + searchFilter, + ldapCaCertificate, + allowedFields, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + } = formData; + + const basePayload = { + organizationId: orgId, + identityId, + searchFilter, + ldapCaCertificate, + allowedFields, + accessTokenTTL: Number(accessTokenTTL), + accessTokenMaxTTL: Number(accessTokenMaxTTL), + accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), + accessTokenTrustedIps + }; + + // Add scope-specific fields + const payload = + submissionScope === "template" + ? { ...basePayload, templateId: submissionTemplateId } + : { + ...basePayload, + url: submissionUrl, + bindDN: submissionBindDN, + bindPass: submissionBindPass, + searchBase: submissionSearchBase + }; + if (data) { - await updateMutateAsync({ - organizationId: orgId, - identityId, - url, - bindDN, - bindPass, - searchBase, - searchFilter, - ldapCaCertificate, - allowedFields, - accessTokenTTL: Number(accessTokenTTL), - accessTokenMaxTTL: Number(accessTokenMaxTTL), - accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), - accessTokenTrustedIps - }); + await updateMutateAsync(payload); } else { - await addMutateAsync({ - organizationId: orgId, - identityId, - url, - bindDN, - bindPass, - searchBase, - searchFilter, - ldapCaCertificate, - allowedFields, - accessTokenTTL: Number(accessTokenTTL), - accessTokenMaxTTL: Number(accessTokenMaxTTL), - accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), - accessTokenTrustedIps - }); + await addMutateAsync(payload); } handlePopUpToggle("identityAuthMethod", false); @@ -247,6 +321,8 @@ export const IdentityLdapAuthForm = ({ onSubmit={handleSubmit(onFormSubmit, (fields) => { setTabValue( [ + "scope", + "templateId", "url", "bindDN", "bindPass", @@ -270,63 +346,124 @@ export const IdentityLdapAuthForm = ({ ( + name="scope" + render={({ field: { value, onChange }, fieldState: { error } }) => ( - - - )} - /> - ( - - - - )} - /> - ( - - - - )} - /> - ( - - + )} /> + {scope === "template" && ( + ( + + + + )} + /> + )} + + {scope === "custom" && ( + <> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + + )} + ( ( ( { @@ -28,6 +31,7 @@ export const IdentitySection = withPermission( const orgId = currentOrg?.id || ""; const { mutateAsync: deleteMutateAsync } = useDeleteIdentity(); + const { mutateAsync: deleteTemplateMutateAsync } = useDeleteIdentityAuthTemplate(); const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "identity", "identityAuthMethod", @@ -35,7 +39,11 @@ export const IdentitySection = withPermission( "universalAuthClientSecret", "deleteUniversalAuthClientSecret", "upgradePlan", - "tokenAuthToken" + "tokenAuthToken", + "createTemplate", + "editTemplate", + "deleteTemplate", + "viewUsages" ] as const); const isMoreIdentitiesAllowed = subscription?.identityLimit @@ -69,6 +77,31 @@ export const IdentitySection = withPermission( } }; + const onDeleteTemplateSubmit = async (templateId: string) => { + try { + await deleteTemplateMutateAsync({ + templateId, + organizationId: orgId + }); + + createNotification({ + text: "Successfully deleted template", + type: "success" + }); + + handlePopUpClose("deleteTemplate"); + } catch (err) { + console.error(err); + const error = err as any; + const text = error?.response?.data?.message ?? "Failed to delete template"; + + createNotification({ + text, + type: "error" + }); + } + }; + return (
@@ -115,7 +148,62 @@ export const IdentitySection = withPermission(
+ + {/* Identity Auth Templates Section */} + {subscription.machineIdentityAuthTemplates && ( +
+
+
+

Identity Auth Templates

+ +
+ + Docs + +
+
+
+ + {(isAllowed) => ( + + )} + +
+ +
+ )} + + handlePopUpClose("viewUsages")} + templateId={ + (popUp?.viewUsages?.data as { template: { id: string; name: string } })?.template?.id || + "" + } + templateName={ + (popUp?.viewUsages?.data as { template: { id: string; name: string } })?.template + ?.name || "" + } + /> {/* + handlePopUpToggle("deleteTemplate", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onDeleteTemplateSubmit( + (popUp?.deleteTemplate?.data as { templateId: string })?.templateId + ) + } + /> handlePopUpToggle("upgradePlan", isOpen)} diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/MachineAuthTemplateUsagesModal.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/MachineAuthTemplateUsagesModal.tsx new file mode 100644 index 000000000..8fdd2239f --- /dev/null +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/MachineAuthTemplateUsagesModal.tsx @@ -0,0 +1,245 @@ +import { useEffect, useState } from "react"; +import { faCertificate, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { + Badge, + Button, + Checkbox, + DeleteActionModal, + EmptyState, + Modal, + ModalContent, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { + MachineAuthTemplateUsage, + TEMPLATE_ERROR_MESSAGES, + TEMPLATE_UI_LABELS, + useGetTemplateUsages, + useUnlinkTemplateUsage +} from "@app/hooks/api/identityAuthTemplates"; +import { usePopUp } from "@app/hooks/usePopUp"; + +type Props = { + isOpen: boolean; + onClose: () => void; + templateId: string; + templateName: string; +}; + +export const MachineAuthTemplateUsagesModal = ({ + isOpen, + onClose, + templateId, + templateName +}: Props) => { + const { currentOrg } = useOrganization(); + const [selectedUsageIds, setSelectedUsageIds] = useState([]); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "unlinkUsages" + ] as const); + + useEffect(() => { + if (!isOpen) { + setSelectedUsageIds([]); + handlePopUpClose("unlinkUsages"); + } + }, [isOpen, handlePopUpClose]); + + const organizationId = currentOrg?.id || ""; + + const { + data: usages = [], + isPending, + refetch + } = useGetTemplateUsages({ + templateId, + organizationId + }); + + const { mutateAsync: unlinkUsage } = useUnlinkTemplateUsage(); + + const handleUnlinkUsages = async (selectedUsages: MachineAuthTemplateUsage[]) => { + try { + await unlinkUsage({ + templateId, + identityIds: selectedUsages.map((usage) => usage.identityId), + organizationId + }); + + createNotification({ + text: TEMPLATE_ERROR_MESSAGES.UNLINK_SUCCESS, + type: "success" + }); + + setSelectedUsageIds([]); + handlePopUpClose("unlinkUsages"); + refetch(); + } catch { + createNotification({ + text: TEMPLATE_ERROR_MESSAGES.UNLINK_FAILED, + type: "error" + }); + } + }; + + const handleUsageToggle = (usageId: string) => { + setSelectedUsageIds((prev) => + prev.includes(usageId) ? prev.filter((id) => id !== usageId) : [...prev, usageId] + ); + }; + + const handleSelectAll = () => { + if (selectedUsageIds.length === usages.length) { + setSelectedUsageIds([]); + } else { + setSelectedUsageIds(usages.map((usage) => usage.identityId)); + } + }; + + return ( + <> + + +
+
0 && "h-16" + )} + > +
+
{selectedUsageIds.length} Selected
+ + +
+
+ + + + + + + + + + + + {isPending && } + {!isPending && + usages.map((usage) => ( + + + + + + ))} + +
+ 0 && selectedUsageIds.length === usages.length} + onCheckedChange={handleSelectAll} + /> + Identity NameIdentity ID
+ handleUsageToggle(usage.identityId)} + /> + {usage.identityName} + {usage.identityId} +
+ {!isPending && usages.length === 0 && ( + + )} +
+
+
+
+ + handlePopUpToggle("unlinkUsages", isDeleteOpen)} + deleteKey="confirm" + onDeleteApproved={() => + handleUnlinkUsages( + popUp.unlinkUsages.data.selectedUsagesList as MachineAuthTemplateUsage[] + ) + } + buttonText={TEMPLATE_UI_LABELS.UNLINK} + > +
+ This template will no longer be used by the following{" "} + {popUp.unlinkUsages.data?.selectedUsagesList?.length > 1 ? "identities" : "identity"}: +
+
+
    + {(popUp.unlinkUsages.data?.selectedUsagesList as MachineAuthTemplateUsage[])?.map( + (usage) => ( +
  • +
    + {usage.identityName} + + {usage.identityId} + +
    +
  • + ) + )} +
+
+
+ + ); +}; diff --git a/frontend/src/pages/organization/RoleByIDPage/components/OrgRoleModifySection.utils.ts b/frontend/src/pages/organization/RoleByIDPage/components/OrgRoleModifySection.utils.ts index 8a0f1610e..65a190902 100644 --- a/frontend/src/pages/organization/RoleByIDPage/components/OrgRoleModifySection.utils.ts +++ b/frontend/src/pages/organization/RoleByIDPage/components/OrgRoleModifySection.utils.ts @@ -9,6 +9,7 @@ import { OrgPermissionGroupActions, OrgPermissionIdentityActions, OrgPermissionKmipActions, + OrgPermissionMachineIdentityAuthTemplateActions, OrgPermissionSecretShareAction } from "@app/context/OrgPermissionContext/types"; import { TPermission } from "@app/hooks/api/roles/types"; @@ -82,6 +83,16 @@ const orgGatewayPermissionSchema = z }) .optional(); +const machineIdentityAuthTemplatePermissionSchema = z + .object({ + [OrgPermissionMachineIdentityAuthTemplateActions.ListTemplates]: z.boolean().optional(), + [OrgPermissionMachineIdentityAuthTemplateActions.EditTemplates]: z.boolean().optional(), + [OrgPermissionMachineIdentityAuthTemplateActions.DeleteTemplates]: z.boolean().optional(), + [OrgPermissionMachineIdentityAuthTemplateActions.CreateTemplates]: z.boolean().optional(), + [OrgPermissionMachineIdentityAuthTemplateActions.UnlinkTemplates]: z.boolean().optional() + }) + .optional(); + const adminConsolePermissionSchmea = z .object({ "access-all-projects": z.boolean().optional() @@ -129,6 +140,7 @@ export const formSchema = z.object({ "app-connections": appConnectionsPermissionSchema, kmip: kmipPermissionSchema, gateway: orgGatewayPermissionSchema, + "machine-identity-auth-template": machineIdentityAuthTemplatePermissionSchema, "secret-share": secretSharingPermissionSchema }) .optional() diff --git a/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/OrgPermissionMachineIdentityAuthTemplateRow.tsx b/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/OrgPermissionMachineIdentityAuthTemplateRow.tsx new file mode 100644 index 000000000..0de32b07b --- /dev/null +++ b/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/OrgPermissionMachineIdentityAuthTemplateRow.tsx @@ -0,0 +1,204 @@ +import { useEffect, useMemo } from "react"; +import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form"; +import { faChevronDown, faChevronRight } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { Checkbox, Select, SelectItem, Td, Tr } from "@app/components/v2"; +import { OrgPermissionMachineIdentityAuthTemplateActions } from "@app/context/OrgPermissionContext/types"; +import { useToggle } from "@app/hooks"; + +import { TFormSchema } from "../OrgRoleModifySection.utils"; + +type Props = { + isEditable: boolean; + setValue: UseFormSetValue; + control: Control; +}; + +enum Permission { + NoAccess = "no-access", + ReadOnly = "read-only", + FullAccess = "full-access", + Custom = "custom" +} + +const PERMISSION_ACTIONS = [ + { + action: OrgPermissionMachineIdentityAuthTemplateActions.ListTemplates, + label: "List Templates" + }, + { + action: OrgPermissionMachineIdentityAuthTemplateActions.CreateTemplates, + label: "Create Templates" + }, + { + action: OrgPermissionMachineIdentityAuthTemplateActions.EditTemplates, + label: "Edit Templates" + }, + { + action: OrgPermissionMachineIdentityAuthTemplateActions.DeleteTemplates, + label: "Delete Templates" + }, + { + action: OrgPermissionMachineIdentityAuthTemplateActions.UnlinkTemplates, + label: "Unlink Templates" + } +] as const; + +export const OrgPermissionMachineIdentityAuthTemplateRow = ({ + isEditable, + control, + setValue +}: Props) => { + const [isRowExpanded, setIsRowExpanded] = useToggle(); + const [isCustom, setIsCustom] = useToggle(); + + const rule = useWatch({ + control, + name: "permissions.machine-identity-auth-template" + }); + + const selectedPermissionCategory = useMemo(() => { + const actions = Object.keys(rule || {}) as Array; + const totalActions = PERMISSION_ACTIONS.length; + const score = actions.map((key) => (rule?.[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number); + + if (isCustom) return Permission.Custom; + if (score === 0) return Permission.NoAccess; + if (score === totalActions) return Permission.FullAccess; + if (score === 1 && rule?.[OrgPermissionMachineIdentityAuthTemplateActions.ListTemplates]) + return Permission.ReadOnly; + + return Permission.Custom; + }, [rule, isCustom]); + + useEffect(() => { + if (selectedPermissionCategory === Permission.Custom) setIsCustom.on(); + else setIsCustom.off(); + }, [selectedPermissionCategory]); + + useEffect(() => { + const isRowCustom = selectedPermissionCategory === Permission.Custom; + if (isRowCustom) { + setIsRowExpanded.on(); + } + }, []); + + const handlePermissionChange = (val: Permission) => { + if (!val) return; + if (val === Permission.Custom) { + setIsRowExpanded.on(); + setIsCustom.on(); + return; + } + setIsCustom.off(); + + switch (val) { + case Permission.FullAccess: + setValue( + "permissions.machine-identity-auth-template", + { + [OrgPermissionMachineIdentityAuthTemplateActions.ListTemplates]: true, + [OrgPermissionMachineIdentityAuthTemplateActions.EditTemplates]: true, + [OrgPermissionMachineIdentityAuthTemplateActions.CreateTemplates]: true, + [OrgPermissionMachineIdentityAuthTemplateActions.DeleteTemplates]: true, + [OrgPermissionMachineIdentityAuthTemplateActions.UnlinkTemplates]: true + }, + { shouldDirty: true } + ); + break; + case Permission.ReadOnly: + setValue( + "permissions.machine-identity-auth-template", + { + [OrgPermissionMachineIdentityAuthTemplateActions.ListTemplates]: true, + [OrgPermissionMachineIdentityAuthTemplateActions.EditTemplates]: false, + [OrgPermissionMachineIdentityAuthTemplateActions.CreateTemplates]: false, + [OrgPermissionMachineIdentityAuthTemplateActions.DeleteTemplates]: false, + [OrgPermissionMachineIdentityAuthTemplateActions.UnlinkTemplates]: false + }, + { shouldDirty: true } + ); + break; + + case Permission.NoAccess: + default: + setValue( + "permissions.machine-identity-auth-template", + { + [OrgPermissionMachineIdentityAuthTemplateActions.ListTemplates]: false, + [OrgPermissionMachineIdentityAuthTemplateActions.EditTemplates]: false, + [OrgPermissionMachineIdentityAuthTemplateActions.CreateTemplates]: false, + [OrgPermissionMachineIdentityAuthTemplateActions.DeleteTemplates]: false, + [OrgPermissionMachineIdentityAuthTemplateActions.UnlinkTemplates]: false + }, + { shouldDirty: true } + ); + } + }; + + return ( + <> + setIsRowExpanded.toggle()} + > + + + + Machine Identity Auth Templates + + + + + {isRowExpanded && ( + + +
+ {PERMISSION_ACTIONS.map(({ action, label }) => { + return ( + ( + { + if (!isEditable) { + createNotification({ + type: "error", + text: "Failed to update default role" + }); + return; + } + field.onChange(e); + }} + id={`permissions.machine-identity-auth-template.${action}`} + > + {label} + + )} + /> + ); + })} +
+ + + )} + + ); +}; diff --git a/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/RolePermissionRow.tsx b/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/RolePermissionRow.tsx index 969024c07..e0702b201 100644 --- a/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/RolePermissionRow.tsx +++ b/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/RolePermissionRow.tsx @@ -65,7 +65,13 @@ type Props = { title: string; formName: keyof Omit< Exclude, - "workspace" | "organization-admin-console" | "kmip" | "gateway" | "secret-share" | "billing" + | "workspace" + | "organization-admin-console" + | "kmip" + | "gateway" + | "secret-share" + | "billing" + | "machine-identity-auth-template" >; setValue: UseFormSetValue; control: Control; diff --git a/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/RolePermissionsSection.tsx b/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/RolePermissionsSection.tsx index d1418a664..6fd848f56 100644 --- a/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/RolePermissionsSection.tsx +++ b/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/RolePermissionsSection.tsx @@ -21,6 +21,7 @@ import { OrgGatewayPermissionRow } from "./OrgPermissionGatewayRow"; import { OrgPermissionGroupRow } from "./OrgPermissionGroupRow"; import { OrgPermissionIdentityRow } from "./OrgPermissionIdentityRow"; import { OrgPermissionKmipRow } from "./OrgPermissionKmipRow"; +import { OrgPermissionMachineIdentityAuthTemplateRow } from "./OrgPermissionMachineIdentityAuthTemplateRow"; import { OrgPermissionSecretShareRow } from "./OrgPermissionSecretShareRow"; import { OrgRoleWorkspaceRow } from "./OrgRoleWorkspaceRow"; import { RolePermissionRow } from "./RolePermissionRow"; @@ -205,6 +206,11 @@ export const RolePermissionsSection = ({ roleId }: Props) => { setValue={setValue} isEditable={isCustomRole} /> +