Add Machine Auth Templates

This commit is contained in:
Carlos Monastyrski
2025-08-03 12:19:57 -03:00
parent ec0be1166f
commit 4f0007faa5
49 changed files with 3056 additions and 176 deletions
+1
View File
@@ -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"
);
+2
View File
@@ -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
+10
View File
@@ -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,
@@ -0,0 +1,36 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
export async function up(knex: Knex): Promise<void> {
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<void> {
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);
}
@@ -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<typeof IdentityAuthTemplatesSchema>;
export type TIdentityAuthTemplatesInsert = Omit<z.input<typeof IdentityAuthTemplatesSchema>, TImmutableDBKeys>;
export type TIdentityAuthTemplatesUpdate = Partial<Omit<z.input<typeof IdentityAuthTemplatesSchema>, TImmutableDBKeys>>;
@@ -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<typeof IdentityLdapAuthsSchema>;
+1
View File
@@ -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",
@@ -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
@@ -31,7 +31,8 @@ export const getDefaultOnPremFeatures = () => {
caCrl: false,
sshHostGroups: false,
enterpriseSecretSyncs: false,
enterpriseAppConnections: false
enterpriseAppConnections: false,
machineIdentityAuthTemplates: false
};
};
@@ -60,7 +60,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
enterpriseSecretSyncs: false,
enterpriseAppConnections: false,
fips: false,
eventSubscriptions: false
eventSubscriptions: false,
machineIdentityAuthTemplates: false
});
export const setupLicenseRequestWithStore = (
@@ -75,6 +75,7 @@ export type TFeatureSet = {
secretScanning: false;
enterpriseSecretSyncs: false;
enterpriseAppConnections: false;
machineIdentityAuthTemplates: false;
fips: false;
eventSubscriptions: false;
};
@@ -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;
};
+40 -1
View File
@@ -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."
+14 -1
View File
@@ -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,
@@ -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()
})
})
}
@@ -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;
}
});
};
+8 -1
View File
@@ -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) => {
@@ -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<typeof identityAuthTemplateDALFactory>;
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
};
};
@@ -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;
@@ -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<TPermissionServiceFactory, "getOrgPermission">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey" | "encryptWithInputKey" | "decryptWithInputKey">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
};
export type TIdentityAuthTemplateServiceFactory = ReturnType<typeof identityAuthTemplateServiceFactory>;
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<string, unknown>;
} & Omit<TOrgPermission, "orgId">) => {
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<string, unknown>;
} & Omit<TOrgPermission, "orgId">) => {
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
};
};
@@ -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<T extends IdentityAuthTemplateMethod = IdentityAuthTemplateMethod> = {
name: string;
authMethod: T;
templateFields: TTemplateFieldsByMethod[T];
} & Omit<TProjectPermission, "projectId">;
export type TUpdateIdentityAuthTemplateDTO<T extends IdentityAuthTemplateMethod = IdentityAuthTemplateMethod> = {
templateId: string;
name?: string;
templateFields?: Partial<TTemplateFieldsByMethod[T]>;
} & Omit<TProjectPermission, "projectId">;
export type TDeleteIdentityAuthTemplateDTO = {
templateId: string;
} & Omit<TProjectPermission, "projectId">;
export type TGetIdentityAuthTemplateDTO = {
templateId: string;
} & Omit<TProjectPermission, "projectId">;
export type TListIdentityAuthTemplatesDTO = {
limit?: number;
offset?: number;
} & Omit<TProjectPermission, "projectId">;
export type TGetTemplatesByAuthMethodDTO = {
authMethod: string;
} & Omit<TProjectPermission, "projectId">;
export type TFindTemplateUsagesDTO = {
templateId: string;
} & Omit<TProjectPermission, "projectId">;
export type TUnlinkTemplateUsageDTO = {
templateId: string;
identityIds: string[];
} & Omit<TProjectPermission, "projectId">;
// Specific LDAP types for convenience
export type TCreateLdapTemplateDTO = TCreateIdentityAuthTemplateDTO<IdentityAuthTemplateMethod.LDAP>;
export type TUpdateLdapTemplateDTO = TUpdateIdentityAuthTemplateDTO<IdentityAuthTemplateMethod.LDAP>;
@@ -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";
@@ -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<TPermissionServiceFactory, "getOrgPermission">;
kmsService: TKmsServiceFactory;
identityDAL: TIdentityDALFactory;
identityAuthTemplateDAL: TIdentityAuthTemplateDALFactory;
};
export type TIdentityLdapAuthServiceFactory = ReturnType<typeof identityLdapAuthServiceFactory>;
@@ -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
@@ -14,11 +14,12 @@ export type TAllowedFields = z.infer<typeof AllowedFieldsSchema>;
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;
+1
View File
@@ -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",
@@ -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
<Steps>
<Step title="Navigate to Auth Templates">
In your organization settings, go to **Access Control > Identities** and scroll down to the **Identity Auth Templates** section.
</Step>
<Step title="Create a new template">
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).
</Step>
<Step title="Configure template settings">
Fill in the template configuration based on your chosen authentication method.
<Tabs>
<Tab title="LDAP Auth Template">
**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.
<Note>
You can read more about LDAP Auth configuration in the [LDAP Auth documentation](/documentation/platform/identities/ldap-auth/general).
</Note>
</Tab>
</Tabs>
</Step>
</Steps>
### 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
<AccordionGroup>
<Accordion title="Can I modify a template after it's been applied to identities?">
Yes, you can edit existing templates. After editing a template, changes to templates will automatically update identities that are already using them.
</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.
</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.
</Accordion>
<Accordion title="Do templates support all authentication methods?">
Currently, auth templates support LDAP Auth. Support for additional authentication methods will be added in future releases.
</Accordion>
</AccordionGroup>
Binary file not shown.

After

Width:  |  Height:  |  Size: 680 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 487 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 461 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 688 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 KiB

@@ -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
@@ -1385,6 +1385,7 @@ export const useAddIdentityLdapAuth = () => {
return useMutation<IdentityLdapAuth, object, AddIdentityLdapAuthDTO>({
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<IdentityLdapAuth, object, UpdateIdentityLdapAuthDTO>({
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,
+11 -8
View File
@@ -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?: {
@@ -0,0 +1,3 @@
export * from "./mutations";
export * from "./queries";
export * from "./types";
@@ -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<MachineAuthTemplateUsage[]>(
`/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 })
});
}
});
};
@@ -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<IdentityAuthTemplate[]>(
"/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<MachineAuthTemplateUsage[]>(
`/api/v1/identities/templates/${dto.templateId}/usage`,
{
params: { organizationId: dto.organizationId }
}
);
return data;
},
enabled: Boolean(dto.templateId) && Boolean(dto.organizationId)
});
};
@@ -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<LdapTemplateFields>;
}
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;
+1
View File
@@ -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";
@@ -53,4 +53,5 @@ export type SubscriptionPlan = {
secretScanning: boolean;
enterpriseSecretSyncs: boolean;
enterpriseAppConnections: boolean;
machineIdentityAuthTemplates: boolean;
};
@@ -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<typeof schema>;
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<FormData>({
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 (
<Modal
isOpen={popUp.createTemplate.isOpen || popUp.editTemplate.isOpen}
onOpenChange={handleClose}
>
<ModalContent
title={isEdit ? "Edit Identity Auth Template" : "Create Identity Auth Template"}
subTitle={
isEdit ? "Update the authentication template" : "Create a new authentication template"
}
>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Template Name"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="My Template" />
</FormControl>
)}
/>
<Controller
control={control}
name="method"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Authentication Method"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Select
{...field}
className="w-full"
position="popper"
placeholder="Select auth method..."
dropdownContainerClassName="max-w-none"
onValueChange={(value) => field.onChange(value)}
>
{authMethods.map(({ label, value }) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
{/* LDAP Configuration Fields */}
{selectedMethod === "ldap" && (
<>
<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
label="Bind DN"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="cn=infisical,ou=Users,dc=example,dc=com" />
</FormControl>
)}
/>
<Controller
control={control}
name="bindPass"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Bind Pass"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="********" type="password" />
</FormControl>
)}
/>
<Controller
control={control}
name="searchBase"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Search Base / DN"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="ou=machines,dc=acme,dc=com" />
</FormControl>
)}
/>
</>
)}
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{isEdit ? "Update Template" : "Create Template"}
</Button>
<Button colorSchema="secondary" variant="plain" onClick={handleClose}>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -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>(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 (
<div>
<div className="mb-4 flex items-center space-x-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search templates by name..."
/>
</div>
<TableContainer>
<Table>
<THead>
<Tr className="h-14">
<Th className="w-1/6">
<div className="flex items-center">
Name
<IconButton
variant="plain"
className={`ml-2 ${orderBy === TemplatesOrderBy.Name ? "" : "opacity-30"}`}
ariaLabel="sort"
onClick={() => handleSort(TemplatesOrderBy.Name)}
>
<FontAwesomeIcon
icon={
orderDirection === OrderByDirection.DESC &&
orderBy === TemplatesOrderBy.Name
? faArrowUp
: faArrowDown
}
/>
</IconButton>
</div>
</Th>
<Th className="w-1/6">
<div className="flex items-center">
Method
<IconButton
variant="plain"
className={`ml-2 ${orderBy === TemplatesOrderBy.AuthMethod ? "" : "opacity-30"}`}
ariaLabel="sort"
onClick={() => handleSort(TemplatesOrderBy.AuthMethod)}
>
<FontAwesomeIcon
icon={
orderDirection === OrderByDirection.DESC &&
orderBy === TemplatesOrderBy.AuthMethod
? faArrowUp
: faArrowDown
}
/>
</IconButton>
</div>
</Th>
<Th className="w-2/3">URL</Th>
<Th className="w-16">{isFetching ? <Spinner size="xs" /> : null}</Th>
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={4} innerKey="identity-auth-templates" />}
{!isPending &&
filteredTemplates?.map((template) => (
<Tr
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
key={`template-${template.id}`}
>
<Td>{template.name}</Td>
<Td>
<div className="flex items-center">
<span className="capitalize">{template.authMethod}</span>
</div>
</Td>
<Td>
<span className="text-sm text-mineshaft-400">
{template.templateFields.url}
</span>
</Td>
<Td>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
className="w-6"
colorSchema="secondary"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end">
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faEye} />}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("viewUsages", { template });
}}
>
{TEMPLATE_UI_LABELS.VIEW_USAGES}
</DropdownMenuItem>
<OrgPermissionCan
I={OrgPermissionMachineIdentityAuthTemplateActions.EditTemplates}
a={OrgPermissionSubjects.MachineIdentityAuthTemplate}
>
{(isAllowed) => (
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faEdit} />}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("editTemplate", { template });
}}
isDisabled={!isAllowed}
>
{TEMPLATE_UI_LABELS.EDIT_TEMPLATE}
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionMachineIdentityAuthTemplateActions.DeleteTemplates}
a={OrgPermissionSubjects.MachineIdentityAuthTemplate}
>
{(isAllowed) => (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteTemplate", {
templateId: template.id,
name: template.name
});
}}
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
>
{TEMPLATE_UI_LABELS.DELETE_TEMPLATE}
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
))}
</TBody>
</Table>
{!isPending && data && totalCount > 0 && (
<Pagination
count={totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={handlePerPageChange}
/>
)}
{!isPending && data && filteredTemplates.length === 0 && (
<EmptyState
title={
debouncedSearch.trim().length > 0
? "No templates match search filter"
: "No identity auth templates have been created"
}
icon={faServer}
/>
)}
</TableContainer>
</div>
);
};
@@ -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<typeof schema>;
@@ -93,6 +141,7 @@ export const IdentityLdapAuthForm = ({
const { mutateAsync: addMutateAsync } = useAddIdentityLdapAuth();
const { mutateAsync: updateMutateAsync } = useUpdateIdentityLdapAuth();
const [tabValue, setTabValue] = useState<IdentityFormTab>(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<FormData>({
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 = ({
<TabPanel value={IdentityFormTab.Configuration}>
<Controller
control={control}
defaultValue="2592000"
name="url"
render={({ field, fieldState: { error } }) => (
name="scope"
render={({ field: { value, onChange }, 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"
label="Configuration Type"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="cn=infisical,ou=Users,dc=example,dc=com" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
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" />
<Select
value={value}
onValueChange={(val) => {
onChange(val);
}}
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
control={control}
name="templateId"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Template"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Select
value={value}
onValueChange={(val) => {
onChange(val);
}}
className="w-full"
position="popper"
dropdownContainerClassName="max-w-none"
placeholder="Select a template"
>
{templates?.map((template) => {
return (
<SelectItem value={template.id} key={template.id}>
{template.name}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
)}
{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="searchFilter"
@@ -452,7 +589,6 @@ export const IdentityLdapAuthForm = ({
<Controller
control={control}
defaultValue="2592000"
name="accessTokenTTL"
render={({ field, fieldState: { error } }) => (
<FormControl
@@ -467,7 +603,6 @@ export const IdentityLdapAuthForm = ({
/>
<Controller
control={control}
defaultValue="2592000"
name="accessTokenMaxTTL"
render={({ field, fieldState: { error } }) => (
<FormControl
@@ -482,7 +617,6 @@ export const IdentityLdapAuthForm = ({
/>
<Controller
control={control}
defaultValue="0"
name="accessTokenNumUsesLimit"
render={({ field, fieldState: { error } }) => (
<FormControl
@@ -11,15 +11,18 @@ import {
useOrganization,
useSubscription
} from "@app/context";
import { OrgPermissionMachineIdentityAuthTemplateActions } from "@app/context/OrgPermissionContext/types";
import { withPermission } from "@app/hoc";
import { useDeleteIdentity } from "@app/hooks/api";
import { useDeleteIdentityAuthTemplate } from "@app/hooks/api/identityAuthTemplates";
import { usePopUp } from "@app/hooks/usePopUp";
// import { IdentityAuthMethodModal } from "./IdentityAuthMethodModal";
import { IdentityAuthTemplateModal } from "./IdentityAuthTemplateModal";
import { IdentityAuthTemplatesTable } from "./IdentityAuthTemplatesTable";
import { IdentityModal } from "./IdentityModal";
import { IdentityTable } from "./IdentityTable";
import { IdentityTokenAuthTokenModal } from "./IdentityTokenAuthTokenModal";
// import { IdentityUniversalAuthClientSecretModal } from "./IdentityUniversalAuthClientSecretModal";
import { MachineAuthTemplateUsagesModal } from "./MachineAuthTemplateUsagesModal";
export const IdentitySection = withPermission(
() => {
@@ -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 (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
@@ -115,7 +148,62 @@ export const IdentitySection = withPermission(
</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/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={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
isOpen={popUp.viewUsages.isOpen}
onClose={() => 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 || ""
}
/>
{/* <IdentityAuthMethodModal
popUp={popUp}
handlePopUpOpen={handlePopUpOpen}
@@ -140,6 +228,19 @@ export const IdentitySection = withPermission(
)
}
/>
<DeleteActionModal
isOpen={popUp.deleteTemplate.isOpen}
title={`Are you sure you want to delete ${
(popUp?.deleteTemplate?.data as { name: string })?.name || ""
}?`}
onChange={(isOpen) => handlePopUpToggle("deleteTemplate", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onDeleteTemplateSubmit(
(popUp?.deleteTemplate?.data as { templateId: string })?.templateId
)
}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
@@ -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<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({
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={4} 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"
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"
>
{usage.identityId}
</Badge>
</div>
</li>
)
)}
</ul>
</div>
</DeleteActionModal>
</>
);
};
@@ -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()
@@ -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<TFormSchema>;
control: Control<TFormSchema>;
};
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<keyof typeof rule>;
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 (
<>
<Tr
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
onClick={() => setIsRowExpanded.toggle()}
>
<Td className="w-4">
<FontAwesomeIcon className="w-4" icon={isRowExpanded ? faChevronDown : faChevronRight} />
</Td>
<Td className="w-full select-none">Machine Identity Auth Templates</Td>
<Td>
<Select
value={selectedPermissionCategory}
className="h-8 w-40 bg-mineshaft-700"
dropdownContainerClassName="border text-left border-mineshaft-600 bg-mineshaft-800"
onValueChange={handlePermissionChange}
isDisabled={!isEditable}
position="popper"
>
<SelectItem value={Permission.NoAccess}>No Access</SelectItem>
<SelectItem value={Permission.ReadOnly}>Read Only</SelectItem>
<SelectItem value={Permission.FullAccess}>Full Access</SelectItem>
<SelectItem value={Permission.Custom}>Custom</SelectItem>
</Select>
</Td>
</Tr>
{isRowExpanded && (
<Tr>
<Td colSpan={3} className="border-mineshaft-500 bg-mineshaft-900 p-8">
<div className="flex flex-grow flex-wrap justify-start gap-x-8 gap-y-4">
{PERMISSION_ACTIONS.map(({ action, label }) => {
return (
<Controller
name={`permissions.machine-identity-auth-template.${action}`}
key={`permissions.machine-identity-auth-template.${action}`}
control={control}
render={({ field }) => (
<Checkbox
isChecked={Boolean(field.value)}
onCheckedChange={(e) => {
if (!isEditable) {
createNotification({
type: "error",
text: "Failed to update default role"
});
return;
}
field.onChange(e);
}}
id={`permissions.machine-identity-auth-template.${action}`}
>
{label}
</Checkbox>
)}
/>
);
})}
</div>
</Td>
</Tr>
)}
</>
);
};
@@ -65,7 +65,13 @@ type Props = {
title: string;
formName: keyof Omit<
Exclude<TFormSchema["permissions"], undefined>,
"workspace" | "organization-admin-console" | "kmip" | "gateway" | "secret-share" | "billing"
| "workspace"
| "organization-admin-console"
| "kmip"
| "gateway"
| "secret-share"
| "billing"
| "machine-identity-auth-template"
>;
setValue: UseFormSetValue<TFormSchema>;
control: Control<TFormSchema>;
@@ -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}
/>
<OrgPermissionMachineIdentityAuthTemplateRow
control={control}
setValue={setValue}
isEditable={isCustomRole}
/>
<OrgPermissionKmipRow
control={control}
setValue={setValue}