From 931abea2bbb91530b2a84ef43cffac01a11d71ac Mon Sep 17 00:00:00 2001 From: x032205 Date: Tue, 26 Aug 2025 03:10:38 -0400 Subject: [PATCH 001/105] feat(machine-identities): LDAP Auth Lockout --- .../20250815022242_identity-lockouts.ts | 44 ++- .../20250819081226_identity-lockouts-ldap.ts | 51 ++++ backend/src/db/schemas/identity-ldap-auths.ts | 6 +- .../ee/services/audit-log/audit-log-types.ts | 17 ++ backend/src/lib/api-docs/constants.ts | 15 +- backend/src/server/routes/index.ts | 3 +- .../routes/v1/identity-ldap-auth-router.ts | 123 +++++++-- .../identity-ldap-auth-service.ts | 142 +++++++++- .../identity-ldap-auth-types.ts | 27 ++ .../src/hooks/api/auditLogs/constants.tsx | 1 + frontend/src/hooks/api/auditLogs/enums.tsx | 1 + frontend/src/hooks/api/auditLogs/types.tsx | 10 +- .../src/hooks/api/identities/mutations.tsx | 44 ++- frontend/src/hooks/api/identities/types.ts | 19 ++ .../IdentitySection/IdentityLdapAuthForm.tsx | 114 +++++++- .../IdentityUniversalAuthForm.tsx | 257 +----------------- .../IdentitySection/lockout/LockoutTab.tsx | 206 ++++++++++++++ .../IdentitySection/lockout/super-refine.ts | 73 +++++ .../IdentityAuthLockoutFields.tsx | 80 ++++++ .../ViewIdentityLdapAuthContent.tsx | 13 +- .../ViewIdentityUniversalAuthContent.tsx | 63 +---- 21 files changed, 956 insertions(+), 353 deletions(-) create mode 100644 backend/src/db/migrations/20250819081226_identity-lockouts-ldap.ts create mode 100644 frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/lockout/LockoutTab.tsx create mode 100644 frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/lockout/super-refine.ts create mode 100644 frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityAuthLockoutFields.tsx diff --git a/backend/src/db/migrations/20250815022242_identity-lockouts.ts b/backend/src/db/migrations/20250815022242_identity-lockouts.ts index 7b27296dd..a0e661d2d 100644 --- a/backend/src/db/migrations/20250815022242_identity-lockouts.ts +++ b/backend/src/db/migrations/20250815022242_identity-lockouts.ts @@ -4,22 +4,48 @@ import { TableName } from "../schemas"; export async function up(knex: Knex): Promise { if (await knex.schema.hasTable(TableName.IdentityUniversalAuth)) { - await knex.schema.alterTable(TableName.IdentityUniversalAuth, (t) => { - t.boolean("lockoutEnabled").notNullable().defaultTo(true); - t.integer("lockoutThreshold").notNullable().defaultTo(3); - t.integer("lockoutDuration").notNullable().defaultTo(300); // 5 minutes (in seconds) - t.integer("lockoutCounterReset").notNullable().defaultTo(30); // 30 seconds + const hasLockoutEnabled = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutEnabled"); + const hasLockoutThreshold = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutThreshold"); + const hasLockoutDuration = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutDuration"); + const hasLockoutCounterReset = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutCounterReset"); + + await knex.schema.alterTable(TableName.IdentityUniversalAuth, async (t) => { + if (!hasLockoutEnabled) { + t.boolean("lockoutEnabled").notNullable().defaultTo(true); + } + if (!hasLockoutThreshold) { + t.integer("lockoutThreshold").notNullable().defaultTo(3); + } + if (!hasLockoutDuration) { + t.integer("lockoutDuration").notNullable().defaultTo(300); // 5 minutes (in seconds) + } + if (!hasLockoutCounterReset) { + t.integer("lockoutCounterReset").notNullable().defaultTo(30); // 30 seconds + } }); } } export async function down(knex: Knex): Promise { if (await knex.schema.hasTable(TableName.IdentityUniversalAuth)) { + const hasLockoutEnabled = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutEnabled"); + const hasLockoutThreshold = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutThreshold"); + const hasLockoutDuration = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutDuration"); + const hasLockoutCounterReset = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutCounterReset"); + await knex.schema.alterTable(TableName.IdentityUniversalAuth, (t) => { - t.dropColumn("lockoutEnabled"); - t.dropColumn("lockoutThreshold"); - t.dropColumn("lockoutDuration"); - t.dropColumn("lockoutCounterReset"); + if (hasLockoutEnabled) { + t.dropColumn("lockoutEnabled"); + } + if (hasLockoutThreshold) { + t.dropColumn("lockoutThreshold"); + } + if (hasLockoutDuration) { + t.dropColumn("lockoutDuration"); + } + if (hasLockoutCounterReset) { + t.dropColumn("lockoutCounterReset"); + } }); } } diff --git a/backend/src/db/migrations/20250819081226_identity-lockouts-ldap.ts b/backend/src/db/migrations/20250819081226_identity-lockouts-ldap.ts new file mode 100644 index 000000000..535e6de7c --- /dev/null +++ b/backend/src/db/migrations/20250819081226_identity-lockouts-ldap.ts @@ -0,0 +1,51 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.IdentityLdapAuth)) { + const hasLockoutEnabled = await knex.schema.hasColumn(TableName.IdentityLdapAuth, "lockoutEnabled"); + const hasLockoutThreshold = await knex.schema.hasColumn(TableName.IdentityLdapAuth, "lockoutThreshold"); + const hasLockoutDuration = await knex.schema.hasColumn(TableName.IdentityLdapAuth, "lockoutDuration"); + const hasLockoutCounterReset = await knex.schema.hasColumn(TableName.IdentityLdapAuth, "lockoutCounterReset"); + + await knex.schema.alterTable(TableName.IdentityLdapAuth, (t) => { + if (!hasLockoutEnabled) { + t.boolean("lockoutEnabled").notNullable().defaultTo(true); + } + if (!hasLockoutThreshold) { + t.integer("lockoutThreshold").notNullable().defaultTo(3); + } + if (!hasLockoutDuration) { + t.integer("lockoutDuration").notNullable().defaultTo(300); // 5 minutes (in seconds) + } + if (!hasLockoutCounterReset) { + t.integer("lockoutCounterReset").notNullable().defaultTo(30); // 30 seconds + } + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.IdentityLdapAuth)) { + const hasLockoutEnabled = await knex.schema.hasColumn(TableName.IdentityLdapAuth, "lockoutEnabled"); + const hasLockoutThreshold = await knex.schema.hasColumn(TableName.IdentityLdapAuth, "lockoutThreshold"); + const hasLockoutDuration = await knex.schema.hasColumn(TableName.IdentityLdapAuth, "lockoutDuration"); + const hasLockoutCounterReset = await knex.schema.hasColumn(TableName.IdentityLdapAuth, "lockoutCounterReset"); + + await knex.schema.alterTable(TableName.IdentityLdapAuth, (t) => { + if (hasLockoutEnabled) { + t.dropColumn("lockoutEnabled"); + } + if (hasLockoutThreshold) { + t.dropColumn("lockoutThreshold"); + } + if (hasLockoutDuration) { + t.dropColumn("lockoutDuration"); + } + if (hasLockoutCounterReset) { + t.dropColumn("lockoutCounterReset"); + } + }); + } +} diff --git a/backend/src/db/schemas/identity-ldap-auths.ts b/backend/src/db/schemas/identity-ldap-auths.ts index 3e4d88649..3a89fdd51 100644 --- a/backend/src/db/schemas/identity-ldap-auths.ts +++ b/backend/src/db/schemas/identity-ldap-auths.ts @@ -26,7 +26,11 @@ export const IdentityLdapAuthsSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), accessTokenPeriod: z.coerce.number().default(0), - templateId: z.string().uuid().nullable().optional() + templateId: z.string().uuid().nullable().optional(), + lockoutEnabled: z.boolean().default(true), + lockoutThreshold: z.number().default(3), + lockoutDuration: z.number().default(300), + lockoutCounterReset: z.number().default(30) }); export type TIdentityLdapAuths = z.infer; diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 622bac031..21c5d6ba1 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -199,6 +199,7 @@ export enum EventType { CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "create-identity-universal-auth-client-secret", REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "revoke-identity-universal-auth-client-secret", CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS = "clear-identity-universal-auth-lockouts", + CLEAR_IDENTITY_LDAP_AUTH_LOCKOUTS = "clear-identity-ldap-lockouts", GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS = "get-identity-universal-auth-client-secret", GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET_BY_ID = "get-identity-universal-auth-client-secret-by-id", @@ -1369,6 +1370,10 @@ interface AddIdentityLdapAuthEvent { allowedFields?: TAllowedFields[]; url: string; templateId?: string | null; + lockoutEnabled: boolean; + lockoutThreshold: number; + lockoutDuration: number; + lockoutCounterReset: number; }; } @@ -1383,6 +1388,10 @@ interface UpdateIdentityLdapAuthEvent { allowedFields?: TAllowedFields[]; url?: string; templateId?: string | null; + lockoutEnabled?: boolean; + lockoutThreshold?: number; + lockoutDuration?: number; + lockoutCounterReset?: number; }; } @@ -1400,6 +1409,13 @@ interface RevokeIdentityLdapAuthEvent { }; } +interface ClearIdentityLdapAuthLockoutsEvent { + type: EventType.CLEAR_IDENTITY_LDAP_AUTH_LOCKOUTS; + metadata: { + identityId: string; + }; +} + interface LoginIdentityOidcAuthEvent { type: EventType.LOGIN_IDENTITY_OIDC_AUTH; metadata: { @@ -3553,6 +3569,7 @@ export type Event = | UpdateIdentityLdapAuthEvent | GetIdentityLdapAuthEvent | RevokeIdentityLdapAuthEvent + | ClearIdentityLdapAuthLockoutsEvent | CreateEnvironmentEvent | GetEnvironmentEvent | UpdateEnvironmentEvent diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index f520d87da..81bac6f85 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -240,7 +240,11 @@ export const LDAP_AUTH = { accessTokenTTL: "The lifetime for an access token in seconds.", accessTokenMaxTTL: "The maximum lifetime for an access token in seconds.", accessTokenNumUsesLimit: "The maximum number of times that an access token can be used.", - accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from." + accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from.", + lockoutEnabled: "Whether the lockout feature is enabled.", + lockoutThreshold: "The amount of times login must fail before locking the identity auth method.", + lockoutDuration: "How long an identity auth method lockout lasts.", + lockoutCounterReset: "How long to wait from the most recent failed login until resetting the lockout counter." }, UPDATE: { identityId: "The ID of the identity to update the configuration for.", @@ -255,13 +259,20 @@ export const LDAP_AUTH = { accessTokenMaxTTL: "The new maximum lifetime for an access token in seconds.", accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used.", accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from.", - templateId: "The ID of the identity auth template to update the configuration to." + templateId: "The ID of the identity auth template to update the configuration to.", + lockoutEnabled: "Whether the lockout feature is enabled.", + lockoutThreshold: "The amount of times login must fail before locking the identity auth method.", + lockoutDuration: "How long an identity auth method lockout lasts.", + lockoutCounterReset: "How long to wait from the most recent failed login until resetting the lockout counter." }, RETRIEVE: { identityId: "The ID of the identity to retrieve the configuration for." }, REVOKE: { identityId: "The ID of the identity to revoke the configuration for." + }, + CLEAR_CLIENT_LOCKOUTS: { + identityId: "The ID of the identity to clear the client lockouts from." } } as const; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 0455a4da0..c6ec3e3ec 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1612,7 +1612,8 @@ export const registerRoutes = async ( identityOrgMembershipDAL, licenseService, identityDAL, - identityAuthTemplateDAL + identityAuthTemplateDAL, + keyStore }); const dynamicSecretProviders = buildDynamicSecretProviders({ diff --git a/backend/src/server/routes/v1/identity-ldap-auth-router.ts b/backend/src/server/routes/v1/identity-ldap-auth-router.ts index 5d3612bf5..5931204c1 100644 --- a/backend/src/server/routes/v1/identity-ldap-auth-router.ts +++ b/backend/src/server/routes/v1/identity-ldap-auth-router.ts @@ -135,19 +135,41 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) }) } }, - preValidation: passport.authenticate("ldapauth", { - failWithError: true, - session: false - }) as any, + preValidation: [ + async (req, res) => { + await server.services.identityLdapAuth.checkLdapLockout({ + identityId: req.body.identityId, + username: req.body.username + }); - errorHandler: (error) => { - if (error.name === "AuthenticationError") { - throw new UnauthorizedError({ message: "Invalid credentials" }); + try { + const passportRes = await ( + passport.authenticate("ldapauth", { + failWithError: true, + session: false + }) as any + )(req, res); + + await server.services.identityLdapAuth.resetLdapLockoutCounter({ + identityId: req.body.identityId, + username: req.body.username + }); + + return passportRes; + } catch (error) { + if ((error as any).status === 401) { + await server.services.identityLdapAuth.incrementLdapLockout({ + identityId: req.body.identityId, + username: req.body.username + }); + + throw new UnauthorizedError({ message: "Invalid credentials" }); + } + + throw error; + } } - - throw error; - }, - + ], handler: async (req) => { if (!req.passportMachineIdentity?.identityId) { throw new UnauthorizedError({ message: "Invalid request. Missing identity ID or LDAP entry details." }); @@ -241,7 +263,11 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) .int() .min(0) .default(0) - .describe(LDAP_AUTH.ATTACH.accessTokenNumUsesLimit) + .describe(LDAP_AUTH.ATTACH.accessTokenNumUsesLimit), + lockoutEnabled: z.boolean().default(true).describe(LDAP_AUTH.ATTACH.lockoutEnabled), + lockoutThreshold: z.number().min(1).max(30).default(3).describe(LDAP_AUTH.ATTACH.lockoutThreshold), + lockoutDuration: z.number().min(30).max(86400).default(300).describe(LDAP_AUTH.ATTACH.lockoutDuration), + lockoutCounterReset: z.number().min(5).max(3600).default(30).describe(LDAP_AUTH.ATTACH.lockoutCounterReset) }) .refine( (val) => val.accessTokenTTL <= val.accessTokenMaxTTL, @@ -291,7 +317,11 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) .int() .min(0) .default(0) - .describe(LDAP_AUTH.ATTACH.accessTokenNumUsesLimit) + .describe(LDAP_AUTH.ATTACH.accessTokenNumUsesLimit), + lockoutEnabled: z.boolean().default(true).describe(LDAP_AUTH.ATTACH.lockoutEnabled), + lockoutThreshold: z.number().min(1).max(30).default(3).describe(LDAP_AUTH.ATTACH.lockoutThreshold), + lockoutDuration: z.number().min(30).max(86400).default(300).describe(LDAP_AUTH.ATTACH.lockoutDuration), + lockoutCounterReset: z.number().min(5).max(3600).default(30).describe(LDAP_AUTH.ATTACH.lockoutCounterReset) }) .refine( (val) => val.accessTokenTTL <= val.accessTokenMaxTTL, @@ -331,7 +361,11 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) accessTokenTTL: identityLdapAuth.accessTokenTTL, accessTokenNumUsesLimit: identityLdapAuth.accessTokenNumUsesLimit, allowedFields: req.body.allowedFields, - templateId: identityLdapAuth.templateId + templateId: identityLdapAuth.templateId, + lockoutEnabled: identityLdapAuth.lockoutEnabled, + lockoutThreshold: identityLdapAuth.lockoutThreshold, + lockoutDuration: identityLdapAuth.lockoutDuration, + lockoutCounterReset: identityLdapAuth.lockoutCounterReset } } }); @@ -395,7 +429,11 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) .max(315360000) .min(0) .optional() - .describe(LDAP_AUTH.UPDATE.accessTokenMaxTTL) + .describe(LDAP_AUTH.UPDATE.accessTokenMaxTTL), + lockoutEnabled: z.boolean().optional().describe(LDAP_AUTH.UPDATE.lockoutEnabled), + lockoutThreshold: z.number().min(1).max(30).optional().describe(LDAP_AUTH.UPDATE.lockoutThreshold), + lockoutDuration: z.number().min(30).max(86400).optional().describe(LDAP_AUTH.UPDATE.lockoutDuration), + lockoutCounterReset: z.number().min(5).max(3600).optional().describe(LDAP_AUTH.UPDATE.lockoutCounterReset) }) .refine( (val) => (val.accessTokenMaxTTL && val.accessTokenTTL ? val.accessTokenTTL <= val.accessTokenMaxTTL : true), @@ -434,7 +472,11 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) accessTokenNumUsesLimit: identityLdapAuth.accessTokenNumUsesLimit, accessTokenTrustedIps: identityLdapAuth.accessTokenTrustedIps as TIdentityTrustedIp[], allowedFields: req.body.allowedFields, - templateId: identityLdapAuth.templateId + templateId: identityLdapAuth.templateId, + lockoutEnabled: identityLdapAuth.lockoutEnabled, + lockoutThreshold: identityLdapAuth.lockoutThreshold, + lockoutDuration: identityLdapAuth.lockoutDuration, + lockoutCounterReset: identityLdapAuth.lockoutCounterReset } } }); @@ -553,4 +595,53 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) return { identityLdapAuth }; } }); + + server.route({ + method: "POST", + url: "/ldap-auth/identities/:identityId/clear-lockouts", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.LdapAuth], + description: "Clear LDAP Auth Lockouts for identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().describe(LDAP_AUTH.CLEAR_CLIENT_LOCKOUTS.identityId) + }), + response: { + 200: z.object({ + deleted: z.number() + }) + } + }, + handler: async (req) => { + const clearLockoutsData = await server.services.identityLdapAuth.clearLdapAuthLockouts({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: clearLockoutsData.orgId, + event: { + type: EventType.CLEAR_IDENTITY_LDAP_AUTH_LOCKOUTS, + metadata: { + identityId: clearLockoutsData.identityId + } + } + }); + + return clearLockoutsData; + } + }); }; diff --git a/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts b/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts index 47188e26d..9445b42e8 100644 --- a/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts +++ b/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts @@ -15,9 +15,10 @@ import { validatePrivilegeChangeOperation } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; -import { BadRequestError, NotFoundError, PermissionBoundaryError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { ActorType, AuthTokenType } from "../auth/auth-type"; @@ -32,8 +33,12 @@ import { TIdentityLdapAuthDALFactory } from "./identity-ldap-auth-dal"; import { AllowedFieldsSchema, TAttachLdapAuthDTO, + TCheckLdapAuthLockoutDTO, + TClearLdapAuthLockoutsDTO, TGetLdapAuthDTO, + TIncrementLdapAuthLockoutDTO, TLoginLdapAuthDTO, + TResetLdapAuthLockoutCounterDTO, TRevokeLdapAuthDTO, TUpdateLdapAuthDTO } from "./identity-ldap-auth-types"; @@ -50,10 +55,16 @@ type TIdentityLdapAuthServiceFactoryDep = { kmsService: TKmsServiceFactory; identityDAL: TIdentityDALFactory; identityAuthTemplateDAL: TIdentityAuthTemplateDALFactory; + keyStore: Pick; }; export type TIdentityLdapAuthServiceFactory = ReturnType; +type LockoutObject = { + lockedOut: boolean; + failedAttempts: number; +}; + export const identityLdapAuthServiceFactory = ({ identityAccessTokenDAL, identityDAL, @@ -62,7 +73,8 @@ export const identityLdapAuthServiceFactory = ({ licenseService, permissionService, kmsService, - identityAuthTemplateDAL + identityAuthTemplateDAL, + keyStore }: TIdentityLdapAuthServiceFactoryDep) => { const getLdapConfig = async (identityId: string) => { const identity = await identityDAL.findOne({ id: identityId }); @@ -126,13 +138,17 @@ export const identityLdapAuthServiceFactory = ({ const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); if (!identityMembershipOrg) { - throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + throw new UnauthorizedError({ + message: "Invalid credentials" + }); } const identityLdapAuth = await identityLdapAuthDAL.findOne({ identityId }); if (!identityLdapAuth) { - throw new NotFoundError({ message: `Failed to find LDAP auth for identity with ID ${identityId}` }); + throw new UnauthorizedError({ + message: "Invalid credentials" + }); } const plan = await licenseService.getPlan(identityMembershipOrg.orgId); @@ -204,7 +220,11 @@ export const identityLdapAuthServiceFactory = ({ actor, actorOrgId, isActorSuperAdmin, - allowedFields + allowedFields, + lockoutEnabled, + lockoutThreshold, + lockoutDuration, + lockoutCounterReset }: TAttachLdapAuthDTO) => { await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); @@ -337,7 +357,11 @@ export const identityLdapAuthServiceFactory = ({ accessTokenNumUsesLimit, accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps), allowedFields: allowedFields ? JSON.stringify(allowedFields) : undefined, - templateId + templateId, + lockoutEnabled, + lockoutThreshold, + lockoutDuration, + lockoutCounterReset }, tx ); @@ -363,7 +387,11 @@ export const identityLdapAuthServiceFactory = ({ actorId, actorAuthMethod, actor, - actorOrgId + actorOrgId, + lockoutEnabled, + lockoutThreshold, + lockoutDuration, + lockoutCounterReset }: TUpdateLdapAuthDTO) => { const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); @@ -511,7 +539,11 @@ export const identityLdapAuthServiceFactory = ({ accessTokenNumUsesLimit, accessTokenTrustedIps: reformattedAccessTokenTrustedIps ? JSON.stringify(reformattedAccessTokenTrustedIps) - : undefined + : undefined, + lockoutEnabled, + lockoutThreshold, + lockoutDuration, + lockoutCounterReset }); return { ...updatedLdapAuth, orgId: identityMembershipOrg.orgId }; @@ -611,12 +643,104 @@ export const identityLdapAuthServiceFactory = ({ return revokedIdentityLdapAuth; }; + const checkLdapLockout = async ({ identityId, username }: TCheckLdapAuthLockoutDTO) => { + const LOCKOUT_KEY = `lockout:identity:${identityId}:${IdentityAuthMethod.LDAP_AUTH}:${username.trim().toLowerCase()}`; + + const lockoutRaw = await keyStore.getItem(LOCKOUT_KEY); + + if (lockoutRaw) { + const lockout = JSON.parse(lockoutRaw) as LockoutObject; + + if (lockout.lockedOut) { + throw new UnauthorizedError({ + message: "This identity auth method is temporarily locked, please try again later" + }); + } + } + }; + + const incrementLdapLockout = async ({ identityId, username }: TIncrementLdapAuthLockoutDTO) => { + const identityLdapAuth = await identityLdapAuthDAL.findOne({ identityId }); + if (!identityLdapAuth) { + throw new UnauthorizedError({ + message: "Invalid credentials" + }); + } + + if (identityLdapAuth.lockoutEnabled) { + const LOCKOUT_KEY = `lockout:identity:${identityId}:${IdentityAuthMethod.LDAP_AUTH}:${username.trim().toLowerCase()}`; + + let lockout: LockoutObject = { + lockedOut: false, + failedAttempts: 0 + }; + + const lockoutRaw = await keyStore.getItem(LOCKOUT_KEY); + if (lockoutRaw) { + lockout = JSON.parse(lockoutRaw) as LockoutObject; + } + + lockout.failedAttempts += 1; + if (lockout.failedAttempts >= identityLdapAuth.lockoutThreshold) { + lockout.lockedOut = true; + } + + await keyStore.setItemWithExpiry( + LOCKOUT_KEY, + lockout.lockedOut ? identityLdapAuth.lockoutDuration : identityLdapAuth.lockoutCounterReset, + JSON.stringify(lockout) + ); + } + }; + + const resetLdapLockoutCounter = async ({ identityId, username }: TResetLdapAuthLockoutCounterDTO) => { + await keyStore.deleteItem( + `lockout:identity:${identityId}:${IdentityAuthMethod.LDAP_AUTH}:${username.trim().toLowerCase()}` + ); + }; + + const clearLdapAuthLockouts = async ({ + identityId, + actorId, + actor, + actorOrgId, + actorAuthMethod + }: TClearLdapAuthLockoutsDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + + if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.LDAP_AUTH)) { + throw new BadRequestError({ + message: "The identity does not have ldap auth" + }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const deleted = await keyStore.deleteItems({ + pattern: `lockout:identity:${identityId}:${IdentityAuthMethod.LDAP_AUTH}:*` + }); + + return { deleted, identityId, orgId: identityMembershipOrg.orgId }; + }; + return { attachLdapAuth, getLdapConfig, updateLdapAuth, login, revokeIdentityLdapAuth, - getLdapAuth + getLdapAuth, + checkLdapLockout, + incrementLdapLockout, + resetLdapLockoutCounter, + clearLdapAuthLockouts }; }; diff --git a/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts b/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts index 8629763bb..543df1b2a 100644 --- a/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts +++ b/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts @@ -27,6 +27,10 @@ export type TAttachLdapAuthDTO = { accessTokenNumUsesLimit: number; accessTokenTrustedIps: { ipAddress: string }[]; isActorSuperAdmin?: boolean; + lockoutEnabled: boolean; + lockoutThreshold: number; + lockoutDuration: number; + lockoutCounterReset: number; } & Omit; export type TUpdateLdapAuthDTO = { @@ -43,6 +47,10 @@ export type TUpdateLdapAuthDTO = { accessTokenMaxTTL?: number; accessTokenNumUsesLimit?: number; accessTokenTrustedIps?: { ipAddress: string }[]; + lockoutEnabled?: boolean; + lockoutThreshold?: number; + lockoutDuration?: number; + lockoutCounterReset?: number; } & Omit; export type TGetLdapAuthDTO = { @@ -56,3 +64,22 @@ export type TLoginLdapAuthDTO = { export type TRevokeLdapAuthDTO = { identityId: string; } & Omit; + +export type TClearLdapAuthLockoutsDTO = { + identityId: string; +} & Omit; + +export type TCheckLdapAuthLockoutDTO = { + identityId: string; + username: string; +}; + +export type TIncrementLdapAuthLockoutDTO = { + identityId: string; + username: string; +}; + +export type TResetLdapAuthLockoutCounterDTO = { + identityId: string; + username: string; +}; diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index 186a8e539..9b9280cf0 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -192,6 +192,7 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.UPDATE_IDENTITY_LDAP_AUTH]: "Updated LDAP Auth for identity", [EventType.GET_IDENTITY_LDAP_AUTH]: "Retrieved LDAP Auth for identity", [EventType.REVOKE_IDENTITY_LDAP_AUTH]: "Revoked LDAP Auth for identity", + [EventType.CLEAR_IDENTITY_LDAP_AUTH_LOCKOUTS]: "Clear LDAP Auth lockouts", [EventType.SECRET_SCANNING_DATA_SOURCE_LIST]: "List Secret Scanning Data Sources", [EventType.SECRET_SCANNING_DATA_SOURCE_CREATE]: "Create Secret Scanning Data Source", diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index 4fe8948dd..13af79788 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -54,6 +54,7 @@ export enum EventType { UPDATE_IDENTITY_LDAP_AUTH = "update-identity-ldap-auth", GET_IDENTITY_LDAP_AUTH = "get-identity-ldap-auth", REVOKE_IDENTITY_LDAP_AUTH = "revoke-identity-ldap-auth", + CLEAR_IDENTITY_LDAP_AUTH_LOCKOUTS = "clear-ldap-auth-lockouts", CREATE_ENVIRONMENT = "create-environment", UPDATE_ENVIRONMENT = "update-environment", diff --git a/frontend/src/hooks/api/auditLogs/types.tsx b/frontend/src/hooks/api/auditLogs/types.tsx index 1ca819e60..a0b1ff50a 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -874,6 +874,13 @@ interface IntegrationSyncedEvent { }; } +interface ClearIdentityLdapAuthLockoutsEvent { + type: EventType.CLEAR_IDENTITY_LDAP_AUTH_LOCKOUTS; + metadata: { + identityId: string; + }; +} + export type Event = | GetSecretsEvent | GetSecretEvent @@ -958,7 +965,8 @@ export type Event = | GetCertificateTemplateEstConfig | UpdateProjectWorkflowIntegrationConfig | GetProjectWorkflowIntegrationConfig - | IntegrationSyncedEvent; + | IntegrationSyncedEvent + | ClearIdentityLdapAuthLockoutsEvent; export type AuditLog = { id: string; diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx index ca903bb48..2cf50d0d0 100644 --- a/frontend/src/hooks/api/identities/mutations.tsx +++ b/frontend/src/hooks/api/identities/mutations.tsx @@ -18,6 +18,7 @@ import { AddIdentityTlsCertAuthDTO, AddIdentityTokenAuthDTO, AddIdentityUniversalAuthDTO, + ClearIdentityLdapAuthLockoutsDTO, ClearIdentityUniversalAuthLockoutsDTO, ClientSecretData, CreateIdentityDTO, @@ -1432,7 +1433,11 @@ export const useAddIdentityLdapAuth = () => { accessTokenTTL, accessTokenMaxTTL, accessTokenNumUsesLimit, - accessTokenTrustedIps + accessTokenTrustedIps, + lockoutEnabled, + lockoutThreshold, + lockoutDuration, + lockoutCounterReset }) => { const { data } = await apiRequest.post<{ identityLdapAuth: IdentityLdapAuth }>( `/api/v1/auth/ldap-auth/identities/${identityId}`, @@ -1448,7 +1453,11 @@ export const useAddIdentityLdapAuth = () => { accessTokenTTL, accessTokenMaxTTL, accessTokenNumUsesLimit, - accessTokenTrustedIps + accessTokenTrustedIps, + lockoutEnabled, + lockoutThreshold, + lockoutDuration, + lockoutCounterReset } ); return data.identityLdapAuth; @@ -1481,7 +1490,11 @@ export const useUpdateIdentityLdapAuth = () => { accessTokenTTL, accessTokenMaxTTL, accessTokenNumUsesLimit, - accessTokenTrustedIps + accessTokenTrustedIps, + lockoutEnabled, + lockoutThreshold, + lockoutDuration, + lockoutCounterReset }) => { const { data } = await apiRequest.patch<{ identityLdapAuth: IdentityLdapAuth }>( `/api/v1/auth/ldap-auth/identities/${identityId}`, @@ -1497,7 +1510,11 @@ export const useUpdateIdentityLdapAuth = () => { accessTokenTTL, accessTokenMaxTTL, accessTokenNumUsesLimit, - accessTokenTrustedIps + accessTokenTrustedIps, + lockoutEnabled, + lockoutThreshold, + lockoutDuration, + lockoutCounterReset } ); return data.identityLdapAuth; @@ -1532,3 +1549,22 @@ export const useDeleteIdentityLdapAuth = () => { } }); }; + +export const useClearIdentityLdapAuthLockouts = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ identityId }) => { + const { + data: { deleted } + } = await apiRequest.post<{ deleted: number }>( + `/api/v1/auth/ldap-auth/identities/${identityId}/clear-lockouts` + ); + return deleted; + }, + onSuccess: (_, { identityId }) => { + queryClient.invalidateQueries({ + queryKey: identitiesKeys.getIdentityLdapAuth(identityId) + }); + } + }); +}; diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index 8f5ec2b8e..ba9bb63fc 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -603,6 +603,11 @@ export type AddIdentityLdapAuthDTO = { accessTokenTrustedIps: { ipAddress: string; }[]; + + lockoutEnabled: boolean; + lockoutThreshold: number; + lockoutDuration: number; + lockoutCounterReset: number; }; export type UpdateIdentityLdapAuthDTO = { @@ -625,6 +630,11 @@ export type UpdateIdentityLdapAuthDTO = { accessTokenTrustedIps?: { ipAddress: string; }[]; + + lockoutEnabled?: boolean; + lockoutThreshold?: number; + lockoutDuration?: number; + lockoutCounterReset?: number; }; export type DeleteIdentityLdapAuthDTO = { @@ -650,6 +660,15 @@ export type IdentityLdapAuth = { accessTokenMaxTTL: number; accessTokenNumUsesLimit: number; accessTokenTrustedIps: IdentityTrustedIp[]; + + lockoutEnabled: boolean; + lockoutThreshold: number; + lockoutDuration: number; + lockoutCounterReset: number; +}; + +export type ClearIdentityLdapAuthLockoutsDTO = { + identityId: string; }; export type AddIdentityTokenAuthDTO = { diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLdapAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLdapAuthForm.tsx index ee0cddc5c..1930f34c9 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLdapAuthForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLdapAuthForm.tsx @@ -25,6 +25,7 @@ import { OrgPermissionMachineIdentityAuthTemplateActions, OrgPermissionSubjects } from "@app/context/OrgPermissionContext/types"; +import { durationToSeconds, getObjectFromSeconds } from "@app/helpers/datetime"; import { MachineIdentityAuthMethod, useAddIdentityLdapAuth, @@ -35,6 +36,8 @@ import { IdentityTrustedIp } from "@app/hooks/api/identities/types"; import { useGetAvailableTemplates } from "@app/hooks/api/identityAuthTemplates/queries"; import { UsePopUpState } from "@app/hooks/usePopUp"; +import { LockoutTab } from "./lockout/LockoutTab"; +import { superRefineLockout } from "./lockout/super-refine"; import { IdentityFormTab } from "./types"; const schema = z @@ -74,9 +77,28 @@ const schema = z ipAddress: z.string().max(50) }) ) - .min(1) + .min(1), + + lockoutEnabled: z.boolean().default(true), + lockoutThreshold: z + .string() + .refine( + (value) => Number(value) <= 30 && Number(value) >= 1, + "Lockout threshold must be between 1 and 30" + ), + lockoutDurationValue: z.string(), + lockoutDurationUnit: z.enum(["s", "m", "h", "d"], { + invalid_type_error: "Please select a valid time unit" + }), + lockoutCounterResetValue: z.string(), + lockoutCounterResetUnit: z.enum(["s", "m", "h"], { + invalid_type_error: "Please select a valid time unit" + }) }) + .required() .superRefine((data, ctx) => { + superRefineLockout(data, ctx); + // Validation based on scope if (data.scope === "template") { if (!data.templateId) { @@ -178,12 +200,25 @@ export const IdentityLdapAuthForm = ({ accessTokenTTL: "2592000", accessTokenMaxTTL: "2592000", accessTokenNumUsesLimit: "0", - accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }], + lockoutEnabled: true, + lockoutThreshold: "3", + lockoutDurationValue: "5", + lockoutDurationUnit: "m", + lockoutCounterResetValue: "30", + lockoutCounterResetUnit: "s" } }); const scope = watch("scope"); + const lockoutEnabledWatch = watch("lockoutEnabled"); + const lockoutThresholdWatch = watch("lockoutThreshold"); + const lockoutDurationValueWatch = watch("lockoutDurationValue"); + const lockoutDurationUnitWatch = watch("lockoutDurationUnit"); + const lockoutCounterResetValueWatch = watch("lockoutCounterResetValue"); + const lockoutCounterResetUnitWatch = watch("lockoutCounterResetUnit"); + const { fields: accessTokenTrustedIpsFields, append: appendAccessTokenTrustedIp, @@ -210,6 +245,9 @@ export const IdentityLdapAuthForm = ({ if (data) { const detectedScope = determineScope(data); + const lockoutDurationObj = getObjectFromSeconds(data.lockoutDuration); + const lockoutCounterResetObj = getObjectFromSeconds(data.lockoutCounterReset); + reset({ scope: detectedScope, templateId: data.templateId || "", @@ -229,7 +267,13 @@ export const IdentityLdapAuthForm = ({ ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}` }; } - ) + ), + lockoutEnabled: data.lockoutEnabled, + lockoutThreshold: String(data.lockoutThreshold), + lockoutDurationValue: String(lockoutDurationObj.value), + lockoutDurationUnit: lockoutDurationObj.unit as "s" | "m" | "h" | "d", + lockoutCounterResetValue: String(lockoutCounterResetObj.value), + lockoutCounterResetUnit: lockoutCounterResetObj.unit as "s" | "m" | "h" }); return; } @@ -247,7 +291,13 @@ export const IdentityLdapAuthForm = ({ accessTokenTTL: "2592000", accessTokenMaxTTL: "2592000", accessTokenNumUsesLimit: "0", - accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }], + lockoutEnabled: true, + lockoutThreshold: "3", + lockoutDurationValue: "5", + lockoutDurationUnit: "m", + lockoutCounterResetValue: "30", + lockoutCounterResetUnit: "s" }); }, [data, reset]); @@ -275,9 +325,21 @@ export const IdentityLdapAuthForm = ({ accessTokenTTL, accessTokenMaxTTL, accessTokenNumUsesLimit, - accessTokenTrustedIps + accessTokenTrustedIps, + lockoutEnabled, + lockoutThreshold, + lockoutDurationValue, + lockoutDurationUnit, + lockoutCounterResetValue, + lockoutCounterResetUnit } = formData; + const lockoutDuration = durationToSeconds(Number(lockoutDurationValue), lockoutDurationUnit); + const lockoutCounterReset = durationToSeconds( + Number(lockoutCounterResetValue), + lockoutCounterResetUnit + ); + const basePayload = { organizationId: orgId, identityId, @@ -287,7 +349,11 @@ export const IdentityLdapAuthForm = ({ accessTokenTTL: Number(accessTokenTTL), accessTokenMaxTTL: Number(accessTokenMaxTTL), accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), - accessTokenTrustedIps + accessTokenTrustedIps, + lockoutEnabled, + lockoutThreshold: Number(lockoutThreshold), + lockoutDuration, + lockoutCounterReset }; // Add scope-specific fields @@ -327,7 +393,10 @@ export const IdentityLdapAuthForm = ({ return (
{ - setTabValue( + const firstErrorField = Object.keys(fields)[0]; + let tab = IdentityFormTab.Advanced; + + if ( [ "scope", "templateId", @@ -340,15 +409,29 @@ export const IdentityLdapAuthForm = ({ "allowedFields", "accessTokenMaxTTL", "accessTokenNumUsesLimit" - ].includes(Object.keys(fields)[0]) - ? IdentityFormTab.Configuration - : IdentityFormTab.Advanced - ); + ].includes(firstErrorField) + ) { + tab = IdentityFormTab.Configuration; + } else if ( + [ + "lockoutEnabled", + "lockoutThreshold", + "lockoutDurationValue", + "lockoutDurationUnit", + "lockoutCounterResetValue", + "lockoutCounterResetUnit" + ].includes(firstErrorField) + ) { + tab = IdentityFormTab.Lockout; + } + + setTabValue(tab); })} > setTabValue(value as IdentityFormTab)}> Configuration + Lockout Advanced @@ -691,6 +774,15 @@ export const IdentityLdapAuthForm = ({ )} /> + { - const { - lockoutDurationValue, - lockoutCounterResetValue, - lockoutDurationUnit, - lockoutCounterResetUnit, - lockoutEnabled - } = data; - - if (!lockoutEnabled) return; - - let isAnyParseError = false; - - const parsedLockoutDuration = parseInt(lockoutDurationValue, 10); - if (Number.isNaN(parsedLockoutDuration)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Lockout duration must be a number", - path: ["lockoutDurationValue"] - }); - isAnyParseError = true; - } - - const parsedLockoutCounterReset = parseInt(lockoutCounterResetValue, 10); - if (Number.isNaN(parsedLockoutCounterReset)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Lockout counter reset must be a number", - path: ["lockoutCounterResetValue"] - }); - isAnyParseError = true; - } - - if (isAnyParseError) return; - - const lockoutDurationInSeconds = durationToSeconds(parsedLockoutDuration, lockoutDurationUnit); - const lockoutCounterResetInSeconds = durationToSeconds( - parsedLockoutCounterReset, - lockoutCounterResetUnit - ); - - if (lockoutDurationInSeconds > 86400 || lockoutDurationInSeconds < 30) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Lockout duration must be between 30 seconds and 1 day", - path: ["lockoutDurationValue"] - }); - } - - if (lockoutCounterResetInSeconds > 3600 || lockoutCounterResetInSeconds < 5) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Lockout counter reset must be between 5 seconds and 1 hour", - path: ["lockoutCounterResetValue"] - }); - } - }); + .superRefine(superRefineLockout); export type FormData = z.infer; @@ -315,8 +258,8 @@ export const IdentityUniversalAuthForm = ({ accessTokenPeriod: Number(accessTokenPeriod), lockoutEnabled, lockoutThreshold: Number(lockoutThreshold), - lockoutDuration: Number(lockoutDuration), - lockoutCounterReset: Number(lockoutCounterReset) + lockoutDuration, + lockoutCounterReset }); } @@ -435,187 +378,15 @@ export const IdentityUniversalAuthForm = ({ )} /> - -
- { - return ( - - - Lockout {value ? "Enabled" : "Disabled"} - - - ); - }} - /> -
- { - return ( - - - - ); - }} - /> -
- { - return ( - - - - ); - }} - /> - ( - - - - )} - /> -
-
- { - return ( - - - - ); - }} - /> - ( - - - - )} - /> -
-
-
-
- + {clientSecretTrustedIpsFields.map(({ id }, index) => (
diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/lockout/LockoutTab.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/lockout/LockoutTab.tsx new file mode 100644 index 000000000..5b6acf6f1 --- /dev/null +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/lockout/LockoutTab.tsx @@ -0,0 +1,206 @@ +import { Control, Controller } from "react-hook-form"; + +import { FormControl, Input, Select, SelectItem, Switch, TabPanel } from "@app/components/v2"; + +import { IdentityFormTab } from "../types"; + +export const LockoutTab = ({ + control, + lockoutEnabled, + lockoutThreshold, + lockoutDurationValue, + lockoutDurationUnit, + lockoutCounterResetValue, + lockoutCounterResetUnit +}: { + control: Control; + lockoutEnabled: boolean; + lockoutThreshold: string; + lockoutDurationValue: string; + lockoutDurationUnit: "s" | "m" | "h" | "d"; + lockoutCounterResetValue: string; + lockoutCounterResetUnit: "s" | "m" | "h"; +}) => { + return ( + +
+ { + return ( + + + Lockout {value ? "Enabled" : "Disabled"} + + + ); + }} + /> +
+ { + return ( + + + + ); + }} + /> +
+ { + return ( + + + + ); + }} + /> + ( + + + + )} + /> +
+
+ { + return ( + + + + ); + }} + /> + ( + + + + )} + /> +
+
+
+
+ ); +}; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/lockout/super-refine.ts b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/lockout/super-refine.ts new file mode 100644 index 000000000..e84989e82 --- /dev/null +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/lockout/super-refine.ts @@ -0,0 +1,73 @@ +import { z } from "zod"; + +import { durationToSeconds } from "@app/helpers/datetime"; + +export function superRefineLockout( + data: { + lockoutDurationValue: string; + lockoutCounterResetValue: string; + lockoutDurationUnit: "s" | "m" | "h" | "d"; + lockoutCounterResetUnit: "s" | "m" | "h"; + lockoutEnabled: boolean; + }, + ctx: z.RefinementCtx +) { + const { + lockoutDurationValue, + lockoutCounterResetValue, + lockoutDurationUnit, + lockoutCounterResetUnit, + lockoutEnabled + } = data; + + if (lockoutEnabled) { + let isAnyParseError = false; + + const parsedLockoutDuration = parseInt(lockoutDurationValue, 10); + if (Number.isNaN(parsedLockoutDuration)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Lockout duration must be a number", + path: ["lockoutDurationValue"] + }); + isAnyParseError = true; + } + + const parsedLockoutCounterReset = parseInt(lockoutCounterResetValue, 10); + if (Number.isNaN(parsedLockoutCounterReset)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Lockout counter reset must be a number", + path: ["lockoutCounterResetValue"] + }); + isAnyParseError = true; + } + + if (!isAnyParseError) { + const lockoutDurationInSeconds = durationToSeconds( + parsedLockoutDuration, + lockoutDurationUnit + ); + const lockoutCounterResetInSeconds = durationToSeconds( + parsedLockoutCounterReset, + lockoutCounterResetUnit + ); + + if (lockoutDurationInSeconds > 86400 || lockoutDurationInSeconds < 30) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Lockout duration must be between 30 seconds and 1 day", + path: ["lockoutDurationValue"] + }); + } + + if (lockoutCounterResetInSeconds > 3600 || lockoutCounterResetInSeconds < 5) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Lockout counter reset must be between 5 seconds and 1 hour", + path: ["lockoutCounterResetValue"] + }); + } + } + } +} diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityAuthLockoutFields.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityAuthLockoutFields.tsx new file mode 100644 index 000000000..b2f39224e --- /dev/null +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityAuthLockoutFields.tsx @@ -0,0 +1,80 @@ +import { useState } from "react"; +import { UseMutationResult } from "@tanstack/react-query"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { Button } from "@app/components/v2"; +import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/context"; + +import { IdentityAuthFieldDisplay } from "./IdentityAuthFieldDisplay"; + +export const LockoutFields = ({ + clearLockoutsResult, + lockedOut, + identityId, + data +}: { + clearLockoutsResult: UseMutationResult; + lockedOut: boolean; + identityId: string; + data: { + lockoutEnabled: boolean; + lockoutThreshold: number; + lockoutDuration: number; + lockoutCounterReset: number; + }; +}) => { + const { mutateAsync, isPending } = clearLockoutsResult; + + const [lockedOutState, setLockedOutState] = useState(lockedOut); + + async function clearLockouts() { + try { + const deleted = await mutateAsync({ identityId }); + createNotification({ + text: `Successfully cleared ${deleted} lockout${deleted === 1 ? "" : "s"}`, + type: "success" + }); + setLockedOutState(false); + } catch (error) { + console.error(error); + createNotification({ + text: "Failed to clear lockouts. Please try again.", + type: "error" + }); + } + } + + return ( + <> +
+ Lockout Options + + {(isAllowed) => ( + + )} + +
+ + {data.lockoutEnabled ? "Enabled" : "Disabled"} + + + {data.lockoutThreshold} + + + {data.lockoutDuration} seconds + + + {data.lockoutCounterReset} seconds + + + ); +}; diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityLdapAuthContent.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityLdapAuthContent.tsx index f732fd5ed..2d6773484 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityLdapAuthContent.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityLdapAuthContent.tsx @@ -2,11 +2,12 @@ import { faBan, faEye } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Badge, EmptyState, Spinner, Tooltip } from "@app/components/v2"; -import { useGetIdentityLdapAuth } from "@app/hooks/api"; +import { useClearIdentityLdapAuthLockouts, useGetIdentityLdapAuth } from "@app/hooks/api"; import { IdentityLdapAuthForm } from "@app/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLdapAuthForm"; import { ViewIdentityContentWrapper } from "@app/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityContentWrapper"; import { IdentityAuthFieldDisplay } from "./IdentityAuthFieldDisplay"; +import { LockoutFields } from "./IdentityAuthLockoutFields"; import { ViewAuthMethodProps } from "./types"; export const ViewIdentityLdapAuthContent = ({ @@ -14,9 +15,11 @@ export const ViewIdentityLdapAuthContent = ({ handlePopUpToggle, handlePopUpOpen, onDelete, - popUp + popUp, + lockedOut }: ViewAuthMethodProps) => { const { data, isPending } = useGetIdentityLdapAuth(identityId); + const clearLockoutsResult = useClearIdentityLdapAuthLockouts(); if (isPending) { return ( @@ -98,6 +101,12 @@ export const ViewIdentityLdapAuthContent = ({ )} + ); }; diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx index f31e59cd8..7ef8790c2 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx @@ -1,11 +1,7 @@ -import { useState } from "react"; import { faBan, faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { createNotification } from "@app/components/notifications"; -import { OrgPermissionCan } from "@app/components/permissions"; -import { Button, EmptyState, IconButton, Spinner, Tooltip } from "@app/components/v2"; -import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/context"; +import { EmptyState, IconButton, Spinner, Tooltip } from "@app/components/v2"; import { useTimedReset } from "@app/hooks"; import { useClearIdentityUniversalAuthLockouts, @@ -15,6 +11,7 @@ import { import { IdentityUniversalAuthForm } from "@app/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityUniversalAuthForm"; import { IdentityAuthFieldDisplay } from "./IdentityAuthFieldDisplay"; +import { LockoutFields } from "./IdentityAuthLockoutFields"; import { IdentityUniversalAuthClientSecretsTable } from "./IdentityUniversalAuthClientSecretsTable"; import { ViewAuthMethodProps } from "./types"; import { ViewIdentityContentWrapper } from "./ViewIdentityContentWrapper"; @@ -30,32 +27,12 @@ export const ViewIdentityUniversalAuthContent = ({ const { data, isPending } = useGetIdentityUniversalAuth(identityId); const { data: clientSecrets = [], isPending: clientSecretsPending } = useGetIdentityUniversalAuthClientSecrets(identityId); - const { mutateAsync: clearLockoutsFn, isPending: isClearLockoutsPending } = - useClearIdentityUniversalAuthLockouts(); - - const [lockedOutState, setLockedOutState] = useState(lockedOut); + const clearLockoutsResult = useClearIdentityUniversalAuthLockouts(); const [copyTextClientId, isCopyingClientId, setCopyTextClientId] = useTimedReset({ initialState: "Copy Client ID to clipboard" }); - async function clearLockouts() { - try { - const deleted = await clearLockoutsFn({ identityId }); - createNotification({ - text: `Successfully cleared ${deleted} lockout${deleted === 1 ? "" : "s"}`, - type: "success" - }); - setLockedOutState(false); - } catch (error) { - console.error(error); - createNotification({ - text: "Failed to clear lockouts. Please try again.", - type: "error" - }); - } - } - if (isPending || clientSecretsPending) { return (
@@ -112,34 +89,12 @@ export const ViewIdentityUniversalAuthContent = ({ {data.clientSecretTrustedIps.map((ip) => ip.ipAddress).join(", ")} -
- Lockout Options - - {(isAllowed) => ( - - )} - -
- - {data.lockoutEnabled ? "Enabled" : "Disabled"} - - - {data.lockoutThreshold} - - - {data.lockoutDuration} seconds - - - {data.lockoutCounterReset} seconds - +
Client ID From f732028290bf305e2e2e99d5e8445939e50b9a67 Mon Sep 17 00:00:00 2001 From: x032205 Date: Tue, 26 Aug 2025 03:20:59 -0400 Subject: [PATCH 002/105] Greptile review fixes --- backend/src/db/migrations/20250815022242_identity-lockouts.ts | 2 +- backend/src/ee/services/audit-log/audit-log-types.ts | 2 +- frontend/src/hooks/api/auditLogs/enums.tsx | 2 +- .../components/IdentitySection/lockout/LockoutTab.tsx | 1 - 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/backend/src/db/migrations/20250815022242_identity-lockouts.ts b/backend/src/db/migrations/20250815022242_identity-lockouts.ts index a0e661d2d..41146d29e 100644 --- a/backend/src/db/migrations/20250815022242_identity-lockouts.ts +++ b/backend/src/db/migrations/20250815022242_identity-lockouts.ts @@ -9,7 +9,7 @@ export async function up(knex: Knex): Promise { const hasLockoutDuration = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutDuration"); const hasLockoutCounterReset = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutCounterReset"); - await knex.schema.alterTable(TableName.IdentityUniversalAuth, async (t) => { + await knex.schema.alterTable(TableName.IdentityUniversalAuth, (t) => { if (!hasLockoutEnabled) { t.boolean("lockoutEnabled").notNullable().defaultTo(true); } diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 21c5d6ba1..09d708325 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -199,7 +199,7 @@ export enum EventType { CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "create-identity-universal-auth-client-secret", REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "revoke-identity-universal-auth-client-secret", CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS = "clear-identity-universal-auth-lockouts", - CLEAR_IDENTITY_LDAP_AUTH_LOCKOUTS = "clear-identity-ldap-lockouts", + CLEAR_IDENTITY_LDAP_AUTH_LOCKOUTS = "clear-identity-ldap-auth-lockouts", GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS = "get-identity-universal-auth-client-secret", GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET_BY_ID = "get-identity-universal-auth-client-secret-by-id", diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index 13af79788..90cb5e237 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -54,7 +54,7 @@ export enum EventType { UPDATE_IDENTITY_LDAP_AUTH = "update-identity-ldap-auth", GET_IDENTITY_LDAP_AUTH = "get-identity-ldap-auth", REVOKE_IDENTITY_LDAP_AUTH = "revoke-identity-ldap-auth", - CLEAR_IDENTITY_LDAP_AUTH_LOCKOUTS = "clear-ldap-auth-lockouts", + CLEAR_IDENTITY_LDAP_AUTH_LOCKOUTS = "clear-identity-ldap-auth-lockouts", CREATE_ENVIRONMENT = "create-environment", UPDATE_ENVIRONMENT = "update-environment", diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/lockout/LockoutTab.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/lockout/LockoutTab.tsx index 5b6acf6f1..02bb10e73 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/lockout/LockoutTab.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/lockout/LockoutTab.tsx @@ -27,7 +27,6 @@ export const LockoutTab = ({ { return ( Date: Tue, 26 Aug 2025 20:55:02 -0400 Subject: [PATCH 003/105] - Swap database column names to include "seconds" - Use MS for frontend time display --- .../20250819081226_identity-lockouts-ldap.ts | 22 +++++--- backend/src/db/schemas/identity-ldap-auths.ts | 4 +- .../ee/services/audit-log/audit-log-types.ts | 8 +-- backend/src/lib/api-docs/constants.ts | 10 ++-- .../routes/v1/identity-ldap-auth-router.ts | 50 +++++++++++++++---- .../identity-ldap-auth-service.ts | 18 +++---- .../identity-ldap-auth-types.ts | 8 +-- .../src/hooks/api/identities/mutations.tsx | 16 +++--- frontend/src/hooks/api/identities/types.ts | 12 ++--- .../IdentitySection/IdentityLdapAuthForm.tsx | 15 +++--- .../IdentityAuthLockoutFields.tsx | 9 ++-- .../ViewIdentityUniversalAuthContent.tsx | 1 - 12 files changed, 107 insertions(+), 66 deletions(-) diff --git a/backend/src/db/migrations/20250819081226_identity-lockouts-ldap.ts b/backend/src/db/migrations/20250819081226_identity-lockouts-ldap.ts index 535e6de7c..6cc851368 100644 --- a/backend/src/db/migrations/20250819081226_identity-lockouts-ldap.ts +++ b/backend/src/db/migrations/20250819081226_identity-lockouts-ldap.ts @@ -6,8 +6,11 @@ export async function up(knex: Knex): Promise { if (await knex.schema.hasTable(TableName.IdentityLdapAuth)) { const hasLockoutEnabled = await knex.schema.hasColumn(TableName.IdentityLdapAuth, "lockoutEnabled"); const hasLockoutThreshold = await knex.schema.hasColumn(TableName.IdentityLdapAuth, "lockoutThreshold"); - const hasLockoutDuration = await knex.schema.hasColumn(TableName.IdentityLdapAuth, "lockoutDuration"); - const hasLockoutCounterReset = await knex.schema.hasColumn(TableName.IdentityLdapAuth, "lockoutCounterReset"); + const hasLockoutDuration = await knex.schema.hasColumn(TableName.IdentityLdapAuth, "lockoutDurationSeconds"); + const hasLockoutCounterReset = await knex.schema.hasColumn( + TableName.IdentityLdapAuth, + "lockoutCounterResetSeconds" + ); await knex.schema.alterTable(TableName.IdentityLdapAuth, (t) => { if (!hasLockoutEnabled) { @@ -17,10 +20,10 @@ export async function up(knex: Knex): Promise { t.integer("lockoutThreshold").notNullable().defaultTo(3); } if (!hasLockoutDuration) { - t.integer("lockoutDuration").notNullable().defaultTo(300); // 5 minutes (in seconds) + t.integer("lockoutDurationSeconds").notNullable().defaultTo(300); // 5 minutes } if (!hasLockoutCounterReset) { - t.integer("lockoutCounterReset").notNullable().defaultTo(30); // 30 seconds + t.integer("lockoutCounterResetSeconds").notNullable().defaultTo(30); // 30 seconds } }); } @@ -30,8 +33,11 @@ export async function down(knex: Knex): Promise { if (await knex.schema.hasTable(TableName.IdentityLdapAuth)) { const hasLockoutEnabled = await knex.schema.hasColumn(TableName.IdentityLdapAuth, "lockoutEnabled"); const hasLockoutThreshold = await knex.schema.hasColumn(TableName.IdentityLdapAuth, "lockoutThreshold"); - const hasLockoutDuration = await knex.schema.hasColumn(TableName.IdentityLdapAuth, "lockoutDuration"); - const hasLockoutCounterReset = await knex.schema.hasColumn(TableName.IdentityLdapAuth, "lockoutCounterReset"); + const hasLockoutDuration = await knex.schema.hasColumn(TableName.IdentityLdapAuth, "lockoutDurationSeconds"); + const hasLockoutCounterReset = await knex.schema.hasColumn( + TableName.IdentityLdapAuth, + "lockoutCounterResetSeconds" + ); await knex.schema.alterTable(TableName.IdentityLdapAuth, (t) => { if (hasLockoutEnabled) { @@ -41,10 +47,10 @@ export async function down(knex: Knex): Promise { t.dropColumn("lockoutThreshold"); } if (hasLockoutDuration) { - t.dropColumn("lockoutDuration"); + t.dropColumn("lockoutDurationSeconds"); } if (hasLockoutCounterReset) { - t.dropColumn("lockoutCounterReset"); + t.dropColumn("lockoutCounterResetSeconds"); } }); } diff --git a/backend/src/db/schemas/identity-ldap-auths.ts b/backend/src/db/schemas/identity-ldap-auths.ts index 3a89fdd51..87c7f1608 100644 --- a/backend/src/db/schemas/identity-ldap-auths.ts +++ b/backend/src/db/schemas/identity-ldap-auths.ts @@ -29,8 +29,8 @@ export const IdentityLdapAuthsSchema = z.object({ templateId: z.string().uuid().nullable().optional(), lockoutEnabled: z.boolean().default(true), lockoutThreshold: z.number().default(3), - lockoutDuration: z.number().default(300), - lockoutCounterReset: z.number().default(30) + lockoutDurationSeconds: z.number().default(300), + lockoutCounterResetSeconds: z.number().default(30) }); export type TIdentityLdapAuths = z.infer; diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index ae32bdab5..82d7ff80c 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -1372,8 +1372,8 @@ interface AddIdentityLdapAuthEvent { templateId?: string | null; lockoutEnabled: boolean; lockoutThreshold: number; - lockoutDuration: number; - lockoutCounterReset: number; + lockoutDurationSeconds: number; + lockoutCounterResetSeconds: number; }; } @@ -1390,8 +1390,8 @@ interface UpdateIdentityLdapAuthEvent { templateId?: string | null; lockoutEnabled?: boolean; lockoutThreshold?: number; - lockoutDuration?: number; - lockoutCounterReset?: number; + lockoutDurationSeconds?: number; + lockoutCounterResetSeconds?: number; }; } diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index ae7f9a58d..7edd421e6 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -245,8 +245,9 @@ export const LDAP_AUTH = { accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from.", lockoutEnabled: "Whether the lockout feature is enabled.", lockoutThreshold: "The amount of times login must fail before locking the identity auth method.", - lockoutDuration: "How long an identity auth method lockout lasts.", - lockoutCounterReset: "How long to wait from the most recent failed login until resetting the lockout counter." + lockoutDurationSeconds: "How long an identity auth method lockout lasts.", + lockoutCounterResetSeconds: + "How long to wait from the most recent failed login until resetting the lockout counter." }, UPDATE: { identityId: "The ID of the identity to update the configuration for.", @@ -264,8 +265,9 @@ export const LDAP_AUTH = { templateId: "The ID of the identity auth template to update the configuration to.", lockoutEnabled: "Whether the lockout feature is enabled.", lockoutThreshold: "The amount of times login must fail before locking the identity auth method.", - lockoutDuration: "How long an identity auth method lockout lasts.", - lockoutCounterReset: "How long to wait from the most recent failed login until resetting the lockout counter." + lockoutDurationSeconds: "How long an identity auth method lockout lasts.", + lockoutCounterResetSeconds: + "How long to wait from the most recent failed login until resetting the lockout counter." }, RETRIEVE: { identityId: "The ID of the identity to retrieve the configuration for." diff --git a/backend/src/server/routes/v1/identity-ldap-auth-router.ts b/backend/src/server/routes/v1/identity-ldap-auth-router.ts index 5931204c1..832f428a8 100644 --- a/backend/src/server/routes/v1/identity-ldap-auth-router.ts +++ b/backend/src/server/routes/v1/identity-ldap-auth-router.ts @@ -266,8 +266,18 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) .describe(LDAP_AUTH.ATTACH.accessTokenNumUsesLimit), lockoutEnabled: z.boolean().default(true).describe(LDAP_AUTH.ATTACH.lockoutEnabled), lockoutThreshold: z.number().min(1).max(30).default(3).describe(LDAP_AUTH.ATTACH.lockoutThreshold), - lockoutDuration: z.number().min(30).max(86400).default(300).describe(LDAP_AUTH.ATTACH.lockoutDuration), - lockoutCounterReset: z.number().min(5).max(3600).default(30).describe(LDAP_AUTH.ATTACH.lockoutCounterReset) + lockoutDurationSeconds: z + .number() + .min(30) + .max(86400) + .default(300) + .describe(LDAP_AUTH.ATTACH.lockoutDurationSeconds), + lockoutCounterResetSeconds: z + .number() + .min(5) + .max(3600) + .default(30) + .describe(LDAP_AUTH.ATTACH.lockoutCounterResetSeconds) }) .refine( (val) => val.accessTokenTTL <= val.accessTokenMaxTTL, @@ -320,8 +330,18 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) .describe(LDAP_AUTH.ATTACH.accessTokenNumUsesLimit), lockoutEnabled: z.boolean().default(true).describe(LDAP_AUTH.ATTACH.lockoutEnabled), lockoutThreshold: z.number().min(1).max(30).default(3).describe(LDAP_AUTH.ATTACH.lockoutThreshold), - lockoutDuration: z.number().min(30).max(86400).default(300).describe(LDAP_AUTH.ATTACH.lockoutDuration), - lockoutCounterReset: z.number().min(5).max(3600).default(30).describe(LDAP_AUTH.ATTACH.lockoutCounterReset) + lockoutDurationSeconds: z + .number() + .min(30) + .max(86400) + .default(300) + .describe(LDAP_AUTH.ATTACH.lockoutDurationSeconds), + lockoutCounterResetSeconds: z + .number() + .min(5) + .max(3600) + .default(30) + .describe(LDAP_AUTH.ATTACH.lockoutCounterResetSeconds) }) .refine( (val) => val.accessTokenTTL <= val.accessTokenMaxTTL, @@ -364,8 +384,8 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) templateId: identityLdapAuth.templateId, lockoutEnabled: identityLdapAuth.lockoutEnabled, lockoutThreshold: identityLdapAuth.lockoutThreshold, - lockoutDuration: identityLdapAuth.lockoutDuration, - lockoutCounterReset: identityLdapAuth.lockoutCounterReset + lockoutDurationSeconds: identityLdapAuth.lockoutDurationSeconds, + lockoutCounterResetSeconds: identityLdapAuth.lockoutCounterResetSeconds } } }); @@ -432,8 +452,18 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) .describe(LDAP_AUTH.UPDATE.accessTokenMaxTTL), lockoutEnabled: z.boolean().optional().describe(LDAP_AUTH.UPDATE.lockoutEnabled), lockoutThreshold: z.number().min(1).max(30).optional().describe(LDAP_AUTH.UPDATE.lockoutThreshold), - lockoutDuration: z.number().min(30).max(86400).optional().describe(LDAP_AUTH.UPDATE.lockoutDuration), - lockoutCounterReset: z.number().min(5).max(3600).optional().describe(LDAP_AUTH.UPDATE.lockoutCounterReset) + lockoutDurationSeconds: z + .number() + .min(30) + .max(86400) + .optional() + .describe(LDAP_AUTH.UPDATE.lockoutDurationSeconds), + lockoutCounterResetSeconds: z + .number() + .min(5) + .max(3600) + .optional() + .describe(LDAP_AUTH.UPDATE.lockoutCounterResetSeconds) }) .refine( (val) => (val.accessTokenMaxTTL && val.accessTokenTTL ? val.accessTokenTTL <= val.accessTokenMaxTTL : true), @@ -475,8 +505,8 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) templateId: identityLdapAuth.templateId, lockoutEnabled: identityLdapAuth.lockoutEnabled, lockoutThreshold: identityLdapAuth.lockoutThreshold, - lockoutDuration: identityLdapAuth.lockoutDuration, - lockoutCounterReset: identityLdapAuth.lockoutCounterReset + lockoutDurationSeconds: identityLdapAuth.lockoutDurationSeconds, + lockoutCounterResetSeconds: identityLdapAuth.lockoutCounterResetSeconds } } }); diff --git a/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts b/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts index 9445b42e8..a8576dc0e 100644 --- a/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts +++ b/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts @@ -223,8 +223,8 @@ export const identityLdapAuthServiceFactory = ({ allowedFields, lockoutEnabled, lockoutThreshold, - lockoutDuration, - lockoutCounterReset + lockoutDurationSeconds, + lockoutCounterResetSeconds }: TAttachLdapAuthDTO) => { await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); @@ -360,8 +360,8 @@ export const identityLdapAuthServiceFactory = ({ templateId, lockoutEnabled, lockoutThreshold, - lockoutDuration, - lockoutCounterReset + lockoutDurationSeconds, + lockoutCounterResetSeconds }, tx ); @@ -390,8 +390,8 @@ export const identityLdapAuthServiceFactory = ({ actorOrgId, lockoutEnabled, lockoutThreshold, - lockoutDuration, - lockoutCounterReset + lockoutDurationSeconds, + lockoutCounterResetSeconds }: TUpdateLdapAuthDTO) => { const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); @@ -542,8 +542,8 @@ export const identityLdapAuthServiceFactory = ({ : undefined, lockoutEnabled, lockoutThreshold, - lockoutDuration, - lockoutCounterReset + lockoutDurationSeconds, + lockoutCounterResetSeconds }); return { ...updatedLdapAuth, orgId: identityMembershipOrg.orgId }; @@ -687,7 +687,7 @@ export const identityLdapAuthServiceFactory = ({ await keyStore.setItemWithExpiry( LOCKOUT_KEY, - lockout.lockedOut ? identityLdapAuth.lockoutDuration : identityLdapAuth.lockoutCounterReset, + lockout.lockedOut ? identityLdapAuth.lockoutDurationSeconds : identityLdapAuth.lockoutCounterResetSeconds, JSON.stringify(lockout) ); } diff --git a/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts b/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts index 543df1b2a..d6a4aba49 100644 --- a/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts +++ b/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts @@ -29,8 +29,8 @@ export type TAttachLdapAuthDTO = { isActorSuperAdmin?: boolean; lockoutEnabled: boolean; lockoutThreshold: number; - lockoutDuration: number; - lockoutCounterReset: number; + lockoutDurationSeconds: number; + lockoutCounterResetSeconds: number; } & Omit; export type TUpdateLdapAuthDTO = { @@ -49,8 +49,8 @@ export type TUpdateLdapAuthDTO = { accessTokenTrustedIps?: { ipAddress: string }[]; lockoutEnabled?: boolean; lockoutThreshold?: number; - lockoutDuration?: number; - lockoutCounterReset?: number; + lockoutDurationSeconds?: number; + lockoutCounterResetSeconds?: number; } & Omit; export type TGetLdapAuthDTO = { diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx index 6f6fa4fbd..4ada1fb9b 100644 --- a/frontend/src/hooks/api/identities/mutations.tsx +++ b/frontend/src/hooks/api/identities/mutations.tsx @@ -1436,8 +1436,8 @@ export const useAddIdentityLdapAuth = () => { accessTokenTrustedIps, lockoutEnabled, lockoutThreshold, - lockoutDuration, - lockoutCounterReset + lockoutDurationSeconds, + lockoutCounterResetSeconds }) => { const { data } = await apiRequest.post<{ identityLdapAuth: IdentityLdapAuth }>( `/api/v1/auth/ldap-auth/identities/${identityId}`, @@ -1456,8 +1456,8 @@ export const useAddIdentityLdapAuth = () => { accessTokenTrustedIps, lockoutEnabled, lockoutThreshold, - lockoutDuration, - lockoutCounterReset + lockoutDurationSeconds, + lockoutCounterResetSeconds } ); return data.identityLdapAuth; @@ -1493,8 +1493,8 @@ export const useUpdateIdentityLdapAuth = () => { accessTokenTrustedIps, lockoutEnabled, lockoutThreshold, - lockoutDuration, - lockoutCounterReset + lockoutDurationSeconds, + lockoutCounterResetSeconds }) => { const { data } = await apiRequest.patch<{ identityLdapAuth: IdentityLdapAuth }>( `/api/v1/auth/ldap-auth/identities/${identityId}`, @@ -1513,8 +1513,8 @@ export const useUpdateIdentityLdapAuth = () => { accessTokenTrustedIps, lockoutEnabled, lockoutThreshold, - lockoutDuration, - lockoutCounterReset + lockoutDurationSeconds, + lockoutCounterResetSeconds } ); return data.identityLdapAuth; diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index 07430770c..a2fa17acc 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -606,8 +606,8 @@ export type AddIdentityLdapAuthDTO = { lockoutEnabled: boolean; lockoutThreshold: number; - lockoutDuration: number; - lockoutCounterReset: number; + lockoutDurationSeconds: number; + lockoutCounterResetSeconds: number; }; export type UpdateIdentityLdapAuthDTO = { @@ -633,8 +633,8 @@ export type UpdateIdentityLdapAuthDTO = { lockoutEnabled?: boolean; lockoutThreshold?: number; - lockoutDuration?: number; - lockoutCounterReset?: number; + lockoutDurationSeconds?: number; + lockoutCounterResetSeconds?: number; }; export type DeleteIdentityLdapAuthDTO = { @@ -663,8 +663,8 @@ export type IdentityLdapAuth = { lockoutEnabled: boolean; lockoutThreshold: number; - lockoutDuration: number; - lockoutCounterReset: number; + lockoutDurationSeconds: number; + lockoutCounterResetSeconds: number; }; export type ClearIdentityLdapAuthLockoutsDTO = { diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLdapAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLdapAuthForm.tsx index 1930f34c9..744942219 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLdapAuthForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLdapAuthForm.tsx @@ -245,8 +245,8 @@ export const IdentityLdapAuthForm = ({ if (data) { const detectedScope = determineScope(data); - const lockoutDurationObj = getObjectFromSeconds(data.lockoutDuration); - const lockoutCounterResetObj = getObjectFromSeconds(data.lockoutCounterReset); + const lockoutDurationObj = getObjectFromSeconds(data.lockoutDurationSeconds); + const lockoutCounterResetObj = getObjectFromSeconds(data.lockoutCounterResetSeconds); reset({ scope: detectedScope, @@ -334,8 +334,11 @@ export const IdentityLdapAuthForm = ({ lockoutCounterResetUnit } = formData; - const lockoutDuration = durationToSeconds(Number(lockoutDurationValue), lockoutDurationUnit); - const lockoutCounterReset = durationToSeconds( + const lockoutDurationSeconds = durationToSeconds( + Number(lockoutDurationValue), + lockoutDurationUnit + ); + const lockoutCounterResetSeconds = durationToSeconds( Number(lockoutCounterResetValue), lockoutCounterResetUnit ); @@ -352,8 +355,8 @@ export const IdentityLdapAuthForm = ({ accessTokenTrustedIps, lockoutEnabled, lockoutThreshold: Number(lockoutThreshold), - lockoutDuration, - lockoutCounterReset + lockoutDurationSeconds, + lockoutCounterResetSeconds }; // Add scope-specific fields diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityAuthLockoutFields.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityAuthLockoutFields.tsx index b2f39224e..aa55f418e 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityAuthLockoutFields.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityAuthLockoutFields.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { UseMutationResult } from "@tanstack/react-query"; +import ms from "ms"; import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; @@ -20,8 +21,8 @@ export const LockoutFields = ({ data: { lockoutEnabled: boolean; lockoutThreshold: number; - lockoutDuration: number; - lockoutCounterReset: number; + lockoutDurationSeconds: number; + lockoutCounterResetSeconds: number; }; }) => { const { mutateAsync, isPending } = clearLockoutsResult; @@ -70,10 +71,10 @@ export const LockoutFields = ({ {data.lockoutThreshold} - {data.lockoutDuration} seconds + {ms(data.lockoutDurationSeconds * 1000, { long: true })} - {data.lockoutCounterReset} seconds + {ms(data.lockoutCounterResetSeconds * 1000, { long: true })} ); diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx index 70487db99..7ef8790c2 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx @@ -1,6 +1,5 @@ import { faBan, faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import ms from "ms"; import { EmptyState, IconButton, Spinner, Tooltip } from "@app/components/v2"; import { useTimedReset } from "@app/hooks"; From 0137138b72f6a0f5b23156eb9d11d4339371eafe Mon Sep 17 00:00:00 2001 From: x032205 Date: Fri, 29 Aug 2025 15:34:03 -0400 Subject: [PATCH 004/105] UX Tweaks --- .../IdentitySection/lockout/LockoutTab.tsx | 2 +- .../IdentityAuthLockoutFields.tsx | 3 --- .../ViewIdentityLdapAuthContent.tsx | 19 ++++++++++++------- .../ViewIdentityUniversalAuthContent.tsx | 19 ++++++++++++------- 4 files changed, 25 insertions(+), 18 deletions(-) diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/lockout/LockoutTab.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/lockout/LockoutTab.tsx index 02bb10e73..3d386a20f 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/lockout/LockoutTab.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/lockout/LockoutTab.tsx @@ -42,7 +42,7 @@ export const LockoutTab = ({ onCheckedChange={onChange} isChecked={value} > - Lockout {value ? "Enabled" : "Disabled"} + Lockout ); diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityAuthLockoutFields.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityAuthLockoutFields.tsx index 1a616178e..b5cb2d901 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityAuthLockoutFields.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityAuthLockoutFields.tsx @@ -67,9 +67,6 @@ export const LockoutFields = ({ )}
- - {data.lockoutEnabled ? "Enabled" : "Disabled"} - {data.lockoutThreshold} diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityLdapAuthContent.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityLdapAuthContent.tsx index 998073c1d..9c20a1bbe 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityLdapAuthContent.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityLdapAuthContent.tsx @@ -102,13 +102,18 @@ export const ViewIdentityLdapAuthContent = ({ )} - + + {data.lockoutEnabled ? "Enabled" : "Disabled"} + + {data.lockoutEnabled && ( + + )} ); }; diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx index 9ae594186..4d7b95087 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx @@ -90,13 +90,18 @@ export const ViewIdentityUniversalAuthContent = ({ {data.clientSecretTrustedIps.map((ip) => ip.ipAddress).join(", ")} - + + {data.lockoutEnabled ? "Enabled" : "Disabled"} + + {data.lockoutEnabled && ( + + )}
Client ID From 77d56468f17560005108f7557353b11676dbdbb1 Mon Sep 17 00:00:00 2001 From: x032205 Date: Tue, 2 Sep 2025 20:49:51 -0400 Subject: [PATCH 005/105] lock to prevent parallel logins --- .../routes/v1/identity-ldap-auth-router.ts | 4 ++- .../identity-ldap-auth-service.ts | 33 +++++++++++++++++-- .../src/services/identity/identity-service.ts | 14 +++++--- 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/backend/src/server/routes/v1/identity-ldap-auth-router.ts b/backend/src/server/routes/v1/identity-ldap-auth-router.ts index 832f428a8..af743fb1d 100644 --- a/backend/src/server/routes/v1/identity-ldap-auth-router.ts +++ b/backend/src/server/routes/v1/identity-ldap-auth-router.ts @@ -137,7 +137,7 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) }, preValidation: [ async (req, res) => { - await server.services.identityLdapAuth.checkLdapLockout({ + const { lock } = await server.services.identityLdapAuth.checkLdapLockout({ identityId: req.body.identityId, username: req.body.username }); @@ -167,6 +167,8 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) } throw error; + } finally { + await lock.release(); } } ], diff --git a/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts b/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts index a8576dc0e..c279ca33d 100644 --- a/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts +++ b/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts @@ -15,11 +15,18 @@ import { validatePrivilegeChangeOperation } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; -import { TKeyStoreFactory } from "@app/keystore/keystore"; +import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; -import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; +import { + BadRequestError, + NotFoundError, + PermissionBoundaryError, + RateLimitError, + UnauthorizedError +} from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; +import { logger } from "@app/lib/logger"; import { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityDALFactory } from "../identity/identity-dal"; @@ -55,7 +62,10 @@ type TIdentityLdapAuthServiceFactoryDep = { kmsService: TKmsServiceFactory; identityDAL: TIdentityDALFactory; identityAuthTemplateDAL: TIdentityAuthTemplateDALFactory; - keyStore: Pick; + keyStore: Pick< + TKeyStoreFactory, + "setItemWithExpiry" | "getItem" | "deleteItem" | "getKeysByPattern" | "deleteItems" | "acquireLock" + >; }; export type TIdentityLdapAuthServiceFactory = ReturnType; @@ -646,17 +656,34 @@ export const identityLdapAuthServiceFactory = ({ const checkLdapLockout = async ({ identityId, username }: TCheckLdapAuthLockoutDTO) => { const LOCKOUT_KEY = `lockout:identity:${identityId}:${IdentityAuthMethod.LDAP_AUTH}:${username.trim().toLowerCase()}`; + let lock: Awaited>; + try { + lock = await keyStore.acquireLock([KeyStorePrefixes.IdentityLockoutLock(LOCKOUT_KEY)], 3000, { + retryCount: 3, + retryDelay: 1500, + retryJitter: 100 + }); + } catch (e) { + logger.info( + `identity login failed to acquire lock [identityId=${identityId}] [authMethod=${IdentityAuthMethod.LDAP_AUTH}]` + ); + throw new RateLimitError({ message: "Rate limit exceeded" }); + } + const lockoutRaw = await keyStore.getItem(LOCKOUT_KEY); if (lockoutRaw) { const lockout = JSON.parse(lockoutRaw) as LockoutObject; if (lockout.lockedOut) { + await lock.release(); throw new UnauthorizedError({ message: "This identity auth method is temporarily locked, please try again later" }); } } + + return { lock }; }; const incrementLdapLockout = async ({ identityId, username }: TIncrementLdapAuthLockoutDTO) => { diff --git a/backend/src/services/identity/identity-service.ts b/backend/src/services/identity/identity-service.ts index 969d00331..f216d6483 100644 --- a/backend/src/services/identity/identity-service.ts +++ b/backend/src/services/identity/identity-service.ts @@ -33,7 +33,7 @@ type TIdentityServiceFactoryDep = { identityProjectDAL: Pick; permissionService: Pick; licenseService: Pick; - keyStore: Pick; + keyStore: Pick; }; export type TIdentityServiceFactory = ReturnType; @@ -261,12 +261,18 @@ export const identityServiceFactory = ({ const activeLockouts = await keyStore.getKeysByPattern(`lockout:identity:${id}:*`); const activeLockoutAuthMethods = new Set(); - activeLockouts.forEach((key) => { + for await (const key of activeLockouts) { const parts = key.split(":"); if (parts.length > 3) { - activeLockoutAuthMethods.add(parts[3]); + const lockoutRaw = await keyStore.getItem(key); + if (lockoutRaw) { + const lockout = JSON.parse(lockoutRaw) as { lockedOut: boolean }; + if (lockout.lockedOut) { + activeLockoutAuthMethods.add(parts[3]); + } + } } - }); + } return { ...identity, From 233f003e0c9df3156acd18a3aa6f8b8bff7d5cd3 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Wed, 3 Sep 2025 22:19:18 -0300 Subject: [PATCH 006/105] Dynamic Secrets: add Temporary Credentials for AWS IAM Roles --- .../dynamic-secret/providers/aws-iam.ts | 383 ++++++++++++++---- .../dynamic-secret/providers/models.ts | 8 + .../platform/dynamic-secrets/aws-iam.mdx | 352 +++++++++++----- frontend/src/hooks/api/dynamicSecret/types.ts | 8 + .../AwsIamInputForm.tsx | 277 ++++++++----- .../CreateDynamicSecretLease.tsx | 18 +- 6 files changed, 745 insertions(+), 301 deletions(-) diff --git a/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts b/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts index d4fceb674..bc325dece 100644 --- a/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts +++ b/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts @@ -16,7 +16,7 @@ import { PutUserPolicyCommand, RemoveUserFromGroupCommand } from "@aws-sdk/client-iam"; -import { AssumeRoleCommand, STSClient } from "@aws-sdk/client-sts"; +import { AssumeRoleCommand, GetSessionTokenCommand, STSClient } from "@aws-sdk/client-sts"; import { z } from "zod"; import { CustomAWSHasher } from "@app/lib/aws/hashing"; @@ -26,9 +26,14 @@ import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; import { sanitizeString } from "@app/lib/fn"; import { alphaNumericNanoId } from "@app/lib/nanoid"; -import { AwsIamAuthType, DynamicSecretAwsIamSchema, TDynamicProviderFns } from "./models"; +import { AwsIamAuthType, AwsIamCredentialType, DynamicSecretAwsIamSchema, TDynamicProviderFns } from "./models"; import { compileUsernameTemplate } from "./templateUtils"; +// AWS STS duration constants (in seconds) +const AWS_STS_MIN_DURATION = 900; // 15 minutes +const AWS_STS_MAX_DURATION_SESSION_TOKEN = 43200; // 12 hours for GetSessionToken +const AWS_STS_MAX_DURATION_ASSUME_ROLE = 43200; // 12 hours for AssumeRole + const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => { const randomUsername = alphaNumericNanoId(32); if (!usernameTemplate) return randomUsername; @@ -120,6 +125,58 @@ export const AwsIamProvider = (): TDynamicProviderFns => { const validateConnection = async (inputs: unknown, { projectId }: { projectId: string }) => { const providerInputs = await validateProviderInputs(inputs); try { + if (providerInputs.credentialType === AwsIamCredentialType.TemporaryCredentials) { + if (providerInputs.method === AwsIamAuthType.AccessKey) { + const stsClient = new STSClient({ + region: providerInputs.region, + useFipsEndpoint: crypto.isFipsModeEnabled(), + sha256: CustomAWSHasher, + credentials: { + accessKeyId: providerInputs.accessKey, + secretAccessKey: providerInputs.secretAccessKey + } + }); + + await stsClient.send(new GetSessionTokenCommand({ DurationSeconds: AWS_STS_MIN_DURATION })); + return true; + } + if (providerInputs.method === AwsIamAuthType.AssumeRole) { + const appCfg = getConfig(); + const stsClient = new STSClient({ + region: providerInputs.region, + useFipsEndpoint: crypto.isFipsModeEnabled(), + sha256: CustomAWSHasher, + credentials: + appCfg.DYNAMIC_SECRET_AWS_ACCESS_KEY_ID && appCfg.DYNAMIC_SECRET_AWS_SECRET_ACCESS_KEY + ? { + accessKeyId: appCfg.DYNAMIC_SECRET_AWS_ACCESS_KEY_ID, + secretAccessKey: appCfg.DYNAMIC_SECRET_AWS_SECRET_ACCESS_KEY + } + : undefined + }); + + await stsClient.send( + new AssumeRoleCommand({ + RoleArn: providerInputs.roleArn, + RoleSessionName: `infisical-validation-${crypto.nativeCrypto.randomUUID()}`, + DurationSeconds: AWS_STS_MIN_DURATION, + ExternalId: projectId + }) + ); + return true; + } + if (providerInputs.method === AwsIamAuthType.IRSA) { + const stsClient = new STSClient({ + region: providerInputs.region, + useFipsEndpoint: crypto.isFipsModeEnabled(), + sha256: CustomAWSHasher + }); + + await stsClient.send(new GetSessionTokenCommand({ DurationSeconds: AWS_STS_MIN_DURATION })); + return true; + } + } + const client = await $getClient(providerInputs, projectId); const isConnected = await client .send(new GetUserCommand({})) @@ -137,13 +194,21 @@ export const AwsIamProvider = (): TDynamicProviderFns => { }); return isConnected; } catch (err) { - const sensitiveTokens = []; + const sensitiveTokens: string[] = []; if (providerInputs.method === AwsIamAuthType.AccessKey) { sensitiveTokens.push(providerInputs.accessKey, providerInputs.secretAccessKey); } if (providerInputs.method === AwsIamAuthType.AssumeRole) { sensitiveTokens.push(providerInputs.roleArn); } + if (providerInputs.credentialType === AwsIamCredentialType.TemporaryCredentials) { + if (providerInputs.method === AwsIamAuthType.AccessKey) { + sensitiveTokens.push(providerInputs.accessKey, providerInputs.secretAccessKey); + } + if (providerInputs.method === AwsIamAuthType.AssumeRole) { + sensitiveTokens.push(providerInputs.roleArn); + } + } const sanitizedErrorMessage = sanitizeString({ unsanitizedString: (err as Error)?.message, tokens: sensitiveTokens @@ -163,102 +228,258 @@ export const AwsIamProvider = (): TDynamicProviderFns => { }; metadata: { projectId: string }; }) => { - const { inputs, usernameTemplate, metadata, identity } = data; + const { inputs, usernameTemplate, metadata, identity, expireAt } = data; const providerInputs = await validateProviderInputs(inputs); - const client = await $getClient(providerInputs, metadata.projectId); - const username = generateUsername(usernameTemplate, identity); - const { policyArns, userGroups, policyDocument, awsPath, permissionBoundaryPolicyArn } = providerInputs; - const awsTags = [{ Key: "createdBy", Value: "infisical-dynamic-secret" }]; + if (providerInputs.credentialType === AwsIamCredentialType.TemporaryCredentials) { + try { + let stsClient: STSClient; + let entityId: string; - if (providerInputs.tags && Array.isArray(providerInputs.tags)) { - const additionalTags = providerInputs.tags.map((tag) => ({ - Key: tag.key, - Value: tag.value - })); - awsTags.push(...additionalTags); + const currentTime = Math.floor(Date.now() / 1000); + const requestedDuration = expireAt - currentTime; + + if (requestedDuration <= 0) { + throw new BadRequestError({ message: "Expiration time must be in the future" }); + } + + let durationSeconds = Math.min(requestedDuration, AWS_STS_MAX_DURATION_SESSION_TOKEN); + + if (providerInputs.method === AwsIamAuthType.AssumeRole) { + const appCfg = getConfig(); + stsClient = new STSClient({ + region: providerInputs.region, + useFipsEndpoint: crypto.isFipsModeEnabled(), + sha256: CustomAWSHasher, + credentials: + appCfg.DYNAMIC_SECRET_AWS_ACCESS_KEY_ID && appCfg.DYNAMIC_SECRET_AWS_SECRET_ACCESS_KEY + ? { + accessKeyId: appCfg.DYNAMIC_SECRET_AWS_ACCESS_KEY_ID, + secretAccessKey: appCfg.DYNAMIC_SECRET_AWS_SECRET_ACCESS_KEY + } + : undefined + }); + + durationSeconds = Math.min(durationSeconds, AWS_STS_MAX_DURATION_ASSUME_ROLE); + + const assumeRoleRes = await stsClient.send( + new AssumeRoleCommand({ + RoleArn: providerInputs.roleArn, + RoleSessionName: `infisical-temp-cred-${crypto.nativeCrypto.randomUUID()}`, + DurationSeconds: Math.max(durationSeconds, AWS_STS_MIN_DURATION), + ExternalId: metadata.projectId + }) + ); + + if ( + !assumeRoleRes.Credentials?.AccessKeyId || + !assumeRoleRes.Credentials?.SecretAccessKey || + !assumeRoleRes.Credentials?.SessionToken + ) { + throw new BadRequestError({ message: "Failed to assume role - verify credentials and role configuration" }); + } + + entityId = `assume-role-${alphaNumericNanoId(8)}`; + return { + entityId, + data: { + ACCESS_KEY: assumeRoleRes.Credentials.AccessKeyId, + SECRET_ACCESS_KEY: assumeRoleRes.Credentials.SecretAccessKey, + SESSION_TOKEN: assumeRoleRes.Credentials.SessionToken + } + }; + } + if (providerInputs.method === AwsIamAuthType.AccessKey) { + stsClient = new STSClient({ + region: providerInputs.region, + useFipsEndpoint: crypto.isFipsModeEnabled(), + sha256: CustomAWSHasher, + credentials: { + accessKeyId: providerInputs.accessKey, + secretAccessKey: providerInputs.secretAccessKey + } + }); + + const sessionTokenRes = await stsClient.send( + new GetSessionTokenCommand({ + DurationSeconds: Math.max(durationSeconds, AWS_STS_MIN_DURATION) + }) + ); + + if ( + !sessionTokenRes.Credentials?.AccessKeyId || + !sessionTokenRes.Credentials?.SecretAccessKey || + !sessionTokenRes.Credentials?.SessionToken + ) { + throw new BadRequestError({ message: "Failed to get session token - verify credentials and permissions" }); + } + + entityId = `session-token-${alphaNumericNanoId(8)}`; + return { + entityId, + data: { + ACCESS_KEY: sessionTokenRes.Credentials.AccessKeyId, + SECRET_ACCESS_KEY: sessionTokenRes.Credentials.SecretAccessKey, + SESSION_TOKEN: sessionTokenRes.Credentials.SessionToken + } + }; + } + if (providerInputs.method === AwsIamAuthType.IRSA) { + stsClient = new STSClient({ + region: providerInputs.region, + useFipsEndpoint: crypto.isFipsModeEnabled(), + sha256: CustomAWSHasher + }); + + const sessionTokenRes = await stsClient.send( + new GetSessionTokenCommand({ + DurationSeconds: Math.max(durationSeconds, AWS_STS_MIN_DURATION) + }) + ); + + if ( + !sessionTokenRes.Credentials?.AccessKeyId || + !sessionTokenRes.Credentials?.SecretAccessKey || + !sessionTokenRes.Credentials?.SessionToken + ) { + throw new BadRequestError({ + message: "Failed to get session token - verify IRSA credentials and permissions" + }); + } + + entityId = `irsa-session-${alphaNumericNanoId(8)}`; + return { + entityId, + data: { + ACCESS_KEY: sessionTokenRes.Credentials.AccessKeyId, + SECRET_ACCESS_KEY: sessionTokenRes.Credentials.SecretAccessKey, + SESSION_TOKEN: sessionTokenRes.Credentials.SessionToken + } + }; + } + + throw new BadRequestError({ message: "Unsupported authentication method for temporary credentials" }); + } catch (err) { + const sensitiveTokens: string[] = []; + if (providerInputs.method === AwsIamAuthType.AccessKey) { + sensitiveTokens.push(providerInputs.accessKey, providerInputs.secretAccessKey); + } + if (providerInputs.method === AwsIamAuthType.AssumeRole) { + sensitiveTokens.push(providerInputs.roleArn); + } + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: sensitiveTokens + }); + throw new BadRequestError({ + message: `Failed to create temporary credentials: ${sanitizedErrorMessage}` + }); + } } - try { - const createUserRes = await client.send( - new CreateUserCommand({ - Path: awsPath, - PermissionsBoundary: permissionBoundaryPolicyArn || undefined, - Tags: awsTags, - UserName: username - }) - ); + if (providerInputs.credentialType === AwsIamCredentialType.IamUser) { + const client = await $getClient(providerInputs, metadata.projectId); - if (!createUserRes.User) throw new BadRequestError({ message: "Failed to create AWS IAM User" }); - if (userGroups) { - await Promise.all( - userGroups - .split(",") - .filter(Boolean) - .map((group) => - client.send(new AddUserToGroupCommand({ UserName: createUserRes?.User?.UserName, GroupName: group })) - ) - ); + const username = generateUsername(usernameTemplate, identity); + const { policyArns, userGroups, policyDocument, awsPath, permissionBoundaryPolicyArn } = providerInputs; + const awsTags = [{ Key: "createdBy", Value: "infisical-dynamic-secret" }]; + + if (providerInputs.tags && Array.isArray(providerInputs.tags)) { + const additionalTags = providerInputs.tags.map((tag) => ({ + Key: tag.key, + Value: tag.value + })); + awsTags.push(...additionalTags); } - if (policyArns) { - await Promise.all( - policyArns - .split(",") - .filter(Boolean) - .map((policyArn) => - client.send( - new AttachUserPolicyCommand({ UserName: createUserRes?.User?.UserName, PolicyArn: policyArn }) - ) - ) - ); - } - if (policyDocument) { - await client.send( - new PutUserPolicyCommand({ - UserName: createUserRes.User.UserName, - PolicyName: `infisical-dynamic-policy-${alphaNumericNanoId(4)}`, - PolicyDocument: policyDocument + + try { + const createUserRes = await client.send( + new CreateUserCommand({ + Path: awsPath, + PermissionsBoundary: permissionBoundaryPolicyArn || undefined, + Tags: awsTags, + UserName: username }) ); - } - const createAccessKeyRes = await client.send( - new CreateAccessKeyCommand({ - UserName: createUserRes.User.UserName - }) - ); - if (!createAccessKeyRes.AccessKey) - throw new BadRequestError({ message: "Failed to create AWS IAM User access key" }); - - return { - entityId: username, - data: { - ACCESS_KEY: createAccessKeyRes.AccessKey.AccessKeyId, - SECRET_ACCESS_KEY: createAccessKeyRes.AccessKey.SecretAccessKey, - USERNAME: username + if (!createUserRes.User) throw new BadRequestError({ message: "Failed to create AWS IAM User" }); + if (userGroups) { + await Promise.all( + userGroups + .split(",") + .filter(Boolean) + .map((group) => + client.send(new AddUserToGroupCommand({ UserName: createUserRes?.User?.UserName, GroupName: group })) + ) + ); } - }; - } catch (err) { - const sensitiveTokens = [username]; - if (providerInputs.method === AwsIamAuthType.AccessKey) { - sensitiveTokens.push(providerInputs.accessKey, providerInputs.secretAccessKey); + if (policyArns) { + await Promise.all( + policyArns + .split(",") + .filter(Boolean) + .map((policyArn) => + client.send( + new AttachUserPolicyCommand({ UserName: createUserRes?.User?.UserName, PolicyArn: policyArn }) + ) + ) + ); + } + if (policyDocument) { + await client.send( + new PutUserPolicyCommand({ + UserName: createUserRes.User.UserName, + PolicyName: `infisical-dynamic-policy-${alphaNumericNanoId(4)}`, + PolicyDocument: policyDocument + }) + ); + } + + const createAccessKeyRes = await client.send( + new CreateAccessKeyCommand({ + UserName: createUserRes.User.UserName + }) + ); + if (!createAccessKeyRes.AccessKey) + throw new BadRequestError({ message: "Failed to create AWS IAM User access key" }); + + return { + entityId: username, + data: { + ACCESS_KEY: createAccessKeyRes.AccessKey.AccessKeyId, + SECRET_ACCESS_KEY: createAccessKeyRes.AccessKey.SecretAccessKey, + USERNAME: username + } + }; + } catch (err) { + const sensitiveTokens = [username]; + if (providerInputs.method === AwsIamAuthType.AccessKey) { + sensitiveTokens.push(providerInputs.accessKey, providerInputs.secretAccessKey); + } + if (providerInputs.method === AwsIamAuthType.AssumeRole) { + sensitiveTokens.push(providerInputs.roleArn); + } + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: sensitiveTokens + }); + throw new BadRequestError({ + message: `Failed to create lease from provider: ${sanitizedErrorMessage}` + }); } - if (providerInputs.method === AwsIamAuthType.AssumeRole) { - sensitiveTokens.push(providerInputs.roleArn); - } - const sanitizedErrorMessage = sanitizeString({ - unsanitizedString: (err as Error)?.message, - tokens: sensitiveTokens - }); - throw new BadRequestError({ - message: `Failed to create lease from provider: ${sanitizedErrorMessage}` - }); } + + throw new BadRequestError({ message: "Invalid credential type specified" }); }; const revoke = async (inputs: unknown, entityId: string, metadata: { projectId: string }) => { const providerInputs = await validateProviderInputs(inputs); + + if (providerInputs.credentialType === AwsIamCredentialType.TemporaryCredentials) { + return { entityId }; + } + const client = await $getClient(providerInputs, metadata.projectId); const username = entityId; diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index ae1bcfc25..b8782efe0 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -32,6 +32,11 @@ export enum AwsIamAuthType { IRSA = "irsa" } +export enum AwsIamCredentialType { + IamUser = "iam-user", + TemporaryCredentials = "temporary-credentials" +} + export enum ElasticSearchAuthTypes { User = "user", ApiKey = "api-key" @@ -202,6 +207,7 @@ export const DynamicSecretAwsIamSchema = z.preprocess( z.discriminatedUnion("method", [ z.object({ method: z.literal(AwsIamAuthType.AccessKey), + credentialType: z.nativeEnum(AwsIamCredentialType).default(AwsIamCredentialType.IamUser), accessKey: z.string().trim().min(1), secretAccessKey: z.string().trim().min(1), region: z.string().trim().min(1), @@ -214,6 +220,7 @@ export const DynamicSecretAwsIamSchema = z.preprocess( }), z.object({ method: z.literal(AwsIamAuthType.AssumeRole), + credentialType: z.nativeEnum(AwsIamCredentialType).default(AwsIamCredentialType.IamUser), roleArn: z.string().trim().min(1, "Role ARN required"), region: z.string().trim().min(1), awsPath: z.string().trim().optional(), @@ -225,6 +232,7 @@ export const DynamicSecretAwsIamSchema = z.preprocess( }), z.object({ method: z.literal(AwsIamAuthType.IRSA), + credentialType: z.nativeEnum(AwsIamCredentialType).default(AwsIamCredentialType.IamUser), region: z.string().trim().min(1), awsPath: z.string().trim().optional(), permissionBoundaryPolicyArn: z.string().trim().optional(), diff --git a/docs/documentation/platform/dynamic-secrets/aws-iam.mdx b/docs/documentation/platform/dynamic-secrets/aws-iam.mdx index 28b177c5f..44bbb1180 100644 --- a/docs/documentation/platform/dynamic-secrets/aws-iam.mdx +++ b/docs/documentation/platform/dynamic-secrets/aws-iam.mdx @@ -3,49 +3,82 @@ title: "AWS IAM" description: "Learn how to dynamically generate AWS IAM Users." --- -The Infisical AWS IAM dynamic secret allows you to generate AWS IAM Users on demand based on a configured AWS policy. Infisical supports several authentication methods to connect to your AWS account, including assuming an IAM Role, using IAM Roles for Service Accounts (IRSA) on EKS, or static Access Keys. +The Infisical AWS IAM dynamic secret allows you to generate AWS IAM Users and temporary credentials on demand based on a configured AWS policy. Infisical supports several authentication methods to connect to your AWS account, including assuming an IAM Role, using IAM Roles for Service Accounts (IRSA) on EKS, or static Access Keys. ## Prerequisite -Infisical needs an AWS IAM principal (a user or a role) with the required permissions to create and manage other IAM users. This principal will be responsible for the lifecycle of the dynamically generated users. +Infisical needs an AWS IAM principal (a user or a role) with the required permissions to create and manage other IAM users and temporary credentials. This principal will be responsible for the lifecycle of the dynamically generated users and temporary credentials. -```json -{ - "Version": "2012-10-17", - "Statement": [ + + + Required permissions for creating temporary IAM users: + + ```json { - "Effect": "Allow", - "Action": [ - "iam:AttachUserPolicy", - "iam:CreateAccessKey", - "iam:CreateUser", - "iam:DeleteAccessKey", - "iam:DeleteUser", - "iam:DeleteUserPolicy", - "iam:DetachUserPolicy", - "iam:GetUser", - "iam:ListAccessKeys", - "iam:ListAttachedUserPolicies", - "iam:ListGroupsForUser", - "iam:ListUserPolicies", - "iam:PutUserPolicy", - "iam:AddUserToGroup", - "iam:RemoveUserFromGroup", - "iam:TagUser" - ], - "Resource": ["*"] + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "iam:AttachUserPolicy", + "iam:CreateAccessKey", + "iam:CreateUser", + "iam:DeleteAccessKey", + "iam:DeleteUser", + "iam:DeleteUserPolicy", + "iam:DetachUserPolicy", + "iam:GetUser", + "iam:ListAccessKeys", + "iam:ListAttachedUserPolicies", + "iam:ListGroupsForUser", + "iam:ListUserPolicies", + "iam:PutUserPolicy", + "iam:AddUserToGroup", + "iam:RemoveUserFromGroup", + "iam:TagUser" + ], + "Resource": ["*"] + } + ] } - ] -} -``` + ``` -To minimize managing user access you can attach a resource in format + To minimize managing user access you can attach a resource in format -> arn:aws:iam::\:user/\ + > arn:aws:iam::\:user/\ -Replace **\** with your AWS account id and **\** with a path to minimize managing user access. + Replace **\** with your AWS account id and **\** with a path to minimize managing user access. + + + + Required permissions for Access Key and Assume Role methods: + + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "sts:GetSessionToken", + "sts:AssumeRole" + ], + "Resource": ["*"] + } + ] + } + ``` + + + To minimize managing user access you can attach a resource in format + + > arn:aws:iam::\:user/\ + + Replace **\** with your AWS account id and **\** with a path to minimize managing user access. + + @@ -170,43 +203,72 @@ Replace **\** with your AWS account id and **\** w Select *Assume Role* method. - - The ARN of the AWS Role to assume. + + Choose the credential generation approach: + - **IAM User (Default)**: Creates new temporary IAM users in your AWS account + - **Temporary Credentials**: Generates temporary credentials from your role connection - - [IAM AWS Path](https://aws.amazon.com/blogs/security/optimize-aws-administration-with-iam-paths/) to scope created IAM User resource access. + + The ARN of the AWS Role to assume. The AWS data center region. - - The IAM Policy ARN of the [AWS Permissions Boundary](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html) to attach to IAM users created in the role. - + + + + [IAM AWS Path](https://aws.amazon.com/blogs/security/optimize-aws-administration-with-iam-paths/) to scope created IAM User resource access. + - - The AWS IAM groups that should be assigned to the created users. Multiple values can be provided by separating them with commas - + + The IAM Policy ARN of the [AWS Permissions Boundary](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html) to attach to IAM users created in the role. + - - The AWS IAM managed policies that should be attached to the created users. Multiple values can be provided by separating them with commas - + + The AWS IAM groups that should be assigned to the created users. Multiple values can be provided by separating them with commas. + - - The AWS IAM inline policy that should be attached to the created users. - Multiple values can be provided by separating them with commas - + + The AWS IAM managed policies that should be attached to the created users. Multiple values can be provided by separating them with commas. + - - Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. + + The AWS IAM inline policy that should be attached to the created users. Multiple values can be provided by separating them with commas. + - Allowed template variables are + + Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. - - `{{randomUsername}}`: Random username string - - `{{unixTimestamp}}`: Current Unix timestamp - + Allowed template variables are: + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + - `{{identity.name}}`: Name of the identity that is generating the secret + - `{{random N}}`: Random string of N characters + + Allowed template functions are: + - `truncate`: Truncates a string to a specified length + - `replace`: Replaces a substring with another value + + + + Tags to be added to the created IAM User resource. + + + + + When **Credential Type** is set to **Temporary Credentials**: + + + No additional configuration parameters are required. The generated credentials will: + - Inherit the permissions of the assumed role + - Include an AWS Session Token + - Be valid for the duration specified in Default TTL + + + @@ -232,6 +294,18 @@ Replace **\** with your AWS account id and **\** w Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you. + **Credentials format depends on your chosen credential type:** + + **IAM User credential type:** + - AWS Username + - AWS Access Key ID + - AWS Secret Access Key + + **Temporary Credentials credential type:** + - AWS Access Key ID + - AWS Secret Access Key + - AWS Session Token + ![Provision Lease](/images/platform/dynamic-secrets/lease-values-aws-iam.png) @@ -342,36 +416,71 @@ Replace **\** with your AWS account id and **\** w Select *IRSA* method. + + Choose the credential generation approach: + - **IAM User**: Creates new temporary IAM users in your AWS account + - **Temporary Credentials**: Generates temporary credentials from your IRSA role connection + The ARN of the AWS IAM Role for the service account to assume. - - [IAM AWS Path](https://aws.amazon.com/blogs/security/optimize-aws-administration-with-iam-paths/) to scope created IAM User resource access. - + The AWS data center region. - - The IAM Policy ARN of the [AWS Permissions Boundary](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html) to attach to IAM users created in the role. - - - The AWS IAM groups that should be assigned to the created users. Multiple values can be provided by separating them with commas - - - The AWS IAM managed policies that should be attached to the created users. Multiple values can be provided by separating them with commas - - - The AWS IAM inline policy that should be attached to the created users. - Multiple values can be provided by separating them with commas - - - Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. - Allowed template variables are + + + + [IAM AWS Path](https://aws.amazon.com/blogs/security/optimize-aws-administration-with-iam-paths/) to scope created IAM User resource access. + - - `{{randomUsername}}`: Random username string - - `{{unixTimestamp}}`: Current Unix timestamp - + + The IAM Policy ARN of the [AWS Permissions Boundary](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html) to attach to IAM users created in the role. + + + + The AWS IAM groups that should be assigned to the created users. Multiple values can be provided by separating them with commas. + + + + The AWS IAM managed policies that should be attached to the created users. Multiple values can be provided by separating them with commas. + + + + The AWS IAM inline policy that should be attached to the created users. Multiple values can be provided by separating them with commas. + + + + Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. + + Allowed template variables are: + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + - `{{identity.name}}`: Name of the identity that is generating the secret + - `{{random N}}`: Random string of N characters + + Allowed template functions are: + - `truncate`: Truncates a string to a specified length + - `replace`: Replaces a substring with another value + + + + Tags to be added to the created IAM User resource. + + + + + When **Credential Type** is set to **Temporary Credentials**: + + + No additional configuration parameters are required. The generated credentials will: + - Inherit the permissions of the assumed IRSA role + - Include an AWS Session Token + - Be valid for the duration specified in Default TTL + + + After submitting the form, you will see a dynamic secret created in the dashboard. @@ -429,6 +538,12 @@ Replace **\** with your AWS account id and **\** w Select *Access Key* method. + + Choose the credential generation approach: + - **IAM User**: Creates new temporary IAM users in your AWS account + - **Temporary Credentials**: Generates temporary credentials from your access key connection + + The managing AWS IAM User Access Key @@ -437,43 +552,62 @@ Replace **\** with your AWS account id and **\** w The managing AWS IAM User Secret Key - - [IAM AWS Path](https://aws.amazon.com/blogs/security/optimize-aws-administration-with-iam-paths/) to scope created IAM User resource access. - - The AWS data center region. - - The IAM Policy ARN of the [AWS Permissions Boundary](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html) to attach to IAM users created in the role. - + + + + [IAM AWS Path](https://aws.amazon.com/blogs/security/optimize-aws-administration-with-iam-paths/) to scope created IAM User resource access. + - - The AWS IAM groups that should be assigned to the created users. Multiple values can be provided by separating them with commas - + + The IAM Policy ARN of the [AWS Permissions Boundary](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html) to attach to IAM users created in the role. + - - The AWS IAM managed policies that should be attached to the created users. Multiple values can be provided by separating them with commas - + + The AWS IAM groups that should be assigned to the created users. Multiple values can be provided by separating them with commas. + - - The AWS IAM inline policy that should be attached to the created users. - Multiple values can be provided by separating them with commas - + + The AWS IAM managed policies that should be attached to the created users. Multiple values can be provided by separating them with commas. + - - Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. + + The AWS IAM inline policy that should be attached to the created users. Multiple values can be provided by separating them with commas. + - Allowed template variables are + + Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. - - `{{randomUsername}}`: Random username string - - `{{unixTimestamp}}`: Current Unix timestamp - + Allowed template variables are: + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + - `{{identity.name}}`: Name of the identity that is generating the secret + - `{{random N}}`: Random string of N characters - - Tags to be added to the created IAM User resource. - + Allowed template functions are: + - `truncate`: Truncates a string to a specified length + - `replace`: Replaces a substring with another value + + + + Tags to be added to the created IAM User resource. + + + + + When **Credential Type** is set to **Temporary Credentials**: + + + No additional configuration parameters are required. The generated credentials will: + - Inherit the permissions of your access key connection + - Include an AWS Session Token + - Be valid for the duration specified in Default TTL + + + @@ -500,6 +634,18 @@ Replace **\** with your AWS account id and **\** w Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you. + **Credentials format depends on your chosen credential type:** + + **IAM User credential type:** + - AWS Username + - AWS Access Key ID + - AWS Secret Access Key + + **Temporary Credentials credential type:** + - AWS Access Key ID + - AWS Secret Access Key + - AWS Session Token + ![Provision Lease](/images/platform/dynamic-secrets/lease-values-aws-iam.png) diff --git a/frontend/src/hooks/api/dynamicSecret/types.ts b/frontend/src/hooks/api/dynamicSecret/types.ts index 289bc1d04..616c8eef3 100644 --- a/frontend/src/hooks/api/dynamicSecret/types.ts +++ b/frontend/src/hooks/api/dynamicSecret/types.ts @@ -59,6 +59,11 @@ export enum DynamicSecretAwsIamAuth { IRSA = "irsa" } +export enum DynamicSecretAwsIamCredentialType { + IamUser = "iam-user", + TemporaryCredentials = "temporary-credentials" +} + export type TDynamicSecretProvider = | { type: DynamicSecretProviders.SqlDatabase; @@ -97,6 +102,7 @@ export type TDynamicSecretProvider = inputs: | { method: DynamicSecretAwsIamAuth.AccessKey; + credentialType?: DynamicSecretAwsIamCredentialType; accessKey: string; secretAccessKey: string; region: string; @@ -107,6 +113,7 @@ export type TDynamicSecretProvider = } | { method: DynamicSecretAwsIamAuth.AssumeRole; + credentialType?: DynamicSecretAwsIamCredentialType; roleArn: string; region: string; awsPath?: string; @@ -116,6 +123,7 @@ export type TDynamicSecretProvider = } | { method: DynamicSecretAwsIamAuth.IRSA; + credentialType?: DynamicSecretAwsIamCredentialType; region: string; awsPath?: string; policyDocument?: string; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/AwsIamInputForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/AwsIamInputForm.tsx index f9ed9967c..616010f93 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/AwsIamInputForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/AwsIamInputForm.tsx @@ -18,6 +18,7 @@ import { useCreateDynamicSecret } from "@app/hooks/api"; import { useGetServerConfig } from "@app/hooks/api/admin"; import { DynamicSecretAwsIamAuth, + DynamicSecretAwsIamCredentialType, DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types"; import { WorkspaceEnv } from "@app/hooks/api/types"; @@ -28,6 +29,9 @@ const formSchema = z.object({ provider: z.discriminatedUnion("method", [ z.object({ method: z.literal(DynamicSecretAwsIamAuth.AccessKey), + credentialType: z + .nativeEnum(DynamicSecretAwsIamCredentialType) + .default(DynamicSecretAwsIamCredentialType.IamUser), accessKey: z.string().trim().min(1), secretAccessKey: z.string().trim().min(1), region: z.string().trim().min(1), @@ -47,6 +51,9 @@ const formSchema = z.object({ }), z.object({ method: z.literal(DynamicSecretAwsIamAuth.AssumeRole), + credentialType: z + .nativeEnum(DynamicSecretAwsIamCredentialType) + .default(DynamicSecretAwsIamCredentialType.IamUser), roleArn: z.string().trim().min(1), region: z.string().trim().min(1), awsPath: z.string().trim().optional(), @@ -65,6 +72,9 @@ const formSchema = z.object({ }), z.object({ method: z.literal(DynamicSecretAwsIamAuth.IRSA), + credentialType: z + .nativeEnum(DynamicSecretAwsIamCredentialType) + .default(DynamicSecretAwsIamCredentialType.IamUser), region: z.string().trim().min(1), awsPath: z.string().trim().optional(), permissionBoundaryPolicyArn: z.string().trim().optional(), @@ -137,13 +147,15 @@ export const AwsIamInputForm = ({ environment: isSingleEnvironmentMode ? environments[0] : undefined, usernameTemplate: "{{randomUsername}}", provider: { - method: DynamicSecretAwsIamAuth.AssumeRole + method: DynamicSecretAwsIamAuth.AssumeRole, + credentialType: DynamicSecretAwsIamCredentialType.IamUser } } }); const createDynamicSecret = useCreateDynamicSecret(); const method = watch("provider.method"); + const credentialType = watch("provider.credentialType"); const handleCreateDynamicSecret = async ({ name, @@ -264,6 +276,39 @@ export const AwsIamInputForm = ({ )} /> + ( + + <> + +
+ {value === DynamicSecretAwsIamCredentialType.IamUser + ? "Creates temporary IAM users with access keys" + : "Uses STS to generate temporary credentials from your connection. Duration is controlled by the Default TTL setting above."} +
+ +
+ )} + /> {method === DynamicSecretAwsIamAuth.AccessKey && (
)}
- ( - - - - )} - /> + {credentialType !== DynamicSecretAwsIamCredentialType.TemporaryCredentials && ( + ( + + + + )} + /> + )} ( @@ -350,97 +401,105 @@ export const AwsIamInputForm = ({ )} />
- ( - - - - )} - /> - ( - - - - )} - /> - ( - - - - )} - /> - ( - -