Compare commits

...

10 Commits

Author SHA1 Message Date
Carlos Monastyrski
3e803debb4 Allow users to type the same original value on bulk commits and remove them if no changes are left 2025-08-04 18:22:30 -03:00
carlosmonastyrski
e8eb1b5f8b Merge pull request #4300 from Infisical/feat/machineAuthTemplates
Add Machine Auth Templates
2025-08-04 17:24:10 -03:00
x032205
6e37b9f969 Merge pull request #4309 from Infisical/log-available-auth-methods-on-pass-reset
Log available auth methods on password reset
2025-08-04 16:22:44 -04:00
Carlos Monastyrski
098a8b81be Final improvements on machine auth templates 2025-08-04 17:01:44 -03:00
Carlos Monastyrski
830a2f9581 Renamed identity auth template permissions 2025-08-04 16:28:57 -03:00
Carlos Monastyrski
dc4db40936 Add space between identities tables 2025-08-04 16:14:24 -03:00
Carlos Monastyrski
0beff3cc1c Fixed /ldap-auth/identities/:identityId response schema 2025-08-04 16:05:39 -03:00
Carlos Monastyrski
3dde786621 General improvements on auth templates 2025-08-04 15:29:07 -03:00
Carlos Monastyrski
ebe05661d3 Addressed pr comments 2025-08-03 13:02:20 -03:00
Carlos Monastyrski
4f0007faa5 Add Machine Auth Templates 2025-08-03 12:19:57 -03:00
55 changed files with 3195 additions and 249 deletions

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"
);

View File

@@ -18,6 +18,7 @@ import { TExternalKmsServiceFactory } from "@app/ee/services/external-kms/extern
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
import { TGithubOrgSyncServiceFactory } from "@app/ee/services/github-org-sync/github-org-sync-service";
import { TGroupServiceFactory } from "@app/ee/services/group/group-service";
import { TIdentityAuthTemplateServiceFactory } from "@app/ee/services/identity-auth-template";
import { TIdentityProjectAdditionalPrivilegeServiceFactory } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service";
import { TIdentityProjectAdditionalPrivilegeV2ServiceFactory } from "@app/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-service";
import { TKmipClientDALFactory } from "@app/ee/services/kmip/kmip-client-dal";
@@ -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

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,

View File

@@ -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, "templateId")) {
await knex.schema.alterTable(TableName.IdentityLdapAuth, (t) => {
t.dropForeign(["templateId"]);
t.dropColumn("templateId");
});
}
await knex.schema.dropTableIfExists(TableName.IdentityAuthTemplate);
await dropOnUpdateTrigger(knex, TableName.IdentityAuthTemplate);
}

View File

@@ -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>>;

View File

@@ -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>;

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",

View File

@@ -0,0 +1,391 @@
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 {
IdentityAuthTemplateMethod,
TEMPLATE_SUCCESS_MESSAGES,
TEMPLATE_VALIDATION_MESSAGES
} from "@app/ee/services/identity-auth-template/identity-auth-template-enums";
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";
const ldapTemplateFieldsSchema = z.object({
url: z.string().min(1, TEMPLATE_VALIDATION_MESSAGES.LDAP.URL_REQUIRED),
bindDN: z.string().min(1, TEMPLATE_VALIDATION_MESSAGES.LDAP.BIND_DN_REQUIRED),
bindPass: z.string().min(1, TEMPLATE_VALIDATION_MESSAGES.LDAP.BIND_PASSWORD_REQUIRED),
searchBase: z.string().min(1, TEMPLATE_VALIDATION_MESSAGES.LDAP.SEARCH_BASE_REQUIRED),
ldapCaCertificate: z.string().trim().optional()
});
export const registerIdentityTemplateRouter = async (server: FastifyZodProvider) => {
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)
.max(64, TEMPLATE_VALIDATION_MESSAGES.TEMPLATE_NAME_MAX_LENGTH),
authMethod: z.nativeEnum(IdentityAuthTemplateMethod),
templateFields: ldapTemplateFieldsSchema
}),
response: {
200: IdentityAuthTemplatesSchema.extend({
templateFields: z.record(z.string(), z.unknown())
})
}
},
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 template;
}
});
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()
.min(1, TEMPLATE_VALIDATION_MESSAGES.TEMPLATE_NAME_REQUIRED)
.max(64, TEMPLATE_VALIDATION_MESSAGES.TEMPLATE_NAME_MAX_LENGTH)
.optional(),
templateFields: ldapTemplateFieldsSchema.partial().optional()
}),
response: {
200: IdentityAuthTemplatesSchema.extend({
templateFields: z.record(z.string(), z.unknown())
})
}
},
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 template;
}
});
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: 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(),
search: z.string().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,
search: req.query.search,
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 template usage by template ID",
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/delete-usage",
config: {
rateLimit: writeLimit
},
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;
}
});
};

View File

@@ -13,6 +13,7 @@ import { registerGatewayRouter } from "./gateway-router";
import { registerGithubOrgSyncRouter } from "./github-org-sync-router";
import { registerGroupRouter } from "./group-router";
import { registerIdentityProjectAdditionalPrivilegeRouter } from "./identity-project-additional-privilege-router";
import { registerIdentityTemplateRouter } from "./identity-template-router";
import { registerKmipRouter } from "./kmip-router";
import { registerKmipSpecRouter } from "./kmip-spec-router";
import { registerLdapRouter } from "./ldap-router";
@@ -125,6 +126,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => {
await server.register(registerExternalKmsRouter, {
prefix: "/external-kms"
});
await server.register(registerIdentityTemplateRouter, { prefix: "/identity-templates" });
await server.register(registerProjectTemplateRouter, { prefix: "/project-templates" });

View File

@@ -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: {
@@ -1325,6 +1352,7 @@ interface AddIdentityLdapAuthEvent {
accessTokenTrustedIps?: Array<TIdentityTrustedIp>;
allowedFields?: TAllowedFields[];
url: string;
templateId?: string | null;
};
}
@@ -1338,6 +1366,7 @@ interface UpdateIdentityLdapAuthEvent {
accessTokenTrustedIps?: Array<TIdentityTrustedIp>;
allowedFields?: TAllowedFields[];
url?: string;
templateId?: string | null;
};
}
@@ -3439,6 +3468,9 @@ export type Event =
| UpdateIdentityEvent
| DeleteIdentityEvent
| LoginIdentityUniversalAuthEvent
| MachineIdentityAuthTemplateCreateEvent
| MachineIdentityAuthTemplateUpdateEvent
| MachineIdentityAuthTemplateDeleteEvent
| AddIdentityUniversalAuthEvent
| UpdateIdentityUniversalAuthEvent
| DeleteIdentityUniversalAuthEvent

View File

@@ -0,0 +1,83 @@
/* 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, search, tx }: { limit?: number; offset?: number; search?: string; tx?: Knex } = {}
) => {
let query = (tx || db.replicaNode())(TableName.IdentityAuthTemplate).where({ orgId });
let countQuery = (tx || db.replicaNode())(TableName.IdentityAuthTemplate).where({ orgId });
if (search) {
const searchFilter = `%${search.toLowerCase()}%`;
query = query.whereRaw("LOWER(name) LIKE ?", [searchFilter]);
countQuery = countQuery.whereRaw("LOWER(name) LIKE ?", [searchFilter]);
}
query = query.orderBy("createdAt", "desc");
if (limit !== undefined) {
query = query.limit(limit);
}
if (offset !== undefined) {
query = query.offset(offset);
}
const docs = await query;
const [{ count }] = (await countQuery.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 [];
}
};
const findByIdAndOrgId = async (id: string, orgId: string, tx?: Knex) => {
const query = (tx || db.replicaNode())(TableName.IdentityAuthTemplate).where({ id, orgId });
const doc = await query;
return doc?.[0];
};
return {
...identityAuthTemplateOrm,
findByOrgId,
findByAuthMethod,
findTemplateUsages,
findByIdAndOrgId
};
};

View File

@@ -0,0 +1,22 @@
export enum IdentityAuthTemplateMethod {
LDAP = "ldap"
}
export const TEMPLATE_VALIDATION_MESSAGES = {
TEMPLATE_NAME_REQUIRED: "Template name is required",
TEMPLATE_NAME_MAX_LENGTH: "Template name must be at most 64 characters long",
AUTH_METHOD_REQUIRED: "Auth method is required",
TEMPLATE_ID_REQUIRED: "Template ID is required",
LDAP: {
URL_REQUIRED: "LDAP URL is required",
BIND_DN_REQUIRED: "Bind DN is required",
BIND_PASSWORD_REQUIRED: "Bind password is required",
SEARCH_BASE_REQUIRED: "Search base is required"
}
} as const;
export const TEMPLATE_SUCCESS_MESSAGES = {
CREATED: "Template created successfully",
UPDATED: "Template updated successfully",
DELETED: "Template deleted successfully"
} as const;

View File

@@ -0,0 +1,454 @@
import { ForbiddenError } from "@casl/ability";
import { EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import {
OrgPermissionMachineIdentityAuthTemplateActions,
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 { ActorType } from "@app/services/auth/auth-type";
import { TIdentityLdapAuthDALFactory } from "@app/services/identity-ldap-auth/identity-ldap-auth-dal";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/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">;
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
};
export type TIdentityAuthTemplateServiceFactory = ReturnType<typeof identityAuthTemplateServiceFactory>;
export const identityAuthTemplateServiceFactory = ({
identityAuthTemplateDAL,
identityLdapAuthDAL,
permissionService,
kmsService,
licenseService,
auditLogService
}: 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, templateFields };
};
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.findByIdAndOrgId(templateId, actorOrgId);
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
});
let finalTemplateFields: Record<string, unknown> = {};
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 };
finalTemplateFields = mergedTemplateFields;
const ldapUpdateData: {
url?: string;
searchBase?: string;
encryptedBindDN?: Buffer;
encryptedBindPass?: Buffer;
encryptedLdapCaCertificate?: 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 ("ldapCaCertificate" in templateFields) {
ldapUpdateData.encryptedLdapCaCertificate = encryptor({
plainText: Buffer.from(mergedTemplateFields.ldapCaCertificate || "")
}).cipherTextBlob;
}
if (Object.keys(ldapUpdateData).length > 0) {
const updatedLdapAuths = await identityLdapAuthDAL.update({ templateId }, ldapUpdateData, tx);
await Promise.all(
updatedLdapAuths.map(async (updatedLdapAuth) => {
await auditLogService.createAuditLog({
actor: {
type: ActorType.PLATFORM,
metadata: {}
},
orgId: actorOrgId,
event: {
type: EventType.UPDATE_IDENTITY_LDAP_AUTH,
metadata: {
identityId: updatedLdapAuth.identityId,
templateId: template.id
}
}
});
})
);
}
}
return authTemplate;
});
return { ...updatedTemplate, templateFields: finalTemplateFields };
};
const deleteTemplate = async ({
templateId,
actorId,
actorAuthMethod,
actor,
actorOrgId
}: TDeleteIdentityAuthTemplateDTO) => {
await $checkPlan(actorOrgId);
const template = await identityAuthTemplateDAL.findByIdAndOrgId(templateId, actorOrgId);
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
const updatedLdapAuths = await identityLdapAuthDAL.update({ templateId }, { templateId: null }, tx);
await Promise.all(
updatedLdapAuths.map(async (updatedLdapAuth) => {
await auditLogService.createAuditLog({
actor: {
type: ActorType.PLATFORM,
metadata: {}
},
orgId: actorOrgId,
event: {
type: EventType.UPDATE_IDENTITY_LDAP_AUTH,
metadata: {
identityId: updatedLdapAuth.identityId,
templateId: template.id
}
}
});
})
);
// Delete the template
const [deletedTpl] = await identityAuthTemplateDAL.delete({ id: templateId }, tx);
return deletedTpl;
});
return deletedTemplate;
};
const getTemplate = async ({
templateId,
actorId,
actorAuthMethod,
actor,
actorOrgId
}: TGetIdentityAuthTemplateDTO) => {
await $checkPlan(actorOrgId);
const template = await identityAuthTemplateDAL.findByIdAndOrgId(templateId, actorOrgId);
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: template.orgId
});
const decryptedTemplateFields = decryptor({ cipherTextBlob: template.templateFields }).toString();
return {
...template,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
templateFields: JSON.parse(decryptedTemplateFields)
};
};
const listTemplates = async ({
limit,
offset,
search,
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, search });
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.AttachTemplates,
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.findByIdAndOrgId(templateId, actorOrgId);
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.UnlinkTemplates,
OrgPermissionSubjects.MachineIdentityAuthTemplate
);
const template = await identityAuthTemplateDAL.findByIdAndOrgId(templateId, actorOrgId);
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
};
};

View File

@@ -0,0 +1,61 @@
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;
ldapCaCertificate?: string;
};
// Union type for all template field types
export type TTemplateFieldsByMethod = {
[IdentityAuthTemplateMethod.LDAP]: TLdapTemplateFields;
};
// Generic base types that use conditional types for type safety
export type TCreateIdentityAuthTemplateDTO = {
name: string;
authMethod: IdentityAuthTemplateMethod;
templateFields: TTemplateFieldsByMethod[IdentityAuthTemplateMethod];
} & Omit<TProjectPermission, "projectId">;
export type TUpdateIdentityAuthTemplateDTO = {
templateId: string;
name?: string;
templateFields?: Partial<TTemplateFieldsByMethod[IdentityAuthTemplateMethod]>;
} & 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;
search?: string;
} & 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;
export type TUpdateLdapTemplateDTO = TUpdateIdentityAuthTemplateDTO;

View File

@@ -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";

View File

@@ -31,7 +31,8 @@ export const getDefaultOnPremFeatures = () => {
caCrl: false,
sshHostGroups: false,
enterpriseSecretSyncs: false,
enterpriseAppConnections: false
enterpriseAppConnections: false,
machineIdentityAuthTemplates: false
};
};

View File

@@ -60,7 +60,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
enterpriseSecretSyncs: false,
enterpriseAppConnections: false,
fips: false,
eventSubscriptions: false
eventSubscriptions: false,
machineIdentityAuthTemplates: false
});
export const setupLicenseRequestWithStore = (

View File

@@ -75,6 +75,7 @@ export type TFeatureSet = {
secretScanning: false;
enterpriseSecretSyncs: false;
enterpriseAppConnections: false;
machineIdentityAuthTemplates: false;
fips: false;
eventSubscriptions: false;
};

View File

@@ -28,6 +28,15 @@ export enum OrgPermissionKmipActions {
Setup = "setup"
}
export enum OrgPermissionMachineIdentityAuthTemplateActions {
ListTemplates = "list-templates",
EditTemplates = "edit-templates",
CreateTemplates = "create-templates",
DeleteTemplates = "delete-templates",
UnlinkTemplates = "unlink-templates",
AttachTemplates = "attach-templates"
}
export enum OrgPermissionAdminConsoleAction {
AccessAllProjects = "access-all-projects"
}
@@ -88,6 +97,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 +136,7 @@ export type OrgPermissionSet =
)
]
| [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole]
| [OrgPermissionMachineIdentityAuthTemplateActions, OrgPermissionSubjects.MachineIdentityAuthTemplate]
| [OrgPermissionKmipActions, OrgPermissionSubjects.Kmip]
| [OrgPermissionSecretShareAction, OrgPermissionSubjects.SecretShare];
@@ -237,6 +248,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 +369,25 @@ 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(
OrgPermissionMachineIdentityAuthTemplateActions.AttachTemplates,
OrgPermissionSubjects.MachineIdentityAuthTemplate
);
can(OrgPermissionSecretShareAction.ManageSettings, OrgPermissionSubjects.SecretShare);
return rules;
@@ -385,6 +423,16 @@ const buildMemberPermission = () => {
can(OrgPermissionGatewayActions.CreateGateways, OrgPermissionSubjects.Gateway);
can(OrgPermissionGatewayActions.AttachGateways, OrgPermissionSubjects.Gateway);
can(OrgPermissionMachineIdentityAuthTemplateActions.ListTemplates, OrgPermissionSubjects.MachineIdentityAuthTemplate);
can(
OrgPermissionMachineIdentityAuthTemplateActions.UnlinkTemplates,
OrgPermissionSubjects.MachineIdentityAuthTemplate
);
can(
OrgPermissionMachineIdentityAuthTemplateActions.AttachTemplates,
OrgPermissionSubjects.MachineIdentityAuthTemplate
);
return rules;
};

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",
@@ -214,6 +215,7 @@ export const LDAP_AUTH = {
password: "The password of the LDAP user to login."
},
ATTACH: {
templateId: "The ID of the identity auth template to attach the configuration onto.",
identityId: "The ID of the identity to attach the configuration onto.",
url: "The URL of the LDAP server.",
allowedFields:
@@ -240,7 +242,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 identity auth template to update the configuration to."
},
RETRIEVE: {
identityId: "The ID of the identity to retrieve the configuration for."

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/ee/services/identity-auth-template/identity-auth-template-dal";
import { identityAuthTemplateServiceFactory } from "@app/ee/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,15 @@ export const registerRoutes = async (
identityMetadataDAL
});
const identityAuthTemplateService = identityAuthTemplateServiceFactory({
identityAuthTemplateDAL,
identityLdapAuthDAL,
permissionService,
kmsService,
licenseService,
auditLogService
});
const identityAccessTokenService = identityAccessTokenServiceFactory({
identityAccessTokenDAL,
identityOrgMembershipDAL,
@@ -1604,7 +1616,8 @@ export const registerRoutes = async (
identityAccessTokenDAL,
identityOrgMembershipDAL,
licenseService,
identityDAL
identityDAL,
identityAuthTemplateDAL
});
const dynamicSecretProviders = buildDynamicSecretProviders({
@@ -2008,6 +2021,7 @@ export const registerRoutes = async (
webhook: webhookService,
serviceToken: serviceTokenService,
identity: identityService,
identityAuthTemplate: identityAuthTemplateService,
identityAccessToken: identityAccessTokenService,
identityProject: identityProjectService,
identityTokenAuth: identityTokenAuthService,

View File

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

View File

@@ -2,9 +2,14 @@
import { ForbiddenError } from "@casl/ability";
import { IdentityAuthMethod } from "@app/db/schemas";
import { TIdentityAuthTemplateDALFactory } from "@app/ee/services/identity-auth-template";
import { testLDAPConfig } from "@app/ee/services/ldap-config/ldap-fns";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
import {
OrgPermissionIdentityActions,
OrgPermissionMachineIdentityAuthTemplateActions,
OrgPermissionSubjects
} from "@app/ee/services/permission/org-permission";
import {
constructPermissionErrorMessage,
validatePrivilegeChangeOperation
@@ -44,6 +49,7 @@ type TIdentityLdapAuthServiceFactoryDep = {
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
kmsService: TKmsServiceFactory;
identityDAL: TIdentityDALFactory;
identityAuthTemplateDAL: TIdentityAuthTemplateDALFactory;
};
export type TIdentityLdapAuthServiceFactory = ReturnType<typeof identityLdapAuthServiceFactory>;
@@ -55,7 +61,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 +180,7 @@ export const identityLdapAuthServiceFactory = ({
const attachLdapAuth = async ({
identityId,
templateId,
url,
searchBase,
searchFilter,
@@ -213,6 +221,14 @@ export const identityLdapAuthServiceFactory = ({
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity);
if (templateId) {
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionMachineIdentityAuthTemplateActions.AttachTemplates,
OrgPermissionSubjects.MachineIdentityAuthTemplate
);
}
const plan = await licenseService.getPlan(identityMembershipOrg.orgId);
if (!plan.ldap) {
@@ -241,33 +257,55 @@ 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.findByIdAndOrgId(templateId, identityMembershipOrg.orgId)
: undefined;
let ldapConfig: { bindDN: string; bindPass: string; searchBase: string; url: string; ldapCaCertificate?: 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,
ldapCaCertificate
};
}
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;
if (ldapCaCertificate) {
if (ldapConfig.ldapCaCertificate) {
const { cipherTextBlob: encryptedCertificate } = encryptor({
plainText: Buffer.from(ldapCaCertificate)
plainText: Buffer.from(ldapConfig.ldapCaCertificate)
});
encryptedLdapCaCertificate = encryptedCertificate;
}
const { cipherTextBlob: encryptedBindDN } = encryptor({
plainText: Buffer.from(bindDN)
});
const isConnected = await testLDAPConfig({
bindDN,
bindPass,
caCert: ldapCaCertificate || "",
url
bindDN: ldapConfig.bindDN,
bindPass: ldapConfig.bindPass,
caCert: ldapConfig.ldapCaCertificate || "",
url: ldapConfig.url
});
if (!isConnected) {
@@ -282,15 +320,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 +340,7 @@ export const identityLdapAuthServiceFactory = ({
const updateLdapAuth = async ({
identityId,
templateId,
url,
searchBase,
searchFilter,
@@ -344,6 +384,13 @@ export const identityLdapAuthServiceFactory = ({
);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity);
if (templateId) {
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionMachineIdentityAuthTemplateActions.AttachTemplates,
OrgPermissionSubjects.MachineIdentityAuthTemplate
);
}
const plan = await licenseService.getPlan(identityMembershipOrg.orgId);
if (!plan.ldap) {
@@ -371,33 +418,56 @@ 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.findByIdAndOrgId(templateId, identityMembershipOrg.orgId)
: undefined;
let config: {
bindDN?: string;
bindPass?: string;
searchBase?: string;
url?: string;
ldapCaCertificate?: string;
};
if (template) {
config = JSON.parse(decryptor({ cipherTextBlob: template.templateFields }).toString());
} else {
config = {
bindDN,
bindPass,
searchBase,
url,
ldapCaCertificate
};
}
let encryptedBindPass: Buffer | undefined;
if (bindPass) {
if (config.bindPass) {
const { cipherTextBlob: bindPassCiphertext } = encryptor({
plainText: Buffer.from(bindPass)
plainText: Buffer.from(config.bindPass)
});
encryptedBindPass = bindPassCiphertext;
}
let encryptedLdapCaCertificate: Buffer | undefined;
if (ldapCaCertificate) {
if (config.ldapCaCertificate) {
const { cipherTextBlob: ldapCaCertificateCiphertext } = encryptor({
plainText: Buffer.from(ldapCaCertificate)
plainText: Buffer.from(config.ldapCaCertificate)
});
encryptedLdapCaCertificate = ldapCaCertificateCiphertext;
}
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 +476,10 @@ export const identityLdapAuthServiceFactory = ({
const { ldapConfig } = await getLdapConfig(identityId);
const isConnected = await testLDAPConfig({
bindDN: bindDN || ldapConfig.bindDN,
bindPass: bindPass || ldapConfig.bindPass,
caCert: ldapCaCertificate || ldapConfig.caCert,
url: url || ldapConfig.url
bindDN: config.bindDN || ldapConfig.bindDN,
bindPass: config.bindPass || ldapConfig.bindPass,
caCert: config.ldapCaCertificate || ldapConfig.caCert,
url: config.url || ldapConfig.url
});
if (!isConnected) {
@@ -420,14 +490,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

View File

@@ -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;

View File

@@ -322,6 +322,7 @@
}
]
},
"documentation/platform/identities/auth-templates",
"documentation/platform/token",
"documentation/platform/mfa",
"documentation/platform/github-org-sync"

View File

@@ -0,0 +1,96 @@
---
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`.
- **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.
![Attach Template](/images/platform/identities/auth-templates/machine-identity-page.png)
![Attach Template Form](/images/platform/identities/auth-templates/attach-template-form.png)
### Managing Template Usage
You can view which identities are using a specific template by clicking **View Usages** in the template's dropdown menu.
![Template Usages](/images/platform/identities/auth-templates/template-usages.png)
![Template Usages Modal](/images/platform/identities/auth-templates/template-usages-modal.png)
## 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. However, the link to the template will be broken, and you won't be able to use the template for new identities.
</Accordion>
<Accordion title="Can I see which identities are using a specific template?">
Yes, click **View Usages** in the template's dropdown menu to see all identities currently using that template.
</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>

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 487 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 660 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 688 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 KiB

View File

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

View File

@@ -21,6 +21,15 @@ export enum OrgGatewayPermissionActions {
AttachGateways = "attach-gateways"
}
export enum OrgPermissionMachineIdentityAuthTemplateActions {
ListTemplates = "list-templates",
CreateTemplates = "create-templates",
EditTemplates = "edit-templates",
DeleteTemplates = "delete-templates",
UnlinkTemplates = "unlink-templates",
AttachTemplates = "attach-templates"
}
export enum OrgPermissionSubjects {
Workspace = "workspace",
Role = "role",
@@ -42,7 +51,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 +123,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

View File

@@ -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,

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?: {

View File

@@ -0,0 +1,3 @@
export * from "./mutations";
export * from "./queries";
export * from "./types";

View File

@@ -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/identity-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/identity-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/identity-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/identity-templates/${dto.templateId}/delete-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 })
});
}
});
};

View File

@@ -0,0 +1,89 @@
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import {
GetIdentityAuthTemplatesDTO,
GetTemplateUsagesDTO,
IdentityAuthTemplate,
MachineAuthTemplateUsage,
MachineIdentityAuthMethod
} 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,
getAvailableTemplates: (authMethod: MachineIdentityAuthMethod) =>
[...identityAuthTemplatesKeys.all, "available", 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/identity-templates/search", {
params: {
organizationId: dto.organizationId,
limit: dto.limit || 50,
offset: dto.offset || 0,
...(dto.search && { search: dto.search })
}
});
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<IdentityAuthTemplate>(
`/api/v1/identity-templates/${templateId}`,
{
params: { organizationId }
}
);
return data;
},
enabled: Boolean(templateId) && Boolean(organizationId)
});
};
export const useGetAvailableTemplates = (authMethod: MachineIdentityAuthMethod) => {
return useQuery({
queryKey: identityAuthTemplatesKeys.getAvailableTemplates(authMethod),
queryFn: async () => {
const { data } = await apiRequest.get<IdentityAuthTemplate[]>("/api/v1/identity-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/identity-templates/${dto.templateId}/usage`,
{
params: { organizationId: dto.organizationId }
}
);
return data;
},
enabled: Boolean(dto.templateId) && Boolean(dto.organizationId)
});
};

View File

@@ -0,0 +1,78 @@
export enum MachineIdentityAuthMethod {
LDAP = "ldap"
}
export interface LdapTemplateFields {
url: string;
bindDN: string;
bindPass: string;
searchBase: string;
ldapCaCertificate?: 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;
search?: string;
}
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;

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";

View File

@@ -53,4 +53,5 @@ export type SubscriptionPlan = {
secretScanning: boolean;
enterpriseSecretSyncs: boolean;
enterpriseAppConnections: boolean;
machineIdentityAuthTemplates: boolean;
};

View File

@@ -0,0 +1,317 @@
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,
TextArea
} 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"),
ldapCaCertificate: z
.string()
.optional()
.transform((val) => val || undefined)
});
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: "",
ldapCaCertificate: ""
}
});
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 || "",
ldapCaCertificate: template.templateFields?.ldapCaCertificate || ""
});
} else {
reset({
name: "",
method: MachineIdentityAuthMethod.LDAP,
url: "",
bindDN: "",
bindPass: "",
searchBase: "",
ldapCaCertificate: ""
});
}
}, [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,
ldapCaCertificate: data.ldapCaCertificate
}
});
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,
ldapCaCertificate: data.ldapCaCertificate
}
});
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>
)}
/>
<Controller
control={control}
name="ldapCaCertificate"
render={({ field, fieldState: { error } }) => (
<FormControl
label="CA Certificate"
isOptional
errorText={error?.message}
isError={Boolean(error)}
tooltipText="An optional PEM-encoded CA cert for the LDAP server. This is used by the TLS client for secure communication with the LDAP server."
>
<TextArea {...field} placeholder="-----BEGIN CERTIFICATE----- ..." />
</FormControl>
)}
/>
</>
)}
<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>
);
};

View File

@@ -0,0 +1,281 @@
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,
search: debouncedSearch
});
const { templates = [], totalCount = 0 } = data ?? {};
useResetPageHelper({
totalCount,
offset,
setPage
});
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 &&
templates?.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="uppercase">{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 && templates.length === 0 && (
<EmptyState
title={
debouncedSearch.trim().length > 0
? "No templates match search filter"
: "No identity auth templates have been created"
}
icon={faServer}
/>
)}
</TableContainer>
</div>
);
};

View File

@@ -11,6 +11,8 @@ import {
FormControl,
IconButton,
Input,
Select,
SelectItem,
Tab,
TabList,
TabPanel,
@@ -18,23 +20,31 @@ import {
TextArea,
Tooltip
} from "@app/components/v2";
import { useOrganization, useSubscription } from "@app/context";
import { useOrganization, useOrgPermission, useSubscription } from "@app/context";
import {
OrgPermissionMachineIdentityAuthTemplateActions,
OrgPermissionSubjects
} from "@app/context/OrgPermissionContext/types";
import {
MachineIdentityAuthMethod,
useAddIdentityLdapAuth,
useGetIdentityLdapAuth,
useUpdateIdentityLdapAuth
} from "@app/hooks/api";
import { IdentityTrustedIp } from "@app/hooks/api/identities/types";
import { useGetAvailableTemplates } 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 +76,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 +146,13 @@ export const IdentityLdapAuthForm = ({
const { mutateAsync: addMutateAsync } = useAddIdentityLdapAuth();
const { mutateAsync: updateMutateAsync } = useUpdateIdentityLdapAuth();
const [tabValue, setTabValue] = useState<IdentityFormTab>(IdentityFormTab.Configuration);
const { data: templates } = useGetAvailableTemplates(MachineIdentityAuthMethod.LDAP);
const { permission } = useOrgPermission();
const canAttachTemplates = permission.can(
OrgPermissionMachineIdentityAuthTemplateActions.AttachTemplates,
OrgPermissionSubjects.MachineIdentityAuthTemplate
);
const { data } = useGetIdentityLdapAuth(identityId ?? "", {
enabled: isUpdate
@@ -102,11 +162,14 @@ export const IdentityLdapAuthForm = ({
control,
handleSubmit,
reset,
watch,
setValue,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
scope: "custom",
templateId: "",
url: "",
bindDN: "",
bindPass: "",
@@ -119,6 +182,8 @@ export const IdentityLdapAuthForm = ({
}
});
const scope = watch("scope");
const {
fields: accessTokenTrustedIpsFields,
append: appendAccessTokenTrustedIp,
@@ -131,16 +196,30 @@ 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 (authData.templateId) {
return "template";
}
// Default to custom if we can't determine
return "custom";
};
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 +231,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: "custom",
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 +329,8 @@ export const IdentityLdapAuthForm = ({
onSubmit={handleSubmit(onFormSubmit, (fields) => {
setTabValue(
[
"scope",
"templateId",
"url",
"bindDN",
"bindPass",
@@ -268,18 +352,102 @@ export const IdentityLdapAuthForm = ({
<Tab value={IdentityFormTab.Advanced}>Advanced</Tab>
</TabList>
<TabPanel value={IdentityFormTab.Configuration}>
{canAttachTemplates && (
<Controller
control={control}
name="scope"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Configuration Type"
isError={Boolean(error)}
errorText={error?.message}
>
<Select
value={value}
onValueChange={(val) => {
onChange(val);
setValue("templateId", data?.templateId || "");
setValue("url", data?.url || "");
setValue("bindDN", data?.bindDN || "");
setValue("bindPass", data?.bindPass || "");
setValue("searchBase", data?.searchBase || "");
setValue("ldapCaCertificate", data?.ldapCaCertificate || "");
}}
className="w-full"
position="popper"
dropdownContainerClassName="max-w-none"
>
<SelectItem value="template">Use Template</SelectItem>
<SelectItem value="custom">Custom Configuration</SelectItem>
</Select>
</FormControl>
)}
/>
)}
{scope === "template" && (
<Controller
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);
const tmp = templates?.find((t) => t.id === val);
if (!tmp) return;
setValue("url", tmp.templateFields.url);
setValue("bindDN", tmp.templateFields.bindDN);
setValue("bindPass", tmp.templateFields.bindPass);
setValue("searchBase", tmp.templateFields.searchBase);
setValue("ldapCaCertificate", tmp.templateFields.ldapCaCertificate);
}}
className="w-full"
position="popper"
dropdownContainerClassName="max-w-none"
placeholder="Select a template"
>
{templates?.map((template) => {
return (
<SelectItem value={template.id} key={template.id}>
{template.name}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
)}
<Controller
control={control}
defaultValue="2592000"
name="url"
render={({ field, fieldState: { error } }) => (
<FormControl
label="LDAP URL"
isError={Boolean(error)}
errorText={error?.message}
tooltipText={
scope === "template"
? "This field cannot be modified when using a template"
: undefined
}
isRequired
>
<Input {...field} placeholder="ldaps://domain-or-ip:636" type="text" />
<Input
{...field}
placeholder="ldaps://domain-or-ip:636"
type="text"
isDisabled={scope === "template"}
containerClassName={scope === "template" ? "opacity-55" : ""}
/>
</FormControl>
)}
/>
@@ -292,14 +460,23 @@ export const IdentityLdapAuthForm = ({
label="Bind DN"
isError={Boolean(error)}
errorText={error?.message}
tooltipText={
scope === "template"
? "This field cannot be modified when using a template"
: undefined
}
>
<Input {...field} placeholder="cn=infisical,ou=Users,dc=example,dc=com" />
<Input
{...field}
containerClassName={scope === "template" ? "opacity-55" : ""}
placeholder="cn=infisical,ou=Users,dc=example,dc=com"
isDisabled={scope === "template"}
/>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="bindPass"
render={({ field, fieldState: { error } }) => (
<FormControl
@@ -307,8 +484,19 @@ export const IdentityLdapAuthForm = ({
label="Bind Pass"
isError={Boolean(error)}
errorText={error?.message}
tooltipText={
scope === "template"
? "This field cannot be modified when using a template"
: undefined
}
>
<Input {...field} placeholder="********" type="password" />
<Input
{...field}
placeholder="********"
type="password"
containerClassName={scope === "template" ? "opacity-55" : ""}
isDisabled={scope === "template"}
/>
</FormControl>
)}
/>
@@ -321,8 +509,18 @@ export const IdentityLdapAuthForm = ({
label="Search Base / DN"
isError={Boolean(error)}
errorText={error?.message}
tooltipText={
scope === "template"
? "This field cannot be modified when using a template"
: undefined
}
>
<Input {...field} placeholder="ou=machines,dc=acme,dc=com" />
<Input
{...field}
placeholder="ou=machines,dc=acme,dc=com"
containerClassName={scope === "template" ? "opacity-55" : ""}
isDisabled={scope === "template"}
/>
</FormControl>
)}
/>
@@ -452,7 +650,6 @@ export const IdentityLdapAuthForm = ({
<Controller
control={control}
defaultValue="2592000"
name="accessTokenTTL"
render={({ field, fieldState: { error } }) => (
<FormControl
@@ -467,7 +664,6 @@ export const IdentityLdapAuthForm = ({
/>
<Controller
control={control}
defaultValue="2592000"
name="accessTokenMaxTTL"
render={({ field, fieldState: { error } }) => (
<FormControl
@@ -482,7 +678,6 @@ export const IdentityLdapAuthForm = ({
/>
<Controller
control={control}
defaultValue="0"
name="accessTokenNumUsesLimit"
render={({ field, fieldState: { error } }) => (
<FormControl
@@ -506,9 +701,18 @@ export const IdentityLdapAuthForm = ({
isOptional
errorText={error?.message}
isError={Boolean(error)}
tooltipText="An optional PEM-encoded CA cert for the LDAP server. This is used by the TLS client for secure communication with the LDAP server."
tooltipText={
scope === "template"
? "This field cannot be modified when using a template"
: "An optional PEM-encoded CA cert for the LDAP server. This is used by the TLS client for secure communication with the LDAP server."
}
>
<TextArea {...field} placeholder="-----BEGIN CERTIFICATE----- ..." />
<TextArea
{...field}
placeholder="-----BEGIN CERTIFICATE----- ..."
className={scope === "template" ? "opacity-55" : ""}
isDisabled={scope === "template"}
/>
</FormControl>
)}
/>

View File

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

View File

@@ -0,0 +1,90 @@
import { faCertificate } from "@fortawesome/free-solid-svg-icons";
import { useNavigate } from "@tanstack/react-router";
import {
EmptyState,
Modal,
ModalContent,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useGetTemplateUsages } from "@app/hooks/api/identityAuthTemplates";
type Props = {
isOpen: boolean;
onClose: () => void;
templateId: string;
templateName: string;
};
export const MachineAuthTemplateUsagesModal = ({
isOpen,
onClose,
templateId,
templateName
}: Props) => {
const { currentOrg } = useOrganization();
const navigate = useNavigate();
const organizationId = currentOrg?.id || "";
const { data: usages = [], isPending } = useGetTemplateUsages({
templateId,
organizationId
});
return (
<Modal isOpen={isOpen} onOpenChange={onClose}>
<ModalContent title={`Usages for Identity Auth Template: ${templateName}`}>
<div>
<TableContainer>
<Table>
<THead>
<Tr className="h-14">
<Th>Identity Name</Th>
<Th>Identity ID</Th>
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={3} innerKey="template-usages" />}
{!isPending &&
usages.map((usage) => (
<Tr
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
key={`usage-${usage.identityId}`}
onClick={() =>
navigate({
to: "/organization/identities/$identityId",
params: {
identityId: usage.identityId
}
})
}
>
<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>
);
};

View File

@@ -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,17 @@ 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(),
[OrgPermissionMachineIdentityAuthTemplateActions.AttachTemplates]: z.boolean().optional()
})
.optional();
const adminConsolePermissionSchmea = z
.object({
"access-all-projects": z.boolean().optional()
@@ -129,6 +141,7 @@ export const formSchema = z.object({
"app-connections": appConnectionsPermissionSchema,
kmip: kmipPermissionSchema,
gateway: orgGatewayPermissionSchema,
"machine-identity-auth-template": machineIdentityAuthTemplatePermissionSchema,
"secret-share": secretSharingPermissionSchema
})
.optional()

View File

@@ -0,0 +1,211 @@
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"
},
{
action: OrgPermissionMachineIdentityAuthTemplateActions.AttachTemplates,
label: "Attach 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,
[OrgPermissionMachineIdentityAuthTemplateActions.AttachTemplates]: 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,
[OrgPermissionMachineIdentityAuthTemplateActions.AttachTemplates]: true
},
{ 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,
[OrgPermissionMachineIdentityAuthTemplateActions.AttachTemplates]: 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>
)}
</>
);
};

View File

@@ -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>;

View File

@@ -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}

View File

@@ -222,7 +222,7 @@ export const SecretItem = memo(
}
if (isDirty && !isSubmitting && !isAutoSavingRef.current) {
const debounceTime = 600;
const debounceTime = 200;
autoSaveTimeoutRef.current = setTimeout(() => {
autoSaveChanges(formValues);

View File

@@ -343,31 +343,18 @@ export const SecretListView = ({
id: orgSecret.id,
type: PendingAction.Update,
secretKey: trueOriginalSecret.key,
...(key !== trueOriginalSecret.key && { newSecretName: key }),
...(value !== trueOriginalSecret.value && {
originalValue: trueOriginalSecret.value,
secretValue: value
}),
...(comment !== trueOriginalSecret.comment && {
originalComment: trueOriginalSecret.comment,
secretComment: comment
}),
...(modSecret.skipMultilineEncoding !==
trueOriginalSecret.skipMultilineEncoding && {
originalSkipMultilineEncoding: trueOriginalSecret.skipMultilineEncoding,
skipMultilineEncoding: modSecret.skipMultilineEncoding
}),
...(!isSameTags && {
originalTags:
trueOriginalSecret.tags?.map((tag) => ({ id: tag.id, slug: tag.slug })) || [],
tags: tags?.map((tag) => ({ id: tag.id, slug: tag.name || tag.slug || "" })) || []
}),
...(JSON.stringify(secretMetadata) !==
JSON.stringify(trueOriginalSecret.secretMetadata) && {
originalSecretMetadata: trueOriginalSecret.secretMetadata || [],
secretMetadata: secretMetadata || []
}),
newSecretName: key,
originalValue: trueOriginalSecret.value,
secretValue: value,
originalComment: trueOriginalSecret.comment,
secretComment: comment,
originalSkipMultilineEncoding: trueOriginalSecret.skipMultilineEncoding,
skipMultilineEncoding: modSecret.skipMultilineEncoding,
originalTags:
trueOriginalSecret.tags?.map((tag) => ({ id: tag.id, slug: tag.slug })) || [],
tags: tags?.map((tag) => ({ id: tag.id, slug: tag.name || tag.slug || "" })) || [],
originalSecretMetadata: trueOriginalSecret.secretMetadata || [],
secretMetadata: secretMetadata || [],
timestamp: Date.now(),
resourceType: "secret",
existingSecret: orgSecret