General improvements on auth templates

This commit is contained in:
Carlos Monastyrski
2025-08-04 15:29:07 -03:00
parent ebe05661d3
commit 3dde786621
29 changed files with 598 additions and 557 deletions

View File

@@ -1352,6 +1352,7 @@ interface AddIdentityLdapAuthEvent {
accessTokenTrustedIps?: Array<TIdentityTrustedIp>;
allowedFields?: TAllowedFields[];
url: string;
templateId?: string | null;
};
}
@@ -1365,6 +1366,7 @@ interface UpdateIdentityLdapAuthEvent {
accessTokenTrustedIps?: Array<TIdentityTrustedIp>;
allowedFields?: TAllowedFields[];
url?: string;
templateId?: string | null;
};
}

View File

@@ -33,7 +33,8 @@ export enum OrgPermissionMachineIdentityAuthTemplateActions {
EditTemplates = "edit-templates",
CreateTemplates = "create-templates",
DeleteTemplates = "delete-templates",
UnlinkTemplates = "unlink-templates"
UnlinkTemplates = "unlink-templates",
UseTemplates = "use-templates"
}
export enum OrgPermissionAdminConsoleAction {
@@ -382,6 +383,7 @@ const buildAdminPermission = () => {
OrgPermissionMachineIdentityAuthTemplateActions.UnlinkTemplates,
OrgPermissionSubjects.MachineIdentityAuthTemplate
);
can(OrgPermissionMachineIdentityAuthTemplateActions.UseTemplates, OrgPermissionSubjects.MachineIdentityAuthTemplate);
can(OrgPermissionSecretShareAction.ManageSettings, OrgPermissionSubjects.SecretShare);
@@ -423,6 +425,7 @@ const buildMemberPermission = () => {
OrgPermissionMachineIdentityAuthTemplateActions.UnlinkTemplates,
OrgPermissionSubjects.MachineIdentityAuthTemplate
);
can(OrgPermissionMachineIdentityAuthTemplateActions.UseTemplates, OrgPermissionSubjects.MachineIdentityAuthTemplate);
return rules;
};

View File

@@ -148,42 +148,6 @@ 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.",
@@ -251,7 +215,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.",
templateId: "The ID of the identity auth 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:
@@ -279,7 +243,7 @@ export const LDAP_AUTH = {
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.",
templateId: "The ID of the template to update the configuration for."
templateId: "The ID of the identity auth template to update the configuration to."
},
RETRIEVE: {
identityId: "The ID of the identity to retrieve the configuration for."

View File

@@ -1469,7 +1469,8 @@ export const registerRoutes = async (
identityLdapAuthDAL,
permissionService,
kmsService,
licenseService
licenseService,
auditLogService
});
const identityAccessTokenService = identityAccessTokenServiceFactory({

View File

@@ -200,55 +200,104 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider)
params: z.object({
identityId: z.string().trim().describe(LDAP_AUTH.ATTACH.identityId)
}),
body: z
.object({
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()
.min(1)
.default("(uid={{username}})")
.refine(isValidLdapFilter, "Invalid LDAP search filter")
.describe(LDAP_AUTH.ATTACH.searchFilter),
allowedFields: AllowedFieldsSchema.array().optional().describe(LDAP_AUTH.ATTACH.allowedFields),
ldapCaCertificate: z.string().trim().optional().describe(LDAP_AUTH.ATTACH.ldapCaCertificate),
accessTokenTrustedIps: z
.object({
ipAddress: z.string().trim()
})
.array()
.min(1)
.default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }])
.describe(LDAP_AUTH.ATTACH.accessTokenTrustedIps),
accessTokenTTL: z
.number()
.int()
.min(0)
.max(315360000)
.default(2592000)
.describe(LDAP_AUTH.ATTACH.accessTokenTTL),
accessTokenMaxTTL: z
.number()
.int()
.min(1)
.max(315360000)
.default(2592000)
.describe(LDAP_AUTH.ATTACH.accessTokenMaxTTL),
accessTokenNumUsesLimit: z.number().int().min(0).default(0).describe(LDAP_AUTH.ATTACH.accessTokenNumUsesLimit)
})
.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."),
body: z.union([
// Template-based configuration
z
.object({
templateId: z.string().trim().describe(LDAP_AUTH.ATTACH.templateId),
searchFilter: z
.string()
.trim()
.min(1)
.default("(uid={{username}})")
.refine(isValidLdapFilter, "Invalid LDAP search filter")
.describe(LDAP_AUTH.ATTACH.searchFilter),
allowedFields: AllowedFieldsSchema.array().optional().describe(LDAP_AUTH.ATTACH.allowedFields),
ldapCaCertificate: z.string().trim().optional().describe(LDAP_AUTH.ATTACH.ldapCaCertificate),
accessTokenTrustedIps: z
.object({
ipAddress: z.string().trim()
})
.array()
.min(1)
.default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }])
.describe(LDAP_AUTH.ATTACH.accessTokenTrustedIps),
accessTokenTTL: z
.number()
.int()
.min(0)
.max(315360000)
.default(2592000)
.describe(LDAP_AUTH.ATTACH.accessTokenTTL),
accessTokenMaxTTL: z
.number()
.int()
.min(1)
.max(315360000)
.default(2592000)
.describe(LDAP_AUTH.ATTACH.accessTokenMaxTTL),
accessTokenNumUsesLimit: z
.number()
.int()
.min(0)
.default(0)
.describe(LDAP_AUTH.ATTACH.accessTokenNumUsesLimit)
})
.refine(
(val) => val.accessTokenTTL <= val.accessTokenMaxTTL,
"Access Token TTL cannot be greater than Access Token Max TTL."
),
// Manual configuration
z
.object({
url: z.string().trim().describe(LDAP_AUTH.ATTACH.url),
bindDN: z.string().trim().describe(LDAP_AUTH.ATTACH.bindDN),
bindPass: z.string().trim().describe(LDAP_AUTH.ATTACH.bindPass),
searchBase: z.string().trim().describe(LDAP_AUTH.ATTACH.searchBase),
searchFilter: z
.string()
.trim()
.min(1)
.default("(uid={{username}})")
.refine(isValidLdapFilter, "Invalid LDAP search filter")
.describe(LDAP_AUTH.ATTACH.searchFilter),
allowedFields: AllowedFieldsSchema.array().optional().describe(LDAP_AUTH.ATTACH.allowedFields),
ldapCaCertificate: z.string().trim().optional().describe(LDAP_AUTH.ATTACH.ldapCaCertificate),
accessTokenTrustedIps: z
.object({
ipAddress: z.string().trim()
})
.array()
.min(1)
.default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }])
.describe(LDAP_AUTH.ATTACH.accessTokenTrustedIps),
accessTokenTTL: z
.number()
.int()
.min(0)
.max(315360000)
.default(2592000)
.describe(LDAP_AUTH.ATTACH.accessTokenTTL),
accessTokenMaxTTL: z
.number()
.int()
.min(1)
.max(315360000)
.default(2592000)
.describe(LDAP_AUTH.ATTACH.accessTokenMaxTTL),
accessTokenNumUsesLimit: z
.number()
.int()
.min(0)
.default(0)
.describe(LDAP_AUTH.ATTACH.accessTokenNumUsesLimit)
})
.refine(
(val) => val.accessTokenTTL <= val.accessTokenMaxTTL,
"Access Token TTL cannot be greater than Access Token Max TTL."
)
]),
response: {
200: z.object({
identityLdapAuth: IdentityLdapAuthsSchema.omit({
@@ -281,7 +330,8 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider)
accessTokenMaxTTL: identityLdapAuth.accessTokenMaxTTL,
accessTokenTTL: identityLdapAuth.accessTokenTTL,
accessTokenNumUsesLimit: identityLdapAuth.accessTokenNumUsesLimit,
allowedFields: req.body.allowedFields
allowedFields: req.body.allowedFields,
templateId: identityLdapAuth.templateId
}
}
});
@@ -383,7 +433,8 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider)
accessTokenTTL: identityLdapAuth.accessTokenTTL,
accessTokenNumUsesLimit: identityLdapAuth.accessTokenNumUsesLimit,
accessTokenTrustedIps: identityLdapAuth.accessTokenTrustedIps as TIdentityTrustedIp[],
allowedFields: req.body.allowedFields
allowedFields: req.body.allowedFields,
templateId: identityLdapAuth.templateId
}
}
});

View File

@@ -12,10 +12,11 @@ import {
} 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)
url: z.string().min(1, TEMPLATE_VALIDATION_MESSAGES.LDAP.URL_REQUIRED),
bindDN: z.string().min(1, TEMPLATE_VALIDATION_MESSAGES.LDAP.BIND_DN_REQUIRED),
bindPass: z.string().min(1, TEMPLATE_VALIDATION_MESSAGES.LDAP.BIND_PASSWORD_REQUIRED),
searchBase: z.string().min(1, TEMPLATE_VALIDATION_MESSAGES.LDAP.SEARCH_BASE_REQUIRED),
ldapCaCertificate: z.string().trim().optional()
});
export const registerIdentityTemplateRouter = async (server: FastifyZodProvider) => {
@@ -44,8 +45,8 @@ export const registerIdentityTemplateRouter = async (server: FastifyZodProvider)
templateFields: ldapTemplateFieldsSchema
}),
response: {
200: z.object({
message: z.string()
200: IdentityAuthTemplatesSchema.extend({
templateFields: z.record(z.string(), z.unknown())
})
}
},
@@ -72,7 +73,7 @@ export const registerIdentityTemplateRouter = async (server: FastifyZodProvider)
}
});
return { message: TEMPLATE_SUCCESS_MESSAGES.CREATED };
return template;
}
});
@@ -104,8 +105,8 @@ export const registerIdentityTemplateRouter = async (server: FastifyZodProvider)
templateFields: ldapTemplateFieldsSchema.partial().optional()
}),
response: {
200: z.object({
message: z.string()
200: IdentityAuthTemplatesSchema.extend({
templateFields: z.record(z.string(), z.unknown())
})
}
},
@@ -132,7 +133,7 @@ export const registerIdentityTemplateRouter = async (server: FastifyZodProvider)
}
});
return { message: TEMPLATE_SUCCESS_MESSAGES.UPDATED };
return template;
}
});
@@ -204,10 +205,8 @@ export const registerIdentityTemplateRouter = async (server: FastifyZodProvider)
templateId: z.string().min(1, TEMPLATE_VALIDATION_MESSAGES.TEMPLATE_ID_REQUIRED)
}),
response: {
200: z.object({
template: IdentityAuthTemplatesSchema.extend({
templateFields: ldapTemplateFieldsSchema
})
200: IdentityAuthTemplatesSchema.extend({
templateFields: ldapTemplateFieldsSchema
})
}
},
@@ -220,7 +219,7 @@ export const registerIdentityTemplateRouter = async (server: FastifyZodProvider)
actorOrgId: req.permission.orgId
});
return { template };
return template;
}
});
@@ -347,7 +346,7 @@ export const registerIdentityTemplateRouter = async (server: FastifyZodProvider)
server.route({
method: "POST",
url: "/:templateId/usage",
url: "/:templateId/delete-usage",
config: {
rateLimit: writeLimit
},

View File

@@ -135,13 +135,8 @@ 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(
async (identitiesRouter) => {
await identitiesRouter.register(registerIdentityRouter);
await identitiesRouter.register(registerIdentityTemplateRouter, { prefix: "/templates" });
},
{ prefix: "/identities" }
);
await server.register(registerIdentityRouter, { prefix: "/identities" });
await server.register(registerIdentityTemplateRouter, { prefix: "/identity-templates" });
await server.register(
async (secretSharingRouter) => {

View File

@@ -7,10 +7,12 @@ export const TEMPLATE_VALIDATION_MESSAGES = {
TEMPLATE_NAME_MAX_LENGTH: "Template name must be at most 64 characters long",
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"
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 = {

View File

@@ -1,5 +1,6 @@
import { ForbiddenError } from "@casl/ability";
import { EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import {
OrgPermissionMachineIdentityAuthTemplateActions,
@@ -9,6 +10,7 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { TOrgPermission } from "@app/lib/types";
import { ActorType } from "../auth/auth-type";
import { TIdentityLdapAuthDALFactory } from "../identity-ldap-auth/identity-ldap-auth-dal";
import { TKmsServiceFactory } from "../kms/kms-service";
import { KmsDataKey } from "../kms/kms-types";
@@ -30,6 +32,7 @@ type TIdentityAuthTemplateServiceFactoryDep = {
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey" | "encryptWithInputKey" | "decryptWithInputKey">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
};
export type TIdentityAuthTemplateServiceFactory = ReturnType<typeof identityAuthTemplateServiceFactory>;
@@ -39,7 +42,8 @@ export const identityAuthTemplateServiceFactory = ({
identityLdapAuthDAL,
permissionService,
kmsService,
licenseService
licenseService,
auditLogService
}: TIdentityAuthTemplateServiceFactoryDep) => {
// Plan check
const $checkPlan = async (orgId: string) => {
@@ -87,7 +91,7 @@ export const identityAuthTemplateServiceFactory = ({
orgId: actorOrgId
});
return template;
return { ...template, templateFields };
};
const updateTemplate = async ({
@@ -104,7 +108,7 @@ export const identityAuthTemplateServiceFactory = ({
templateFields?: Record<string, unknown>;
} & Omit<TOrgPermission, "orgId">) => {
await $checkPlan(actorOrgId);
const template = await identityAuthTemplateDAL.findById(templateId);
const template = await identityAuthTemplateDAL.findByIdAndOrgId(templateId, actorOrgId);
if (!template) {
throw new NotFoundError({ message: "Template not found" });
}
@@ -126,6 +130,8 @@ export const identityAuthTemplateServiceFactory = ({
orgId: template.orgId
});
let finalTemplateFields: Record<string, unknown> = {};
const updatedTemplate = await identityAuthTemplateDAL.transaction(async (tx) => {
const authTemplate = await identityAuthTemplateDAL.updateById(
templateId,
@@ -149,12 +155,13 @@ export const identityAuthTemplateServiceFactory = ({
) as TLdapTemplateFields;
const mergedTemplateFields: TLdapTemplateFields = { ...currentTemplateFields, ...templateFields };
finalTemplateFields = mergedTemplateFields;
const ldapUpdateData: {
url?: string;
searchBase?: string;
encryptedBindDN?: Buffer;
encryptedBindPass?: Buffer;
encryptedLdapCaCertificate?: Buffer;
} = {};
if ("url" in templateFields) {
@@ -173,16 +180,38 @@ export const identityAuthTemplateServiceFactory = ({
plainText: Buffer.from(mergedTemplateFields.bindPass)
}).cipherTextBlob;
}
if ("ldapCaCertificate" in templateFields) {
ldapUpdateData.encryptedLdapCaCertificate = encryptor({
plainText: Buffer.from(mergedTemplateFields.ldapCaCertificate || "")
}).cipherTextBlob;
}
if (Object.keys(ldapUpdateData).length > 0) {
await identityLdapAuthDAL.update({ templateId }, ldapUpdateData, tx);
const updatedLdapAuths = await identityLdapAuthDAL.update({ templateId }, ldapUpdateData, tx);
await Promise.all(
updatedLdapAuths.map(async (updatedLdapAuth) => {
await auditLogService.createAuditLog({
actor: {
type: ActorType.PLATFORM,
metadata: {}
},
orgId: actorOrgId,
event: {
type: EventType.UPDATE_IDENTITY_LDAP_AUTH,
metadata: {
identityId: updatedLdapAuth.identityId,
templateId: template.id
}
}
});
})
);
}
}
return authTemplate;
});
return updatedTemplate;
return { ...updatedTemplate, templateFields: finalTemplateFields };
};
const deleteTemplate = async ({
@@ -193,7 +222,7 @@ export const identityAuthTemplateServiceFactory = ({
actorOrgId
}: TDeleteIdentityAuthTemplateDTO) => {
await $checkPlan(actorOrgId);
const template = await identityAuthTemplateDAL.findById(templateId);
const template = await identityAuthTemplateDAL.findByIdAndOrgId(templateId, actorOrgId);
if (!template) {
throw new NotFoundError({ message: "Template not found" });
}
@@ -212,7 +241,25 @@ export const identityAuthTemplateServiceFactory = ({
const deletedTemplate = await identityAuthTemplateDAL.transaction(async (tx) => {
// Remove template reference from identityLdapAuth records
await identityLdapAuthDAL.update({ templateId }, { templateId: null }, tx);
const updatedLdapAuths = await identityLdapAuthDAL.update({ templateId }, { templateId: null }, tx);
await Promise.all(
updatedLdapAuths.map(async (updatedLdapAuth) => {
await auditLogService.createAuditLog({
actor: {
type: ActorType.PLATFORM,
metadata: {}
},
orgId: actorOrgId,
event: {
type: EventType.UPDATE_IDENTITY_LDAP_AUTH,
metadata: {
identityId: updatedLdapAuth.identityId,
templateId: template.id
}
}
});
})
);
// Delete the template
const [deletedTpl] = await identityAuthTemplateDAL.delete({ id: templateId }, tx);
@@ -230,7 +277,7 @@ export const identityAuthTemplateServiceFactory = ({
actorOrgId
}: TGetIdentityAuthTemplateDTO) => {
await $checkPlan(actorOrgId);
const template = await identityAuthTemplateDAL.findById(templateId);
const template = await identityAuthTemplateDAL.findByIdAndOrgId(templateId, actorOrgId);
if (!template) {
throw new NotFoundError({ message: "Template not found" });
}
@@ -313,7 +360,7 @@ export const identityAuthTemplateServiceFactory = ({
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionMachineIdentityAuthTemplateActions.ListTemplates,
OrgPermissionMachineIdentityAuthTemplateActions.UseTemplates,
OrgPermissionSubjects.MachineIdentityAuthTemplate
);
@@ -350,7 +397,7 @@ export const identityAuthTemplateServiceFactory = ({
OrgPermissionSubjects.MachineIdentityAuthTemplate
);
const template = await identityAuthTemplateDAL.findById(templateId);
const template = await identityAuthTemplateDAL.findByIdAndOrgId(templateId, actorOrgId);
if (!template) {
throw new NotFoundError({ message: "Template not found" });
}
@@ -376,11 +423,11 @@ export const identityAuthTemplateServiceFactory = ({
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionMachineIdentityAuthTemplateActions.ListTemplates,
OrgPermissionMachineIdentityAuthTemplateActions.UnlinkTemplates,
OrgPermissionSubjects.MachineIdentityAuthTemplate
);
const template = await identityAuthTemplateDAL.findById(templateId);
const template = await identityAuthTemplateDAL.findByIdAndOrgId(templateId, actorOrgId);
if (!template) {
throw new NotFoundError({ message: "Template not found" });
}

View File

@@ -8,6 +8,7 @@ export type TLdapTemplateFields = {
bindDN: string;
bindPass: string;
searchBase: string;
ldapCaCertificate?: string;
};
// Union type for all template field types

View File

@@ -4,7 +4,11 @@ import { ForbiddenError } from "@casl/ability";
import { IdentityAuthMethod } from "@app/db/schemas";
import { testLDAPConfig } from "@app/ee/services/ldap-config/ldap-fns";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
import {
OrgPermissionIdentityActions,
OrgPermissionMachineIdentityAuthTemplateActions,
OrgPermissionSubjects
} from "@app/ee/services/permission/org-permission";
import {
constructPermissionErrorMessage,
validatePrivilegeChangeOperation
@@ -217,6 +221,14 @@ export const identityLdapAuthServiceFactory = ({
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity);
if (templateId) {
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionMachineIdentityAuthTemplateActions.UseTemplates,
OrgPermissionSubjects.MachineIdentityAuthTemplate
);
}
const plan = await licenseService.getPlan(identityMembershipOrg.orgId);
if (!plan.ldap) {
@@ -254,7 +266,7 @@ export const identityLdapAuthServiceFactory = ({
? await identityAuthTemplateDAL.findByIdAndOrgId(templateId, identityMembershipOrg.orgId)
: undefined;
let ldapConfig: { bindDN: string; bindPass: string; searchBase: string; url: string };
let ldapConfig: { bindDN: string; bindPass: string; searchBase: string; url: string; ldapCaCertificate?: string };
if (template) {
ldapConfig = JSON.parse(decryptor({ cipherTextBlob: template.templateFields }).toString());
} else {
@@ -267,7 +279,8 @@ export const identityLdapAuthServiceFactory = ({
bindDN,
bindPass,
searchBase,
url
url,
ldapCaCertificate
};
}
@@ -280,9 +293,9 @@ export const identityLdapAuthServiceFactory = ({
});
let encryptedLdapCaCertificate: Buffer | undefined;
if (ldapCaCertificate) {
if (ldapConfig.ldapCaCertificate) {
const { cipherTextBlob: encryptedCertificate } = encryptor({
plainText: Buffer.from(ldapCaCertificate)
plainText: Buffer.from(ldapConfig.ldapCaCertificate)
});
encryptedLdapCaCertificate = encryptedCertificate;
@@ -291,7 +304,7 @@ export const identityLdapAuthServiceFactory = ({
const isConnected = await testLDAPConfig({
bindDN: ldapConfig.bindDN,
bindPass: ldapConfig.bindPass,
caCert: ldapCaCertificate || "",
caCert: ldapConfig.ldapCaCertificate || "",
url: ldapConfig.url
});
@@ -371,6 +384,13 @@ export const identityLdapAuthServiceFactory = ({
);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity);
if (templateId) {
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionMachineIdentityAuthTemplateActions.UseTemplates,
OrgPermissionSubjects.MachineIdentityAuthTemplate
);
}
const plan = await licenseService.getPlan(identityMembershipOrg.orgId);
if (!plan.ldap) {
@@ -411,6 +431,7 @@ export const identityLdapAuthServiceFactory = ({
bindPass?: string;
searchBase?: string;
url?: string;
ldapCaCertificate?: string;
};
if (template) {
@@ -420,7 +441,8 @@ export const identityLdapAuthServiceFactory = ({
bindDN,
bindPass,
searchBase,
url
url,
ldapCaCertificate
};
}
@@ -434,9 +456,9 @@ export const identityLdapAuthServiceFactory = ({
}
let encryptedLdapCaCertificate: Buffer | undefined;
if (ldapCaCertificate) {
if (config.ldapCaCertificate) {
const { cipherTextBlob: ldapCaCertificateCiphertext } = encryptor({
plainText: Buffer.from(ldapCaCertificate)
plainText: Buffer.from(config.ldapCaCertificate)
});
encryptedLdapCaCertificate = ldapCaCertificateCiphertext;
@@ -456,7 +478,7 @@ export const identityLdapAuthServiceFactory = ({
const isConnected = await testLDAPConfig({
bindDN: config.bindDN || ldapConfig.bindDN,
bindPass: config.bindPass || ldapConfig.bindPass,
caCert: ldapCaCertificate || ldapConfig.caCert,
caCert: config.ldapCaCertificate || ldapConfig.caCert,
url: config.url || ldapConfig.url
});

View File

@@ -291,7 +291,6 @@
{
"group": "Machine Identities",
"pages": [
"documentation/platform/identities/auth-templates",
"documentation/platform/identities/alicloud-auth",
"documentation/platform/identities/aws-auth",
"documentation/platform/identities/azure-auth",
@@ -323,6 +322,7 @@
}
]
},
"documentation/platform/identities/auth-templates",
"documentation/platform/token",
"documentation/platform/mfa",
"documentation/platform/github-org-sync"

View File

@@ -67,16 +67,16 @@ Auth templates are managed in **Organization Settings > Access Control > Identit
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.
![Attach Template](/images/platform/identities/auth-templates/machine-identity-page.png)
![Attach Template Form](/images/platform/identities/auth-templates/attach-template-form.png)
### 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:
You can view which identities are using a specific template by clicking **View Usages** in the template's dropdown menu.
![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
<AccordionGroup>
@@ -85,11 +85,11 @@ You can view which identities are using a specific template by clicking **View U
</Accordion>
<Accordion title="What happens if I delete a template that's in use?">
If you delete a template that's currently being used by identities, those identities will continue to function with their existing configuration.
If you delete a template that's currently being used by identities, those identities will continue to function with their existing configuration. However, the link to the template will be broken, and you won't be able to use the template for new identities.
</Accordion>
<Accordion title="Can I see which identities are using a specific template?">
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.
Yes, click **View Usages** in the template's dropdown menu to see all identities currently using that template.
</Accordion>
<Accordion title="Do templates support all authentication methods?">

View File

@@ -5,6 +5,12 @@ description: "Learn how to authenticate with Infisical using LDAP."
**LDAP Auth** is an LDAP based authentication method that allows you to authenticate with Infisical using a machine identity configured with an [LDAP](https://en.wikipedia.org/wiki/Lightweight_Directory_Access_Protocol) directory.
## Templates
You can create reusable LDAP authentication templates to standardize configurations across multiple machine identities. Templates help ensure consistency, reduce configuration errors, and simplify identity management at scale.
To create and manage LDAP auth templates, see our [Machine Identity Auth Templates documentation](/documentation/platform/identities/auth-templates). Once you've created a template, you can apply it when configuring LDAP auth for your identities in the guide below.
## Guide
<Steps>
<Step title="Creating an identity">

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 660 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 461 KiB

After

Width:  |  Height:  |  Size: 192 KiB

View File

@@ -217,3 +217,14 @@ Supports conditions and permission inversion
| `edit-gateways` | Modify existing gateway settings |
| `delete-gateways` | Remove gateways from organization |
| `attach-gateways` | Attach gateways to resources |
#### Subject: `machine-identity-auth-template`
| Action | Description |
| ------------------ | ---------------------------------------------- |
| `list-templates` | View identity auth templates |
| `create-templates` | Create new identity auth templates |
| `edit-templates` | Modify existing identity auth templates |
| `delete-templates` | Remove identity auth templates |
| `unlink-templates` | Unlink identity auth templates from identities |
| `use-templates` | Attach identity auth templates to identities |

View File

@@ -26,7 +26,8 @@ export enum OrgPermissionMachineIdentityAuthTemplateActions {
CreateTemplates = "create-templates",
EditTemplates = "edit-templates",
DeleteTemplates = "delete-templates",
UnlinkTemplates = "unlink-templates"
UnlinkTemplates = "unlink-templates",
UseTemplates = "use-templates"
}
export enum OrgPermissionSubjects {

View File

@@ -18,7 +18,7 @@ export const useCreateIdentityAuthTemplate = () => {
return useMutation({
mutationFn: async (dto: CreateIdentityAuthTemplateDTO) => {
const { data } = await apiRequest.post<{ template: IdentityAuthTemplate }>(
"/api/v1/identities/templates",
"/api/v1/identity-templates",
dto
);
return data.template;
@@ -37,7 +37,7 @@ export const useUpdateIdentityAuthTemplate = () => {
return useMutation({
mutationFn: async (dto: UpdateIdentityAuthTemplateDTO) => {
const { data } = await apiRequest.patch<{ template: IdentityAuthTemplate }>(
`/api/v1/identities/templates/${dto.templateId}`,
`/api/v1/identity-templates/${dto.templateId}`,
dto
);
return data.template;
@@ -58,7 +58,7 @@ export const useDeleteIdentityAuthTemplate = () => {
return useMutation({
mutationFn: async (dto: DeleteIdentityAuthTemplateDTO) => {
await apiRequest.delete(`/api/v1/identities/templates/${dto.templateId}`, {
await apiRequest.delete(`/api/v1/identity-templates/${dto.templateId}`, {
params: { organizationId: dto.organizationId }
});
},
@@ -79,7 +79,7 @@ export const useUnlinkTemplateUsage = () => {
return useMutation({
mutationFn: async (dto: UnlinkTemplateUsageDTO) => {
const { data } = await apiRequest.post<MachineAuthTemplateUsage[]>(
`/api/v1/identities/templates/${dto.templateId}/usage`,
`/api/v1/identity-templates/${dto.templateId}/delete-usage`,
{ identityIds: dto.identityIds },
{ params: { organizationId: dto.organizationId } }
);

View File

@@ -29,7 +29,7 @@ export const useGetIdentityAuthTemplates = (dto: GetIdentityAuthTemplatesDTO) =>
const { data } = await apiRequest.get<{
templates: IdentityAuthTemplate[];
totalCount: number;
}>("/api/v1/identities/templates/search", {
}>("/api/v1/identity-templates/search", {
params: {
organizationId: dto.organizationId,
limit: dto.limit || 50,
@@ -47,13 +47,13 @@ export const useGetIdentityAuthTemplate = (templateId: string, organizationId: s
return useQuery({
queryKey: identityAuthTemplatesKeys.getTemplate(templateId),
queryFn: async () => {
const { data } = await apiRequest.get<{ template: IdentityAuthTemplate }>(
`/api/v1/identities/templates/${templateId}`,
const { data } = await apiRequest.get<IdentityAuthTemplate>(
`/api/v1/identity-templates/${templateId}`,
{
params: { organizationId }
}
);
return data.template;
return data;
},
enabled: Boolean(templateId) && Boolean(organizationId)
});
@@ -63,12 +63,9 @@ export const useGetAvailableTemplates = (authMethod: MachineIdentityAuthMethod)
return useQuery({
queryKey: identityAuthTemplatesKeys.getAvailableTemplates(authMethod),
queryFn: async () => {
const { data } = await apiRequest.get<IdentityAuthTemplate[]>(
"/api/v1/identities/templates",
{
params: { authMethod }
}
);
const { data } = await apiRequest.get<IdentityAuthTemplate[]>("/api/v1/identity-templates", {
params: { authMethod }
});
return data;
},
enabled: Boolean(authMethod)
@@ -80,7 +77,7 @@ export const useGetTemplateUsages = (dto: GetTemplateUsagesDTO) => {
queryKey: identityAuthTemplatesKeys.getTemplateUsages(dto.templateId),
queryFn: async () => {
const { data } = await apiRequest.get<MachineAuthTemplateUsage[]>(
`/api/v1/identities/templates/${dto.templateId}/usage`,
`/api/v1/identity-templates/${dto.templateId}/usage`,
{
params: { organizationId: dto.organizationId }
}

View File

@@ -7,6 +7,7 @@ export interface LdapTemplateFields {
bindDN: string;
bindPass: string;
searchBase: string;
ldapCaCertificate?: string;
}
export interface IdentityAuthTemplate {

View File

@@ -11,7 +11,8 @@ import {
Modal,
ModalContent,
Select,
SelectItem
SelectItem,
TextArea
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import {
@@ -29,7 +30,11 @@ const schema = z.object({
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")
searchBase: z.string().min(1, "Search Base / DN is required"),
ldapCaCertificate: z
.string()
.optional()
.transform((val) => val || undefined)
});
export type FormData = z.infer<typeof schema>;
@@ -66,7 +71,8 @@ export const IdentityAuthTemplateModal = ({ popUp, handlePopUpToggle }: Props) =
url: "",
bindDN: "",
bindPass: "",
searchBase: ""
searchBase: "",
ldapCaCertificate: ""
}
});
@@ -78,7 +84,8 @@ export const IdentityAuthTemplateModal = ({ popUp, handlePopUpToggle }: Props) =
url: template.templateFields?.url || "",
bindDN: template.templateFields?.bindDN || "",
bindPass: template.templateFields?.bindPass || "",
searchBase: template.templateFields?.searchBase || ""
searchBase: template.templateFields?.searchBase || "",
ldapCaCertificate: template.templateFields?.ldapCaCertificate || ""
});
} else {
reset({
@@ -87,7 +94,8 @@ export const IdentityAuthTemplateModal = ({ popUp, handlePopUpToggle }: Props) =
url: "",
bindDN: "",
bindPass: "",
searchBase: ""
searchBase: "",
ldapCaCertificate: ""
});
}
}, [isEdit, template, reset]);
@@ -105,7 +113,8 @@ export const IdentityAuthTemplateModal = ({ popUp, handlePopUpToggle }: Props) =
url: data.url,
bindDN: data.bindDN,
bindPass: data.bindPass,
searchBase: data.searchBase
searchBase: data.searchBase,
ldapCaCertificate: data.ldapCaCertificate
}
});
createNotification({
@@ -121,7 +130,8 @@ export const IdentityAuthTemplateModal = ({ popUp, handlePopUpToggle }: Props) =
url: data.url,
bindDN: data.bindDN,
bindPass: data.bindPass,
searchBase: data.searchBase
searchBase: data.searchBase,
ldapCaCertificate: data.ldapCaCertificate
}
});
createNotification({
@@ -267,6 +277,22 @@ export const IdentityAuthTemplateModal = ({ popUp, handlePopUpToggle }: Props) =
</FormControl>
)}
/>
<Controller
control={control}
name="ldapCaCertificate"
render={({ field, fieldState: { error } }) => (
<FormControl
label="CA Certificate"
isOptional
errorText={error?.message}
isError={Boolean(error)}
tooltipText="An optional PEM-encoded CA cert for the LDAP server. This is used by the TLS client for secure communication with the LDAP server."
>
<TextArea {...field} placeholder="-----BEGIN CERTIFICATE----- ..." />
</FormControl>
)}
/>
</>
)}

View File

@@ -182,7 +182,7 @@ export const IdentityAuthTemplatesTable = ({ handlePopUpOpen }: Props) => {
<Td>{template.name}</Td>
<Td>
<div className="flex items-center">
<span className="capitalize">{template.authMethod}</span>
<span className="uppercase">{template.authMethod}</span>
</div>
</Td>
<Td>

View File

@@ -20,7 +20,11 @@ import {
TextArea,
Tooltip
} from "@app/components/v2";
import { useOrganization, useSubscription } from "@app/context";
import { useOrganization, useOrgPermission, useSubscription } from "@app/context";
import {
OrgPermissionMachineIdentityAuthTemplateActions,
OrgPermissionSubjects
} from "@app/context/OrgPermissionContext/types";
import {
MachineIdentityAuthMethod,
useAddIdentityLdapAuth,
@@ -143,6 +147,12 @@ export const IdentityLdapAuthForm = ({
const { mutateAsync: updateMutateAsync } = useUpdateIdentityLdapAuth();
const [tabValue, setTabValue] = useState<IdentityFormTab>(IdentityFormTab.Configuration);
const { data: templates } = useGetAvailableTemplates(MachineIdentityAuthMethod.LDAP);
const { permission } = useOrgPermission();
const canUseTemplates = permission.can(
OrgPermissionMachineIdentityAuthTemplateActions.UseTemplates,
OrgPermissionSubjects.MachineIdentityAuthTemplate
);
const { data } = useGetIdentityLdapAuth(identityId ?? "", {
enabled: isUpdate
@@ -153,11 +163,12 @@ export const IdentityLdapAuthForm = ({
handleSubmit,
reset,
watch,
setValue,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
scope: "template",
scope: "custom",
templateId: "",
url: "",
bindDN: "",
@@ -188,15 +199,11 @@ export const IdentityLdapAuthForm = ({
// 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";
// Default to custom if we can't determine
return "custom";
};
useEffect(() => {
@@ -228,7 +235,7 @@ export const IdentityLdapAuthForm = ({
}
reset({
scope: "template",
scope: "custom",
templateId: "",
url: "",
bindDN: "",
@@ -345,30 +352,38 @@ export const IdentityLdapAuthForm = ({
<Tab value={IdentityFormTab.Advanced}>Advanced</Tab>
</TabList>
<TabPanel value={IdentityFormTab.Configuration}>
<Controller
control={control}
name="scope"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Configuration Type"
isError={Boolean(error)}
errorText={error?.message}
>
<Select
value={value}
onValueChange={(val) => {
onChange(val);
}}
className="w-full"
position="popper"
dropdownContainerClassName="max-w-none"
{canUseTemplates && (
<Controller
control={control}
name="scope"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Configuration Type"
isError={Boolean(error)}
errorText={error?.message}
>
<SelectItem value="template">Use Template</SelectItem>
<SelectItem value="custom">Custom Configuration</SelectItem>
</Select>
</FormControl>
)}
/>
<Select
value={value}
onValueChange={(val) => {
onChange(val);
setValue("templateId", data?.templateId || "");
setValue("url", data?.url || "");
setValue("bindDN", data?.bindDN || "");
setValue("bindPass", data?.bindPass || "");
setValue("searchBase", data?.searchBase || "");
setValue("ldapCaCertificate", data?.ldapCaCertificate || "");
}}
className="w-full"
position="popper"
dropdownContainerClassName="max-w-none"
>
<SelectItem value="template">Use Template</SelectItem>
<SelectItem value="custom">Custom Configuration</SelectItem>
</Select>
</FormControl>
)}
/>
)}
{scope === "template" && (
<Controller
@@ -385,6 +400,13 @@ export const IdentityLdapAuthForm = ({
value={value}
onValueChange={(val) => {
onChange(val);
const tmp = templates?.find((t) => t.id === val);
if (!tmp) return;
setValue("url", tmp.templateFields.url);
setValue("bindDN", tmp.templateFields.bindDN);
setValue("bindPass", tmp.templateFields.bindPass);
setValue("searchBase", tmp.templateFields.searchBase);
setValue("ldapCaCertificate", tmp.templateFields.ldapCaCertificate);
}}
className="w-full"
position="popper"
@@ -404,66 +426,104 @@ export const IdentityLdapAuthForm = ({
/>
)}
{scope === "custom" && (
<>
<Controller
control={control}
name="url"
render={({ field, fieldState: { error } }) => (
<FormControl
label="LDAP URL"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="ldaps://domain-or-ip:636" type="text" />
</FormControl>
)}
/>
<Controller
control={control}
name="bindDN"
render={({ field, fieldState: { error } }) => (
<FormControl
isRequired
label="Bind DN"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="cn=infisical,ou=Users,dc=example,dc=com" />
</FormControl>
)}
/>
<Controller
control={control}
name="bindPass"
render={({ field, fieldState: { error } }) => (
<FormControl
isRequired
label="Bind Pass"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="********" type="password" />
</FormControl>
)}
/>
<Controller
control={control}
name="searchBase"
render={({ field, fieldState: { error } }) => (
<FormControl
isRequired
label="Search Base / DN"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="ou=machines,dc=acme,dc=com" />
</FormControl>
)}
/>
</>
)}
<Controller
control={control}
name="url"
render={({ field, fieldState: { error } }) => (
<FormControl
label="LDAP URL"
isError={Boolean(error)}
errorText={error?.message}
tooltipText={
scope === "template"
? "This field cannot be modified when using a template"
: undefined
}
isRequired
>
<Input
{...field}
placeholder="ldaps://domain-or-ip:636"
type="text"
isDisabled={scope === "template"}
containerClassName={scope === "template" ? "opacity-55" : ""}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="bindDN"
render={({ field, fieldState: { error } }) => (
<FormControl
isRequired
label="Bind DN"
isError={Boolean(error)}
errorText={error?.message}
tooltipText={
scope === "template"
? "This field cannot be modified when using a template"
: undefined
}
>
<Input
{...field}
containerClassName={scope === "template" ? "opacity-55" : ""}
placeholder="cn=infisical,ou=Users,dc=example,dc=com"
isDisabled={scope === "template"}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="bindPass"
render={({ field, fieldState: { error } }) => (
<FormControl
isRequired
label="Bind Pass"
isError={Boolean(error)}
errorText={error?.message}
tooltipText={
scope === "template"
? "This field cannot be modified when using a template"
: undefined
}
>
<Input
{...field}
placeholder="********"
type="password"
containerClassName={scope === "template" ? "opacity-55" : ""}
isDisabled={scope === "template"}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="searchBase"
render={({ field, fieldState: { error } }) => (
<FormControl
isRequired
label="Search Base / DN"
isError={Boolean(error)}
errorText={error?.message}
tooltipText={
scope === "template"
? "This field cannot be modified when using a template"
: undefined
}
>
<Input
{...field}
placeholder="ou=machines,dc=acme,dc=com"
containerClassName={scope === "template" ? "opacity-55" : ""}
isDisabled={scope === "template"}
/>
</FormControl>
)}
/>
<Controller
control={control}
@@ -641,9 +701,18 @@ export const IdentityLdapAuthForm = ({
isOptional
errorText={error?.message}
isError={Boolean(error)}
tooltipText="An optional PEM-encoded CA cert for the LDAP server. This is used by the TLS client for secure communication with the LDAP server."
tooltipText={
scope === "template"
? "This field cannot be modified when using a template"
: "An optional PEM-encoded CA cert for the LDAP server. This is used by the TLS client for secure communication with the LDAP server."
}
>
<TextArea {...field} placeholder="-----BEGIN CERTIFICATE----- ..." />
<TextArea
{...field}
placeholder="-----BEGIN CERTIFICATE----- ..."
className={scope === "template" ? "opacity-55" : ""}
isDisabled={scope === "template"}
/>
</FormControl>
)}
/>

View File

@@ -103,93 +103,93 @@ export const IdentitySection = withPermission(
};
return (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-1">
<p className="text-xl font-semibold text-mineshaft-100">Identities</p>
<a
href="https://infisical.com/docs/documentation/platform/identities/overview"
target="_blank"
rel="noopener noreferrer"
>
<div className="ml-1 mt-[0.16rem] inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
<span>Docs</span>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.07rem] ml-1.5 text-[10px]"
/>
</div>
</a>
</div>
<OrgPermissionCan
I={OrgPermissionIdentityActions.Create}
a={OrgPermissionSubjects.Identity}
>
{(isAllowed) => (
<Button
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
if (!isMoreIdentitiesAllowed && !isEnterprise) {
handlePopUpOpen("upgradePlan", {
description: "You can add more identities if you upgrade your Infisical plan."
});
return;
}
handlePopUpOpen("identity");
}}
isDisabled={!isAllowed}
<div>
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-1">
<p className="text-xl font-semibold text-mineshaft-100">Identities</p>
<a
href="https://infisical.com/docs/documentation/platform/identities/overview"
target="_blank"
rel="noopener noreferrer"
>
Create Identity
</Button>
)}
</OrgPermissionCan>
</div>
<IdentityTable handlePopUpOpen={handlePopUpOpen} />
{/* Identity Auth Templates Section */}
{subscription.machineIdentityAuthTemplates && (
<div className="mt-8">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-1">
<p className="text-xl font-semibold text-mineshaft-100">Identity Auth Templates</p>
<a
href="https://infisical.com/docs/documentation/platform/identities/auth-templates"
target="_blank"
rel="noopener noreferrer"
>
<div className="ml-1 mt-[0.16rem] inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
<span>Docs</span>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.07rem] ml-1.5 text-[10px]"
/>
</div>
</a>
</div>
<OrgPermissionCan
I={OrgPermissionMachineIdentityAuthTemplateActions.CreateTemplates}
a={OrgPermissionSubjects.MachineIdentityAuthTemplate}
>
{(isAllowed) => (
<Button
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("createTemplate")}
isDisabled={!isAllowed}
>
Create Template
</Button>
)}
</OrgPermissionCan>
<div className="ml-1 mt-[0.16rem] inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
<span>Docs</span>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.07rem] ml-1.5 text-[10px]"
/>
</div>
</a>
</div>
<IdentityAuthTemplatesTable handlePopUpOpen={handlePopUpOpen} />
<OrgPermissionCan
I={OrgPermissionIdentityActions.Create}
a={OrgPermissionSubjects.Identity}
>
{(isAllowed) => (
<Button
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
if (!isMoreIdentitiesAllowed && !isEnterprise) {
handlePopUpOpen("upgradePlan", {
description:
"You can add more identities if you upgrade your Infisical plan."
});
return;
}
handlePopUpOpen("identity");
}}
isDisabled={!isAllowed}
>
Create Identity
</Button>
)}
</OrgPermissionCan>
</div>
)}
<IdentityTable handlePopUpOpen={handlePopUpOpen} />
</div>
{/* Identity Auth Templates Section */}
<div className="mb-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-1">
<p className="text-xl font-semibold text-mineshaft-100">Identity Auth Templates</p>
<a
href="https://infisical.com/docs/documentation/platform/identities/auth-templates"
target="_blank"
rel="noopener noreferrer"
>
<div className="ml-1 mt-[0.16rem] inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
<span>Docs</span>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.07rem] ml-1.5 text-[10px]"
/>
</div>
</a>
</div>
<OrgPermissionCan
I={OrgPermissionMachineIdentityAuthTemplateActions.CreateTemplates}
a={OrgPermissionSubjects.MachineIdentityAuthTemplate}
>
{(isAllowed) => (
<Button
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("createTemplate")}
isDisabled={!isAllowed}
>
Create Template
</Button>
)}
</OrgPermissionCan>
</div>
<IdentityAuthTemplatesTable handlePopUpOpen={handlePopUpOpen} />
</div>
<IdentityModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<IdentityAuthTemplateModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<MachineAuthTemplateUsagesModal

View File

@@ -1,14 +1,6 @@
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 { faCertificate } from "@fortawesome/free-solid-svg-icons";
import { createNotification } from "@app/components/notifications";
import {
Badge,
Button,
Checkbox,
DeleteActionModal,
EmptyState,
Modal,
ModalContent,
@@ -22,14 +14,7 @@ import {
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";
import { useGetTemplateUsages } from "@app/hooks/api/identityAuthTemplates";
type Props = {
isOpen: boolean;
@@ -45,201 +30,51 @@ export const MachineAuthTemplateUsagesModal = ({
templateName
}: Props) => {
const { currentOrg } = useOrganization();
const [selectedUsageIds, setSelectedUsageIds] = useState<string[]>([]);
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({
const { data: usages = [], isPending } = 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 (
<>
<Modal isOpen={isOpen} onOpenChange={onClose}>
<ModalContent
title={`Auth Template Usages: ${templateName}`}
subTitle="Manage identities using this template"
className="max-w-4xl"
>
<div>
<div
className={twMerge(
"h-0 flex-shrink-0 overflow-hidden transition-all",
selectedUsageIds.length > 0 && "h-16"
)}
>
<div className="flex items-center rounded-md border border-mineshaft-600 bg-mineshaft-800 px-4 py-2 text-bunker-300">
<div className="mr-2 text-sm">{selectedUsageIds.length} Selected</div>
<button
type="button"
className="mr-auto text-xs text-mineshaft-400 underline-offset-2 hover:text-mineshaft-200 hover:underline"
onClick={() => setSelectedUsageIds([])}
>
{TEMPLATE_UI_LABELS.UNSELECT_ALL}
</button>
<Button
variant="outline_bg"
colorSchema="danger"
leftIcon={<FontAwesomeIcon icon={faTrash} />}
className="ml-2"
onClick={() => {
const selectedUsagesList = usages.filter((usage) =>
selectedUsageIds.includes(usage.identityId)
);
if (!selectedUsagesList.length) return;
handlePopUpOpen("unlinkUsages", { selectedUsagesList });
}}
size="xs"
>
{TEMPLATE_UI_LABELS.UNLINK}
</Button>
</div>
</div>
<TableContainer>
<Table>
<THead>
<Tr className="h-14">
<Th className="w-12">
<Checkbox
id="select-all"
className="mr-2"
isChecked={usages.length > 0 && selectedUsageIds.length === usages.length}
onCheckedChange={handleSelectAll}
/>
</Th>
<Th>Identity Name</Th>
<Th>Identity ID</Th>
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={3} innerKey="template-usages" />}
{!isPending &&
usages.map((usage) => (
<Tr
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
key={`usage-${usage.identityId}`}
>
<Td>
<Checkbox
id={`select-usage-${usage.identityId}`}
isChecked={selectedUsageIds.includes(usage.identityId)}
onCheckedChange={() => handleUsageToggle(usage.identityId)}
/>
</Td>
<Td>{usage.identityName}</Td>
<Td>
<span className="text-sm text-mineshaft-400">{usage.identityId}</span>
</Td>
</Tr>
))}
</TBody>
</Table>
{!isPending && usages.length === 0 && (
<EmptyState
title="This template is not currently being used by any identities"
icon={faCertificate}
/>
)}
</TableContainer>
</div>
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.unlinkUsages.isOpen}
title="Are you sure you want to unlink the following template usages?"
onChange={(isDeleteOpen) => handlePopUpToggle("unlinkUsages", isDeleteOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
handleUnlinkUsages(
popUp.unlinkUsages.data.selectedUsagesList as MachineAuthTemplateUsage[]
)
}
buttonText={TEMPLATE_UI_LABELS.UNLINK}
>
<div className="mt-4 text-sm text-mineshaft-400">
This template will no longer be used by the following{" "}
{popUp.unlinkUsages.data?.selectedUsagesList?.length > 1 ? "identities" : "identity"}:
</div>
<div className="mt-2 max-h-[20rem] overflow-y-auto rounded border border-mineshaft-600 bg-red/10 p-4 pl-8 text-sm text-red-200">
<ul className="list-disc">
{(popUp.unlinkUsages.data?.selectedUsagesList as MachineAuthTemplateUsage[])?.map(
(usage) => (
<li key={usage.identityId}>
<div className="mb-1 flex items-center">
<span className="break-all">{usage.identityName}</span>
<Badge
variant="danger"
className="ml-2 inline-flex w-min items-center gap-1.5 whitespace-nowrap"
<Modal isOpen={isOpen} onOpenChange={onClose}>
<ModalContent title={`Usages for Identity Auth Template: ${templateName}`}>
<div>
<TableContainer>
<Table>
<THead>
<Tr className="h-14">
<Th>Identity Name</Th>
<Th>Identity ID</Th>
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={3} innerKey="template-usages" />}
{!isPending &&
usages.map((usage) => (
<Tr
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
key={`usage-${usage.identityId}`}
>
{usage.identityId}
</Badge>
</div>
</li>
)
<Td>{usage.identityName}</Td>
<Td>
<span className="text-sm text-mineshaft-400">{usage.identityId}</span>
</Td>
</Tr>
))}
</TBody>
</Table>
{!isPending && usages.length === 0 && (
<EmptyState
title="This template is not currently being used by any identities"
icon={faCertificate}
/>
)}
</ul>
</TableContainer>
</div>
</DeleteActionModal>
</>
</ModalContent>
</Modal>
);
};

View File

@@ -89,7 +89,8 @@ const machineIdentityAuthTemplatePermissionSchema = z
[OrgPermissionMachineIdentityAuthTemplateActions.EditTemplates]: z.boolean().optional(),
[OrgPermissionMachineIdentityAuthTemplateActions.DeleteTemplates]: z.boolean().optional(),
[OrgPermissionMachineIdentityAuthTemplateActions.CreateTemplates]: z.boolean().optional(),
[OrgPermissionMachineIdentityAuthTemplateActions.UnlinkTemplates]: z.boolean().optional()
[OrgPermissionMachineIdentityAuthTemplateActions.UnlinkTemplates]: z.boolean().optional(),
[OrgPermissionMachineIdentityAuthTemplateActions.UseTemplates]: z.boolean().optional()
})
.optional();

View File

@@ -43,6 +43,10 @@ const PERMISSION_ACTIONS = [
{
action: OrgPermissionMachineIdentityAuthTemplateActions.UnlinkTemplates,
label: "Unlink Templates"
},
{
action: OrgPermissionMachineIdentityAuthTemplateActions.UseTemplates,
label: "Attach Templates"
}
] as const;
@@ -103,7 +107,8 @@ export const OrgPermissionMachineIdentityAuthTemplateRow = ({
[OrgPermissionMachineIdentityAuthTemplateActions.EditTemplates]: true,
[OrgPermissionMachineIdentityAuthTemplateActions.CreateTemplates]: true,
[OrgPermissionMachineIdentityAuthTemplateActions.DeleteTemplates]: true,
[OrgPermissionMachineIdentityAuthTemplateActions.UnlinkTemplates]: true
[OrgPermissionMachineIdentityAuthTemplateActions.UnlinkTemplates]: true,
[OrgPermissionMachineIdentityAuthTemplateActions.UseTemplates]: true
},
{ shouldDirty: true }
);
@@ -116,7 +121,8 @@ export const OrgPermissionMachineIdentityAuthTemplateRow = ({
[OrgPermissionMachineIdentityAuthTemplateActions.EditTemplates]: false,
[OrgPermissionMachineIdentityAuthTemplateActions.CreateTemplates]: false,
[OrgPermissionMachineIdentityAuthTemplateActions.DeleteTemplates]: false,
[OrgPermissionMachineIdentityAuthTemplateActions.UnlinkTemplates]: false
[OrgPermissionMachineIdentityAuthTemplateActions.UnlinkTemplates]: false,
[OrgPermissionMachineIdentityAuthTemplateActions.UseTemplates]: true
},
{ shouldDirty: true }
);
@@ -131,7 +137,8 @@ export const OrgPermissionMachineIdentityAuthTemplateRow = ({
[OrgPermissionMachineIdentityAuthTemplateActions.EditTemplates]: false,
[OrgPermissionMachineIdentityAuthTemplateActions.CreateTemplates]: false,
[OrgPermissionMachineIdentityAuthTemplateActions.DeleteTemplates]: false,
[OrgPermissionMachineIdentityAuthTemplateActions.UnlinkTemplates]: false
[OrgPermissionMachineIdentityAuthTemplateActions.UnlinkTemplates]: false,
[OrgPermissionMachineIdentityAuthTemplateActions.UseTemplates]: false
},
{ shouldDirty: true }
);