Merge branch 'main' into ENG-2807

This commit is contained in:
x032205
2025-05-23 01:51:06 -04:00
93 changed files with 2299 additions and 780 deletions
@@ -0,0 +1,47 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
const hasEmail = await knex.schema.hasColumn(TableName.Users, "email");
const hasUsername = await knex.schema.hasColumn(TableName.Users, "username");
if (hasEmail) {
await knex(TableName.Users)
.where({ isGhost: false })
.update({
// @ts-expect-error email assume string this is expected
email: knex.raw("lower(email)")
});
}
if (hasUsername) {
await knex.schema.raw(`
CREATE INDEX IF NOT EXISTS ${TableName.Users}_lower_username_idx
ON ${TableName.Users} (LOWER(username))
`);
const duplicatesSubquery = knex(TableName.Users)
.select(knex.raw("lower(username) as lowercase_username"))
.groupBy("lowercase_username")
.having(knex.raw("count(*)"), ">", 1);
// Update usernames to lowercase where they won't create duplicates
await knex(TableName.Users)
.where({ isGhost: false })
.whereRaw("username <> lower(username)") // Only update if not already lowercase
// @ts-expect-error username assume string this is expected
.whereNotIn(knex.raw("lower(username)"), duplicatesSubquery)
.update({
// @ts-expect-error username assume string this is expected
username: knex.raw("lower(username)")
});
}
}
export async function down(knex: Knex): Promise<void> {
const hasUsername = await knex.schema.hasColumn(TableName.Users, "username");
if (hasUsername) {
await knex.schema.raw(`
DROP INDEX IF EXISTS ${TableName.Users}_lower_username_idx
`);
}
}
@@ -0,0 +1,22 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable(TableName.SecretSync, (t) => {
t.string("name", 64).notNullable().alter();
});
await knex.schema.alterTable(TableName.ProjectTemplates, (t) => {
t.string("name", 64).notNullable().alter();
});
await knex.schema.alterTable(TableName.AppConnection, (t) => {
t.string("name", 64).notNullable().alter();
});
await knex.schema.alterTable(TableName.SecretRotationV2, (t) => {
t.string("name", 64).notNullable().alter();
});
}
export async function down(): Promise<void> {
// No down migration or it will error
}
+1 -1
View File
@@ -145,7 +145,7 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
const { isUserCompleted, providerAuthToken } = await server.services.saml.samlLogin({ const { isUserCompleted, providerAuthToken } = await server.services.saml.samlLogin({
externalId: profile.nameID, externalId: profile.nameID,
email, email: email.toLowerCase(),
firstName, firstName,
lastName: lastName as string, lastName: lastName as string,
relayState: (req.body as { RelayState?: string }).RelayState, relayState: (req.body as { RelayState?: string }).RelayState,
@@ -2,7 +2,7 @@ import { ForbiddenError } from "@casl/ability";
import { ActionProjectType } from "@app/db/schemas"; import { ActionProjectType } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { ProjectPermissionApprovalActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal"; import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal";
@@ -98,7 +98,7 @@ export const accessApprovalPolicyServiceFactory = ({
}); });
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Create, ProjectPermissionApprovalActions.Create,
ProjectPermissionSub.SecretApproval ProjectPermissionSub.SecretApproval
); );
const env = await projectEnvDAL.findOne({ slug: environment, projectId: project.id }); const env = await projectEnvDAL.findOne({ slug: environment, projectId: project.id });
@@ -256,7 +256,10 @@ export const accessApprovalPolicyServiceFactory = ({
actionProjectType: ActionProjectType.SecretManager actionProjectType: ActionProjectType.SecretManager
}); });
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval); ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionApprovalActions.Edit,
ProjectPermissionSub.SecretApproval
);
const updatedPolicy = await accessApprovalPolicyDAL.transaction(async (tx) => { const updatedPolicy = await accessApprovalPolicyDAL.transaction(async (tx) => {
const doc = await accessApprovalPolicyDAL.updateById( const doc = await accessApprovalPolicyDAL.updateById(
@@ -341,7 +344,7 @@ export const accessApprovalPolicyServiceFactory = ({
actionProjectType: ActionProjectType.SecretManager actionProjectType: ActionProjectType.SecretManager
}); });
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Delete, ProjectPermissionApprovalActions.Delete,
ProjectPermissionSub.SecretApproval ProjectPermissionSub.SecretApproval
); );
@@ -432,7 +435,10 @@ export const accessApprovalPolicyServiceFactory = ({
actionProjectType: ActionProjectType.SecretManager actionProjectType: ActionProjectType.SecretManager
}); });
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionApprovalActions.Read,
ProjectPermissionSub.SecretApproval
);
return policy; return policy;
}; };
+2 -2
View File
@@ -111,9 +111,9 @@ export const groupDALFactory = (db: TDbClient) => {
} }
if (search) { if (search) {
void query.andWhereRaw(`CONCAT_WS(' ', "firstName", "lastName", "username") ilike ?`, [`%${search}%`]); void query.andWhereRaw(`CONCAT_WS(' ', "firstName", "lastName", lower("username")) ilike ?`, [`%${search}%`]);
} else if (username) { } else if (username) {
void query.andWhere(`${TableName.Users}.username`, "ilike", `%${username}%`); void query.andWhereRaw(`lower("${TableName.Users}"."username") ilike ?`, `%${username}%`);
} }
switch (filter) { switch (filter) {
@@ -30,7 +30,7 @@ import {
import { TUserGroupMembershipDALFactory } from "./user-group-membership-dal"; import { TUserGroupMembershipDALFactory } from "./user-group-membership-dal";
type TGroupServiceFactoryDep = { type TGroupServiceFactoryDep = {
userDAL: Pick<TUserDALFactory, "find" | "findUserEncKeyByUserIdsBatch" | "transaction" | "findOne">; userDAL: Pick<TUserDALFactory, "find" | "findUserEncKeyByUserIdsBatch" | "transaction" | "findUserByUsername">;
groupDAL: Pick< groupDAL: Pick<
TGroupDALFactory, TGroupDALFactory,
"create" | "findOne" | "update" | "delete" | "findAllGroupPossibleMembers" | "findById" | "transaction" "create" | "findOne" | "update" | "delete" | "findAllGroupPossibleMembers" | "findById" | "transaction"
@@ -380,7 +380,10 @@ export const groupServiceFactory = ({
details: { missingPermissions: permissionBoundary.missingPermissions } details: { missingPermissions: permissionBoundary.missingPermissions }
}); });
const user = await userDAL.findOne({ username }); const usersWithUsername = await userDAL.findUserByUsername(username);
// akhilmhdh: case sensitive email resolution
const user =
usersWithUsername?.length > 1 ? usersWithUsername.find((el) => el.username === username) : usersWithUsername?.[0];
if (!user) throw new NotFoundError({ message: `Failed to find user with username ${username}` }); if (!user) throw new NotFoundError({ message: `Failed to find user with username ${username}` });
const users = await addUsersToGroupByUserIds({ const users = await addUsersToGroupByUserIds({
@@ -461,7 +464,10 @@ export const groupServiceFactory = ({
details: { missingPermissions: permissionBoundary.missingPermissions } details: { missingPermissions: permissionBoundary.missingPermissions }
}); });
const user = await userDAL.findOne({ username }); const usersWithUsername = await userDAL.findUserByUsername(username);
// akhilmhdh: case sensitive email resolution
const user =
usersWithUsername?.length > 1 ? usersWithUsername.find((el) => el.username === username) : usersWithUsername?.[0];
if (!user) throw new NotFoundError({ message: `Failed to find user with username ${username}` }); if (!user) throw new NotFoundError({ message: `Failed to find user with username ${username}` });
const users = await removeUsersFromGroupByUserIds({ const users = await removeUsersFromGroupByUserIds({
+7 -3
View File
@@ -24,9 +24,13 @@ export const initializeHsmModule = (envConfig: Pick<TEnvConfig, "isHsmConfigured
isInitialized = true; isInitialized = true;
logger.info("PKCS#11 module initialized"); logger.info("PKCS#11 module initialized");
} catch (err) { } catch (error) {
logger.error(err, "Failed to initialize PKCS#11 module"); if (error instanceof pkcs11js.Pkcs11Error && error.code === pkcs11js.CKR_CRYPTOKI_ALREADY_INITIALIZED) {
throw err; logger.info("Skipping HSM initialization because it's already initialized.");
} else {
logger.error(error, "Failed to initialize PKCS#11 module");
throw error;
}
} }
}; };
@@ -380,7 +380,7 @@ export const ldapConfigServiceFactory = ({
if (serverCfg.trustLdapEmails) { if (serverCfg.trustLdapEmails) {
newUser = await userDAL.findOne( newUser = await userDAL.findOne(
{ {
email, email: email.toLowerCase(),
isEmailVerified: true isEmailVerified: true
}, },
tx tx
@@ -391,8 +391,8 @@ export const ldapConfigServiceFactory = ({
const uniqueUsername = await normalizeUsername(username, userDAL); const uniqueUsername = await normalizeUsername(username, userDAL);
newUser = await userDAL.create( newUser = await userDAL.create(
{ {
username: serverCfg.trustLdapEmails ? email : uniqueUsername, username: serverCfg.trustLdapEmails ? email.toLowerCase() : uniqueUsername,
email, email: email.toLowerCase(),
isEmailVerified: serverCfg.trustLdapEmails, isEmailVerified: serverCfg.trustLdapEmails,
firstName, firstName,
lastName, lastName,
@@ -429,7 +429,7 @@ export const ldapConfigServiceFactory = ({
await orgMembershipDAL.create( await orgMembershipDAL.create(
{ {
userId: newUser.id, userId: newUser.id,
inviteEmail: email, inviteEmail: email.toLowerCase(),
orgId, orgId,
role, role,
roleId, roleId,
@@ -2,6 +2,7 @@ import axios, { AxiosError } from "axios";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { request } from "@app/lib/config/request"; import { request } from "@app/lib/config/request";
import { logger } from "@app/lib/logger";
import { TFeatureSet } from "./license-types"; import { TFeatureSet } from "./license-types";
@@ -100,9 +101,10 @@ export const setupLicenseRequestWithStore = (baseURL: string, refreshUrl: string
(response) => response, (response) => response,
async (err) => { async (err) => {
const originalRequest = (err as AxiosError).config; const originalRequest = (err as AxiosError).config;
const errStatusCode = Number((err as AxiosError)?.response?.status);
logger.error((err as AxiosError)?.response?.data, "License server call error");
// eslint-disable-next-line // eslint-disable-next-line
if ((err as AxiosError)?.response?.status === 401 && !(originalRequest as any)._retry) { if ((errStatusCode === 401 || errStatusCode === 403) && !(originalRequest as any)._retry) {
// eslint-disable-next-line // eslint-disable-next-line
(originalRequest as any)._retry = true; // injected (originalRequest as any)._retry = true; // injected
@@ -348,8 +348,8 @@ export const licenseServiceFactory = ({
} = await licenseServerCloudApi.request.post( } = await licenseServerCloudApi.request.post(
`/api/license-server/v1/customers/${organization.customerId}/billing-details/payment-methods`, `/api/license-server/v1/customers/${organization.customerId}/billing-details/payment-methods`,
{ {
success_url: `${appCfg.SITE_URL}/dashboard`, success_url: `${appCfg.SITE_URL}/organization/billing`,
cancel_url: `${appCfg.SITE_URL}/dashboard` cancel_url: `${appCfg.SITE_URL}/organization/billing`
} }
); );
@@ -362,7 +362,7 @@ export const licenseServiceFactory = ({
} = await licenseServerCloudApi.request.post( } = await licenseServerCloudApi.request.post(
`/api/license-server/v1/customers/${organization.customerId}/billing-details/billing-portal`, `/api/license-server/v1/customers/${organization.customerId}/billing-details/billing-portal`,
{ {
return_url: `${appCfg.SITE_URL}/dashboard` return_url: `${appCfg.SITE_URL}/organization/billing`
} }
); );
@@ -379,7 +379,7 @@ export const licenseServiceFactory = ({
message: `Organization with ID '${orgId}' not found` message: `Organization with ID '${orgId}' not found`
}); });
} }
if (instanceType !== InstanceType.OnPrem && instanceType !== InstanceType.EnterpriseOnPremOffline) { if (instanceType === InstanceType.Cloud) {
const { data } = await licenseServerCloudApi.request.get( const { data } = await licenseServerCloudApi.request.get(
`/api/license-server/v1/customers/${organization.customerId}/cloud-plan/billing` `/api/license-server/v1/customers/${organization.customerId}/cloud-plan/billing`
); );
@@ -407,11 +407,38 @@ export const licenseServiceFactory = ({
message: `Organization with ID '${orgId}' not found` message: `Organization with ID '${orgId}' not found`
}); });
} }
if (instanceType !== InstanceType.OnPrem && instanceType !== InstanceType.EnterpriseOnPremOffline) {
const { data } = await licenseServerCloudApi.request.get( const orgMembersUsed = await orgDAL.countAllOrgMembers(orgId);
`/api/license-server/v1/customers/${organization.customerId}/cloud-plan/table` const identityUsed = await identityOrgMembershipDAL.countAllOrgIdentities({ orgId });
); const projects = await projectDAL.find({ orgId });
return data; const projectCount = projects.length;
if (instanceType === InstanceType.Cloud) {
const { data } = await licenseServerCloudApi.request.get<{
head: { name: string }[];
rows: { name: string; allowed: boolean }[];
}>(`/api/license-server/v1/customers/${organization.customerId}/cloud-plan/table`);
const formattedData = {
head: data.head,
rows: data.rows.map((el) => {
let used = "-";
if (el.name === BillingPlanRows.MemberLimit.name) {
used = orgMembersUsed.toString();
} else if (el.name === BillingPlanRows.WorkspaceLimit.name) {
used = projectCount.toString();
} else if (el.name === BillingPlanRows.IdentityLimit.name) {
used = (identityUsed + orgMembersUsed).toString();
}
return {
...el,
used
};
})
};
return formattedData;
} }
const mappedRows = await Promise.all( const mappedRows = await Promise.all(
@@ -420,14 +447,11 @@ export const licenseServiceFactory = ({
let used = "-"; let used = "-";
if (field === BillingPlanRows.MemberLimit.field) { if (field === BillingPlanRows.MemberLimit.field) {
const orgMemberships = await orgDAL.countAllOrgMembers(orgId); used = orgMembersUsed.toString();
used = orgMemberships.toString();
} else if (field === BillingPlanRows.WorkspaceLimit.field) { } else if (field === BillingPlanRows.WorkspaceLimit.field) {
const projects = await projectDAL.find({ orgId }); used = projectCount.toString();
used = projects.length.toString();
} else if (field === BillingPlanRows.IdentityLimit.field) { } else if (field === BillingPlanRows.IdentityLimit.field) {
const identities = await identityOrgMembershipDAL.countAllOrgIdentities({ orgId }); used = identityUsed.toString();
used = identities.toString();
} }
return { return {
@@ -171,8 +171,8 @@ export const oidcConfigServiceFactory = ({
}; };
const oidcLogin = async ({ const oidcLogin = async ({
externalId,
email, email,
externalId,
firstName, firstName,
lastName, lastName,
orgId, orgId,
@@ -717,7 +717,7 @@ export const oidcConfigServiceFactory = ({
const groups = typeof claims.groups === "string" ? [claims.groups] : (claims.groups as string[] | undefined); const groups = typeof claims.groups === "string" ? [claims.groups] : (claims.groups as string[] | undefined);
oidcLogin({ oidcLogin({
email: claims.email, email: claims.email.toLowerCase(),
externalId: claims.sub, externalId: claims.sub,
firstName: claims.given_name ?? "", firstName: claims.given_name ?? "",
lastName: claims.family_name ?? "", lastName: claims.family_name ?? "",
@@ -2,6 +2,7 @@ import { AbilityBuilder, createMongoAbility, MongoAbility } from "@casl/ability"
import { import {
ProjectPermissionActions, ProjectPermissionActions,
ProjectPermissionApprovalActions,
ProjectPermissionCertificateActions, ProjectPermissionCertificateActions,
ProjectPermissionCmekActions, ProjectPermissionCmekActions,
ProjectPermissionDynamicSecretActions, ProjectPermissionDynamicSecretActions,
@@ -25,7 +26,6 @@ const buildAdminPermissionRules = () => {
[ [
ProjectPermissionSub.SecretFolders, ProjectPermissionSub.SecretFolders,
ProjectPermissionSub.SecretImports, ProjectPermissionSub.SecretImports,
ProjectPermissionSub.SecretApproval,
ProjectPermissionSub.Role, ProjectPermissionSub.Role,
ProjectPermissionSub.Integrations, ProjectPermissionSub.Integrations,
ProjectPermissionSub.Webhooks, ProjectPermissionSub.Webhooks,
@@ -55,6 +55,17 @@ const buildAdminPermissionRules = () => {
); );
}); });
can(
[
ProjectPermissionApprovalActions.Read,
ProjectPermissionApprovalActions.Edit,
ProjectPermissionApprovalActions.Create,
ProjectPermissionApprovalActions.Delete,
ProjectPermissionApprovalActions.AllowChangeBypass
],
ProjectPermissionSub.SecretApproval
);
can( can(
[ [
ProjectPermissionCertificateActions.Read, ProjectPermissionCertificateActions.Read,
@@ -243,7 +254,7 @@ const buildMemberPermissionRules = () => {
ProjectPermissionSub.SecretImports ProjectPermissionSub.SecretImports
); );
can([ProjectPermissionActions.Read], ProjectPermissionSub.SecretApproval); can([ProjectPermissionApprovalActions.Read], ProjectPermissionSub.SecretApproval);
can([ProjectPermissionSecretRotationActions.Read], ProjectPermissionSub.SecretRotation); can([ProjectPermissionSecretRotationActions.Read], ProjectPermissionSub.SecretRotation);
can([ProjectPermissionActions.Read, ProjectPermissionActions.Create], ProjectPermissionSub.SecretRollback); can([ProjectPermissionActions.Read, ProjectPermissionActions.Create], ProjectPermissionSub.SecretRollback);
@@ -391,7 +402,7 @@ const buildViewerPermissionRules = () => {
can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretFolders); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretFolders);
can(ProjectPermissionDynamicSecretActions.ReadRootCredential, ProjectPermissionSub.DynamicSecrets); can(ProjectPermissionDynamicSecretActions.ReadRootCredential, ProjectPermissionSub.DynamicSecrets);
can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretImports); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretImports);
can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); can(ProjectPermissionApprovalActions.Read, ProjectPermissionSub.SecretApproval);
can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback);
can(ProjectPermissionSecretRotationActions.Read, ProjectPermissionSub.SecretRotation); can(ProjectPermissionSecretRotationActions.Read, ProjectPermissionSub.SecretRotation);
can(ProjectPermissionMemberActions.Read, ProjectPermissionSub.Member); can(ProjectPermissionMemberActions.Read, ProjectPermissionSub.Member);
@@ -34,6 +34,14 @@ export enum ProjectPermissionSecretActions {
Delete = "delete" Delete = "delete"
} }
export enum ProjectPermissionApprovalActions {
Read = "read",
Create = "create",
Edit = "edit",
Delete = "delete",
AllowChangeBypass = "allow-change-bypass"
}
export enum ProjectPermissionCmekActions { export enum ProjectPermissionCmekActions {
Read = "read", Read = "read",
Create = "create", Create = "create",
@@ -242,7 +250,7 @@ export type ProjectPermissionSet =
| [ProjectPermissionActions, ProjectPermissionSub.IpAllowList] | [ProjectPermissionActions, ProjectPermissionSub.IpAllowList]
| [ProjectPermissionActions, ProjectPermissionSub.Settings] | [ProjectPermissionActions, ProjectPermissionSub.Settings]
| [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens] | [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens]
| [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] | [ProjectPermissionApprovalActions, ProjectPermissionSub.SecretApproval]
| [ | [
ProjectPermissionSecretRotationActions, ProjectPermissionSecretRotationActions,
( (
@@ -439,7 +447,7 @@ const PkiSubscriberConditionSchema = z
const GeneralPermissionSchema = [ const GeneralPermissionSchema = [
z.object({ z.object({
subject: z.literal(ProjectPermissionSub.SecretApproval).describe("The entity this permission pertains to."), subject: z.literal(ProjectPermissionSub.SecretApproval).describe("The entity this permission pertains to."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionApprovalActions).describe(
"Describe what action an entity can take." "Describe what action an entity can take."
) )
}), }),
@@ -605,7 +613,7 @@ const GeneralPermissionSchema = [
}) })
]; ];
// Do not update this schema anymore, as it's kept purely for backwards compatability. Update V2 schema only. // Do not update this schema anymore, as it's kept purely for backwards compatibility. Update V2 schema only.
export const ProjectPermissionV1Schema = z.discriminatedUnion("subject", [ export const ProjectPermissionV1Schema = z.discriminatedUnion("subject", [
z.object({ z.object({
subject: z.literal(ProjectPermissionSub.Secrets).describe("The entity this permission pertains to."), subject: z.literal(ProjectPermissionSub.Secrets).describe("The entity this permission pertains to."),
+8 -8
View File
@@ -342,7 +342,7 @@ export const scimServiceFactory = ({
orgMembership = await orgMembershipDAL.create( orgMembership = await orgMembershipDAL.create(
{ {
userId: userAlias.userId, userId: userAlias.userId,
inviteEmail: email, inviteEmail: email.toLowerCase(),
orgId, orgId,
role, role,
roleId, roleId,
@@ -364,7 +364,7 @@ export const scimServiceFactory = ({
if (trustScimEmails) { if (trustScimEmails) {
user = await userDAL.findOne( user = await userDAL.findOne(
{ {
email, email: email.toLowerCase(),
isEmailVerified: true isEmailVerified: true
}, },
tx tx
@@ -379,8 +379,8 @@ export const scimServiceFactory = ({
); );
user = await userDAL.create( user = await userDAL.create(
{ {
username: trustScimEmails ? email : uniqueUsername, username: trustScimEmails ? email.toLowerCase() : uniqueUsername,
email, email: email.toLowerCase(),
isEmailVerified: trustScimEmails, isEmailVerified: trustScimEmails,
firstName, firstName,
lastName, lastName,
@@ -396,7 +396,7 @@ export const scimServiceFactory = ({
userId: user.id, userId: user.id,
aliasType, aliasType,
externalId, externalId,
emails: email ? [email] : [], emails: email ? [email.toLowerCase()] : [],
orgId orgId
}, },
tx tx
@@ -418,7 +418,7 @@ export const scimServiceFactory = ({
orgMembership = await orgMembershipDAL.create( orgMembership = await orgMembershipDAL.create(
{ {
userId: user.id, userId: user.id,
inviteEmail: email, inviteEmail: email.toLowerCase(),
orgId, orgId,
role, role,
roleId, roleId,
@@ -529,7 +529,7 @@ export const scimServiceFactory = ({
membership.userId, membership.userId,
{ {
firstName: scimUser.name.givenName, firstName: scimUser.name.givenName,
email: scimUser.emails[0].value, email: scimUser.emails[0].value.toLowerCase(),
lastName: scimUser.name.familyName, lastName: scimUser.name.familyName,
isEmailVerified: hasEmailChanged ? trustScimEmails : undefined isEmailVerified: hasEmailChanged ? trustScimEmails : undefined
}, },
@@ -606,7 +606,7 @@ export const scimServiceFactory = ({
membership.userId, membership.userId,
{ {
firstName, firstName,
email, email: email?.toLowerCase(),
lastName, lastName,
isEmailVerified: isEmailVerified:
org.orgAuthMethod === OrgAuthMethod.OIDC ? serverCfg.trustOidcEmails : serverCfg.trustSamlEmails org.orgAuthMethod === OrgAuthMethod.OIDC ? serverCfg.trustOidcEmails : serverCfg.trustSamlEmails
@@ -3,7 +3,7 @@ import picomatch from "picomatch";
import { ActionProjectType } from "@app/db/schemas"; import { ActionProjectType } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { ProjectPermissionApprovalActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { removeTrailingSlash } from "@app/lib/fn"; import { removeTrailingSlash } from "@app/lib/fn";
import { containsGlobPatterns } from "@app/lib/picomatch"; import { containsGlobPatterns } from "@app/lib/picomatch";
@@ -89,7 +89,7 @@ export const secretApprovalPolicyServiceFactory = ({
actionProjectType: ActionProjectType.SecretManager actionProjectType: ActionProjectType.SecretManager
}); });
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Create, ProjectPermissionApprovalActions.Create,
ProjectPermissionSub.SecretApproval ProjectPermissionSub.SecretApproval
); );
@@ -204,7 +204,10 @@ export const secretApprovalPolicyServiceFactory = ({
actorOrgId, actorOrgId,
actionProjectType: ActionProjectType.SecretManager actionProjectType: ActionProjectType.SecretManager
}); });
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval); ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionApprovalActions.Edit,
ProjectPermissionSub.SecretApproval
);
const plan = await licenseService.getPlan(actorOrgId); const plan = await licenseService.getPlan(actorOrgId);
if (!plan.secretApproval) { if (!plan.secretApproval) {
@@ -301,7 +304,7 @@ export const secretApprovalPolicyServiceFactory = ({
actionProjectType: ActionProjectType.SecretManager actionProjectType: ActionProjectType.SecretManager
}); });
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Delete, ProjectPermissionApprovalActions.Delete,
ProjectPermissionSub.SecretApproval ProjectPermissionSub.SecretApproval
); );
@@ -340,7 +343,10 @@ export const secretApprovalPolicyServiceFactory = ({
actorOrgId, actorOrgId,
actionProjectType: ActionProjectType.SecretManager actionProjectType: ActionProjectType.SecretManager
}); });
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionApprovalActions.Read,
ProjectPermissionSub.SecretApproval
);
const sapPolicies = await secretApprovalPolicyDAL.find({ projectId, deletedAt: null }); const sapPolicies = await secretApprovalPolicyDAL.find({ projectId, deletedAt: null });
return sapPolicies; return sapPolicies;
@@ -413,7 +419,10 @@ export const secretApprovalPolicyServiceFactory = ({
actionProjectType: ActionProjectType.SecretManager actionProjectType: ActionProjectType.SecretManager
}); });
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionApprovalActions.Read,
ProjectPermissionSub.SecretApproval
);
return sapPolicy; return sapPolicy;
}; };
@@ -62,7 +62,11 @@ import { TUserDALFactory } from "@app/services/user/user-dal";
import { TLicenseServiceFactory } from "../license/license-service"; import { TLicenseServiceFactory } from "../license/license-service";
import { throwIfMissingSecretReadValueOrDescribePermission } from "../permission/permission-fns"; import { throwIfMissingSecretReadValueOrDescribePermission } from "../permission/permission-fns";
import { TPermissionServiceFactory } from "../permission/permission-service"; import { TPermissionServiceFactory } from "../permission/permission-service";
import { ProjectPermissionSecretActions, ProjectPermissionSub } from "../permission/project-permission"; import {
ProjectPermissionApprovalActions,
ProjectPermissionSecretActions,
ProjectPermissionSub
} from "../permission/project-permission";
import { TSecretApprovalPolicyDALFactory } from "../secret-approval-policy/secret-approval-policy-dal"; import { TSecretApprovalPolicyDALFactory } from "../secret-approval-policy/secret-approval-policy-dal";
import { TSecretSnapshotServiceFactory } from "../secret-snapshot/secret-snapshot-service"; import { TSecretSnapshotServiceFactory } from "../secret-snapshot/secret-snapshot-service";
import { TSecretApprovalRequestDALFactory } from "./secret-approval-request-dal"; import { TSecretApprovalRequestDALFactory } from "./secret-approval-request-dal";
@@ -504,7 +508,7 @@ export const secretApprovalRequestServiceFactory = ({
}); });
} }
const { hasRole } = await permissionService.getProjectPermission({ const { hasRole, permission } = await permissionService.getProjectPermission({
actor: ActorType.USER, actor: ActorType.USER,
actorId, actorId,
projectId, projectId,
@@ -531,7 +535,13 @@ export const secretApprovalRequestServiceFactory = ({
).length; ).length;
const isSoftEnforcement = secretApprovalRequest.policy.enforcementLevel === EnforcementLevel.Soft; const isSoftEnforcement = secretApprovalRequest.policy.enforcementLevel === EnforcementLevel.Soft;
if (!hasMinApproval && !isSoftEnforcement) if (
!hasMinApproval &&
!(
isSoftEnforcement &&
permission.can(ProjectPermissionApprovalActions.AllowChangeBypass, ProjectPermissionSub.SecretApproval)
)
)
throw new BadRequestError({ message: "Doesn't have minimum approvals needed" }); throw new BadRequestError({ message: "Doesn't have minimum approvals needed" });
const { botKey, shouldUseSecretV2Bridge, project } = await projectBotService.getBotKey(projectId); const { botKey, shouldUseSecretV2Bridge, project } = await projectBotService.getBotKey(projectId);
@@ -1,4 +1,4 @@
import ldap from "ldapjs"; import ldap, { Client, SearchOptions } from "ldapjs";
import { import {
TRotationFactory, TRotationFactory,
@@ -8,26 +8,73 @@ import {
TRotationFactoryRotateCredentials TRotationFactoryRotateCredentials
} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { DistinguishedNameRegex } from "@app/lib/regex";
import { encryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns"; import { encryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns";
import { getLdapConnectionClient, LdapProvider, TLdapConnection } from "@app/services/app-connection/ldap"; import { getLdapConnectionClient, LdapProvider, TLdapConnection } from "@app/services/app-connection/ldap";
import { generatePassword } from "../shared/utils"; import { generatePassword } from "../shared/utils";
import { import {
LdapPasswordRotationMethod,
TLdapPasswordRotationGeneratedCredentials, TLdapPasswordRotationGeneratedCredentials,
TLdapPasswordRotationInput,
TLdapPasswordRotationWithConnection TLdapPasswordRotationWithConnection
} from "./ldap-password-rotation-types"; } from "./ldap-password-rotation-types";
const getEncodedPassword = (password: string) => Buffer.from(`"${password}"`, "utf16le"); const getEncodedPassword = (password: string) => Buffer.from(`"${password}"`, "utf16le");
const getDN = async (dn: string, client: Client): Promise<string> => {
if (DistinguishedNameRegex.test(dn)) return dn;
const opts: SearchOptions = {
filter: `(userPrincipalName=${dn})`,
scope: "sub",
attributes: ["dn"]
};
const base = dn
.split("@")[1]
.split(".")
.map((dc) => `dc=${dc}`)
.join(",");
return new Promise((resolve, reject) => {
// Perform the search
client.search(base, opts, (err, res) => {
if (err) {
logger.error(err, "LDAP Failed to get DN");
reject(new Error(`Provider Resolve DN Error: ${err.message}`));
}
let userDn: string | null;
res.on("searchEntry", (entry) => {
userDn = entry.objectName;
});
res.on("error", (error) => {
logger.error(error, "LDAP Failed to get DN");
reject(new Error(`Provider Resolve DN Error: ${error.message}`));
});
res.on("end", () => {
if (userDn) {
resolve(userDn);
} else {
reject(new Error(`Unable to resolve DN for ${dn}.`));
}
});
});
});
};
export const ldapPasswordRotationFactory: TRotationFactory< export const ldapPasswordRotationFactory: TRotationFactory<
TLdapPasswordRotationWithConnection, TLdapPasswordRotationWithConnection,
TLdapPasswordRotationGeneratedCredentials TLdapPasswordRotationGeneratedCredentials,
TLdapPasswordRotationInput["temporaryParameters"]
> = (secretRotation, appConnectionDAL, kmsService) => { > = (secretRotation, appConnectionDAL, kmsService) => {
const { const { connection, parameters, secretsMapping, activeIndex } = secretRotation;
connection,
parameters: { dn, passwordRequirements }, const { dn, passwordRequirements } = parameters;
secretsMapping
} = secretRotation;
const $verifyCredentials = async (credentials: Pick<TLdapConnection["credentials"], "dn" | "password">) => { const $verifyCredentials = async (credentials: Pick<TLdapConnection["credentials"], "dn" | "password">) => {
try { try {
@@ -40,13 +87,21 @@ export const ldapPasswordRotationFactory: TRotationFactory<
} }
}; };
const $rotatePassword = async () => { const $rotatePassword = async (currentPassword?: string) => {
const { credentials, orgId } = connection; const { credentials, orgId } = connection;
if (!credentials.url.startsWith("ldaps")) throw new Error("Password Rotation requires an LDAPS connection"); if (!credentials.url.startsWith("ldaps")) throw new Error("Password Rotation requires an LDAPS connection");
const client = await getLdapConnectionClient(credentials); const client = await getLdapConnectionClient(
const isPersonalRotation = credentials.dn === dn; currentPassword
? {
...credentials,
password: currentPassword,
dn
}
: credentials
);
const isConnectionRotation = credentials.dn === dn;
const password = generatePassword(passwordRequirements); const password = generatePassword(passwordRequirements);
@@ -58,8 +113,8 @@ export const ldapPasswordRotationFactory: TRotationFactory<
const encodedPassword = getEncodedPassword(password); const encodedPassword = getEncodedPassword(password);
// service account vs personal password rotation require different changes // service account vs personal password rotation require different changes
if (isPersonalRotation) { if (isConnectionRotation || currentPassword) {
const currentEncodedPassword = getEncodedPassword(credentials.password); const currentEncodedPassword = getEncodedPassword(currentPassword || credentials.password);
changes = [ changes = [
new ldap.Change({ new ldap.Change({
@@ -93,8 +148,9 @@ export const ldapPasswordRotationFactory: TRotationFactory<
} }
try { try {
const userDn = await getDN(dn, client);
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
client.modify(dn, changes, (err) => { client.modify(userDn, changes, (err) => {
if (err) { if (err) {
logger.error(err, "LDAP Password Rotation Failed"); logger.error(err, "LDAP Password Rotation Failed");
reject(new Error(`Provider Modify Error: ${err.message}`)); reject(new Error(`Provider Modify Error: ${err.message}`));
@@ -110,7 +166,7 @@ export const ldapPasswordRotationFactory: TRotationFactory<
await $verifyCredentials({ dn, password }); await $verifyCredentials({ dn, password });
if (isPersonalRotation) { if (isConnectionRotation) {
const updatedCredentials: TLdapConnection["credentials"] = { const updatedCredentials: TLdapConnection["credentials"] = {
...credentials, ...credentials,
password password
@@ -128,29 +184,41 @@ export const ldapPasswordRotationFactory: TRotationFactory<
return { dn, password }; return { dn, password };
}; };
const issueCredentials: TRotationFactoryIssueCredentials<TLdapPasswordRotationGeneratedCredentials> = async ( const issueCredentials: TRotationFactoryIssueCredentials<
callback TLdapPasswordRotationGeneratedCredentials,
) => { TLdapPasswordRotationInput["temporaryParameters"]
const credentials = await $rotatePassword(); > = async (callback, temporaryParameters) => {
const credentials = await $rotatePassword(
parameters.rotationMethod === LdapPasswordRotationMethod.TargetPrincipal
? temporaryParameters?.password
: undefined
);
return callback(credentials); return callback(credentials);
}; };
const revokeCredentials: TRotationFactoryRevokeCredentials<TLdapPasswordRotationGeneratedCredentials> = async ( const revokeCredentials: TRotationFactoryRevokeCredentials<TLdapPasswordRotationGeneratedCredentials> = async (
_, credentialsToRevoke,
callback callback
) => { ) => {
const currentPassword = credentialsToRevoke[activeIndex].password;
// we just rotate to a new password, essentially revoking old credentials // we just rotate to a new password, essentially revoking old credentials
await $rotatePassword(); await $rotatePassword(
parameters.rotationMethod === LdapPasswordRotationMethod.TargetPrincipal ? currentPassword : undefined
);
return callback(); return callback();
}; };
const rotateCredentials: TRotationFactoryRotateCredentials<TLdapPasswordRotationGeneratedCredentials> = async ( const rotateCredentials: TRotationFactoryRotateCredentials<TLdapPasswordRotationGeneratedCredentials> = async (
_, _,
callback callback,
activeCredentials
) => { ) => {
const credentials = await $rotatePassword(); const credentials = await $rotatePassword(
parameters.rotationMethod === LdapPasswordRotationMethod.TargetPrincipal ? activeCredentials.password : undefined
);
return callback(credentials); return callback(credentials);
}; };
@@ -1,6 +1,6 @@
import RE2 from "re2";
import { z } from "zod"; import { z } from "zod";
import { LdapPasswordRotationMethod } from "@app/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-types";
import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
import { import {
BaseCreateSecretRotationSchema, BaseCreateSecretRotationSchema,
@@ -9,7 +9,7 @@ import {
} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-schemas"; } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-schemas";
import { PasswordRequirementsSchema } from "@app/ee/services/secret-rotation-v2/shared/general"; import { PasswordRequirementsSchema } from "@app/ee/services/secret-rotation-v2/shared/general";
import { SecretRotations } from "@app/lib/api-docs"; import { SecretRotations } from "@app/lib/api-docs";
import { DistinguishedNameRegex } from "@app/lib/regex"; import { DistinguishedNameRegex, UserPrincipalNameRegex } from "@app/lib/regex";
import { SecretNameSchema } from "@app/server/lib/schemas"; import { SecretNameSchema } from "@app/server/lib/schemas";
import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { AppConnection } from "@app/services/app-connection/app-connection-enums";
@@ -26,10 +26,16 @@ const LdapPasswordRotationParametersSchema = z.object({
dn: z dn: z
.string() .string()
.trim() .trim()
.regex(new RE2(DistinguishedNameRegex), "Invalid DN format, ie; CN=user,OU=users,DC=example,DC=com") .min(1, "DN/UPN required")
.min(1, "Distinguished Name (DN) Required") .refine((value) => DistinguishedNameRegex.test(value) || UserPrincipalNameRegex.test(value), {
message: "Invalid DN/UPN format"
})
.describe(SecretRotations.PARAMETERS.LDAP_PASSWORD.dn), .describe(SecretRotations.PARAMETERS.LDAP_PASSWORD.dn),
passwordRequirements: PasswordRequirementsSchema.optional() passwordRequirements: PasswordRequirementsSchema.optional(),
rotationMethod: z
.nativeEnum(LdapPasswordRotationMethod)
.optional()
.describe(SecretRotations.PARAMETERS.LDAP_PASSWORD.rotationMethod)
}); });
const LdapPasswordRotationSecretsMappingSchema = z.object({ const LdapPasswordRotationSecretsMappingSchema = z.object({
@@ -50,10 +56,28 @@ export const LdapPasswordRotationSchema = BaseSecretRotationSchema(SecretRotatio
secretsMapping: LdapPasswordRotationSecretsMappingSchema secretsMapping: LdapPasswordRotationSecretsMappingSchema
}); });
export const CreateLdapPasswordRotationSchema = BaseCreateSecretRotationSchema(SecretRotation.LdapPassword).extend({ export const CreateLdapPasswordRotationSchema = BaseCreateSecretRotationSchema(SecretRotation.LdapPassword)
parameters: LdapPasswordRotationParametersSchema, .extend({
secretsMapping: LdapPasswordRotationSecretsMappingSchema parameters: LdapPasswordRotationParametersSchema,
}); secretsMapping: LdapPasswordRotationSecretsMappingSchema,
temporaryParameters: z
.object({
password: z.string().min(1, "Password required").describe(SecretRotations.PARAMETERS.LDAP_PASSWORD.password)
})
.optional()
})
.superRefine((val, ctx) => {
if (
val.parameters.rotationMethod === LdapPasswordRotationMethod.TargetPrincipal &&
!val.temporaryParameters?.password
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Password required",
path: ["temporaryParameters", "password"]
});
}
});
export const UpdateLdapPasswordRotationSchema = BaseUpdateSecretRotationSchema(SecretRotation.LdapPassword).extend({ export const UpdateLdapPasswordRotationSchema = BaseUpdateSecretRotationSchema(SecretRotation.LdapPassword).extend({
parameters: LdapPasswordRotationParametersSchema.optional(), parameters: LdapPasswordRotationParametersSchema.optional(),
@@ -9,6 +9,11 @@ import {
LdapPasswordRotationSchema LdapPasswordRotationSchema
} from "./ldap-password-rotation-schemas"; } from "./ldap-password-rotation-schemas";
export enum LdapPasswordRotationMethod {
ConnectionPrincipal = "connection-principal",
TargetPrincipal = "target-principal"
}
export type TLdapPasswordRotation = z.infer<typeof LdapPasswordRotationSchema>; export type TLdapPasswordRotation = z.infer<typeof LdapPasswordRotationSchema>;
export type TLdapPasswordRotationInput = z.infer<typeof CreateLdapPasswordRotationSchema>; export type TLdapPasswordRotationInput = z.infer<typeof CreateLdapPasswordRotationSchema>;
@@ -1,12 +1,13 @@
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { BadRequestError } from "@app/lib/errors";
import { KmsDataKey } from "@app/services/kms/kms-types"; import { KmsDataKey } from "@app/services/kms/kms-types";
import { AUTH0_CLIENT_SECRET_ROTATION_LIST_OPTION } from "./auth0-client-secret"; import { AUTH0_CLIENT_SECRET_ROTATION_LIST_OPTION } from "./auth0-client-secret";
import { AWS_IAM_USER_SECRET_ROTATION_LIST_OPTION } from "./aws-iam-user-secret"; import { AWS_IAM_USER_SECRET_ROTATION_LIST_OPTION } from "./aws-iam-user-secret";
import { AZURE_CLIENT_SECRET_ROTATION_LIST_OPTION } from "./azure-client-secret"; import { AZURE_CLIENT_SECRET_ROTATION_LIST_OPTION } from "./azure-client-secret";
import { LDAP_PASSWORD_ROTATION_LIST_OPTION } from "./ldap-password"; import { LDAP_PASSWORD_ROTATION_LIST_OPTION, TLdapPasswordRotation } from "./ldap-password";
import { MSSQL_CREDENTIALS_ROTATION_LIST_OPTION } from "./mssql-credentials"; import { MSSQL_CREDENTIALS_ROTATION_LIST_OPTION } from "./mssql-credentials";
import { POSTGRES_CREDENTIALS_ROTATION_LIST_OPTION } from "./postgres-credentials"; import { POSTGRES_CREDENTIALS_ROTATION_LIST_OPTION } from "./postgres-credentials";
import { SecretRotation, SecretRotationStatus } from "./secret-rotation-v2-enums"; import { SecretRotation, SecretRotationStatus } from "./secret-rotation-v2-enums";
@@ -15,7 +16,8 @@ import {
TSecretRotationV2, TSecretRotationV2,
TSecretRotationV2GeneratedCredentials, TSecretRotationV2GeneratedCredentials,
TSecretRotationV2ListItem, TSecretRotationV2ListItem,
TSecretRotationV2Raw TSecretRotationV2Raw,
TUpdateSecretRotationV2DTO
} from "./secret-rotation-v2-types"; } from "./secret-rotation-v2-types";
const SECRET_ROTATION_LIST_OPTIONS: Record<SecretRotation, TSecretRotationV2ListItem> = { const SECRET_ROTATION_LIST_OPTIONS: Record<SecretRotation, TSecretRotationV2ListItem> = {
@@ -228,3 +230,30 @@ export const parseRotationErrorMessage = (err: unknown): string => {
? errorMessage ? errorMessage
: `${errorMessage.substring(0, MAX_MESSAGE_LENGTH - 3)}...`; : `${errorMessage.substring(0, MAX_MESSAGE_LENGTH - 3)}...`;
}; };
function haveUnequalProperties<T>(obj1: T, obj2: T, properties: (keyof T)[]): boolean {
return properties.some((prop) => obj1[prop] !== obj2[prop]);
}
export const throwOnImmutableParameterUpdate = (
updatePayload: TUpdateSecretRotationV2DTO,
secretRotation: TSecretRotationV2Raw
) => {
if (!updatePayload.parameters) return;
switch (updatePayload.type) {
case SecretRotation.LdapPassword:
if (
haveUnequalProperties(
updatePayload.parameters as TLdapPasswordRotation["parameters"],
secretRotation.parameters as TLdapPasswordRotation["parameters"],
["rotationMethod", "dn"]
)
) {
throw new BadRequestError({ message: "Cannot update rotation method or DN" });
}
break;
default:
// do nothing
}
};
@@ -25,7 +25,8 @@ import {
getNextUtcRotationInterval, getNextUtcRotationInterval,
getSecretRotationRotateSecretJobOptions, getSecretRotationRotateSecretJobOptions,
listSecretRotationOptions, listSecretRotationOptions,
parseRotationErrorMessage parseRotationErrorMessage,
throwOnImmutableParameterUpdate
} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-fns"; } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-fns";
import { import {
SECRET_ROTATION_CONNECTION_MAP, SECRET_ROTATION_CONNECTION_MAP,
@@ -46,6 +47,7 @@ import {
TSecretRotationV2, TSecretRotationV2,
TSecretRotationV2GeneratedCredentials, TSecretRotationV2GeneratedCredentials,
TSecretRotationV2Raw, TSecretRotationV2Raw,
TSecretRotationV2TemporaryParameters,
TSecretRotationV2WithConnection, TSecretRotationV2WithConnection,
TUpdateSecretRotationV2DTO TUpdateSecretRotationV2DTO
} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types";
@@ -112,7 +114,8 @@ const MAX_GENERATED_CREDENTIALS_LENGTH = 2;
type TRotationFactoryImplementation = TRotationFactory< type TRotationFactoryImplementation = TRotationFactory<
TSecretRotationV2WithConnection, TSecretRotationV2WithConnection,
TSecretRotationV2GeneratedCredentials TSecretRotationV2GeneratedCredentials,
TSecretRotationV2TemporaryParameters
>; >;
const SECRET_ROTATION_FACTORY_MAP: Record<SecretRotation, TRotationFactoryImplementation> = { const SECRET_ROTATION_FACTORY_MAP: Record<SecretRotation, TRotationFactoryImplementation> = {
[SecretRotation.PostgresCredentials]: sqlCredentialsRotationFactory as TRotationFactoryImplementation, [SecretRotation.PostgresCredentials]: sqlCredentialsRotationFactory as TRotationFactoryImplementation,
@@ -400,6 +403,7 @@ export const secretRotationV2ServiceFactory = ({
environment, environment,
rotateAtUtc = { hours: 0, minutes: 0 }, rotateAtUtc = { hours: 0, minutes: 0 },
secretsMapping, secretsMapping,
temporaryParameters,
...payload ...payload
}: TCreateSecretRotationV2DTO, }: TCreateSecretRotationV2DTO,
actor: OrgServiceActor actor: OrgServiceActor
@@ -546,7 +550,7 @@ export const secretRotationV2ServiceFactory = ({
return createdRotation; return createdRotation;
}); });
}); }, temporaryParameters);
await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId);
await snapshotService.performSnapshot(folder.id); await snapshotService.performSnapshot(folder.id);
@@ -585,10 +589,7 @@ export const secretRotationV2ServiceFactory = ({
} }
}; };
const updateSecretRotation = async ( const updateSecretRotation = async (dto: TUpdateSecretRotationV2DTO, actor: OrgServiceActor) => {
{ type, rotationId, ...payload }: TUpdateSecretRotationV2DTO,
actor: OrgServiceActor
) => {
const plan = await licenseService.getPlan(actor.orgId); const plan = await licenseService.getPlan(actor.orgId);
if (!plan.secretRotation) if (!plan.secretRotation)
@@ -596,6 +597,8 @@ export const secretRotationV2ServiceFactory = ({
message: "Failed to update secret rotation due to plan restriction. Upgrade plan to update secret rotations." message: "Failed to update secret rotation due to plan restriction. Upgrade plan to update secret rotations."
}); });
const { type, rotationId, ...payload } = dto;
const secretRotation = await secretRotationV2DAL.findById(rotationId); const secretRotation = await secretRotationV2DAL.findById(rotationId);
if (!secretRotation) if (!secretRotation)
@@ -603,6 +606,8 @@ export const secretRotationV2ServiceFactory = ({
message: `Could not find ${SECRET_ROTATION_NAME_MAP[type]} Rotation with ID ${rotationId}` message: `Could not find ${SECRET_ROTATION_NAME_MAP[type]} Rotation with ID ${rotationId}`
}); });
throwOnImmutableParameterUpdate(dto, secretRotation);
const { folder, environment, projectId, folderId, connection } = secretRotation; const { folder, environment, projectId, folderId, connection } = secretRotation;
const secretsMapping = secretRotation.secretsMapping as TSecretRotationV2["secretsMapping"]; const secretsMapping = secretRotation.secretsMapping as TSecretRotationV2["secretsMapping"];
@@ -877,6 +882,7 @@ export const secretRotationV2ServiceFactory = ({
const inactiveIndex = (activeIndex + 1) % MAX_GENERATED_CREDENTIALS_LENGTH; const inactiveIndex = (activeIndex + 1) % MAX_GENERATED_CREDENTIALS_LENGTH;
const inactiveCredentials = generatedCredentials[inactiveIndex]; const inactiveCredentials = generatedCredentials[inactiveIndex];
const activeCredentials = generatedCredentials[activeIndex];
const rotationFactory = SECRET_ROTATION_FACTORY_MAP[type as SecretRotation]( const rotationFactory = SECRET_ROTATION_FACTORY_MAP[type as SecretRotation](
{ {
@@ -887,73 +893,77 @@ export const secretRotationV2ServiceFactory = ({
kmsService kmsService
); );
const updatedRotation = await rotationFactory.rotateCredentials(inactiveCredentials, async (newCredentials) => { const updatedRotation = await rotationFactory.rotateCredentials(
const updatedCredentials = [...generatedCredentials]; inactiveCredentials,
updatedCredentials[inactiveIndex] = newCredentials; async (newCredentials) => {
const updatedCredentials = [...generatedCredentials];
updatedCredentials[inactiveIndex] = newCredentials;
const encryptedUpdatedCredentials = await encryptSecretRotationCredentials({ const encryptedUpdatedCredentials = await encryptSecretRotationCredentials({
projectId, projectId,
generatedCredentials: updatedCredentials as TSecretRotationV2GeneratedCredentials, generatedCredentials: updatedCredentials as TSecretRotationV2GeneratedCredentials,
kmsService kmsService
});
return secretRotationV2DAL.transaction(async (tx) => {
const secretsPayload = rotationFactory.getSecretsPayload(newCredentials);
const { encryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
projectId
}); });
// update mapped secrets with new credential values return secretRotationV2DAL.transaction(async (tx) => {
await fnSecretBulkUpdate({ const secretsPayload = rotationFactory.getSecretsPayload(newCredentials);
folderId,
orgId: connection.orgId,
tx,
inputSecrets: secretsPayload.map(({ key, value }) => ({
filter: {
key,
folderId,
type: SecretType.Shared
},
data: {
encryptedValue: encryptor({
plainText: Buffer.from(value)
}).cipherTextBlob,
references: []
}
})),
secretDAL: secretV2BridgeDAL,
secretVersionDAL: secretVersionV2BridgeDAL,
secretVersionTagDAL: secretVersionTagV2BridgeDAL,
secretTagDAL,
resourceMetadataDAL
});
const currentTime = new Date(); const { encryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
projectId
});
return secretRotationV2DAL.updateById( // update mapped secrets with new credential values
secretRotation.id, await fnSecretBulkUpdate({
{ folderId,
encryptedGeneratedCredentials: encryptedUpdatedCredentials, orgId: connection.orgId,
activeIndex: inactiveIndex, tx,
isLastRotationManual: isManualRotation, inputSecrets: secretsPayload.map(({ key, value }) => ({
lastRotatedAt: currentTime, filter: {
lastRotationAttemptedAt: currentTime, key,
nextRotationAt: calculateNextRotationAt({ folderId,
...(secretRotation as TSecretRotationV2), type: SecretType.Shared
rotationStatus: SecretRotationStatus.Success, },
data: {
encryptedValue: encryptor({
plainText: Buffer.from(value)
}).cipherTextBlob,
references: []
}
})),
secretDAL: secretV2BridgeDAL,
secretVersionDAL: secretVersionV2BridgeDAL,
secretVersionTagDAL: secretVersionTagV2BridgeDAL,
secretTagDAL,
resourceMetadataDAL
});
const currentTime = new Date();
return secretRotationV2DAL.updateById(
secretRotation.id,
{
encryptedGeneratedCredentials: encryptedUpdatedCredentials,
activeIndex: inactiveIndex,
isLastRotationManual: isManualRotation,
lastRotatedAt: currentTime, lastRotatedAt: currentTime,
isManualRotation lastRotationAttemptedAt: currentTime,
}), nextRotationAt: calculateNextRotationAt({
rotationStatus: SecretRotationStatus.Success, ...(secretRotation as TSecretRotationV2),
lastRotationJobId: jobId, rotationStatus: SecretRotationStatus.Success,
encryptedLastRotationMessage: null lastRotatedAt: currentTime,
}, isManualRotation
tx }),
); rotationStatus: SecretRotationStatus.Success,
}); lastRotationJobId: jobId,
}); encryptedLastRotationMessage: null
},
tx
);
});
},
activeCredentials
);
await auditLogService.createAuditLog({ await auditLogService.createAuditLog({
...(auditLogInfo ?? { ...(auditLogInfo ?? {
@@ -87,6 +87,8 @@ export type TSecretRotationV2ListItem =
| TLdapPasswordRotationListItem | TLdapPasswordRotationListItem
| TAwsIamUserSecretRotationListItem; | TAwsIamUserSecretRotationListItem;
export type TSecretRotationV2TemporaryParameters = TLdapPasswordRotationInput["temporaryParameters"] | undefined;
export type TSecretRotationV2Raw = NonNullable<Awaited<ReturnType<TSecretRotationV2DALFactory["findById"]>>>; export type TSecretRotationV2Raw = NonNullable<Awaited<ReturnType<TSecretRotationV2DALFactory["findById"]>>>;
export type TListSecretRotationsV2ByProjectId = { export type TListSecretRotationsV2ByProjectId = {
@@ -120,6 +122,7 @@ export type TCreateSecretRotationV2DTO = Pick<
environment: string; environment: string;
isAutoRotationEnabled?: boolean; isAutoRotationEnabled?: boolean;
rotateAtUtc?: TRotateAtUtc; rotateAtUtc?: TRotateAtUtc;
temporaryParameters?: TSecretRotationV2TemporaryParameters;
}; };
export type TUpdateSecretRotationV2DTO = Partial< export type TUpdateSecretRotationV2DTO = Partial<
@@ -186,8 +189,12 @@ export type TSecretRotationSendNotificationJobPayload = {
// transactional behavior. By passing in the rotation mutation, if this mutation fails we can roll back the // transactional behavior. By passing in the rotation mutation, if this mutation fails we can roll back the
// third party credential changes (when supported), preventing credentials getting out of sync // third party credential changes (when supported), preventing credentials getting out of sync
export type TRotationFactoryIssueCredentials<T extends TSecretRotationV2GeneratedCredentials> = ( export type TRotationFactoryIssueCredentials<
callback: (newCredentials: T[number]) => Promise<TSecretRotationV2Raw> T extends TSecretRotationV2GeneratedCredentials,
P extends TSecretRotationV2TemporaryParameters = undefined
> = (
callback: (newCredentials: T[number]) => Promise<TSecretRotationV2Raw>,
temporaryParameters?: P
) => Promise<TSecretRotationV2Raw>; ) => Promise<TSecretRotationV2Raw>;
export type TRotationFactoryRevokeCredentials<T extends TSecretRotationV2GeneratedCredentials> = ( export type TRotationFactoryRevokeCredentials<T extends TSecretRotationV2GeneratedCredentials> = (
@@ -197,7 +204,8 @@ export type TRotationFactoryRevokeCredentials<T extends TSecretRotationV2Generat
export type TRotationFactoryRotateCredentials<T extends TSecretRotationV2GeneratedCredentials> = ( export type TRotationFactoryRotateCredentials<T extends TSecretRotationV2GeneratedCredentials> = (
credentialsToRevoke: T[number] | undefined, credentialsToRevoke: T[number] | undefined,
callback: (newCredentials: T[number]) => Promise<TSecretRotationV2Raw> callback: (newCredentials: T[number]) => Promise<TSecretRotationV2Raw>,
activeCredentials: T[number]
) => Promise<TSecretRotationV2Raw>; ) => Promise<TSecretRotationV2Raw>;
export type TRotationFactoryGetSecretsPayload<T extends TSecretRotationV2GeneratedCredentials> = ( export type TRotationFactoryGetSecretsPayload<T extends TSecretRotationV2GeneratedCredentials> = (
@@ -206,13 +214,14 @@ export type TRotationFactoryGetSecretsPayload<T extends TSecretRotationV2Generat
export type TRotationFactory< export type TRotationFactory<
T extends TSecretRotationV2WithConnection, T extends TSecretRotationV2WithConnection,
C extends TSecretRotationV2GeneratedCredentials C extends TSecretRotationV2GeneratedCredentials,
P extends TSecretRotationV2TemporaryParameters = undefined
> = ( > = (
secretRotation: T, secretRotation: T,
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "update" | "updateById">, appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "update" | "updateById">,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey"> kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
) => { ) => {
issueCredentials: TRotationFactoryIssueCredentials<C>; issueCredentials: TRotationFactoryIssueCredentials<C, P>;
revokeCredentials: TRotationFactoryRevokeCredentials<C>; revokeCredentials: TRotationFactoryRevokeCredentials<C>;
rotateCredentials: TRotationFactoryRotateCredentials<C>; rotateCredentials: TRotationFactoryRotateCredentials<C>;
getSecretsPayload: TRotationFactoryGetSecretsPayload<C>; getSecretsPayload: TRotationFactoryGetSecretsPayload<C>;
+6 -3
View File
@@ -2063,7 +2063,7 @@ export const AppConnections = {
LDAP: { LDAP: {
provider: "The type of LDAP provider. Determines provider-specific behaviors.", provider: "The type of LDAP provider. Determines provider-specific behaviors.",
url: "The LDAP/LDAPS URL to connect to (e.g., 'ldap://domain-or-ip:389' or 'ldaps://domain-or-ip:636').", url: "The LDAP/LDAPS URL to connect to (e.g., 'ldap://domain-or-ip:389' or 'ldaps://domain-or-ip:636').",
dn: "The Distinguished Name (DN) of the principal to bind with (e.g., 'CN=John,CN=Users,DC=example,DC=com').", dn: "The Distinguished Name (DN) or User Principal Name (UPN) of the principal to bind with (e.g., 'CN=John,CN=Users,DC=example,DC=com').",
password: "The password to bind with for authentication.", password: "The password to bind with for authentication.",
sslRejectUnauthorized: sslRejectUnauthorized:
"Whether or not to reject unauthorized SSL certificates (true/false) when using ldaps://. Set to false only in test environments.", "Whether or not to reject unauthorized SSL certificates (true/false) when using ldaps://. Set to false only in test environments.",
@@ -2308,7 +2308,10 @@ export const SecretRotations = {
clientId: "The client ID of the Azure Application to rotate the client secret for." clientId: "The client ID of the Azure Application to rotate the client secret for."
}, },
LDAP_PASSWORD: { LDAP_PASSWORD: {
dn: "The Distinguished Name (DN) of the principal to rotate the password for." dn: "The Distinguished Name (DN) or User Principal Name (UPN) of the principal to rotate the password for.",
rotationMethod:
'Whether the rotation should be performed by the LDAP "connection-principal" or the "target-principal" (defaults to \'connection-principal\').',
password: 'The password of the provided principal if "parameters.rotationMethod" is set to "target-principal".'
}, },
GENERAL: { GENERAL: {
PASSWORD_REQUIREMENTS: { PASSWORD_REQUIREMENTS: {
@@ -2342,7 +2345,7 @@ export const SecretRotations = {
clientSecret: "The name of the secret that the rotated client secret will be mapped to." clientSecret: "The name of the secret that the rotated client secret will be mapped to."
}, },
LDAP_PASSWORD: { LDAP_PASSWORD: {
dn: "The name of the secret that the Distinguished Name (DN) of the principal will be mapped to.", dn: "The name of the secret that the Distinguished Name (DN) or User Principal Name (UPN) of the principal will be mapped to.",
password: "The name of the secret that the rotated password will be mapped to." password: "The name of the secret that the rotated password will be mapped to."
}, },
AWS_IAM_USER_SECRET: { AWS_IAM_USER_SECRET: {
+22 -4
View File
@@ -1,6 +1,8 @@
import { Knex } from "knex"; import { Knex } from "knex";
import { Compare, Filter, parse } from "scim2-parse-filter"; import { Compare, Filter, parse } from "scim2-parse-filter";
import { TableName } from "@app/db/schemas";
const appendParentToGroupingOperator = (parentPath: string, filter: Filter) => { const appendParentToGroupingOperator = (parentPath: string, filter: Filter) => {
if (filter.op !== "[]" && filter.op !== "and" && filter.op !== "or" && filter.op !== "not") { if (filter.op !== "[]" && filter.op !== "and" && filter.op !== "or" && filter.op !== "not") {
return { ...filter, attrPath: `${parentPath}.${(filter as Compare).attrPath}` }; return { ...filter, attrPath: `${parentPath}.${(filter as Compare).attrPath}` };
@@ -27,8 +29,12 @@ const processDynamicQuery = (
const { scimFilterAst, query } = stack.pop()!; const { scimFilterAst, query } = stack.pop()!;
switch (scimFilterAst.op) { switch (scimFilterAst.op) {
case "eq": { case "eq": {
let sanitizedValue = scimFilterAst.compValue;
const attrPath = getAttributeField(scimFilterAst.attrPath); const attrPath = getAttributeField(scimFilterAst.attrPath);
if (attrPath) void query.where(attrPath, scimFilterAst.compValue); if (attrPath === `${TableName.Users}.email` && typeof sanitizedValue === "string") {
sanitizedValue = sanitizedValue.toLowerCase();
}
if (attrPath) void query.where(attrPath, sanitizedValue);
break; break;
} }
case "pr": { case "pr": {
@@ -62,18 +68,30 @@ const processDynamicQuery = (
break; break;
} }
case "ew": { case "ew": {
let sanitizedValue = scimFilterAst.compValue;
const attrPath = getAttributeField(scimFilterAst.attrPath); const attrPath = getAttributeField(scimFilterAst.attrPath);
if (attrPath) void query.whereILike(attrPath, `%${scimFilterAst.compValue}`); if (attrPath === `${TableName.Users}.email` && typeof sanitizedValue === "string") {
sanitizedValue = sanitizedValue.toLowerCase();
}
if (attrPath) void query.whereILike(attrPath, `%${sanitizedValue}`);
break; break;
} }
case "co": { case "co": {
let sanitizedValue = scimFilterAst.compValue;
const attrPath = getAttributeField(scimFilterAst.attrPath); const attrPath = getAttributeField(scimFilterAst.attrPath);
if (attrPath) void query.whereILike(attrPath, `%${scimFilterAst.compValue}%`); if (attrPath === `${TableName.Users}.email` && typeof sanitizedValue === "string") {
sanitizedValue = sanitizedValue.toLowerCase();
}
if (attrPath) void query.whereILike(attrPath, `%${sanitizedValue}%`);
break; break;
} }
case "ne": { case "ne": {
let sanitizedValue = scimFilterAst.compValue;
const attrPath = getAttributeField(scimFilterAst.attrPath); const attrPath = getAttributeField(scimFilterAst.attrPath);
if (attrPath) void query.whereNot(attrPath, "=", scimFilterAst.compValue); if (attrPath === `${TableName.Users}.email` && typeof sanitizedValue === "string") {
sanitizedValue = sanitizedValue.toLowerCase();
}
if (attrPath) void query.whereNot(attrPath, "=", sanitizedValue);
break; break;
} }
case "and": { case "and": {
+9 -1
View File
@@ -1,3 +1,11 @@
import RE2 from "re2";
export const DistinguishedNameRegex = export const DistinguishedNameRegex =
// DN format, ie; CN=user,OU=users,DC=example,DC=com // DN format, ie; CN=user,OU=users,DC=example,DC=com
/^(?:(?:[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)(?:(?:\\+[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)*)(?:,(?:[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)(?:(?:\\+[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)*))*)?$/; new RE2(
/^(?:(?:[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)(?:(?:\\+[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)*)(?:,(?:[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)(?:(?:\\+[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)*))*)?$/
);
export const UserPrincipalNameRegex = new RE2(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9._-]+\.[a-zA-Z]{2,}$/);
export const LdapUrlRegex = new RE2(/^ldaps?:\/\//);
+1 -1
View File
@@ -9,7 +9,7 @@ interface SlugSchemaInputs {
field?: string; field?: string;
} }
export const slugSchema = ({ min = 1, max = 32, field = "Slug" }: SlugSchemaInputs = {}) => { export const slugSchema = ({ min = 1, max = 64, field = "Slug" }: SlugSchemaInputs = {}) => {
return z return z
.string() .string()
.trim() .trim()
-1
View File
@@ -625,7 +625,6 @@ export const registerRoutes = async (
const userService = userServiceFactory({ const userService = userServiceFactory({
userDAL, userDAL,
userAliasDAL,
orgMembershipDAL, orgMembershipDAL,
tokenService, tokenService,
permissionService, permissionService,
@@ -16,7 +16,12 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => {
method: "POST", method: "POST",
schema: { schema: {
body: z.object({ body: z.object({
inviteeEmails: z.array(z.string().trim().email()), inviteeEmails: z
.string()
.trim()
.email()
.array()
.refine((val) => val.every((el) => el === el.toLowerCase()), "Email must be lowercase"),
organizationId: z.string().trim(), organizationId: z.string().trim(),
projects: z projects: z
.object({ .object({
@@ -115,7 +120,11 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => {
}, },
schema: { schema: {
body: z.object({ body: z.object({
email: z.string().trim().email(), email: z
.string()
.trim()
.email()
.refine((val) => val === val.toLowerCase(), "Email must be lowercase"),
organizationId: z.string().trim(), organizationId: z.string().trim(),
code: z.string().trim() code: z.string().trim()
}), }),
@@ -46,6 +46,54 @@ export const registerUserRouter = async (server: FastifyZodProvider) => {
} }
}); });
server.route({
method: "GET",
url: "/duplicate-accounts",
config: {
rateLimit: readLimit
},
schema: {
response: {
200: z.object({
users: UsersSchema.extend({
isMyAccount: z.boolean(),
organizations: z.object({ name: z.string(), slug: z.string() }).array()
}).array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT], { requireOrg: false }),
handler: async (req) => {
if (req.auth.authMode === AuthMode.JWT && req.auth.user.email) {
const users = await server.services.user.getAllMyAccounts(req.auth.user.email, req.permission.id);
return { users };
}
return { users: [] };
}
});
server.route({
method: "POST",
url: "/remove-duplicate-accounts",
config: {
rateLimit: writeLimit
},
schema: {
response: {
200: z.object({
message: z.string()
})
}
},
onRequest: verifyAuth([AuthMode.JWT], { requireOrg: false }),
handler: async (req) => {
if (req.auth.authMode === AuthMode.JWT && req.auth.user.email) {
await server.services.user.removeMyDuplicateAccounts(req.auth.user.email, req.permission.id);
}
return { message: "Removed all duplicate accounts" };
}
});
server.route({ server.route({
method: "GET", method: "GET",
url: "/private-key", url: "/private-key",
@@ -27,8 +27,19 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider
projectId: z.string().describe(PROJECT_USERS.INVITE_MEMBER.projectId) projectId: z.string().describe(PROJECT_USERS.INVITE_MEMBER.projectId)
}), }),
body: z.object({ body: z.object({
emails: z.string().email().array().default([]).describe(PROJECT_USERS.INVITE_MEMBER.emails), emails: z
usernames: z.string().array().default([]).describe(PROJECT_USERS.INVITE_MEMBER.usernames), .string()
.email()
.array()
.default([])
.describe(PROJECT_USERS.INVITE_MEMBER.emails)
.refine((val) => val.every((el) => el === el.toLowerCase()), "Email must be lowercase"),
usernames: z
.string()
.array()
.default([])
.describe(PROJECT_USERS.INVITE_MEMBER.usernames)
.refine((val) => val.every((el) => el === el.toLowerCase()), "Username must be lowercase"),
roleSlugs: z.string().array().min(1).optional().describe(PROJECT_USERS.INVITE_MEMBER.roleSlugs) roleSlugs: z.string().array().min(1).optional().describe(PROJECT_USERS.INVITE_MEMBER.roleSlugs)
}), }),
response: { response: {
@@ -92,8 +103,19 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider
projectId: z.string().describe(PROJECT_USERS.REMOVE_MEMBER.projectId) projectId: z.string().describe(PROJECT_USERS.REMOVE_MEMBER.projectId)
}), }),
body: z.object({ body: z.object({
emails: z.string().email().array().default([]).describe(PROJECT_USERS.REMOVE_MEMBER.emails), emails: z
usernames: z.string().array().default([]).describe(PROJECT_USERS.REMOVE_MEMBER.usernames) .string()
.email()
.array()
.default([])
.describe(PROJECT_USERS.REMOVE_MEMBER.emails)
.refine((val) => val.every((el) => el === el.toLowerCase()), "Email must be lowercase"),
usernames: z
.string()
.array()
.default([])
.describe(PROJECT_USERS.REMOVE_MEMBER.usernames)
.refine((val) => val.every((el) => el === el.toLowerCase()), "Username must be lowercase")
}), }),
response: { response: {
200: z.object({ 200: z.object({
@@ -1,8 +1,7 @@
import RE2 from "re2";
import { z } from "zod"; import { z } from "zod";
import { AppConnections } from "@app/lib/api-docs"; import { AppConnections } from "@app/lib/api-docs";
import { DistinguishedNameRegex } from "@app/lib/regex"; import { DistinguishedNameRegex, LdapUrlRegex, UserPrincipalNameRegex } from "@app/lib/regex";
import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { import {
BaseAppConnectionSchema, BaseAppConnectionSchema,
@@ -14,17 +13,14 @@ import { LdapConnectionMethod, LdapProvider } from "./ldap-connection-enums";
export const LdapConnectionSimpleBindCredentialsSchema = z.object({ export const LdapConnectionSimpleBindCredentialsSchema = z.object({
provider: z.nativeEnum(LdapProvider).describe(AppConnections.CREDENTIALS.LDAP.provider), provider: z.nativeEnum(LdapProvider).describe(AppConnections.CREDENTIALS.LDAP.provider),
url: z url: z.string().trim().min(1, "URL required").regex(LdapUrlRegex).describe(AppConnections.CREDENTIALS.LDAP.url),
.string()
.trim()
.min(1, "URL required")
.regex(new RE2(/^ldaps?:\/\//))
.describe(AppConnections.CREDENTIALS.LDAP.url),
dn: z dn: z
.string() .string()
.trim() .trim()
.regex(new RE2(DistinguishedNameRegex), "Invalid DN format, ie; CN=user,OU=users,DC=example,DC=com") .min(1, "DN/UPN required")
.min(1, "Distinguished Name (DN) required") .refine((value) => DistinguishedNameRegex.test(value) || UserPrincipalNameRegex.test(value), {
message: "Invalid DN/UPN format"
})
.describe(AppConnections.CREDENTIALS.LDAP.dn), .describe(AppConnections.CREDENTIALS.LDAP.dn),
password: z.string().trim().min(1, "Password required").describe(AppConnections.CREDENTIALS.LDAP.password), password: z.string().trim().min(1, "Password required").describe(AppConnections.CREDENTIALS.LDAP.password),
sslRejectUnauthorized: z.boolean().optional().describe(AppConnections.CREDENTIALS.LDAP.sslRejectUnauthorized), sslRejectUnauthorized: z.boolean().optional().describe(AppConnections.CREDENTIALS.LDAP.sslRejectUnauthorized),
@@ -199,9 +199,12 @@ export const authLoginServiceFactory = ({
providerAuthToken, providerAuthToken,
clientPublicKey clientPublicKey
}: TLoginGenServerPublicKeyDTO) => { }: TLoginGenServerPublicKeyDTO) => {
const userEnc = await userDAL.findUserEncKeyByUsername({ // akhilmhdh: case sensitive email resolution
const usersByUsername = await userDAL.findUserEncKeyByUsername({
username: email username: email
}); });
const userEnc =
usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0];
const serverCfg = await getServerCfg(); const serverCfg = await getServerCfg();
@@ -250,9 +253,12 @@ export const authLoginServiceFactory = ({
}: TLoginClientProofDTO) => { }: TLoginClientProofDTO) => {
const appCfg = getConfig(); const appCfg = getConfig();
const userEnc = await userDAL.findUserEncKeyByUsername({ // akhilmhdh: case sensitive email resolution
const usersByUsername = await userDAL.findUserEncKeyByUsername({
username: email username: email
}); });
const userEnc =
usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0];
if (!userEnc) throw new Error("Failed to find user"); if (!userEnc) throw new Error("Failed to find user");
const user = await userDAL.findById(userEnc.userId); const user = await userDAL.findById(userEnc.userId);
const cfg = getConfig(); const cfg = getConfig();
@@ -649,10 +655,12 @@ export const authLoginServiceFactory = ({
* OAuth2 login for google,github, and other oauth2 provider * OAuth2 login for google,github, and other oauth2 provider
* */ * */
const oauth2Login = async ({ email, firstName, lastName, authMethod, callbackPort }: TOauthLoginDTO) => { const oauth2Login = async ({ email, firstName, lastName, authMethod, callbackPort }: TOauthLoginDTO) => {
let user = await userDAL.findUserByUsername(email); // akhilmhdh: case sensitive email resolution
const usersByUsername = await userDAL.findUserByUsername(email);
let user = usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0];
const serverCfg = await getServerCfg(); const serverCfg = await getServerCfg();
if (serverCfg.enabledLoginMethods) { if (serverCfg.enabledLoginMethods && user) {
switch (authMethod) { switch (authMethod) {
case AuthMethod.GITHUB: { case AuthMethod.GITHUB: {
if (!serverCfg.enabledLoginMethods.includes(LoginMethod.GITHUB)) { if (!serverCfg.enabledLoginMethods.includes(LoginMethod.GITHUB)) {
@@ -715,8 +723,8 @@ export const authLoginServiceFactory = ({
} }
user = await userDAL.create({ user = await userDAL.create({
username: email, username: email.trim().toLowerCase(),
email, email: email.trim().toLowerCase(),
isEmailVerified: true, isEmailVerified: true,
firstName, firstName,
lastName, lastName,
@@ -814,11 +822,14 @@ export const authLoginServiceFactory = ({
? decodedProviderToken.orgId ? decodedProviderToken.orgId
: undefined; : undefined;
const userEnc = await userDAL.findUserEncKeyByUsername({ // akhilmhdh: case sensitive email resolution
const usersByUsername = await userDAL.findUserEncKeyByUsername({
username: email username: email
}); });
if (!userEnc) throw new BadRequestError({ message: "Invalid token" }); const userEnc =
if (!userEnc.serverEncryptedPrivateKey) usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0];
if (!userEnc?.serverEncryptedPrivateKey)
throw new BadRequestError({ message: "Key handoff incomplete. Please try logging in again." }); throw new BadRequestError({ message: "Key handoff incomplete. Please try logging in again." });
const token = await generateUserTokens({ const token = await generateUserTokens({
@@ -121,7 +121,10 @@ export const authPaswordServiceFactory = ({
*/ */
const sendPasswordResetEmail = async (email: string) => { const sendPasswordResetEmail = async (email: string) => {
const sendEmail = async () => { const sendEmail = async () => {
const user = await userDAL.findUserByUsername(email); const users = await userDAL.findUserByUsername(email);
// akhilmhdh: case sensitive email resolution
const user = users?.length > 1 ? users.find((el) => el.username === email) : users?.[0];
if (!user) throw new BadRequestError({ message: "Failed to find user data" });
if (user && user.isAccepted) { if (user && user.isAccepted) {
const cfg = getConfig(); const cfg = getConfig();
@@ -152,7 +155,10 @@ export const authPaswordServiceFactory = ({
* */ * */
const verifyPasswordResetEmail = async (email: string, code: string) => { const verifyPasswordResetEmail = async (email: string, code: string) => {
const cfg = getConfig(); const cfg = getConfig();
const user = await userDAL.findUserByUsername(email); const users = await userDAL.findUserByUsername(email);
// akhilmhdh: case sensitive email resolution
const user = users?.length > 1 ? users.find((el) => el.username === email) : users?.[0];
if (!user) throw new BadRequestError({ message: "Failed to find user data" });
const userEnc = await userDAL.findUserEncKeyByUserId(user.id); const userEnc = await userDAL.findUserEncKeyByUserId(user.id);
@@ -189,16 +195,15 @@ export const authPaswordServiceFactory = ({
throw new BadRequestError({ message: `User encryption key not found for user with ID '${userId}'` }); throw new BadRequestError({ message: `User encryption key not found for user with ID '${userId}'` });
} }
if (!user.hashedPassword) {
throw new BadRequestError({ message: "Unable to reset password, no password is set" });
}
if (!user.authMethods?.includes(AuthMethod.EMAIL)) { if (!user.authMethods?.includes(AuthMethod.EMAIL)) {
throw new BadRequestError({ message: "Unable to reset password, no email authentication method is configured" }); throw new BadRequestError({ message: "Unable to reset password, no email authentication method is configured" });
} }
// we check the old password if the user is resetting their password while logged in // we check the old password if the user is resetting their password while logged in
if (type === ResetPasswordV2Type.LoggedInReset) { if (type === ResetPasswordV2Type.LoggedInReset) {
if (!user.hashedPassword) {
throw new BadRequestError({ message: "Unable to change password, no password is set" });
}
if (!oldPassword) { if (!oldPassword) {
throw new BadRequestError({ message: "Current password is required." }); throw new BadRequestError({ message: "Current password is required." });
} }
@@ -73,18 +73,27 @@ export const authSignupServiceFactory = ({
}: TAuthSignupDep) => { }: TAuthSignupDep) => {
// first step of signup. create user and send email // first step of signup. create user and send email
const beginEmailSignupProcess = async (email: string) => { const beginEmailSignupProcess = async (email: string) => {
const isEmailInvalid = await isDisposableEmail(email); const sanitizedEmail = email.trim().toLowerCase();
const isEmailInvalid = await isDisposableEmail(sanitizedEmail);
if (isEmailInvalid) { if (isEmailInvalid) {
throw new Error("Provided a disposable email"); throw new Error("Provided a disposable email");
} }
let user = await userDAL.findUserByUsername(email); // akhilmhdh: case sensitive email resolution
const usersByUsername = await userDAL.findUserByUsername(sanitizedEmail);
let user =
usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === sanitizedEmail) : usersByUsername?.[0];
if (user && user.isAccepted) { if (user && user.isAccepted) {
// TODO(akhilmhdh-pg): copy as old one. this needs to be changed due to security issues // TODO(akhilmhdh-pg): copy as old one. this needs to be changed due to security issues
throw new Error("Failed to send verification code for complete account"); throw new BadRequestError({ message: "Failed to send verification code for complete account" });
} }
if (!user) { if (!user) {
user = await userDAL.create({ authMethods: [AuthMethod.EMAIL], username: email, email, isGhost: false }); user = await userDAL.create({
authMethods: [AuthMethod.EMAIL],
username: sanitizedEmail,
email: sanitizedEmail,
isGhost: false
});
} }
if (!user) throw new Error("Failed to create user"); if (!user) throw new Error("Failed to create user");
@@ -96,7 +105,7 @@ export const authSignupServiceFactory = ({
await smtpService.sendMail({ await smtpService.sendMail({
template: SmtpTemplates.SignupEmailVerification, template: SmtpTemplates.SignupEmailVerification,
subjectLine: "Infisical confirmation code", subjectLine: "Infisical confirmation code",
recipients: [user.email as string], recipients: [sanitizedEmail],
substitutions: { substitutions: {
code: token code: token
} }
@@ -104,11 +113,15 @@ export const authSignupServiceFactory = ({
}; };
const verifyEmailSignup = async (email: string, code: string) => { const verifyEmailSignup = async (email: string, code: string) => {
const user = await userDAL.findUserByUsername(email); const sanitizedEmail = email.trim().toLowerCase();
const usersByUsername = await userDAL.findUserByUsername(sanitizedEmail);
const user =
usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === sanitizedEmail) : usersByUsername?.[0];
if (!user || (user && user.isAccepted)) { if (!user || (user && user.isAccepted)) {
// TODO(akhilmhdh): copy as old one. this needs to be changed due to security issues // TODO(akhilmhdh): copy as old one. this needs to be changed due to security issues
throw new Error("Failed to send verification code for complete account"); throw new Error("Failed to send verification code for complete account");
} }
const appCfg = getConfig(); const appCfg = getConfig();
await tokenService.validateTokenForUser({ await tokenService.validateTokenForUser({
type: TokenType.TOKEN_EMAIL_CONFIRMATION, type: TokenType.TOKEN_EMAIL_CONFIRMATION,
@@ -153,12 +166,15 @@ export const authSignupServiceFactory = ({
authorization, authorization,
useDefaultOrg useDefaultOrg
}: TCompleteAccountSignupDTO) => { }: TCompleteAccountSignupDTO) => {
const sanitizedEmail = email.trim().toLowerCase();
const appCfg = getConfig(); const appCfg = getConfig();
const serverCfg = await getServerCfg(); const serverCfg = await getServerCfg();
const user = await userDAL.findOne({ username: email }); const usersByUsername = await userDAL.findUserByUsername(sanitizedEmail);
const user =
usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === sanitizedEmail) : usersByUsername?.[0];
if (!user || (user && user.isAccepted)) { if (!user || (user && user.isAccepted)) {
throw new Error("Failed to complete account for complete user"); throw new BadRequestError({ message: "Failed to complete account for complete user" });
} }
let organizationId: string | null = null; let organizationId: string | null = null;
@@ -315,7 +331,7 @@ export const authSignupServiceFactory = ({
} }
const updatedMembersips = await orgDAL.updateMembership( const updatedMembersips = await orgDAL.updateMembership(
{ inviteEmail: email, status: OrgMembershipStatus.Invited }, { inviteEmail: sanitizedEmail, status: OrgMembershipStatus.Invited },
{ userId: user.id, status: OrgMembershipStatus.Accepted } { userId: user.id, status: OrgMembershipStatus.Accepted }
); );
const uniqueOrgId = [...new Set(updatedMembersips.map(({ orgId }) => orgId))]; const uniqueOrgId = [...new Set(updatedMembersips.map(({ orgId }) => orgId))];
@@ -382,9 +398,9 @@ export const authSignupServiceFactory = ({
* User signup flow when they are invited to join the org * User signup flow when they are invited to join the org
* */ * */
const completeAccountInvite = async ({ const completeAccountInvite = async ({
email,
ip, ip,
salt, salt,
email,
password, password,
verifier, verifier,
firstName, firstName,
@@ -399,7 +415,10 @@ export const authSignupServiceFactory = ({
encryptedPrivateKeyTag, encryptedPrivateKeyTag,
authorization authorization
}: TCompleteAccountInviteDTO) => { }: TCompleteAccountInviteDTO) => {
const user = await userDAL.findUserByUsername(email); const sanitizedEmail = email.trim().toLowerCase();
const usersByUsername = await userDAL.findUserByUsername(sanitizedEmail);
const user =
usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === sanitizedEmail) : usersByUsername?.[0];
if (!user || (user && user.isAccepted)) { if (!user || (user && user.isAccepted)) {
throw new Error("Failed to complete account for complete user"); throw new Error("Failed to complete account for complete user");
} }
@@ -407,7 +426,7 @@ export const authSignupServiceFactory = ({
validateSignUpAuthorization(authorization, user.id); validateSignUpAuthorization(authorization, user.id);
const [orgMembership] = await orgDAL.findMembership({ const [orgMembership] = await orgDAL.findMembership({
inviteEmail: email, inviteEmail: sanitizedEmail,
status: OrgMembershipStatus.Invited status: OrgMembershipStatus.Invited
}); });
if (!orgMembership) if (!orgMembership)
@@ -454,7 +473,7 @@ export const authSignupServiceFactory = ({
const serverGeneratedPrivateKey = await getUserPrivateKey(serverGeneratedPassword, { const serverGeneratedPrivateKey = await getUserPrivateKey(serverGeneratedPassword, {
...systemGeneratedUserEncryptionKey ...systemGeneratedUserEncryptionKey
}); });
const encKeys = await generateUserSrpKeys(email, password, { const encKeys = await generateUserSrpKeys(sanitizedEmail, password, {
publicKey: systemGeneratedUserEncryptionKey.publicKey, publicKey: systemGeneratedUserEncryptionKey.publicKey,
privateKey: serverGeneratedPrivateKey privateKey: serverGeneratedPrivateKey
}); });
@@ -505,7 +524,7 @@ export const authSignupServiceFactory = ({
} }
const updatedMembersips = await orgDAL.updateMembership( const updatedMembersips = await orgDAL.updateMembership(
{ inviteEmail: email, status: OrgMembershipStatus.Invited }, { inviteEmail: sanitizedEmail, status: OrgMembershipStatus.Invited },
{ userId: us.id, status: OrgMembershipStatus.Accepted }, { userId: us.id, status: OrgMembershipStatus.Accepted },
tx tx
); );
+1 -1
View File
@@ -206,7 +206,7 @@ export const orgDALFactory = (db: TDbClient) => {
.where(`${TableName.OrgMembership}.orgId`, orgId) .where(`${TableName.OrgMembership}.orgId`, orgId)
.count("*") .count("*")
.join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`)
.where({ isGhost: false }) .where({ isGhost: false, [`${TableName.OrgMembership}.isActive` as "isActive"]: true })
.first(); .first();
return parseInt((count as unknown as CountResult).count || "0", 10); return parseInt((count as unknown as CountResult).count || "0", 10);
+9 -2
View File
@@ -827,7 +827,11 @@ export const orgServiceFactory = ({
const users: Pick<TUsers, "id" | "firstName" | "lastName" | "email" | "username">[] = []; const users: Pick<TUsers, "id" | "firstName" | "lastName" | "email" | "username">[] = [];
for await (const inviteeEmail of inviteeEmails) { for await (const inviteeEmail of inviteeEmails) {
let inviteeUser = await userDAL.findUserByUsername(inviteeEmail, tx); const usersByUsername = await userDAL.findUserByUsername(inviteeEmail, tx);
let inviteeUser =
usersByUsername?.length > 1
? usersByUsername.find((el) => el.username === inviteeEmail)
: usersByUsername?.[0];
// if the user doesn't exist we create the user with the email // if the user doesn't exist we create the user with the email
if (!inviteeUser) { if (!inviteeUser) {
@@ -1239,10 +1243,13 @@ export const orgServiceFactory = ({
* magic link and issue a temporary signup token for user to complete setting up their account * magic link and issue a temporary signup token for user to complete setting up their account
*/ */
const verifyUserToOrg = async ({ orgId, email, code }: TVerifyUserToOrgDTO) => { const verifyUserToOrg = async ({ orgId, email, code }: TVerifyUserToOrgDTO) => {
const user = await userDAL.findUserByUsername(email); const usersByUsername = await userDAL.findUserByUsername(email);
const user =
usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0];
if (!user) { if (!user) {
throw new NotFoundError({ message: "User not found" }); throw new NotFoundError({ message: "User not found" });
} }
const [orgMembership] = await orgDAL.findMembership({ const [orgMembership] = await orgDAL.findMembership({
[`${TableName.OrgMembership}.userId` as "userId"]: user.id, [`${TableName.OrgMembership}.userId` as "userId"]: user.id,
status: OrgMembershipStatus.Invited, status: OrgMembershipStatus.Invited,
@@ -257,8 +257,8 @@ export const superAdminServiceFactory = ({
const adminSignUp = async ({ const adminSignUp = async ({
lastName, lastName,
firstName, firstName,
salt,
email, email,
salt,
password, password,
verifier, verifier,
publicKey, publicKey,
@@ -272,7 +272,8 @@ export const superAdminServiceFactory = ({
userAgent userAgent
}: TAdminSignUpDTO) => { }: TAdminSignUpDTO) => {
const appCfg = getConfig(); const appCfg = getConfig();
const existingUser = await userDAL.findOne({ email }); const sanitizedEmail = email.trim().toLowerCase();
const existingUser = await userDAL.findOne({ username: sanitizedEmail });
if (existingUser) throw new BadRequestError({ name: "Admin sign up", message: "User already exists" }); if (existingUser) throw new BadRequestError({ name: "Admin sign up", message: "User already exists" });
const privateKey = await getUserPrivateKey(password, { const privateKey = await getUserPrivateKey(password, {
@@ -292,8 +293,8 @@ export const superAdminServiceFactory = ({
{ {
firstName, firstName,
lastName, lastName,
username: email, username: sanitizedEmail,
email, email: sanitizedEmail,
superAdmin: true, superAdmin: true,
isGhost: false, isGhost: false,
isAccepted: true, isAccepted: true,
@@ -348,12 +349,13 @@ export const superAdminServiceFactory = ({
const bootstrapInstance = async ({ email, password, organizationName }: TAdminBootstrapInstanceDTO) => { const bootstrapInstance = async ({ email, password, organizationName }: TAdminBootstrapInstanceDTO) => {
const appCfg = getConfig(); const appCfg = getConfig();
const sanitizedEmail = email.trim().toLowerCase();
const serverCfg = await serverCfgDAL.findById(ADMIN_CONFIG_DB_UUID); const serverCfg = await serverCfgDAL.findById(ADMIN_CONFIG_DB_UUID);
if (serverCfg?.initialized) { if (serverCfg?.initialized) {
throw new BadRequestError({ message: "Instance has already been set up" }); throw new BadRequestError({ message: "Instance has already been set up" });
} }
const existingUser = await userDAL.findOne({ email }); const existingUser = await userDAL.findOne({ email: sanitizedEmail });
if (existingUser) throw new BadRequestError({ name: "Instance initialization", message: "User already exists" }); if (existingUser) throw new BadRequestError({ name: "Instance initialization", message: "User already exists" });
const userInfo = await userDAL.transaction(async (tx) => { const userInfo = await userDAL.transaction(async (tx) => {
@@ -361,8 +363,8 @@ export const superAdminServiceFactory = ({
{ {
firstName: "Admin", firstName: "Admin",
lastName: "User", lastName: "User",
username: email, username: sanitizedEmail,
email, email: sanitizedEmail,
superAdmin: true, superAdmin: true,
isGhost: false, isGhost: false,
isAccepted: true, isAccepted: true,
@@ -372,7 +374,7 @@ export const superAdminServiceFactory = ({
tx tx
); );
const { tag, encoding, ciphertext, iv } = infisicalSymmetricEncypt(password); const { tag, encoding, ciphertext, iv } = infisicalSymmetricEncypt(password);
const encKeys = await generateUserSrpKeys(email, password); const encKeys = await generateUserSrpKeys(sanitizedEmail, password);
const userEnc = await userDAL.createUserEncryption( const userEnc = await userDAL.createUserEncryption(
{ {
+42 -8
View File
@@ -8,16 +8,18 @@ import {
TUserEncryptionKeys, TUserEncryptionKeys,
TUserEncryptionKeysInsert, TUserEncryptionKeysInsert,
TUserEncryptionKeysUpdate, TUserEncryptionKeysUpdate,
TUsers TUsers,
UsersSchema
} from "@app/db/schemas"; } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors"; import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols } from "@app/lib/knex"; import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex";
export type TUserDALFactory = ReturnType<typeof userDALFactory>; export type TUserDALFactory = ReturnType<typeof userDALFactory>;
export const userDALFactory = (db: TDbClient) => { export const userDALFactory = (db: TDbClient) => {
const userOrm = ormify(db, TableName.Users); const userOrm = ormify(db, TableName.Users);
const findUserByUsername = async (username: string, tx?: Knex) => userOrm.findOne({ username }, tx); const findUserByUsername = async (username: string, tx?: Knex) =>
(tx || db)(TableName.Users).whereRaw('lower("username") = :username', { username: username.toLowerCase() });
const getUsersByFilter = async ({ const getUsersByFilter = async ({
limit, limit,
@@ -41,7 +43,7 @@ export const userDALFactory = (db: TDbClient) => {
.whereILike("email", `%${searchTerm}%`) .whereILike("email", `%${searchTerm}%`)
.orWhereILike("firstName", `%${searchTerm}%`) .orWhereILike("firstName", `%${searchTerm}%`)
.orWhereILike("lastName", `%${searchTerm}%`) .orWhereILike("lastName", `%${searchTerm}%`)
.orWhereLike("username", `%${searchTerm}%`); .orWhereRaw('lower("username") like ?', `%${searchTerm}%`);
}); });
} }
@@ -65,12 +67,11 @@ export const userDALFactory = (db: TDbClient) => {
try { try {
return await db return await db
.replicaNode()(TableName.Users) .replicaNode()(TableName.Users)
.whereRaw('lower("username") = :username', { username: username.toLowerCase() })
.where({ .where({
username,
isGhost: false isGhost: false
}) })
.join(TableName.UserEncryptionKey, `${TableName.Users}.id`, `${TableName.UserEncryptionKey}.userId`) .join(TableName.UserEncryptionKey, `${TableName.Users}.id`, `${TableName.UserEncryptionKey}.userId`);
.first();
} catch (error) { } catch (error) {
throw new DatabaseError({ error, name: "Find user enc by email" }); throw new DatabaseError({ error, name: "Find user enc by email" });
} }
@@ -168,6 +169,38 @@ export const userDALFactory = (db: TDbClient) => {
} }
}; };
const findAllMyAccounts = async (email: string) => {
try {
const doc = await db(TableName.Users)
.where({ email })
.leftJoin(TableName.OrgMembership, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`)
.leftJoin(TableName.Organization, `${TableName.Organization}.id`, `${TableName.OrgMembership}.orgId`)
.select(selectAllTableCols(TableName.Users))
.select(
db.ref("name").withSchema(TableName.Organization).as("orgName"),
db.ref("slug").withSchema(TableName.Organization).as("orgSlug")
);
const formattedDoc = sqlNestRelationships({
data: doc,
key: "id",
parentMapper: (el) => UsersSchema.parse(el),
childrenMapper: [
{
key: "orgSlug",
label: "organizations" as const,
mapper: ({ orgSlug, orgName }) => ({
slug: orgSlug,
name: orgName
})
}
]
});
return formattedDoc;
} catch (error) {
throw new DatabaseError({ error, name: "Upsert user enc key" });
}
};
// USER ACTION FUNCTIONS // USER ACTION FUNCTIONS
// --------------------- // ---------------------
const findOneUserAction = (filter: TUserActionsUpdate, tx?: Knex) => { const findOneUserAction = (filter: TUserActionsUpdate, tx?: Knex) => {
@@ -200,6 +233,7 @@ export const userDALFactory = (db: TDbClient) => {
createUserEncryption, createUserEncryption,
findOneUserAction, findOneUserAction,
createUserAction, createUserAction,
getUsersByFilter getUsersByFilter,
findAllMyAccounts
}; };
}; };
+36 -87
View File
@@ -9,7 +9,6 @@ import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-se
import { TokenType } from "@app/services/auth-token/auth-token-types"; import { TokenType } from "@app/services/auth-token/auth-token-types";
import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal";
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal";
import { AuthMethod } from "../auth/auth-type"; import { AuthMethod } from "../auth/auth-type";
import { TGroupProjectDALFactory } from "../group-project/group-project-dal"; import { TGroupProjectDALFactory } from "../group-project/group-project-dal";
@@ -21,7 +20,7 @@ type TUserServiceFactoryDep = {
userDAL: Pick< userDAL: Pick<
TUserDALFactory, TUserDALFactory,
| "find" | "find"
| "findOne" | "findUserByUsername"
| "findById" | "findById"
| "transaction" | "transaction"
| "updateById" | "updateById"
@@ -31,8 +30,8 @@ type TUserServiceFactoryDep = {
| "createUserAction" | "createUserAction"
| "findUserEncKeyByUserId" | "findUserEncKeyByUserId"
| "delete" | "delete"
| "findAllMyAccounts"
>; >;
userAliasDAL: Pick<TUserAliasDALFactory, "find" | "insertMany">;
groupProjectDAL: Pick<TGroupProjectDALFactory, "findByUserId">; groupProjectDAL: Pick<TGroupProjectDALFactory, "findByUserId">;
orgMembershipDAL: Pick<TOrgMembershipDALFactory, "find" | "insertMany" | "findOne" | "updateById">; orgMembershipDAL: Pick<TOrgMembershipDALFactory, "find" | "insertMany" | "findOne" | "updateById">;
tokenService: Pick<TAuthTokenServiceFactory, "createTokenForUser" | "validateTokenForUser">; tokenService: Pick<TAuthTokenServiceFactory, "createTokenForUser" | "validateTokenForUser">;
@@ -45,7 +44,6 @@ export type TUserServiceFactory = ReturnType<typeof userServiceFactory>;
export const userServiceFactory = ({ export const userServiceFactory = ({
userDAL, userDAL,
userAliasDAL,
orgMembershipDAL, orgMembershipDAL,
projectMembershipDAL, projectMembershipDAL,
groupProjectDAL, groupProjectDAL,
@@ -54,8 +52,11 @@ export const userServiceFactory = ({
permissionService permissionService
}: TUserServiceFactoryDep) => { }: TUserServiceFactoryDep) => {
const sendEmailVerificationCode = async (username: string) => { const sendEmailVerificationCode = async (username: string) => {
const user = await userDAL.findOne({ username }); // akhilmhdh: case sensitive email resolution
const users = await userDAL.findUserByUsername(username);
const user = users?.length > 1 ? users.find((el) => el.username === username) : users?.[0];
if (!user) throw new NotFoundError({ name: `User with username '${username}' not found` }); if (!user) throw new NotFoundError({ name: `User with username '${username}' not found` });
if (!user.email) if (!user.email)
throw new BadRequestError({ name: "Failed to send email verification code due to no email on user" }); throw new BadRequestError({ name: "Failed to send email verification code due to no email on user" });
if (user.isEmailVerified) if (user.isEmailVerified)
@@ -77,7 +78,10 @@ export const userServiceFactory = ({
}; };
const verifyEmailVerificationCode = async (username: string, code: string) => { const verifyEmailVerificationCode = async (username: string, code: string) => {
const user = await userDAL.findOne({ username }); // akhilmhdh: case sensitive email resolution
const usersByusername = await userDAL.findUserByUsername(username);
const user =
usersByusername?.length > 1 ? usersByusername.find((el) => el.username === username) : usersByusername?.[0];
if (!user) throw new NotFoundError({ name: `User with username '${username}' not found` }); if (!user) throw new NotFoundError({ name: `User with username '${username}' not found` });
if (!user.email) if (!user.email)
throw new BadRequestError({ name: "Failed to verify email verification code due to no email on user" }); throw new BadRequestError({ name: "Failed to verify email verification code due to no email on user" });
@@ -90,84 +94,8 @@ export const userServiceFactory = ({
code code
}); });
const { email } = user; await userDAL.updateById(user.id, {
isEmailVerified: true
await userDAL.transaction(async (tx) => {
await userDAL.updateById(
user.id,
{
isEmailVerified: true
},
tx
);
// check if there are verified users with the same email.
const users = await userDAL.find(
{
email,
isEmailVerified: true
},
{ tx }
);
if (users.length > 1) {
// merge users
const mergeUser = users.find((u) => u.id !== user.id);
if (!mergeUser) throw new NotFoundError({ name: "Failed to find merge user" });
const mergeUserOrgMembershipSet = new Set(
(await orgMembershipDAL.find({ userId: mergeUser.id }, { tx })).map((m) => m.orgId)
);
const myOrgMemberships = (await orgMembershipDAL.find({ userId: user.id }, { tx })).filter(
(m) => !mergeUserOrgMembershipSet.has(m.orgId)
);
const userAliases = await userAliasDAL.find(
{
userId: user.id
},
{ tx }
);
await userDAL.deleteById(user.id, tx);
if (myOrgMemberships.length) {
await orgMembershipDAL.insertMany(
myOrgMemberships.map((orgMembership) => ({
...orgMembership,
userId: mergeUser.id
})),
tx
);
}
if (userAliases.length) {
await userAliasDAL.insertMany(
userAliases.map((userAlias) => ({
...userAlias,
userId: mergeUser.id
})),
tx
);
}
} else {
await userDAL.delete(
{
email,
isAccepted: false,
isEmailVerified: false
},
tx
);
// update current user's username to [email]
await userDAL.updateById(
user.id,
{
username: email
},
tx
);
}
}); });
}; };
@@ -212,6 +140,23 @@ export const userServiceFactory = ({
return updatedUser; return updatedUser;
}; };
const getAllMyAccounts = async (email: string, userId: string) => {
const users = await userDAL.findAllMyAccounts(email);
return users?.map((el) => ({ ...el, isMyAccount: el.id === userId }));
};
const removeMyDuplicateAccounts = async (email: string, userId: string) => {
const users = await userDAL.find({ email });
const duplicatedAccounts = users?.filter((el) => el.id !== userId);
const myAccount = users?.find((el) => el.id === userId);
if (duplicatedAccounts.length && myAccount) {
await userDAL.transaction(async (tx) => {
await userDAL.delete({ $in: { id: duplicatedAccounts?.map((el) => el.id) } }, tx);
await userDAL.updateById(userId, { username: (myAccount.email || myAccount.username).toLowerCase() }, tx);
});
}
};
const getMe = async (userId: string) => { const getMe = async (userId: string) => {
const user = await userDAL.findUserEncKeyByUserId(userId); const user = await userDAL.findUserEncKeyByUserId(userId);
if (!user) throw new NotFoundError({ message: `User with ID '${userId}' not found`, name: "GetMe" }); if (!user) throw new NotFoundError({ message: `User with ID '${userId}' not found`, name: "GetMe" });
@@ -313,9 +258,11 @@ export const userServiceFactory = ({
}; };
const listUserGroups = async ({ username, actorOrgId, actor, actorId, actorAuthMethod }: TListUserGroupsDTO) => { const listUserGroups = async ({ username, actorOrgId, actor, actorId, actorAuthMethod }: TListUserGroupsDTO) => {
const user = await userDAL.findOne({ // akhilmhdh: case sensitive email resolution
username const usersByusername = await userDAL.findUserByUsername(username);
}); const user =
usersByusername?.length > 1 ? usersByusername.find((el) => el.username === username) : usersByusername?.[0];
if (!user) throw new NotFoundError({ name: `User with username '${username}' not found` });
// This makes it so the user can always read information about themselves, but no one else if they don't have the Members Read permission. // This makes it so the user can always read information about themselves, but no one else if they don't have the Members Read permission.
if (user.id !== actorId) { if (user.id !== actorId) {
@@ -346,7 +293,9 @@ export const userServiceFactory = ({
getUserAction, getUserAction,
unlockUser, unlockUser,
getUserPrivateKey, getUserPrivateKey,
getAllMyAccounts,
getUserProjectFavorites, getUserProjectFavorites,
removeMyDuplicateAccounts,
updateUserProjectFavorites updateUserProjectFavorites
}; };
}; };
+6
View File
@@ -884,6 +884,12 @@ func (tm *AgentManager) MonitorSecretChanges(secretTemplate Template, templateId
if err != nil { if err != nil {
log.Error().Msgf("unable to process template because %v", err) log.Error().Msgf("unable to process template because %v", err)
// case: if exit-after-auth is true, it should exit the agent once an error on secret fetching occurs with the appropriate exit code (1)
// previous behavior would exit after 25 sec with status code 0, even if this step errors
if tm.exitAfterAuth {
os.Exit(1)
}
} else { } else {
if (existingEtag != currentEtag) || firstRun { if (existingEtag != currentEtag) || firstRun {
+12 -3
View File
@@ -6,9 +6,14 @@ description: "The guide to spending money at Infisical."
Fairly frequently, you might run into situations when you need to spend company money. Fairly frequently, you might run into situations when you need to spend company money.
<Note>
Please spend money in a way that you think is in the best interest of the company. # Expensing Meals
</Note>
As a perk of working at Infisical, we cover some of your meal expenses.
HQ team members: meals and unlimited snacks are provided on-site at no cost.
Remote team members: a food stipend is allocated based on location.
# Trivial expenses # Trivial expenses
@@ -18,6 +23,10 @@ This means expenses that are:
1. Non-recurring AND less than $75/month in total. 1. Non-recurring AND less than $75/month in total.
2. Recurring AND less than $20/month. 2. Recurring AND less than $20/month.
<Note>
Please spend money in a way that you think is in the best interest of the company.
</Note>
## Saving receipts ## Saving receipts
Make sure you keep copies for all receipts. If you expense something on a company card and cannot provide a receipt, this may be deducted from your pay. Make sure you keep copies for all receipts. If you expense something on a company card and cannot provide a receipt, this may be deducted from your pay.
@@ -38,7 +38,7 @@ Enabling HSM encryption has a set of key benefits:
### Requirements ### Requirements
- An Infisical instance with a version number that is equal to or greater than `v0.91.0`. - An Infisical instance with a version number that is equal to or greater than `v0.91.0`.
- If you are using Docker, your instance must be using the `infisical/infisical-fips` image. - If you are using Docker, your instance must be using the `infisical/infisical-fips` image.
- An HSM device from a provider such as [Thales Luna HSM](https://cpl.thalesgroup.com/encryption/data-protection-on-demand/services/luna-cloud-hsm), [AWS CloudHSM](https://aws.amazon.com/cloudhsm/), or others. - An HSM device from a provider such as [Thales Luna HSM](https://cpl.thalesgroup.com/encryption/data-protection-on-demand/services/luna-cloud-hsm), [AWS CloudHSM](https://aws.amazon.com/cloudhsm/), [Fortanix HSM](https://www.fortanix.com/platform/data-security-manager), or others.
### FIPS Compliance ### FIPS Compliance
@@ -53,14 +53,14 @@ For organizations that work with US government agencies, FIPS compliance is almo
<Steps> <Steps>
<Step title="Setting up an HSM Device"> <Step title="Setting up an HSM Device">
To set up HSM encryption, you need to configure an HSM provider and HSM key. The HSM provider is used to connect to the HSM device, and the HSM key is used to encrypt Infisical's KMS keys. We recommend using a Cloud HSM provider such as [Thales Luna HSM](https://cpl.thalesgroup.com/encryption/data-protection-on-demand/services/luna-cloud-hsm) or [AWS CloudHSM](https://aws.amazon.com/cloudhsm/). To set up HSM encryption, you need to configure an HSM provider and HSM key. The HSM provider is used to connect to the HSM device, and the HSM key is used to encrypt Infisical's KMS keys. We recommend using a Cloud HSM provider such as [Thales Luna HSM](https://cpl.thalesgroup.com/encryption/data-protection-on-demand/services/luna-cloud-hsm), [AWS CloudHSM](https://aws.amazon.com/cloudhsm/), or [Fortanix HSM](https://www.fortanix.com/platform/data-security-manager).
You need to follow the instructions provided by the HSM provider to set up the HSM device. Once the HSM device is set up, the HSM device can be used within Infisical. You need to follow the instructions provided by the HSM provider to set up the HSM device. Once the HSM device is set up, the HSM device can be used within Infisical.
After setting up the HSM from your provider, you will have a set of files that you can use to access the HSM. These files need to be present on the machine where Infisical is running. After setting up the HSM from your provider, you will have a set of files that you can use to access the HSM. These files need to be present on the machine where Infisical is running.
If you are using containers, you will need to mount the folder where these files are stored as a volume in the container. If you are using containers, you will need to mount the folder where these files are stored as a volume in the container.
The setup process for an HSM device varies depending on the provider. We have created a guide for Thales Luna Cloud HSM, which you can find below. The setup process for an HSM device varies depending on the provider. We have created guides for Thales Luna Cloud HSM and Fortanix HSM, which you can find below.
</Step> </Step>
<Step title="Configure HSM on Infisical"> <Step title="Configure HSM on Infisical">
@@ -255,6 +255,78 @@ For organizations that work with US government agencies, FIPS compliance is almo
</Steps> </Steps>
After following these steps, your Docker setup will be ready to use HSM encryption. After following these steps, your Docker setup will be ready to use HSM encryption.
</Tab> </Tab>
<Tab title="Fortanix HSM">
<Steps>
<Step title="Set up Fortanix HSM">
To use Fortanix HSM with Infisical, you need to:
1. Create an App in Fortanix:
- Set Interface value to be PKCS#11
- Select API key as authentication method
- Assign app to a group
![Fortanix HSM Setup](/images/platform/kms/hsm/fortanix-hsm-setup.png)
2. Take note of the domain (e.g., apac.smartkey.io). You will need this to set up the configuration file for the Fortanix client.
</Step>
<Step title="Install PKCS11 Library">
The easiest approach would be to download the `.so` file for Linux directly from the [Fortanix PKCS#11 installation page](https://fortanix.zendesk.com/hc/en-us/sections/4408769080724-PKCS-11).
Create a configuration file named `pkcs11.conf` with the following content:
```
api_endpoint = "https://apac.smartkey.io"
prevent_duplicate_opaque_objects = true
retry_timeout_millis = 60000
```
Note: Replace `apac.smartkey.io` with your actual Fortanix domain if different. For more details about the configuration file format and additional options, refer to the [Fortanix PKCS#11 Configuration File Documentation](https://support.fortanix.com/docs/clients-pkcs11-library#511-configuration-file-format).
</Step>
<Step title="Create a directory for Fortanix files">
Create a directory to store the Fortanix library and configuration file:
```bash
mkdir -p /etc/fortanix-hsm
```
Copy the downloaded `.so` file and the `pkcs11.conf` file to this directory:
```bash
cp /path/to/fortanix_pkcs11_4.37.2554.so /etc/fortanix-hsm/
cp /path/to/pkcs11.conf /etc/fortanix-hsm/
```
</Step>
<Step title="Run Docker">
Run Docker with Fortanix HSM by mounting the directory and setting the required environment variables:
```bash
docker run -p 80:8080 \
-v /etc/fortanix-hsm:/etc/fortanix-hsm \
-e HSM_LIB_PATH="/etc/fortanix-hsm/fortanix_pkcs11_4.37.2554.so" \ # Path to the PKCS#11 library
-e HSM_PIN="MDE3YWUxO..." \ # Your Fortanix app API key used for authentication
-e HSM_SLOT=0 \ # Slot value (arbitrary for Fortanix HSM)
-e HSM_KEY_LABEL="hsm-key-label" \ # Label to identify the encryption key in the HSM
-e FORTANIX_PKCS11_CONFIG_PATH="/etc/fortanix-hsm/pkcs11.conf" \ # Path to Fortanix configuration file
# The rest are unrelated to HSM setup...
-e ENCRYPTION_KEY="<>" \
-e AUTH_SECRET="<>" \
-e DB_CONNECTION_URI="<>" \
-e REDIS_URL="<>" \
-e SITE_URL="<>" \
infisical/infisical-fips:<version> # Replace <version> with the version you want to use
```
<Warning>
Note: Fortanix HSM integration only works for AMD64 CPU architectures.
</Warning>
</Step>
</Steps>
After following these steps, your Docker setup will be ready to use Fortanix HSM encryption.
</Tab>
</Tabs> </Tabs>
</Tab> </Tab>
<Tab title="Kubernetes"> <Tab title="Kubernetes">
@@ -569,6 +641,173 @@ For organizations that work with US government agencies, FIPS compliance is almo
</Steps> </Steps>
After following these steps, your Kubernetes setup will be ready to use HSM encryption. After following these steps, your Kubernetes setup will be ready to use HSM encryption.
</Tab> </Tab>
<Tab title="Fortanix HSM">
<Steps>
<Step title="Set up Fortanix HSM">
First, you need to set up Fortanix HSM by:
1. Creating an App in Fortanix:
- Set Interface value to be PKCS#11
- Select API key as authentication method
- Assign app to a group
![Fortanix HSM Setup](/images/platform/kms/hsm/fortanix-hsm-setup.png)
2. Take note of the domain (e.g., apac.smartkey.io). You will need this when setting up the configuration file.
</Step>
<Step title="Create configuration files">
Create a directory to store the Fortanix configuration files:
```bash
mkdir -p /etc/fortanix-hsm
```
Download the Fortanix PKCS#11 library for Linux from the [Fortanix PKCS#11 installation page](https://fortanix.zendesk.com/hc/en-us/sections/4408769080724-PKCS-11).
Create a configuration file named `pkcs11.conf` with the following content:
```
api_endpoint = "https://apac.smartkey.io"
prevent_duplicate_opaque_objects = true
retry_timeout_millis = 60000
```
Note: Replace `apac.smartkey.io` with your actual Fortanix domain if different.
</Step>
<Step title="Creating a Persistent Volume Claim (PVC)">
Create a Persistent Volume Claim to store the Fortanix files:
```bash
kubectl apply -f - <<EOF
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: fortanix-hsm-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 100Mi
EOF
```
Create a temporary pod to upload the files:
```bash
kubectl apply -f - <<EOF
apiVersion: v1
kind: Pod
metadata:
name: fortanix-setup-pod
spec:
containers:
- name: setup
image: busybox
command: ["/bin/sh", "-c", "sleep 3600"]
volumeMounts:
- name: fortanix-data
mountPath: /data
volumes:
- name: fortanix-data
persistentVolumeClaim:
claimName: fortanix-hsm-pvc
EOF
```
Ensure the pod is running:
```bash
kubectl wait --for=condition=Ready pod/fortanix-setup-pod --timeout=60s
```
Copy the Fortanix files to the PVC:
```bash
kubectl exec fortanix-setup-pod -- mkdir -p /data/
kubectl cp /etc/fortanix-hsm/fortanix_pkcs11_4.37.2554.so fortanix-setup-pod:/data/
kubectl cp /etc/fortanix-hsm/pkcs11.conf fortanix-setup-pod:/data/
kubectl exec fortanix-setup-pod -- chmod -R 755 /data/
```
Delete the temporary pod:
```bash
kubectl delete pod fortanix-setup-pod
```
</Step>
<Step title="Update the Kubernetes Secret">
Update your Kubernetes secret with the Fortanix HSM environment variables:
```yaml
apiVersion: v1
kind: Secret
metadata:
name: infisical-secrets
type: Opaque
stringData:
# ... Other environment variables ...
HSM_LIB_PATH: "/etc/fortanix-hsm/fortanix_pkcs11_4.37.2554.so" # Path to the PKCS#11 library in the container
HSM_PIN: "<your-fortanix-api-key>" # Your Fortanix app API key used for authentication
HSM_SLOT: "0" # Slot value (can be set to 0 for Fortanix HSM as it's arbitrary)
HSM_KEY_LABEL: "hsm-key-label" # Label to identify the encryption key in the HSM
FORTANIX_PKCS11_CONFIG_PATH: "/etc/fortanix-hsm/pkcs11.conf" # Path to Fortanix configuration file
```
Apply the updated secret:
```bash
kubectl apply -f ./secret-file-name.yaml
```
</Step>
<Step title="Update Helm Values">
Update your Helm values to use the FIPS-compliant image and mount the Fortanix HSM files:
```yaml
# ... The rest of the values.yaml file ...
image:
repository: infisical/infisical-fips # Must use "infisical/infisical-fips"
tag: "v0.117.1-postgres"
pullPolicy: IfNotPresent
extraVolumeMounts:
- name: fortanix-data
mountPath: /etc/fortanix-hsm # The path where Fortanix files will be available
extraVolumes:
- name: fortanix-data
persistentVolumeClaim:
claimName: fortanix-hsm-pvc
# ... The rest of the values.yaml file ...
```
<Warning>
Note: Fortanix HSM integration only works for AMD64 CPU architectures.
</Warning>
</Step>
<Step title="Upgrade and Restart">
Upgrade the Helm chart with the new values:
```bash
helm upgrade --install infisical infisical-helm-charts/infisical-standalone --values /path/to/values.yaml
```
Restart the deployment:
```bash
kubectl rollout restart deployment/infisical-infisical
```
</Step>
</Steps>
After following these steps, your Kubernetes setup will be ready to use Fortanix HSM encryption.
</Tab>
</Tabs> </Tabs>
</Tab> </Tab>
</Tabs> </Tabs>
@@ -28,7 +28,7 @@ description: "Learn how to automatically rotate LDAP passwords."
3. Select the **LDAP Connection** to use and configure the rotation behavior. Then click **Next**. 3. Select the **LDAP Connection** to use and configure the rotation behavior. Then click **Next**.
![Rotation Configuration](/images/secret-rotations-v2/ldap-password/ldap-password-configuration.png) ![Rotation Configuration](/images/secret-rotations-v2/ldap-password/ldap-password-configuration.png)
- **LDAP Connection** - the connection that will perform the rotation of the configured DN's password. - **LDAP Connection** - the connection that will perform the rotation of the configured principal's password.
<Note> <Note>
LDAP Password Rotations require an LDAP Connection that uses ldaps:// protocol. LDAP Password Rotations require an LDAP Connection that uses ldaps:// protocol.
</Note> </Note>
@@ -40,13 +40,20 @@ description: "Learn how to automatically rotate LDAP passwords."
</Note> </Note>
4. Specify the Distinguished Name (DN) of the principal whose password you want to rotate and configure the password requirements. Then click **Next**. 4. Configure the required Parameters for your rotation. Then click **Next**.
![Rotation Parameters](/images/secret-rotations-v2/ldap-password/ldap-password-parameters.png) ![Rotation Parameters](/images/secret-rotations-v2/ldap-password/ldap-password-parameters.png)
- **Rotation Method** - The method to use when rotating the target principal's password.
- **Connection Principal** - Infisical will use the LDAP Connection's binding principal to rotate the target principal's password.
- **Target Principal** - Infisical will bind with the target Principal to rotate their own password.
- **DN/UPN** - The Distinguished Name (DN), or User Principal Name (UPN) if supported, of the principal whose password you want to rotate.
- **Password** - The target principal's password (if **Rotation Method** is set to **Target Principal**).
- **Password Requirements** - The constraints to apply when generating new passwords.
5. Specify the secret names that the client credentials should be mapped to. Then click **Next**. 5. Specify the secret names that the client credentials should be mapped to. Then click **Next**.
![Rotation Secrets Mapping](/images/secret-rotations-v2/ldap-password/ldap-password-secrets-mapping.png) ![Rotation Secrets Mapping](/images/secret-rotations-v2/ldap-password/ldap-password-secrets-mapping.png)
- **DN** - the name of the secret that the principal's Distinguished Name (DN) will be mapped to. - **DN/UPN** - the name of the secret that the principal's Distinguished Name (DN) or User Principal Name (UPN) will be mapped to.
- **Password** - the name of the secret that the rotated password will be mapped to. - **Password** - the name of the secret that the rotated password will be mapped to.
6. Give your rotation a name and description (optional). Then click **Next**. 6. Give your rotation a name and description (optional). Then click **Next**.
@@ -85,6 +92,7 @@ description: "Learn how to automatically rotate LDAP passwords."
"minutes": 0 "minutes": 0
}, },
"parameters": { "parameters": {
"rotationMethod": "connection-principal",
"dn": "CN=John,CN=Users,DC=example,DC=com", "dn": "CN=John,CN=Users,DC=example,DC=com",
"passwordRequirements": { "passwordRequirements": {
"length": 48, "length": 48,
@@ -154,6 +162,7 @@ description: "Learn how to automatically rotate LDAP passwords."
"lastRotationMessage": null, "lastRotationMessage": null,
"type": "ldap-password", "type": "ldap-password",
"parameters": { "parameters": {
"rotationMethod": "connection-principal",
"dn": "CN=John,CN=Users,DC=example,DC=com", "dn": "CN=John,CN=Users,DC=example,DC=com",
"passwordRequirements": { "passwordRequirements": {
"length": 48, "length": 48,
Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 758 KiB

After

Width:  |  Height:  |  Size: 778 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 782 KiB

After

Width:  |  Height:  |  Size: 791 KiB

+1 -1
View File
@@ -10,7 +10,7 @@ Infisical supports the use of [Simple Binding](https://ldap.com/the-ldap-bind-op
You will need the following information to establish an LDAP connection: You will need the following information to establish an LDAP connection:
- **LDAP URL** - The LDAP/LDAPS URL to connect to (e.g., ldap://domain-or-ip:389 or ldaps://domain-or-ip:636) - **LDAP URL** - The LDAP/LDAPS URL to connect to (e.g., ldap://domain-or-ip:389 or ldaps://domain-or-ip:636)
- **Binding DN** - The Distinguished Name (DN) of the principal to bind with (e.g., 'CN=John,CN=Users,DC=example,DC=com') - **Binding DN/UPN** - The Distinguished Name (DN), or User Principal Name (UPN) if supported, of the principal to bind with (e.g., 'CN=John,CN=Users,DC=example,DC=com')
- **Binding Password** - The password to bind with for authentication - **Binding Password** - The password to bind with for authentication
- **CA Certificate** - The SSL certificate (PEM format) to use for secure connection when using ldaps:// with a self-signed certificate - **CA Certificate** - The SSL certificate (PEM format) to use for secure connection when using ldaps:// with a self-signed certificate
@@ -1,6 +1,6 @@
--- ---
title: "Kubernetes CSI" title: "Kubernetes CSI"
description: "How to use Infisical to inject secrets directly into Kubernetes pods." description: "How to use the Infisical Kubernetes CSI provider to inject secrets directly into Kubernetes pods."
--- ---
## Overview ## Overview
@@ -15,9 +15,9 @@ flowchart LR
CSP --> CSD(Secrets Store CSI Driver) CSP --> CSD(Secrets Store CSI Driver)
end end
subgraph Application subgraph Pod
CSD --> V(Volume) CSD --> V(Volume)
V <--> P(Pod) V <--> P(Application)
end end
``` ```
@@ -0,0 +1,317 @@
---
title: "Kubernetes Agent Injector"
description: "How to use the Infisical Kubernetes Agent Injector to inject secrets directly into Kubernetes pods."
---
## Overview
The Infisical Kubernetes Agent Injector allows you to inject secrets directly into your Kubernetes pods. The Injector will create a [Infisical Agent](/integrations/platforms/infisical-agent) container within your pod that syncs secrets from Infisical into a shared volume mount within your pod.
The Infisical Agent Injector will patch and modify your pod's deployment to contain an [Infisical Agent](/integrations/platforms/infisical-agent) container which renders your Infisical secrets into a shared volume mount within your pod.
The Infisical Agent Injector is built on [Kubernetes Mutating Admission Webhooks](https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers), and will watch for `CREATE` and `UPDATE` events on pods in your cluster.
The injector is namespace-agnostic, and will watch for pods in any namespace, but will only patch pods that have the `org.infisical.com/inject` annotation set to `true`.
```mermaid
flowchart LR
subgraph Secrets Management
SS(Infisical) --> INJ(Infisical Injector)
end
subgraph Pod
INJ --> INIT(Agent Init Container)
INIT --> V(Volume)
V <--> P(Application)
end
```
## Install the Infisical Agent Injector
To install the Infisical Agent Injector, you will need to install our helm charts using [Helm](https://helm.sh/).
```bash
helm repo add infisical-helm-charts 'https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/'
helm repo update
helm install --generate-name infisical-helm-charts/infisical-agent-injector
```
After installing the helm chart you can verify that the injector is running and working as intended by checking the logs of the injector pod.
```bash
$ kubectl logs deployment/infisical-agent-injector
2025/05/19 14:20:05 Starting infisical-agent-injector...
2025/05/19 14:20:05 Generating self-signed certificate...
2025/05/19 14:20:06 Creating directory: /tmp/tls
2025/05/19 14:20:06 Writing cert to: /tmp/tls/tls.crt
2025/05/19 14:20:06 Writing key to: /tmp/tls/tls.key
2025/05/19 14:20:06 Starting HTTPS server on port 8585...
2025/05/19 14:20:06 Attempting to update webhook config (attempt 1)...
2025/05/19 14:20:06 Successfully updated webhook configuration with CA bundle
```
## Supported annotations
The Infisical Agent Injector supports the following annotations:
<Accordion title="org.infisical.com/inject">
The inject annotation is used to enable the injector on a pod. Set the value to `true` and the pod will be patched with an Infisical Agent container on update or create.
</Accordion>
<Accordion title="org.infisical.com/inject-mode">
The inject mode annotation is used to specify the mode to use to inject the secrets into the pod. Currently only `init` mode is supported.
- `init`: The init method will create an init container for the pod that will render the secrets into a shared volume mount within the pod. The agent init container will run before any other containers in the pod runs, including other init containers.
</Accordion>
<Accordion title="org.infisical.com/agent-config-map">
The agent config map annotation is used to specify the name of the config map that contains the configuration for the injector. The config map must be in the same namespace as the pod.
</Accordion>
## ConfigMap Configuration
### Supported Fields
When you are configuring a pod to use the injector, you must create a config map in the same namespace as the pod you want to inject secrets into.
The entire config needs to be of string format and needs to be assigned to the `config.yaml` key in the config map. You can find a full example of the config at the end of this section.
<Accordion title="infisical.address">
The address of your Infisical instance. This field is optional and will default to `https://app.infisical.com` if not provided.
</Accordion>
<Accordion title="infisical.auth.type">
The authentication type to use to connect to Infisical. Currently only the `kubernetes` authentication type is supported.
You can refer to our [Kubernetes Auth](/documentation/platform/identities/kubernetes-auth) documentation for more information on how to create a machine identity for Kubernetes Auth.
Please note that the pod's default service account will be used to authenticate with Infisical.
</Accordion>
<Accordion title="infisical.auth.config.identity-id">
The ID of the machine identity to use to connect to Infisical. This field is required if the `infisical.auth.type` is set to `kubernetes`.
</Accordion>
<Accordion title="templates[]">
The templates hold an array of templates that will be rendered and injected into the pod.
</Accordion>
<Accordion title="templates[].destination-path">
The path to inject the secrets into within the pod.
If not specified, this will default to `/shared/infisical-secrets`. If you have multiple templates and don't provide a destination path, the destination paths will default to `/shared/infisical-secrets-1`, `/shared/infisical-secrets-2`, etc.
</Accordion>
<Accordion title="templates[].template-content">
The content of the template to render.
This will be rendered as a [Go Template](https://pkg.go.dev/text/template) and will have access to the following variables.
It follows the templating format and supports the same functions as the [Infisical Agent](/integrations/platforms/infisical-agent#quick-start-infisical-agent)
</Accordion>
### Authentication
The Infisical Agent Injector only supports Machine Identity [Kubernetes Auth](/documentation/platform/identities/kubernetes-auth) authentication at the moment.
To configure Kubernetes Auth, you need to set the `auth.type` field to `kubernetes` and set the `auth.config.identity-id` to the ID of the machine identity you wish to use for authentication.
```yaml
auth:
type: "kubernetes"
config:
identity-id: "<your-infisical-machine-identity-id>"
```
### Example ConfigMap
```yaml config-map.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: demo-config-map
data:
config.yaml: |
infisical:
address: "https://app.infisical.com"
auth:
type: "kubernetes"
config:
identity-id: "<your-infisical-machine-identity-id>"
templates:
- destination-path: "/path/to/save/secrets/file.txt"
template-content: |
{{- with secret "<your-project-id>" "dev" "/" }}
{{- range . }}
{{ .Key }}={{ .Value }}
{{- end }}
{{- end }}
```
```bash
kubectl apply -f config-map.yaml
```
To use the config map in your pod, you will need to add the `org.infisical.com/agent-config-map` annotation to your pod's deployment. The value of the annotation is the name of the config map you created above.
```yaml
apiVersion: v1
kind: Pod
metadata:
name: demo
labels:
app: demo
annotations:
org.infisical.com/inject: "true" # Set to true for the injector to patch the pod on create/update events
org.infisical.com/inject-mode: "init" # The mode to use to inject the secrets into the pod. Currently only `init` mode is supported.
org.infisical.com/agent-config-map: "name-of-config-map" # The name of the config map that you created above, which contains all the settings for injecting the secrets into the pod
spec:
# ...
```
## Quick Start
In this section we'll walk through a full example of how to inject secrets into a pod using the Infisical Agent Injector.
In this example we'll create a basic nginx deployment and print a Infisical secret called `API_KEY` to the container logs.
### Create secrets in Infisical
First you'll need to create the secret in Infisical.
- `API_KEY`: The API key to use for the nginx deployment.
Once you've created the secret, save your project ID, environment slug, and secret path, as these will be used in the next step.
### Configuration
To use the injector you must create a config map in the same namespace as the pod you want to inject secrets into. In this example we'll create a config map in the `test-namespace` namespace.
The agent injector will authenticate with Infisical using a [Kubernetes Auth](/documentation/platform/identities/kubernetes-auth) machine identity. Please follow the [instructions](/documentation/platform/identities/kubernetes-auth) to create a machine identity configured for Kubernetes Auth.
The agent injector will use the service account token of the pod to authenticate with Infisical.
The `template-content` will be rendered as a [Go Template](https://pkg.go.dev/text/template) and will have access to the following variables. It follows the templating format and supports the same functions as the [Infisical Agent](/integrations/platforms/infisical-agent#quick-start-infisical-agent)
The `destination-path` refers to the path within the pod that the secrets will be injected into. In this case we're injecting the secrets into a file called `/infisical/secrets`.
Replace the `<your-project-id>`, `<your-environment-slug>`, with your project ID and the environment slug of where you created your secrets in Infisical. Replace `<your-infisical-machine-identity-id>` with the ID of your machine identity configured for Kubernetes Auth.
```yaml config-map.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: nginx-infisical-config-map
namespace: test-namespace
data:
config.yaml: |
infisical:
address: "https://app.infisical.com"
auth:
type: "kubernetes"
config:
identity-id: "<your-infisical-machine-identity-id>"
templates:
- destination-path: "/infisical/secrets"
template-content: |
{{- with secret "<your-project-id>" "<your-environment-slug>" "/" }}
{{- range . }}
{{ .Key }}={{ .Value }}
{{- end }}
{{- end }}
```
Now apply the config map:
```bash
kubectl apply -f config-map.yaml
```
### Injecting secrets into your pod
To inject secrets into your pod, you will need to add the `org.infisical.com/inject: "true"` annotation to your pod's deployment.
The `org.infisical.com/agent-config-map` annotation will point to the config map we created in the previous step. It's important that the config map is in the same namespace as the pod.
We are creating a nginx deployment with a PVC to store the database data.
```yaml nginx.yaml
---
apiVersion: v1
kind: Pod
metadata:
name: nginx-pod
namespace: test-namespace
labels:
app: nginx
annotations:
org.infisical.com/inject: "true"
org.infisical.com/inject-mode: "init"
org.infisical.com/agent-config-map: "nginx-infisical-config-map"
spec:
containers:
- name: simple-app-demo
image: nginx:alpine
command: ["/bin/sh", "-c"]
args:
- |
export $(cat /infisical/secrets | xargs)
echo "API_KEY is set to: $API_KEY"
nginx -g "daemon off;"
```
### Applying the deployment
To apply the deployment, you can use the following command:
```bash
kubectl apply -f nginx.yaml
```
It may take a few minutes for the pod to be ready and for the Infisical secrets to be injected. You can check the status of the pod by running:
```bash
kubectl get pods -n test-namespace
```
### Verifying the secrets are injected
To verify the secrets are injected, you can check the pod's logs:
```bash
$ kubectl exec -it pod/nginx-pod -n test-namespace -- cat /infisical/secrets
Defaulted container "simple-app-demo" out of: simple-app-demo, infisical-agent-init (init)
API_KEY=sk_api_... # The secret you created in Infisical
```
Additionally you can now check that the `API_KEY` secret is being logged to the nginx container logs:
```bash
$ kubectl logs pod/nginx-pod -n test-namespace
Defaulted container "simple-app-demo" out of: simple-app-demo, infisical-agent-init (init)
API_KEY is set to: sk_api_... # The secret you created in Infisical
```
## Troubleshooting
<Accordion title="The pod is stuck in `Init` state">
If the pod is stuck in `Init` state, it means the Agent init container is failing to start or is stuck in a restart loop.
This could be due to a number of reasons, such as the machine identity not having the correct permissions, or trying to fetch secrets from a non-existent project/environment.
You can check the logs of the infisical init container by running:
```bash
# For deployments
kubectl logs deployment/your-deployment-name -c infisical-agent-init -n "<namespace>"
# For pods
kubectl logs pod/your-pod-name -c infisical-agent-init -n "<namespace>"
```
You can also check the logs of the pod by running:
```bash
kubectl logs deployment/postgres-deployment -n test-namespace
```
When checking the logs of the agent init container, you may see something like the following:
```bash
Starting infisical agent...
11:10AM INF starting Infisical agent...
11:10AM INF Infisical instance address set to https://daniel1.tunn.dev
11:10AM INF template engine started for template 1...
11:10AM INF attempting to authenticate...
11:10AM INF new access token saved to file at path '/home/infisical/config/identity-access-token'
11:10AM ERR unable to process template because template: literalTemplate:1:9: executing "literalTemplate" at <secret "3c0d3ff6-165c-4dc9-b52c-ff3ffaedfce311111" "dev" "/">: error calling secret: CallGetRawSecretsV3: Unsuccessful response [GET https://daniel1.tunn.dev/api/v3/secrets/raw?environment=dev&expandSecretReferences=true&include_imports=true&secretPath=%2F&workspaceId=3c0d3ff6-165c-4dc9-b52c-ff3ffaedfce311111] [status-code=404] [response={"reqId":"req-ljqNq567jchFrK","statusCode":404,"message":"Project with ID '3c0d3ff6-165c-4dc9-b52c-ff3ffaedfce311111' not found during bot lookup. Are you sure you are using the correct project ID?","error":"NotFound"}]
+ echo 'Agent failed with exit code 1'
+ exit 1
Agent failed with exit code 1
```
In the above error, the project ID was invalid in the config map.
</Accordion>
+3 -1
View File
@@ -10,8 +10,10 @@ We value reports that help identify vulnerabilities that affect the integrity of
### How to Report ### How to Report
- Send reports to **[email protected]** with clear steps to reproduce, impact, and (if possible) a proof-of-concept. - Send reports to **[email protected]** with clear steps to reproduce, impact, and (if possible) a proof-of-concept.
- We will acknowledge receipt within 3 business days. - We will acknowledge receipt within 3 business days for reports that are clearly written, technically sound, and plausibly within scope.
- We'll provide an initial assessment or next steps within 5 business days. - We'll provide an initial assessment or next steps within 5 business days.
- **Please note**: We do not respond to spam, auto generated reports, inaccurate claims, or submissions that are clearly out of scope.
### What's in Scope? ### What's in Scope?
@@ -178,12 +178,13 @@ Supports conditions and permission inversion
#### Subject: `secret-approval` #### Subject: `secret-approval`
| Action | Description | | Action | Description |
| -------- | ----------------------------------- | | --------------------- | ---------------------------------------------------------------------------- |
| `read` | View approval policies and requests | | `read` | View approval policies and requests |
| `create` | Create new approval policies | | `create` | Create new approval policies |
| `edit` | Modify approval policies | | `edit` | Modify approval policies |
| `delete` | Remove approval policies | | `delete` | Remove approval policies |
| `allow-change-bypass` | Allow request creators to bypass policy in break-glass situations |
#### Subject: `secret-rotation` #### Subject: `secret-rotation`
+1
View File
@@ -441,6 +441,7 @@
"integrations/platforms/kubernetes/infisical-dynamic-secret-crd" "integrations/platforms/kubernetes/infisical-dynamic-secret-crd"
] ]
}, },
"integrations/platforms/kubernetes-injector",
"integrations/platforms/kubernetes-csi", "integrations/platforms/kubernetes-csi",
"integrations/platforms/docker-swarm-with-agent", "integrations/platforms/docker-swarm-with-agent",
"integrations/platforms/ecs-with-agent" "integrations/platforms/ecs-with-agent"
@@ -32,7 +32,7 @@ const formSchema = z.object({
environments: z environments: z
.object({ .object({
name: z.string().trim().min(1), name: z.string().trim().min(1),
slug: slugSchema({ min: 1, max: 32 }) slug: slugSchema({ min: 1, max: 64 })
}) })
.array() .array()
.nullish() .nullish()
@@ -21,7 +21,7 @@ import {
import { slugSchema } from "@app/lib/schemas"; import { slugSchema } from "@app/lib/schemas";
const formSchema = z.object({ const formSchema = z.object({
name: slugSchema({ min: 1, max: 32, field: "Name" }), name: slugSchema({ min: 1, max: 64, field: "Name" }),
description: z.string().max(500).optional() description: z.string().max(500).optional()
}); });
@@ -18,9 +18,7 @@ export const ViewLdapPasswordRotationGeneratedCredentials = ({
<ViewRotationGeneratedCredentialsDisplay <ViewRotationGeneratedCredentialsDisplay
activeCredentials={ activeCredentials={
<> <>
<CredentialDisplay label="Distinguished Name (DN)"> <CredentialDisplay label="DN/UPN">{activeCredentials?.dn}</CredentialDisplay>
{activeCredentials?.dn}
</CredentialDisplay>
<CredentialDisplay isSensitive label="Password"> <CredentialDisplay isSensitive label="Password">
{activeCredentials?.password} {activeCredentials?.password}
</CredentialDisplay> </CredentialDisplay>
@@ -28,9 +26,7 @@ export const ViewLdapPasswordRotationGeneratedCredentials = ({
} }
inactiveCredentials={ inactiveCredentials={
<> <>
<CredentialDisplay label="Distinguished Name (DN)"> <CredentialDisplay label="DN/UPN">{inactiveCredentials?.dn}</CredentialDisplay>
{inactiveCredentials?.dn}
</CredentialDisplay>
<CredentialDisplay isSensitive label="Password"> <CredentialDisplay isSensitive label="Password">
{inactiveCredentials?.password} {inactiveCredentials?.password}
</CredentialDisplay> </CredentialDisplay>
@@ -48,7 +48,8 @@ const FORM_TABS: { name: string; key: string; fields: (keyof TSecretRotationV2Fo
"rotateAtUtc" "rotateAtUtc"
] ]
}, },
{ name: "Parameters", key: "parameters", fields: ["parameters"] }, // @ts-expect-error temporary parameters aren't present on all forms
{ name: "Parameters", key: "parameters", fields: ["parameters", "temporaryParameters"] },
{ name: "Mappings", key: "secretsMapping", fields: ["secretsMapping"] }, { name: "Mappings", key: "secretsMapping", fields: ["secretsMapping"] },
{ name: "Details", key: "details", fields: ["name", "description"] }, { name: "Details", key: "details", fields: ["name", "description"] },
{ name: "Review", key: "review", fields: [] } { name: "Review", key: "review", fields: [] }
@@ -75,7 +76,7 @@ export const SecretRotationV2Form = ({
const { rotationOption } = useSecretRotationV2Option(type); const { rotationOption } = useSecretRotationV2Option(type);
const formMethods = useForm<TSecretRotationV2Form>({ const formMethods = useForm<TSecretRotationV2Form>({
resolver: zodResolver(SecretRotationV2FormSchema), resolver: zodResolver(SecretRotationV2FormSchema(Boolean(secretRotation))),
defaultValues: secretRotation defaultValues: secretRotation
? { ? {
...secretRotation, ...secretRotation,
@@ -2,40 +2,135 @@ import { Controller, useFormContext } from "react-hook-form";
import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas"; import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas";
import { DEFAULT_PASSWORD_REQUIREMENTS } from "@app/components/secret-rotations-v2/forms/schemas/shared"; import { DEFAULT_PASSWORD_REQUIREMENTS } from "@app/components/secret-rotations-v2/forms/schemas/shared";
import { FormControl, Input } from "@app/components/v2"; import { FormControl, Input, Select, SelectItem } from "@app/components/v2";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
import { LdapPasswordRotationMethod } from "@app/hooks/api/secretRotationsV2/types/ldap-password-rotation";
export const LdapPasswordRotationParametersFields = () => { export const LdapPasswordRotationParametersFields = () => {
const { control } = useFormContext< const { control, watch, setValue } = useFormContext<
TSecretRotationV2Form & { TSecretRotationV2Form & {
type: SecretRotation.LdapPassword; type: SecretRotation.LdapPassword;
} }
>(); >();
const [id, rotationMethod] = watch(["id", "parameters.rotationMethod"]);
const isUpdate = Boolean(id);
return ( return (
<> <>
<Controller <Controller
name="parameters.dn" name="parameters.rotationMethod"
control={control} control={control}
defaultValue={LdapPasswordRotationMethod.ConnectionPrincipal}
render={({ field: { value, onChange }, fieldState: { error } }) => ( render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl <FormControl
isError={Boolean(error)} tooltipText={
<>
<span>Determines how the rotation will be performed:</span>
<ul className="ml-4 mt-2 flex list-disc flex-col gap-2">
<li>
<span className="font-medium">Connection Principal</span> - The Connection
principal will rotate the target principal&#39;s password.
</li>
<li>
<span className="font-medium">Target Principal</span> - The target principal
will rotate their own password.
</li>
</ul>
</>
}
tooltipClassName="max-w-sm"
errorText={error?.message} errorText={error?.message}
label="Distinguished Name (DN)" isError={Boolean(error?.message)}
label="Rotation Method"
helperText={
// eslint-disable-next-line no-nested-ternary
isUpdate
? "Cannot be updated."
: value === LdapPasswordRotationMethod.ConnectionPrincipal
? "The connection principal will rotate the target principal's password"
: "The target principal will rotate their own password"
}
> >
<Input <Select
isDisabled={isUpdate}
value={value} value={value}
onChange={onChange} onValueChange={(val) => {
placeholder="CN=John,OU=Users,DC=example,DC=com" setValue(
/> "temporaryParameters",
val === LdapPasswordRotationMethod.TargetPrincipal
? {
password: ""
}
: undefined
);
onChange(val);
}}
className="w-full border border-mineshaft-500 capitalize"
position="popper"
dropdownContainerClassName="max-w-none"
>
{Object.values(LdapPasswordRotationMethod).map((method) => {
return (
<SelectItem value={method} className="capitalize" key={method}>
{method.replace("-", " ")}
</SelectItem>
);
})}
</Select>
</FormControl> </FormControl>
)} )}
/> />
<div className="flex gap-3">
<Controller
name="parameters.dn"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
className="flex-1"
isError={Boolean(error)}
errorText={error?.message}
label="Target Principal's DN/UPN"
tooltipText="The DN/UPN of the principal that you want to perform password rotation on."
tooltipClassName="max-w-sm"
helperText={isUpdate ? "Cannot be updated." : undefined}
>
<Input
isDisabled={isUpdate}
value={value}
onChange={onChange}
placeholder="CN=John,OU=Users,DC=example,DC=com"
/>
</FormControl>
)}
/>
{rotationMethod === LdapPasswordRotationMethod.TargetPrincipal && !isUpdate && (
<Controller
name="temporaryParameters.password"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
className="flex-1"
isError={Boolean(error)}
errorText={error?.message}
label="Target Principal's Password"
>
<Input
value={value}
onChange={onChange}
type="password"
placeholder="****************"
/>
</FormControl>
)}
/>
)}
</div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="w-full border-b border-mineshaft-600"> <div className="w-full border-b border-mineshaft-600">
<span className="text-sm text-mineshaft-300">Password Requirements</span> <span className="text-sm text-mineshaft-300">Password Requirements</span>
</div> </div>
<div className="grid grid-cols-2 gap-3 rounded border border-mineshaft-600 bg-mineshaft-700 px-3 py-2"> <div className="grid grid-cols-2 gap-x-3 gap-y-1 rounded border border-mineshaft-600 bg-mineshaft-700 px-3 pt-3">
<Controller <Controller
control={control} control={control}
name="parameters.passwordRequirements.length" name="parameters.passwordRequirements.length"
@@ -45,7 +140,7 @@ export const LdapPasswordRotationParametersFields = () => {
label="Password Length" label="Password Length"
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
helperText="The length of the password to generate" tooltipText="The length of the password to generate"
> >
<Input <Input
type="number" type="number"
@@ -67,7 +162,7 @@ export const LdapPasswordRotationParametersFields = () => {
label="Digit Count" label="Digit Count"
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
helperText="Minimum number of digits" tooltipText="Minimum number of digits"
> >
<Input <Input
type="number" type="number"
@@ -88,7 +183,7 @@ export const LdapPasswordRotationParametersFields = () => {
label="Lowercase Character Count" label="Lowercase Character Count"
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
helperText="Minimum number of lowercase characters" tooltipText="Minimum number of lowercase characters"
> >
<Input <Input
type="number" type="number"
@@ -109,7 +204,7 @@ export const LdapPasswordRotationParametersFields = () => {
label="Uppercase Character Count" label="Uppercase Character Count"
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
helperText="Minimum number of uppercase characters" tooltipText="Minimum number of uppercase characters"
> >
<Input <Input
type="number" type="number"
@@ -130,7 +225,7 @@ export const LdapPasswordRotationParametersFields = () => {
label="Symbol Count" label="Symbol Count"
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
helperText="Minimum number of symbols" tooltipText="Minimum number of symbols"
> >
<Input <Input
type="number" type="number"
@@ -151,7 +246,7 @@ export const LdapPasswordRotationParametersFields = () => {
label="Allowed Symbols" label="Allowed Symbols"
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
helperText="Symbols to use in generated password" tooltipText="Symbols to use in generated password"
> >
<Input <Input
placeholder="-_.~!*" placeholder="-_.~!*"
@@ -15,13 +15,35 @@ export const LdapPasswordRotationReviewFields = () => {
const [parameters, { dn, password }] = watch(["parameters", "secretsMapping"]); const [parameters, { dn, password }] = watch(["parameters", "secretsMapping"]);
const { passwordRequirements } = parameters;
return ( return (
<> <>
<SecretRotationReviewSection label="Parameters"> <SecretRotationReviewSection label="Parameters">
<GenericFieldLabel label="Distinguished Name (DN)">{parameters.dn}</GenericFieldLabel> <GenericFieldLabel label="DN/UPN">{parameters.dn}</GenericFieldLabel>
</SecretRotationReviewSection> </SecretRotationReviewSection>
{passwordRequirements && (
<SecretRotationReviewSection label="Password Requirements">
<GenericFieldLabel label="Length">{passwordRequirements.length}</GenericFieldLabel>
<GenericFieldLabel label="Minimum Digits">
{passwordRequirements.required.digits}
</GenericFieldLabel>
<GenericFieldLabel label="Minimum Lowercase Characters">
{passwordRequirements.required.lowercase}
</GenericFieldLabel>
<GenericFieldLabel label="Minimum Uppercase Characters">
{passwordRequirements.required.uppercase}
</GenericFieldLabel>
<GenericFieldLabel label="Minimum Symbols">
{passwordRequirements.required.symbols}
</GenericFieldLabel>
<GenericFieldLabel label="Allowed Symbols">
{passwordRequirements.allowedSymbols}
</GenericFieldLabel>
</SecretRotationReviewSection>
)}
<SecretRotationReviewSection label="Secrets Mapping"> <SecretRotationReviewSection label="Secrets Mapping">
<GenericFieldLabel label="Distinguished Name (DN)">{dn}</GenericFieldLabel> <GenericFieldLabel label="DN/UPN">{dn}</GenericFieldLabel>
<GenericFieldLabel label="Password">{password}</GenericFieldLabel> <GenericFieldLabel label="Password">{password}</GenericFieldLabel>
</SecretRotationReviewSection> </SecretRotationReviewSection>
</> </>
@@ -1,7 +1,7 @@
import { ReactNode } from "react"; import { ReactNode } from "react";
type Props = { type Props = {
label: "Parameters" | "Secrets Mapping"; label: "Parameters" | "Secrets Mapping" | "Password Requirements";
children: ReactNode; children: ReactNode;
}; };
@@ -17,7 +17,7 @@ export const LdapPasswordRotationSecretsMappingFields = () => {
const items = [ const items = [
{ {
name: "DN", name: "DN/UPN",
input: ( input: (
<Controller <Controller
render={({ field: { value, onChange }, fieldState: { error } }) => ( render={({ field: { value, onChange }, fieldState: { error } }) => (
@@ -6,16 +6,36 @@ import { AzureClientSecretRotationSchema } from "@app/components/secret-rotation
import { LdapPasswordRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/ldap-password-rotation-schema"; import { LdapPasswordRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/ldap-password-rotation-schema";
import { MsSqlCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/mssql-credentials-rotation-schema"; import { MsSqlCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/mssql-credentials-rotation-schema";
import { PostgresCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/postgres-credentials-rotation-schema"; import { PostgresCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/postgres-credentials-rotation-schema";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
import { LdapPasswordRotationMethod } from "@app/hooks/api/secretRotationsV2/types/ldap-password-rotation";
const SecretRotationUnionSchema = z.discriminatedUnion("type", [ export const SecretRotationV2FormSchema = (isUpdate: boolean) =>
Auth0ClientSecretRotationSchema, z
AzureClientSecretRotationSchema, .intersection(
PostgresCredentialsRotationSchema, z.discriminatedUnion("type", [
MsSqlCredentialsRotationSchema, Auth0ClientSecretRotationSchema,
LdapPasswordRotationSchema, AzureClientSecretRotationSchema,
AwsIamUserSecretRotationSchema PostgresCredentialsRotationSchema,
]); MsSqlCredentialsRotationSchema,
LdapPasswordRotationSchema,
AwsIamUserSecretRotationSchema
]),
z.object({ id: z.string().optional() })
)
.superRefine((val, ctx) => {
if (val.type !== SecretRotation.LdapPassword || isUpdate) return;
export const SecretRotationV2FormSchema = SecretRotationUnionSchema; // this has to go on union or breaks discrimination
if (
val.parameters.rotationMethod === LdapPasswordRotationMethod.TargetPrincipal &&
!val.temporaryParameters?.password
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Password required",
path: ["temporaryParameters", "password"]
});
}
});
export type TSecretRotationV2Form = z.infer<typeof SecretRotationV2FormSchema>; export type TSecretRotationV2Form = z.infer<ReturnType<typeof SecretRotationV2FormSchema>>;
@@ -2,8 +2,9 @@ import { z } from "zod";
import { BaseSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/base-secret-rotation-v2-schema"; import { BaseSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/base-secret-rotation-v2-schema";
import { PasswordRequirementsSchema } from "@app/components/secret-rotations-v2/forms/schemas/shared"; import { PasswordRequirementsSchema } from "@app/components/secret-rotations-v2/forms/schemas/shared";
import { DistinguishedNameRegex } from "@app/helpers/string"; import { DistinguishedNameRegex, UserPrincipalNameRegex } from "@app/helpers/string";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
import { LdapPasswordRotationMethod } from "@app/hooks/api/secretRotationsV2/types/ldap-password-rotation";
export const LdapPasswordRotationSchema = z export const LdapPasswordRotationSchema = z
.object({ .object({
@@ -12,13 +13,24 @@ export const LdapPasswordRotationSchema = z
dn: z dn: z
.string() .string()
.trim() .trim()
.regex(DistinguishedNameRegex, "Invalid Distinguished Name format") .min(1, "DN/UPN required")
.min(1, "Distinguished Name (DN) required"), .refine(
passwordRequirements: PasswordRequirementsSchema.optional() (value) => DistinguishedNameRegex.test(value) || UserPrincipalNameRegex.test(value),
{
message: "Invalid DN/UPN format"
}
),
passwordRequirements: PasswordRequirementsSchema.optional(),
rotationMethod: z.nativeEnum(LdapPasswordRotationMethod).optional()
}), }),
secretsMapping: z.object({ secretsMapping: z.object({
dn: z.string().trim().min(1, "Distinguished Name (DN) required"), dn: z.string().trim().min(1, "DN/UPN required"),
password: z.string().trim().min(1, "Password required") password: z.string().trim().min(1, "Password required")
}) }),
temporaryParameters: z
.object({
password: z.string().min(1, "Password required")
})
.optional()
}) })
.merge(BaseSecretRotationSchema); .merge(BaseSecretRotationSchema);
@@ -1,5 +1,7 @@
import { z } from "zod"; import { z } from "zod";
export type TPasswordRequirements = z.infer<typeof PasswordRequirementsSchema>;
export const PasswordRequirementsSchema = z export const PasswordRequirementsSchema = z
.object({ .object({
length: z length: z
@@ -51,6 +51,7 @@ type Props = Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "onChange" | "val
isVisible?: boolean; isVisible?: boolean;
isReadOnly?: boolean; isReadOnly?: boolean;
isDisabled?: boolean; isDisabled?: boolean;
canEditButNotView?: boolean;
secretPath?: string; secretPath?: string;
environment?: string; environment?: string;
containerClassName?: string; containerClassName?: string;
@@ -70,6 +71,7 @@ export const InfisicalSecretInput = forwardRef<HTMLTextAreaElement, Props>(
containerClassName, containerClassName,
secretPath: propSecretPath, secretPath: propSecretPath,
environment: propEnvironment, environment: propEnvironment,
canEditButNotView,
...props ...props
}, },
ref ref
@@ -273,6 +275,7 @@ export const InfisicalSecretInput = forwardRef<HTMLTextAreaElement, Props>(
<Popover.Trigger asChild> <Popover.Trigger asChild>
<SecretInput <SecretInput
{...props} {...props}
canEditButNotView={canEditButNotView}
ref={handleRef} ref={handleRef}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
value={value} value={value}
@@ -3,6 +3,7 @@ import { forwardRef, TextareaHTMLAttributes } from "react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { useToggle } from "@app/hooks"; import { useToggle } from "@app/hooks";
import { HIDDEN_SECRET_VALUE } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem";
const REGEX = /(\${([a-zA-Z0-9-_.]+)})/g; const REGEX = /(\${([a-zA-Z0-9-_.]+)})/g;
const replaceContentWithDot = (str: string) => { const replaceContentWithDot = (str: string) => {
@@ -51,6 +52,7 @@ type Props = TextareaHTMLAttributes<HTMLTextAreaElement> & {
isReadOnly?: boolean; isReadOnly?: boolean;
isDisabled?: boolean; isDisabled?: boolean;
containerClassName?: string; containerClassName?: string;
canEditButNotView?: boolean;
}; };
const commonClassName = "font-mono text-sm caret-white border-none outline-none w-full break-all"; const commonClassName = "font-mono text-sm caret-white border-none outline-none w-full break-all";
@@ -66,6 +68,7 @@ export const SecretInput = forwardRef<HTMLTextAreaElement, Props>(
isDisabled, isDisabled,
isReadOnly, isReadOnly,
onFocus, onFocus,
canEditButNotView,
...props ...props
}, },
ref ref
@@ -93,7 +96,15 @@ export const SecretInput = forwardRef<HTMLTextAreaElement, Props>(
onFocus={(evt) => { onFocus={(evt) => {
onFocus?.(evt); onFocus?.(evt);
setIsSecretFocused.on(); setIsSecretFocused.on();
evt.currentTarget.select(); if (canEditButNotView && value === HIDDEN_SECRET_VALUE) {
evt.currentTarget.select();
}
}}
onMouseDown={(e) => {
if (canEditButNotView && value === HIDDEN_SECRET_VALUE) {
e.preventDefault();
e.currentTarget.select();
}
}} }}
disabled={isDisabled} disabled={isDisabled}
spellCheck={false} spellCheck={false}
@@ -2,6 +2,7 @@ export { useProjectPermission } from "./ProjectPermissionContext";
export type { ProjectPermissionSet, TProjectPermission } from "./types"; export type { ProjectPermissionSet, TProjectPermission } from "./types";
export { export {
ProjectPermissionActions, ProjectPermissionActions,
ProjectPermissionApprovalActions,
ProjectPermissionCertificateActions, ProjectPermissionCertificateActions,
ProjectPermissionCmekActions, ProjectPermissionCmekActions,
ProjectPermissionDynamicSecretActions, ProjectPermissionDynamicSecretActions,
@@ -24,6 +24,14 @@ export enum ProjectPermissionSecretActions {
Delete = "delete" Delete = "delete"
} }
export enum ProjectPermissionApprovalActions {
Read = "read",
Create = "create",
Edit = "edit",
Delete = "delete",
AllowChangeBypass = "allow-change-bypass"
}
export enum ProjectPermissionDynamicSecretActions { export enum ProjectPermissionDynamicSecretActions {
ReadRootCredential = "read-root-credential", ReadRootCredential = "read-root-credential",
CreateRootCredential = "create-root-credential", CreateRootCredential = "create-root-credential",
@@ -285,7 +293,7 @@ export type ProjectPermissionSet =
| [ProjectPermissionActions, ProjectPermissionSub.IpAllowList] | [ProjectPermissionActions, ProjectPermissionSub.IpAllowList]
| [ProjectPermissionActions, ProjectPermissionSub.Settings] | [ProjectPermissionActions, ProjectPermissionSub.Settings]
| [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens] | [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens]
| [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] | [ProjectPermissionApprovalActions, ProjectPermissionSub.SecretApproval]
| [ | [
ProjectPermissionIdentityActions, ProjectPermissionIdentityActions,
( (
+1
View File
@@ -10,6 +10,7 @@ export {
export type { TProjectPermission } from "./ProjectPermissionContext"; export type { TProjectPermission } from "./ProjectPermissionContext";
export { export {
ProjectPermissionActions, ProjectPermissionActions,
ProjectPermissionApprovalActions,
ProjectPermissionCertificateActions, ProjectPermissionCertificateActions,
ProjectPermissionCmekActions, ProjectPermissionCmekActions,
ProjectPermissionDynamicSecretActions, ProjectPermissionDynamicSecretActions,
+2
View File
@@ -15,3 +15,5 @@ export const isValidPath = (val: string): boolean => {
export const DistinguishedNameRegex = export const DistinguishedNameRegex =
/^(?:(?:[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)(?:(?:\\+[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)*)(?:,(?:[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)(?:(?:\\+[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)*))*)?$/; /^(?:(?:[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)(?:(?:\\+[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)*)(?:,(?:[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)(?:(?:\\+[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)*))*)?$/;
export const UserPrincipalNameRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
@@ -1,3 +1,4 @@
import { TPasswordRequirements } from "@app/components/secret-rotations-v2/forms/schemas/shared";
import { AppConnection } from "@app/hooks/api/appConnections/enums"; import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
import { import {
@@ -5,10 +6,17 @@ import {
TSecretRotationV2GeneratedCredentialsResponseBase TSecretRotationV2GeneratedCredentialsResponseBase
} from "@app/hooks/api/secretRotationsV2/types/shared"; } from "@app/hooks/api/secretRotationsV2/types/shared";
export enum LdapPasswordRotationMethod {
ConnectionPrincipal = "connection-principal",
TargetPrincipal = "target-principal"
}
export type TLdapPasswordRotation = TSecretRotationV2Base & { export type TLdapPasswordRotation = TSecretRotationV2Base & {
type: SecretRotation.LdapPassword; type: SecretRotation.LdapPassword;
parameters: { parameters: {
dn: string; dn: string;
rotationMethod?: LdapPasswordRotationMethod;
passwordRequirements?: TPasswordRequirements;
}; };
secretsMapping: { secretsMapping: {
dn: string; dn: string;
+2
View File
@@ -1,6 +1,7 @@
export { export {
useAddUserToWsE2EE, useAddUserToWsE2EE,
useAddUserToWsNonE2EE, useAddUserToWsNonE2EE,
useRemoveMyDuplicateAccounts,
useRevokeMySessionById, useRevokeMySessionById,
useSendEmailVerificationCode, useSendEmailVerificationCode,
useVerifyEmailVerificationCode useVerifyEmailVerificationCode
@@ -14,6 +15,7 @@ export {
useDeleteOrgMembership, useDeleteOrgMembership,
useGetMyAPIKeys, useGetMyAPIKeys,
useGetMyAPIKeysV2, useGetMyAPIKeysV2,
useGetMyDuplicateAccount,
useGetMyIp, useGetMyIp,
useGetMyOrganizationProjects, useGetMyOrganizationProjects,
useGetMySessions, useGetMySessions,
@@ -184,3 +184,12 @@ export const useRevokeMySessionById = () => {
} }
}); });
}; };
export const useRemoveMyDuplicateAccounts = () => {
return useMutation({
mutationFn: async () => {
const { data } = await apiRequest.post("/api/v1/user/remove-duplicate-accounts");
return data;
}
});
};
+27
View File
@@ -37,6 +37,33 @@ export const useGetUser = () =>
queryFn: fetchUserDetails queryFn: fetchUserDetails
}); });
export const fetchUserDuplicateAccounts = async () => {
const { data } = await apiRequest.get<{
users: Array<
User & {
isMyAccount: boolean;
organizations: { name: string; slug: string }[];
devices: {
ip: string;
userAgent: string;
}[];
}
>;
}>("/api/v1/user/duplicate-accounts");
return data.users;
};
export const useGetMyDuplicateAccount = () =>
useQuery({
queryKey: userKeys.getMyDuplicateAccount,
staleTime: 60 * 1000, // 1 min in ms
queryFn: fetchUserDuplicateAccounts,
select: (users) => ({
duplicateAccounts: users.filter((el) => !el.isMyAccount),
myAccount: users?.find((el) => el.isMyAccount)
})
});
export const useDeleteMe = () => { export const useDeleteMe = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -1,5 +1,6 @@
export const userKeys = { export const userKeys = {
getUser: ["user"] as const, getUser: ["user"] as const,
getMyDuplicateAccount: ["user-duplicate-account"] as const,
getPrivateKey: ["user"] as const, getPrivateKey: ["user"] as const,
userAction: ["user-action"] as const, userAction: ["user-action"] as const,
userProjectFavorites: (orgId: string) => [{ orgId }, "user-project-favorites"] as const, userProjectFavorites: (orgId: string) => [{ orgId }, "user-project-favorites"] as const,
+1 -1
View File
@@ -7,7 +7,7 @@ interface SlugSchemaInputs {
field?: string; field?: string;
} }
export const slugSchema = ({ min = 1, max = 32, field = "Slug" }: SlugSchemaInputs = {}) => { export const slugSchema = ({ min = 1, max = 64, field = "Slug" }: SlugSchemaInputs = {}) => {
return z return z
.string() .string()
.trim() .trim()
@@ -18,7 +18,8 @@ import { useToggle } from "@app/hooks";
import { useOauthTokenExchange, useSelectOrganization } from "@app/hooks/api"; import { useOauthTokenExchange, useSelectOrganization } from "@app/hooks/api";
import { MfaMethod } from "@app/hooks/api/auth/types"; import { MfaMethod } from "@app/hooks/api/auth/types";
import { fetchOrganizations } from "@app/hooks/api/organization/queries"; import { fetchOrganizations } from "@app/hooks/api/organization/queries";
import { fetchMyPrivateKey } from "@app/hooks/api/users/queries"; import { fetchMyPrivateKey, fetchUserDuplicateAccounts } from "@app/hooks/api/users/queries";
import { EmailDuplicationConfirmation } from "@app/pages/auth/SelectOrgPage/EmailDuplicationConfirmation";
import { navigateUserToOrg, useNavigateToSelectOrganization } from "../../Login.utils"; import { navigateUserToOrg, useNavigateToSelectOrganization } from "../../Login.utils";
@@ -40,6 +41,7 @@ export const PasswordStep = ({
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [removeDuplicateLater, setRemoveDuplicateLater] = useState(true);
const { mutateAsync: selectOrganization } = useSelectOrganization(); const { mutateAsync: selectOrganization } = useSelectOrganization();
const { mutateAsync: oauthTokenExchange } = useOauthTokenExchange(); const { mutateAsync: oauthTokenExchange } = useOauthTokenExchange();
const [shouldShowMfa, toggleShowMfa] = useToggle(false); const [shouldShowMfa, toggleShowMfa] = useToggle(false);
@@ -109,6 +111,13 @@ export const PasswordStep = ({
return; return;
} }
const userDuplicateAccount = await fetchUserDuplicateAccounts();
const hasDuplicate = userDuplicateAccount?.length > 1;
if (hasDuplicate) {
setRemoveDuplicateLater(false);
return;
}
await navigateUserToOrg(navigate, organizationId); await navigateUserToOrg(navigate, organizationId);
}; };
@@ -306,6 +315,18 @@ export const PasswordStep = ({
); );
} }
if (!removeDuplicateLater) {
return (
<EmailDuplicationConfirmation
onRemoveDuplicateLater={() =>
navigateUserToOrg(navigate, organizationId).catch(() =>
createNotification({ text: "Failed to navigate user", type: "error" })
)
}
/>
);
}
if (hasExchangedPrivateKey) { if (hasExchangedPrivateKey) {
return ( return (
<div className="flex max-h-screen min-h-screen flex-col items-center justify-center gap-2 overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700"> <div className="flex max-h-screen min-h-screen flex-col items-center justify-center gap-2 overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
@@ -0,0 +1,164 @@
import { useCallback } from "react";
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, useNavigate } from "@tanstack/react-router";
import { format } from "date-fns";
import { createNotification } from "@app/components/notifications";
import { Button, DeleteActionModal, Tooltip } from "@app/components/v2";
import { usePopUp } from "@app/hooks";
import {
useGetMyDuplicateAccount,
useLogoutUser,
useRemoveMyDuplicateAccounts
} from "@app/hooks/api";
type Props = {
onRemoveDuplicateLater: () => void;
};
export const EmailDuplicationConfirmation = ({ onRemoveDuplicateLater }: Props) => {
const duplicateAccounts = useGetMyDuplicateAccount();
const removeDuplicateEmails = useRemoveMyDuplicateAccounts();
const { t } = useTranslation();
const navigate = useNavigate();
const logout = useLogoutUser(true);
const { popUp, handlePopUpToggle } = usePopUp(["removeDuplicateConfirm"] as const);
const handleLogout = useCallback(async () => {
try {
console.log("Logging out...");
await logout.mutateAsync();
navigate({ to: "/login" });
} catch (error) {
console.error(error);
}
}, [logout, navigate]);
return (
<div className="flex max-h-screen min-h-screen flex-col justify-center overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
<Helmet>
<title>{t("common.head-title", { title: t("login.title") })}</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
<meta property="og:title" content={t("login.og-title") ?? ""} />
<meta name="og:description" content={t("login.og-description") ?? ""} />
</Helmet>
<div className="mx-auto mt-20 w-fit max-w-2xl rounded-lg border-2 border-mineshaft-500 p-10 shadow-lg">
<Link to="/">
<div className="mb-4 flex justify-center">
<img
src="/images/gradientLogo.svg"
style={{
height: "90px",
width: "120px"
}}
alt="Infisical logo"
/>
</div>
</Link>
<form className="mx-auto flex w-full flex-col items-center justify-center">
<div className="mb-6">
<h1 className="mb-2 bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-2xl font-medium text-transparent">
Multiple Accounts Detected
</h1>
<p className="text-md mb-4 text-center text-white">
<span className="text-slate-300">You&apos;re currently logged in as</span>{" "}
<b>{duplicateAccounts?.data?.myAccount?.username}</b>.
</p>
<div className="mb-4 mt-4 flex flex-col rounded-r border-l-2 border-l-primary bg-mineshaft-300/5 px-4 py-2.5">
<p className="mb-2 mt-1 text-sm text-bunker-300">
We&apos;ve detected multiple accounts using variations of the same email address.
</p>
</div>
</div>
<div className="mb-4 w-full border-b border-mineshaft-400 pb-1 text-sm text-mineshaft-200">
Your other accounts
</div>
<div className="thin-scrollbar flex h-full max-h-60 w-full flex-col items-stretch gap-2 overflow-auto rounded-md">
{duplicateAccounts?.data?.duplicateAccounts?.map((el) => {
const lastSession = el.devices?.at(-1);
return (
<div
key={el.id}
className="flex items-center gap-8 rounded-md bg-mineshaft-700 px-4 py-3 text-gray-200"
>
<div className="group flex flex-grow flex-col">
<div className="truncate text-sm transition-colors">{el.username}</div>
<div className="mt-2 text-xs">
Last logged in at {format(new Date(el.updatedAt), "Pp")}
</div>
<div className="mt-2 text-xs">
Organizations: {el?.organizations?.map((i) => i.slug)?.join(",")}
</div>
</div>
<div>
<Tooltip
className="max-w-lg"
content={
<div className="flex flex-col space-y-1 text-sm">
<div>IP: {lastSession?.ip || "-"}</div>
<div>User Agent: {lastSession?.userAgent || "-"}</div>
</div>
}
>
<FontAwesomeIcon icon={faInfoCircle} />
</Tooltip>
</div>
</div>
);
})}
</div>
<div className="mt-4 flex w-full flex-col">
<div className="flex gap-6">
<Button
className="flex-1 flex-grow"
isLoading={removeDuplicateEmails.isPending}
onClick={() => handlePopUpToggle("removeDuplicateConfirm", true)}
>
Delete all other accounts
</Button>
<Button
variant="outline_bg"
onClick={() => onRemoveDuplicateLater()}
className="flex-1 flex-grow"
>
Remind me later
</Button>
</div>
<Button
isLoading={logout.isPending}
variant="plain"
colorSchema="secondary"
className="mt-4"
onClick={handleLogout}
>
Change Account
</Button>
</div>
</form>
</div>
<div className="pb-28" />
<DeleteActionModal
isOpen={popUp.removeDuplicateConfirm.isOpen}
subTitle={`You’re currently logged in as ${duplicateAccounts?.data?.myAccount?.username}. Once you confirm, your other duplicate accounts will be permanently removed. Please make sure none of those accounts contain any production secrets, as this action cannot be undone.`}
title="Confirmation Required"
onChange={(isOpen) => handlePopUpToggle("removeDuplicateConfirm", isOpen)}
deleteKey="remove"
buttonText="Confirm"
onDeleteApproved={() =>
removeDuplicateEmails.mutateAsync(undefined, {
onSuccess: () => {
createNotification({
type: "success",
text: "Removed duplicate accounts"
});
onRemoveDuplicateLater();
}
})
}
/>
</div>
);
};
@@ -1,33 +1,10 @@
import { useCallback, useEffect, useState } from "react"; import { useState } from "react";
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { faArrowRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, useNavigate } from "@tanstack/react-router";
import axios from "axios";
import { addSeconds, formatISO } from "date-fns";
import { jwtDecode } from "jwt-decode";
import { Mfa } from "@app/components/auth/Mfa"; import { Spinner } from "@app/components/v2";
import { createNotification } from "@app/components/notifications"; import { useGetMyDuplicateAccount } from "@app/hooks/api";
import { IsCliLoginSuccessful } from "@app/components/utilities/attemptCliLogin";
import SecurityClient from "@app/components/utilities/SecurityClient";
import { Button, Spinner } from "@app/components/v2";
import { SessionStorageKeys } from "@app/const";
import { OrgMembershipRole } from "@app/helpers/roles";
import { useToggle } from "@app/hooks";
import {
useGetOrganizations,
useGetUser,
useLogoutUser,
useSelectOrganization
} from "@app/hooks/api";
import { MfaMethod, UserAgentType } from "@app/hooks/api/auth/types";
import { getAuthToken, isLoggedIn } from "@app/hooks/api/reactQuery";
import { Organization } from "@app/hooks/api/types";
import { AuthMethod } from "@app/hooks/api/users/types";
import { navigateUserToOrg } from "../LoginPage/Login.utils"; import { EmailDuplicationConfirmation } from "./EmailDuplicationConfirmation";
import { SelectOrganizationSection } from "./SelectOrgSection";
const LoadingScreen = () => { const LoadingScreen = () => {
return ( return (
@@ -39,253 +16,18 @@ const LoadingScreen = () => {
}; };
export const SelectOrganizationPage = () => { export const SelectOrganizationPage = () => {
const navigate = useNavigate(); const duplicateAccounts = useGetMyDuplicateAccount();
const { t } = useTranslation(); const [removeDuplicateLater, setRemoveDuplicateLater] = useState(false);
const organizations = useGetOrganizations(); if (duplicateAccounts.isPending) {
const selectOrg = useSelectOrganization();
const { data: user, isPending: userLoading } = useGetUser();
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL);
const [isInitialOrgCheckLoading, setIsInitialOrgCheckLoading] = useState(true);
const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {});
const queryParams = new URLSearchParams(window.location.search);
const orgId = queryParams.get("org_id");
const callbackPort = queryParams.get("callback_port");
const isAdminLogin = queryParams.get("is_admin_login") === "true";
const defaultSelectedOrg = organizations.data?.find((org) => org.id === orgId);
const logout = useLogoutUser(true);
const handleLogout = useCallback(async () => {
try {
console.log("Logging out...");
await logout.mutateAsync();
navigate({ to: "/login" });
} catch (error) {
console.error(error);
}
}, [logout, navigate]);
const handleSelectOrganization = useCallback(
async (organization: Organization) => {
const canBypassOrgAuth =
organization.bypassOrgAuthEnabled &&
organization.userRole === OrgMembershipRole.Admin &&
isAdminLogin;
if (organization.authEnforced && !canBypassOrgAuth) {
// org has an org-level auth method enabled (e.g. SAML)
// -> logout + redirect to SAML SSO
await logout.mutateAsync();
let url = "";
if (organization.orgAuthMethod === AuthMethod.OIDC) {
url = `/api/v1/sso/oidc/login?orgSlug=${organization.slug}${
callbackPort ? `&callbackPort=${callbackPort}` : ""
}`;
} else {
url = `/api/v1/sso/redirect/saml2/organizations/${organization.slug}`;
if (callbackPort) {
url += `?callback_port=${callbackPort}`;
}
}
window.location.href = url;
return;
}
const { token, isMfaEnabled, mfaMethod } = await selectOrg
.mutateAsync({
organizationId: organization.id,
userAgent: callbackPort ? UserAgentType.CLI : undefined
})
.finally(() => setIsInitialOrgCheckLoading(false));
if (isMfaEnabled) {
SecurityClient.setMfaToken(token);
if (mfaMethod) {
setRequiredMfaMethod(mfaMethod);
}
toggleShowMfa.on();
setMfaSuccessCallback(() => () => handleSelectOrganization(organization));
return;
}
if (callbackPort) {
const privateKey = localStorage.getItem("PRIVATE_KEY");
let error: string | null = null;
if (!privateKey) error = "Private key not found";
if (!user?.email) error = "User email not found";
if (!token) error = "No token found";
if (error) {
createNotification({
text: error,
type: "error"
});
return;
}
const payload = {
JTWToken: token,
email: user?.email,
privateKey
} as IsCliLoginSuccessful["loginResponse"];
// send request to server endpoint
const instance = axios.create();
await instance.post(`http://127.0.0.1:${callbackPort}/`, payload).catch(() => {
// if error happens to communicate we set the token with an expiry in sessino storage
// the cli-redirect page has logic to show this to user and ask them to paste it in terminal
sessionStorage.setItem(
SessionStorageKeys.CLI_TERMINAL_TOKEN,
JSON.stringify({
expiry: formatISO(addSeconds(new Date(), 30)),
data: window.btoa(JSON.stringify(payload))
})
);
});
navigate({ to: "/cli-redirect" });
// cli page
} else {
navigateUserToOrg(navigate, organization.id);
}
},
[selectOrg]
);
const handleCliRedirect = useCallback(() => {
const authToken = getAuthToken();
if (authToken && !callbackPort) {
const decodedJwt = jwtDecode(authToken) as any;
if (decodedJwt?.organizationId) {
navigateUserToOrg(navigate, decodedJwt.organizationId);
}
}
if (!isLoggedIn()) {
navigate({ to: "/login" });
}
}, []);
useEffect(() => {
if (callbackPort) {
handleCliRedirect();
}
}, [navigate]);
useEffect(() => {
if (organizations.isPending || !organizations.data) return;
// Case: User has no organizations.
// This can happen if the user was previously a member, but the organization was deleted or the user was removed.
if (organizations.data.length === 0) {
navigate({ to: "/organization/none" });
} else if (organizations.data.length === 1) {
if (callbackPort) {
handleCliRedirect();
setIsInitialOrgCheckLoading(false);
} else {
handleSelectOrganization(organizations.data[0]);
}
} else {
setIsInitialOrgCheckLoading(false);
}
}, [organizations.isPending, organizations.data]);
useEffect(() => {
if (defaultSelectedOrg) {
handleSelectOrganization(defaultSelectedOrg);
}
}, [defaultSelectedOrg]);
if (
userLoading ||
!user ||
((isInitialOrgCheckLoading || defaultSelectedOrg) && !shouldShowMfa)
) {
return <LoadingScreen />; return <LoadingScreen />;
} }
return ( if (duplicateAccounts.data?.duplicateAccounts?.length && !removeDuplicateLater) {
<div className="flex max-h-screen min-h-screen flex-col justify-center overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700"> return (
<Helmet> <EmailDuplicationConfirmation onRemoveDuplicateLater={() => setRemoveDuplicateLater(true)} />
<title>{t("common.head-title", { title: t("login.title") })}</title> );
<link rel="icon" href="/infisical.ico" /> }
<meta property="og:image" content="/images/message.png" />
<meta property="og:title" content={t("login.og-title") ?? ""} />
<meta name="og:description" content={t("login.og-description") ?? ""} />
</Helmet>
{shouldShowMfa ? (
<Mfa
email={user.email as string}
successCallback={mfaSuccessCallback}
method={requiredMfaMethod}
/>
) : (
<div className="mx-auto mt-20 w-fit rounded-lg border-2 border-mineshaft-500 p-10 shadow-lg">
<Link to="/">
<div className="mb-4 flex justify-center">
<img
src="/images/gradientLogo.svg"
style={{
height: "90px",
width: "120px"
}}
alt="Infisical logo"
/>
</div>
</Link>
<form className="mx-auto flex w-full flex-col items-center justify-center">
<div className="mb-8 space-y-2">
<h1 className="bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-2xl font-medium text-transparent">
Choose your organization
</h1>
<div className="space-y-1"> return <SelectOrganizationSection />;
<p className="text-md text-center text-gray-500">
You&lsquo;re currently logged in as <strong>{user.username}</strong>
</p>
<p className="text-md text-center text-gray-500">
Not you?{" "}
<Button variant="link" onClick={handleLogout} className="font-semibold">
Change account
</Button>
</p>
</div>
</div>
<div className="mt-2 w-1/4 min-w-[21.2rem] space-y-4 rounded-md text-center md:min-w-[25.1rem] lg:w-1/4">
{organizations.isPending ? (
<Spinner />
) : (
organizations.data?.map((org) => (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
onClick={() => handleSelectOrganization(org)}
key={org.id}
className="group flex cursor-pointer items-center justify-between rounded-md bg-mineshaft-700 px-4 py-3 capitalize text-gray-200 shadow-md transition-colors hover:bg-mineshaft-600"
>
<p className="truncate transition-colors">{org.name}</p>
<FontAwesomeIcon
icon={faArrowRight}
className="text-gray-400 transition-all group-hover:translate-x-2 group-hover:text-primary-500"
/>
</div>
))
)}
</div>
</form>
</div>
)}
<div className="pb-28" />
</div>
);
}; };
@@ -0,0 +1,304 @@
import { useCallback, useEffect, useState } from "react";
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { faArrowRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, useNavigate } from "@tanstack/react-router";
import axios from "axios";
import { addSeconds, formatISO } from "date-fns";
import { jwtDecode } from "jwt-decode";
import { Mfa } from "@app/components/auth/Mfa";
import { createNotification } from "@app/components/notifications";
import { IsCliLoginSuccessful } from "@app/components/utilities/attemptCliLogin";
import SecurityClient from "@app/components/utilities/SecurityClient";
import { Button, Spinner } from "@app/components/v2";
import { SessionStorageKeys } from "@app/const";
import { OrgMembershipRole } from "@app/helpers/roles";
import { useToggle } from "@app/hooks";
import {
useGetOrganizations,
useGetUser,
useLogoutUser,
useSelectOrganization
} from "@app/hooks/api";
import { MfaMethod, UserAgentType } from "@app/hooks/api/auth/types";
import { getAuthToken, isLoggedIn } from "@app/hooks/api/reactQuery";
import { Organization } from "@app/hooks/api/types";
import { AuthMethod } from "@app/hooks/api/users/types";
import { navigateUserToOrg } from "../LoginPage/Login.utils";
const LoadingScreen = () => {
return (
<div className="flex max-h-screen min-h-screen flex-col items-center justify-center gap-2 overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
<Spinner />
<p className="text-white opacity-80">Loading, please wait</p>
</div>
);
};
export const SelectOrganizationSection = () => {
const navigate = useNavigate();
const { t } = useTranslation();
const organizations = useGetOrganizations();
const selectOrg = useSelectOrganization();
const { data: user, isPending: userLoading } = useGetUser();
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL);
const [isInitialOrgCheckLoading, setIsInitialOrgCheckLoading] = useState(true);
const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {});
const queryParams = new URLSearchParams(window.location.search);
const orgId = queryParams.get("org_id");
const callbackPort = queryParams.get("callback_port");
const isAdminLogin = queryParams.get("is_admin_login") === "true";
const defaultSelectedOrg = organizations.data?.find((org) => org.id === orgId);
const logout = useLogoutUser(true);
const handleLogout = useCallback(async () => {
try {
console.log("Logging out...");
await logout.mutateAsync();
navigate({ to: "/login" });
} catch (error) {
console.error(error);
}
}, [logout, navigate]);
const handleSelectOrganization = useCallback(
async (organization: Organization) => {
const isUserOrgAdmin = organization.userRole === OrgMembershipRole.Admin;
const canBypassOrgAuth = organization.bypassOrgAuthEnabled && isUserOrgAdmin && isAdminLogin;
if (isAdminLogin) {
if (!organization.bypassOrgAuthEnabled) {
createNotification({
text: "This organization does not have bypass org auth enabled",
type: "error"
});
return;
}
if (!isUserOrgAdmin) {
createNotification({
text: "Only organization admins can bypass org auth",
type: "error"
});
return;
}
}
if (organization.authEnforced && !canBypassOrgAuth) {
// org has an org-level auth method enabled (e.g. SAML)
// -> logout + redirect to SAML SSO
await logout.mutateAsync();
let url = "";
if (organization.orgAuthMethod === AuthMethod.OIDC) {
url = `/api/v1/sso/oidc/login?orgSlug=${organization.slug}${
callbackPort ? `&callbackPort=${callbackPort}` : ""
}`;
} else {
url = `/api/v1/sso/redirect/saml2/organizations/${organization.slug}`;
if (callbackPort) {
url += `?callback_port=${callbackPort}`;
}
}
window.location.href = url;
return;
}
const { token, isMfaEnabled, mfaMethod } = await selectOrg
.mutateAsync({
organizationId: organization.id,
userAgent: callbackPort ? UserAgentType.CLI : undefined
})
.finally(() => setIsInitialOrgCheckLoading(false));
if (isMfaEnabled) {
SecurityClient.setMfaToken(token);
if (mfaMethod) {
setRequiredMfaMethod(mfaMethod);
}
toggleShowMfa.on();
setMfaSuccessCallback(() => () => handleSelectOrganization(organization));
return;
}
if (callbackPort) {
const privateKey = localStorage.getItem("PRIVATE_KEY");
let error: string | null = null;
if (!privateKey) error = "Private key not found";
if (!user?.email) error = "User email not found";
if (!token) error = "No token found";
if (error) {
createNotification({
text: error,
type: "error"
});
return;
}
const payload = {
JTWToken: token,
email: user?.email,
privateKey
} as IsCliLoginSuccessful["loginResponse"];
// send request to server endpoint
const instance = axios.create();
await instance.post(`http://127.0.0.1:${callbackPort}/`, payload).catch(() => {
// if error happens to communicate we set the token with an expiry in sessino storage
// the cli-redirect page has logic to show this to user and ask them to paste it in terminal
sessionStorage.setItem(
SessionStorageKeys.CLI_TERMINAL_TOKEN,
JSON.stringify({
expiry: formatISO(addSeconds(new Date(), 30)),
data: window.btoa(JSON.stringify(payload))
})
);
});
navigate({ to: "/cli-redirect" });
// cli page
} else {
navigateUserToOrg(navigate, organization.id);
}
},
[selectOrg]
);
const handleCliRedirect = useCallback(() => {
const authToken = getAuthToken();
if (authToken && !callbackPort) {
const decodedJwt = jwtDecode(authToken) as any;
if (decodedJwt?.organizationId) {
navigateUserToOrg(navigate, decodedJwt.organizationId);
}
}
if (!isLoggedIn()) {
navigate({ to: "/login" });
}
}, []);
useEffect(() => {
if (callbackPort) {
handleCliRedirect();
}
}, [navigate]);
useEffect(() => {
if (organizations.isPending || !organizations.data) return;
// Case: User has no organizations.
// This can happen if the user was previously a member, but the organization was deleted or the user was removed.
if (organizations.data.length === 0) {
navigate({ to: "/organization/none" });
} else if (organizations.data.length === 1) {
if (callbackPort) {
handleCliRedirect();
setIsInitialOrgCheckLoading(false);
} else {
handleSelectOrganization(organizations.data[0]);
}
} else {
setIsInitialOrgCheckLoading(false);
}
}, [organizations.isPending, organizations.data]);
useEffect(() => {
if (defaultSelectedOrg) {
handleSelectOrganization(defaultSelectedOrg);
}
}, [defaultSelectedOrg]);
if (
userLoading ||
!user ||
((isInitialOrgCheckLoading || defaultSelectedOrg) && !shouldShowMfa)
) {
return <LoadingScreen />;
}
return (
<div className="flex max-h-screen min-h-screen flex-col justify-center overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
<Helmet>
<title>{t("common.head-title", { title: t("login.title") })}</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
<meta property="og:title" content={t("login.og-title") ?? ""} />
<meta name="og:description" content={t("login.og-description") ?? ""} />
</Helmet>
{shouldShowMfa ? (
<Mfa
email={user.email as string}
successCallback={mfaSuccessCallback}
method={requiredMfaMethod}
/>
) : (
<div className="mx-auto mt-20 w-fit rounded-lg border-2 border-mineshaft-500 p-10 shadow-lg">
<Link to="/">
<div className="mb-4 flex justify-center">
<img
src="/images/gradientLogo.svg"
style={{
height: "90px",
width: "120px"
}}
alt="Infisical logo"
/>
</div>
</Link>
<form className="mx-auto flex w-full flex-col items-center justify-center">
<div className="mb-8 space-y-2">
<h1 className="bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-2xl font-medium text-transparent">
Choose your organization
</h1>
<div className="space-y-1">
<p className="text-md text-center text-gray-500">
You&lsquo;re currently logged in as <strong>{user.username}</strong>
</p>
<p className="text-md text-center text-gray-500">
Not you?{" "}
<Button variant="link" onClick={handleLogout} className="font-semibold">
Change account
</Button>
</p>
</div>
</div>
<div className="mt-2 w-1/4 min-w-[21.2rem] space-y-4 rounded-md text-center md:min-w-[25.1rem] lg:w-1/4">
{organizations.isPending ? (
<Spinner />
) : (
organizations.data?.map((org) => (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
onClick={() => handleSelectOrganization(org)}
key={org.id}
className="group flex cursor-pointer items-center justify-between rounded-md bg-mineshaft-700 px-4 py-3 capitalize text-gray-200 shadow-md transition-colors hover:bg-mineshaft-600"
>
<p className="truncate transition-colors">{org.name}</p>
<FontAwesomeIcon
icon={faArrowRight}
className="text-gray-400 transition-all group-hover:translate-x-2 group-hover:text-primary-500"
/>
</div>
))
)}
</div>
</form>
</div>
)}
<div className="pb-28" />
</div>
);
};
@@ -5,7 +5,7 @@ import { FormControl, Input, TextArea } from "@app/components/v2";
import { slugSchema } from "@app/lib/schemas"; import { slugSchema } from "@app/lib/schemas";
export const genericAppConnectionFieldsSchema = z.object({ export const genericAppConnectionFieldsSchema = z.object({
name: slugSchema({ min: 1, max: 32, field: "Name" }), name: slugSchema({ min: 1, max: 64, field: "Name" }),
description: z.string().trim().max(256, "Description cannot exceed 256 characters").nullish() description: z.string().trim().max(256, "Description cannot exceed 256 characters").nullish()
}); });
@@ -19,7 +19,7 @@ import {
Tooltip Tooltip
} from "@app/components/v2"; } from "@app/components/v2";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import { DistinguishedNameRegex } from "@app/helpers/string"; import { DistinguishedNameRegex, UserPrincipalNameRegex } from "@app/helpers/string";
import { import {
LdapConnectionMethod, LdapConnectionMethod,
LdapConnectionProvider, LdapConnectionProvider,
@@ -55,8 +55,13 @@ const formSchema = z.discriminatedUnion("method", [
dn: z dn: z
.string() .string()
.trim() .trim()
.regex(DistinguishedNameRegex, "Invalid Distinguished Name format") .min(1, "DN/UPN required")
.min(1, "Distinguished Name (DN) required"), .refine(
(value) => DistinguishedNameRegex.test(value) || UserPrincipalNameRegex.test(value),
{
message: "Invalid DN/UPN format"
}
),
password: z.string().trim().min(1, "Password required"), password: z.string().trim().min(1, "Password required"),
sslRejectUnauthorized: z.boolean(), sslRejectUnauthorized: z.boolean(),
sslCertificate: z sslCertificate: z
@@ -223,7 +228,7 @@ export const LdapConnectionForm = ({ appConnection, onSubmit }: Props) => {
<FormControl <FormControl
errorText={error?.message} errorText={error?.message}
isError={Boolean(error?.message)} isError={Boolean(error?.message)}
label="Binding Distinguished Name (DN)" label="Binding DN/UPN"
> >
<Input {...field} placeholder="CN=John,OU=Users,DC=example,DC=com" /> <Input {...field} placeholder="CN=John,OU=Users,DC=example,DC=com" />
</FormControl> </FormControl>
@@ -1,4 +1,9 @@
import { faCircleCheck, faCircleXmark, faFileInvoice } from "@fortawesome/free-solid-svg-icons"; import {
faCircleCheck,
faCircleXmark,
faFileInvoice,
faInfoCircle
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { import {
@@ -10,6 +15,7 @@ import {
Td, Td,
Th, Th,
THead, THead,
Tooltip,
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { useOrganization } from "@app/context"; import { useOrganization } from "@app/context";
@@ -48,9 +54,26 @@ export const CurrentPlanSection = () => {
data && data &&
data?.rows?.length > 0 && data?.rows?.length > 0 &&
data.rows.map(({ name, allowed, used }) => { data.rows.map(({ name, allowed, used }) => {
let toolTipText = null;
if (name === "Organization identity limit") {
toolTipText =
"Identity count is calculated by the total number of user identities and machine identities.";
}
return ( return (
<Tr key={`current-plan-row-${name}`} className="h-12"> <Tr key={`current-plan-row-${name}`} className="h-12">
<Td>{name}</Td> <Td>
{name}
{toolTipText && (
<Tooltip content={toolTipText}>
<FontAwesomeIcon
icon={faInfoCircle}
className="relative bottom-2 left-2"
size="xs"
/>
</Tooltip>
)}
</Td>
<Td>{displayCell(allowed)}</Td> <Td>{displayCell(allowed)}</Td>
<Td>{used}</Td> <Td>{used}</Td>
</Tr> </Tr>
@@ -12,6 +12,7 @@ import {
} from "@app/context"; } from "@app/context";
import { import {
PermissionConditionOperators, PermissionConditionOperators,
ProjectPermissionApprovalActions,
ProjectPermissionDynamicSecretActions, ProjectPermissionDynamicSecretActions,
ProjectPermissionGroupActions, ProjectPermissionGroupActions,
ProjectPermissionIdentityActions, ProjectPermissionIdentityActions,
@@ -52,6 +53,14 @@ const SecretPolicyActionSchema = z.object({
[ProjectPermissionSecretActions.Create]: z.boolean().optional() [ProjectPermissionSecretActions.Create]: z.boolean().optional()
}); });
const ApprovalPolicyActionSchema = z.object({
[ProjectPermissionApprovalActions.Read]: z.boolean().optional(),
[ProjectPermissionApprovalActions.Edit]: z.boolean().optional(),
[ProjectPermissionApprovalActions.Delete]: z.boolean().optional(),
[ProjectPermissionApprovalActions.Create]: z.boolean().optional(),
[ProjectPermissionApprovalActions.AllowChangeBypass]: z.boolean().optional()
});
const CmekPolicyActionSchema = z.object({ const CmekPolicyActionSchema = z.object({
read: z.boolean().optional(), read: z.boolean().optional(),
edit: z.boolean().optional(), edit: z.boolean().optional(),
@@ -261,7 +270,7 @@ export const projectRoleFormSchema = z.object({
.array() .array()
.default([]), .default([]),
[ProjectPermissionSub.SshHostGroups]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.SshHostGroups]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SecretApproval]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.SecretApproval]: ApprovalPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SecretRollback]: SecretRollbackPolicyActionSchema.array().default([]), [ProjectPermissionSub.SecretRollback]: SecretRollbackPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Project]: WorkspacePolicyActionSchema.array().default([]), [ProjectPermissionSub.Project]: WorkspacePolicyActionSchema.array().default([]),
[ProjectPermissionSub.Tags]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.Tags]: GeneralPolicyActionSchema.array().default([]),
@@ -402,7 +411,6 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
ProjectPermissionSub.PkiAlerts, ProjectPermissionSub.PkiAlerts,
ProjectPermissionSub.PkiCollections, ProjectPermissionSub.PkiCollections,
ProjectPermissionSub.CertificateTemplates, ProjectPermissionSub.CertificateTemplates,
ProjectPermissionSub.SecretApproval,
ProjectPermissionSub.Tags, ProjectPermissionSub.Tags,
ProjectPermissionSub.SecretRotation, ProjectPermissionSub.SecretRotation,
ProjectPermissionSub.Kms, ProjectPermissionSub.Kms,
@@ -564,6 +572,25 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
return; return;
} }
if (subject === ProjectPermissionSub.SecretApproval) {
const canCreate = action.includes(ProjectPermissionApprovalActions.Create);
const canDelete = action.includes(ProjectPermissionApprovalActions.Delete);
const canEdit = action.includes(ProjectPermissionApprovalActions.Edit);
const canRead = action.includes(ProjectPermissionApprovalActions.Read);
const canChangeBypass = action.includes(ProjectPermissionApprovalActions.AllowChangeBypass);
if (!formVal[subject]) formVal[subject] = [{}];
// Map actions to the keys defined in ApprovalPolicyActionSchema
if (canCreate) formVal[subject]![0][ProjectPermissionApprovalActions.Create] = true;
if (canDelete) formVal[subject]![0][ProjectPermissionApprovalActions.Delete] = true;
if (canEdit) formVal[subject]![0][ProjectPermissionApprovalActions.Edit] = true;
if (canRead) formVal[subject]![0][ProjectPermissionApprovalActions.Read] = true;
if (canChangeBypass)
formVal[subject]![0][ProjectPermissionApprovalActions.AllowChangeBypass] = true;
return;
}
if (subject === ProjectPermissionSub.SecretRollback) { if (subject === ProjectPermissionSub.SecretRollback) {
const canRead = action.includes(ProjectPermissionActions.Read); const canRead = action.includes(ProjectPermissionActions.Read);
const canCreate = action.includes(ProjectPermissionActions.Create); const canCreate = action.includes(ProjectPermissionActions.Create);
@@ -1181,10 +1208,11 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
[ProjectPermissionSub.SecretApproval]: { [ProjectPermissionSub.SecretApproval]: {
title: "Secret Approval Policies", title: "Secret Approval Policies",
actions: [ actions: [
{ label: "Read", value: "read" }, { label: "Read", value: ProjectPermissionApprovalActions.Read },
{ label: "Create", value: "create" }, { label: "Create", value: ProjectPermissionApprovalActions.Create },
{ label: "Modify", value: "edit" }, { label: "Modify", value: ProjectPermissionApprovalActions.Edit },
{ label: "Remove", value: "delete" } { label: "Remove", value: ProjectPermissionApprovalActions.Delete },
{ label: "Allow Change Bypass", value: ProjectPermissionApprovalActions.AllowChangeBypass }
] ]
}, },
[ProjectPermissionSub.SecretRotation]: { [ProjectPermissionSub.SecretRotation]: {
@@ -1661,7 +1689,7 @@ export const RoleTemplates: Record<ProjectType, RoleTemplate[]> = {
}, },
{ {
subject: ProjectPermissionSub.SecretApproval, subject: ProjectPermissionSub.SecretApproval,
actions: Object.values(ProjectPermissionActions) actions: Object.values(ProjectPermissionApprovalActions)
}, },
{ {
subject: ProjectPermissionSub.ServiceTokens, subject: ProjectPermissionSub.ServiceTokens,
@@ -232,6 +232,7 @@ export const SecretEditRow = ({
environment={environment} environment={environment}
isImport={isImportedSecret} isImport={isImportedSecret}
defaultValue={secretValueHidden ? "" : undefined} defaultValue={secretValueHidden ? "" : undefined}
canEditButNotView={secretValueHidden && !isOverride}
/> />
)} )}
/> />
@@ -29,13 +29,13 @@ import {
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { import {
ProjectPermissionActions,
ProjectPermissionSub, ProjectPermissionSub,
TProjectPermission, TProjectPermission,
useProjectPermission, useProjectPermission,
useSubscription, useSubscription,
useWorkspace useWorkspace
} from "@app/context"; } from "@app/context";
import { ProjectPermissionApprovalActions } from "@app/context/ProjectPermissionContext/types";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { import {
useDeleteAccessApprovalPolicy, useDeleteAccessApprovalPolicy,
@@ -61,8 +61,10 @@ const useApprovalPolicies = (permission: TProjectPermission, currentWorkspace?:
projectSlug: currentWorkspace?.slug as string, projectSlug: currentWorkspace?.slug as string,
options: { options: {
enabled: enabled:
permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval) && permission.can(
!!currentWorkspace?.slug ProjectPermissionApprovalActions.Read,
ProjectPermissionSub.SecretApproval
) && !!currentWorkspace?.slug
} }
} }
); );
@@ -71,8 +73,10 @@ const useApprovalPolicies = (permission: TProjectPermission, currentWorkspace?:
workspaceId: currentWorkspace?.id as string, workspaceId: currentWorkspace?.id as string,
options: { options: {
enabled: enabled:
permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval) && permission.can(
!!currentWorkspace?.id ProjectPermissionApprovalActions.Read,
ProjectPermissionSub.SecretApproval
) && !!currentWorkspace?.id
} }
} }
); );
@@ -160,7 +164,7 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
</div> </div>
<div> <div>
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Create} I={ProjectPermissionApprovalActions.Create}
a={ProjectPermissionSub.SecretApproval} a={ProjectPermissionSub.SecretApproval}
> >
{(isAllowed) => ( {(isAllowed) => (
@@ -126,8 +126,6 @@ export const AccessPolicyForm = ({
const policyName = policyDetails[watch("policyType")]?.name || "Policy"; const policyName = policyDetails[watch("policyType")]?.name || "Policy";
const approversRequired = watch("approvals") || 1;
const handleCreatePolicy = async ({ const handleCreatePolicy = async ({
environment, environment,
groupApprovers, groupApprovers,
@@ -303,73 +301,6 @@ export const AccessPolicyForm = ({
</FormControl> </FormControl>
)} )}
/> />
<Controller
control={control}
name="enforcementLevel"
defaultValue={EnforcementLevel.Hard}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Enforcement Level"
isError={Boolean(error)}
errorText={error?.message}
tooltipText={
<>
<p>
Determines the level of enforcement for required approvers of a request:
</p>
<p className="mt-2">
<span className="font-bold">Hard</span> enforcement requires at least{" "}
<span className="font-bold"> {approversRequired}</span> approver(s) to
approve the request.`
</p>
<p className="mt-2">
<span className="font-bold">Soft</span> enforcement At least{" "}
<span className="font-bold">{approversRequired}</span> approver(s) must
approve the request; however, the requester can bypass approval
requirements in emergencies.
</p>
</>
}
>
<Select
value={field.value}
onValueChange={(val) => field.onChange(val as EnforcementLevel)}
className="w-full border border-mineshaft-500"
>
{Object.values(EnforcementLevel).map((level) => {
return (
<SelectItem value={level} key={`enforcement-level-${level}`}>
<span className="capitalize">{level}</span>
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="environment"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Environment"
isRequired
isError={Boolean(error)}
errorText={error?.message}
>
<FilterableSelect
isDisabled={isEditMode}
value={value}
onChange={onChange}
placeholder="Select environment..."
options={environments}
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>
<Controller <Controller
control={control} control={control}
name="secretPath" name="secretPath"
@@ -386,6 +317,28 @@ export const AccessPolicyForm = ({
)} )}
/> />
</div> </div>
<Controller
control={control}
name="environment"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Environment"
isRequired
isError={Boolean(error)}
errorText={error?.message}
>
<FilterableSelect
isDisabled={isEditMode}
value={value}
onChange={onChange}
placeholder="Select environment..."
options={environments}
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>
<div className="mb-2"> <div className="mb-2">
<p>Approvers</p> <p>Approvers</p>
<p className="font-inter text-xs text-mineshaft-300 opacity-90"> <p className="font-inter text-xs text-mineshaft-300 opacity-90">
@@ -465,6 +418,29 @@ export const AccessPolicyForm = ({
</FormControl> </FormControl>
)} )}
/> />
<Controller
control={control}
name="enforcementLevel"
defaultValue={EnforcementLevel.Hard}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Bypass Approvals"
isError={Boolean(error)}
errorText={error?.message}
>
<Switch
id="bypass-approvals"
thumbClassName="bg-mineshaft-800"
isChecked={value === EnforcementLevel.Soft}
onCheckedChange={(v) =>
onChange(v ? EnforcementLevel.Soft : EnforcementLevel.Hard)
}
>
Allow request creators to bypass policy in break-glass situations
</Switch>
</FormControl>
)}
/>
<div className="mt-8 flex items-center space-x-4"> <div className="mt-8 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting}> <Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting}>
Save Save
@@ -14,7 +14,8 @@ import {
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { Badge } from "@app/components/v2/Badge"; import { Badge } from "@app/components/v2/Badge";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { ProjectPermissionSub } from "@app/context";
import { ProjectPermissionApprovalActions } from "@app/context/ProjectPermissionContext/types";
import { getMemberLabel } from "@app/helpers/members"; import { getMemberLabel } from "@app/helpers/members";
import { policyDetails } from "@app/helpers/policies"; import { policyDetails } from "@app/helpers/policies";
import { Approver } from "@app/hooks/api/accessApproval/types"; import { Approver } from "@app/hooks/api/accessApproval/types";
@@ -117,7 +118,7 @@ export const ApprovalPolicyRow = ({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-[100%] p-1"> <DropdownMenuContent align="center" className="min-w-[100%] p-1">
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Edit} I={ProjectPermissionApprovalActions.Edit}
a={ProjectPermissionSub.SecretApproval} a={ProjectPermissionSub.SecretApproval}
> >
{(isAllowed) => ( {(isAllowed) => (
@@ -136,7 +137,7 @@ export const ApprovalPolicyRow = ({
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Delete} I={ProjectPermissionApprovalActions.Delete}
a={ProjectPermissionSub.SecretApproval} a={ProjectPermissionSub.SecretApproval}
> >
{(isAllowed) => ( {(isAllowed) => (
@@ -14,6 +14,11 @@ import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { Button, Checkbox, FormControl, Input } from "@app/components/v2"; import { Button, Checkbox, FormControl, Input } from "@app/components/v2";
import {
ProjectPermissionApprovalActions,
ProjectPermissionSub,
useProjectPermission
} from "@app/context";
import { import {
usePerformSecretApprovalRequestMerge, usePerformSecretApprovalRequestMerge,
useUpdateSecretApprovalRequestStatus useUpdateSecretApprovalRequestStatus
@@ -49,6 +54,12 @@ export const SecretApprovalRequestAction = ({
const { mutateAsync: updateSecretStatusChange, isPending: isStatusChanging } = const { mutateAsync: updateSecretStatusChange, isPending: isStatusChanging } =
useUpdateSecretApprovalRequestStatus(); useUpdateSecretApprovalRequestStatus();
const { permission } = useProjectPermission();
const canBypassApprovalPermission = permission.can(
ProjectPermissionApprovalActions.AllowChangeBypass,
ProjectPermissionSub.SecretApproval
);
const [byPassApproval, setByPassApproval] = useState(false); const [byPassApproval, setByPassApproval] = useState(false);
const [bypassReason, setBypassReason] = useState(""); const [bypassReason, setBypassReason] = useState("");
@@ -113,7 +124,7 @@ export const SecretApprovalRequestAction = ({
At least {approvals} approving review required At least {approvals} approving review required
{Boolean(statusChangeByEmail) && `. Reopened by ${statusChangeByEmail}`} {Boolean(statusChangeByEmail) && `. Reopened by ${statusChangeByEmail}`}
</span> </span>
{isSoftEnforcement && !isMergable && ( {isSoftEnforcement && !isMergable && canBypassApprovalPermission && (
<div className="mt-2 flex flex-col space-y-2"> <div className="mt-2 flex flex-col space-y-2">
<Checkbox <Checkbox
onCheckedChange={(checked) => setByPassApproval(checked === true)} onCheckedChange={(checked) => setByPassApproval(checked === true)}
@@ -58,7 +58,7 @@ import {
} from "./SecretListView.utils"; } from "./SecretListView.utils";
import { CollapsibleSecretImports } from "./CollapsibleSecretImports"; import { CollapsibleSecretImports } from "./CollapsibleSecretImports";
const hiddenValue = "******"; export const HIDDEN_SECRET_VALUE = "******";
type Props = { type Props = {
secret: SecretV3RawSanitized; secret: SecretV3RawSanitized;
@@ -122,7 +122,7 @@ export const SecretItem = memo(
const getDefaultValue = () => { const getDefaultValue = () => {
if (secret.secretValueHidden) { if (secret.secretValueHidden) {
return canEditSecretValue ? hiddenValue : ""; return canEditSecretValue ? HIDDEN_SECRET_VALUE : "";
} }
return secret.valueOverride || secret.value || ""; return secret.valueOverride || secret.value || "";
}; };
@@ -366,10 +366,11 @@ export const SecretItem = memo(
isReadOnly={isReadOnly || isRotatedSecret} isReadOnly={isReadOnly || isRotatedSecret}
key="secret-value" key="secret-value"
isVisible={isVisible && !secretValueHidden} isVisible={isVisible && !secretValueHidden}
canEditButNotView={secretValueHidden && !isOverriden}
environment={environment} environment={environment}
secretPath={secretPath} secretPath={secretPath}
{...field} {...field}
defaultValue={secretValueHidden ? hiddenValue : undefined} defaultValue={secretValueHidden ? HIDDEN_SECRET_VALUE : undefined}
containerClassName="py-1.5 rounded-md transition-all" containerClassName="py-1.5 rounded-md transition-all"
/> />
)} )}
+2 -2
View File
@@ -13,9 +13,9 @@ type: application
# This is the chart version. This version number should be incremented each time you make changes # This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version. # to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/) # Versions are expected to follow Semantic Versioning (https://semver.org/)
version: v0.9.2 version: v0.9.3
# This is the version number of the application being deployed. This version number should be # This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to # incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using. # follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes. # It is recommended to use it with quotes.
appVersion: "v0.9.2" appVersion: "v0.9.3"
+1 -1
View File
@@ -32,7 +32,7 @@ controllerManager:
- ALL - ALL
image: image:
repository: infisical/kubernetes-operator repository: infisical/kubernetes-operator
tag: v0.9.2 tag: v0.9.3
resources: resources:
limits: limits:
cpu: 500m cpu: 500m
@@ -30,9 +30,9 @@ import (
// InfisicalSecretReconciler reconciles a InfisicalSecret object // InfisicalSecretReconciler reconciles a InfisicalSecret object
type InfisicalPushSecretReconciler struct { type InfisicalPushSecretReconciler struct {
client.Client client.Client
IsNamespaceScoped bool
BaseLogger logr.Logger BaseLogger logr.Logger
Scheme *runtime.Scheme Scheme *runtime.Scheme
} }
var infisicalPushSecretResourceVariablesMap map[string]util.ResourceVariables = make(map[string]util.ResourceVariables) var infisicalPushSecretResourceVariablesMap map[string]util.ResourceVariables = make(map[string]util.ResourceVariables)
@@ -51,7 +51,7 @@ func (r *InfisicalPushSecretReconciler) GetLogger(req ctrl.Request) logr.Logger
//+kubebuilder:rbac:groups="",resources=pods,verbs=get;list //+kubebuilder:rbac:groups="",resources=pods,verbs=get;list
//+kubebuilder:rbac:groups="authentication.k8s.io",resources=tokenreviews,verbs=create //+kubebuilder:rbac:groups="authentication.k8s.io",resources=tokenreviews,verbs=create
//+kubebuilder:rbac:groups="",resources=serviceaccounts/token,verbs=create //+kubebuilder:rbac:groups="",resources=serviceaccounts/token,verbs=create
// +kubebuilder:rbac:groups=secrets.infisical.com,resources=clustergenerators,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups=secrets.infisical.com,resources=clustergenerators,verbs=get;list;watch;create;update;patch;delete
// Reconcile is part of the main kubernetes reconciliation loop which aims to // Reconcile is part of the main kubernetes reconciliation loop which aims to
// move the current state of the cluster closer to the desired state. // move the current state of the cluster closer to the desired state.
// For more details, check Reconcile and its Result here: // For more details, check Reconcile and its Result here:
@@ -249,19 +249,26 @@ func (r *InfisicalPushSecretReconciler) SetupWithManager(mgr ctrl.Manager) error
}, },
} }
return ctrl.NewControllerManagedBy(mgr). controllerManager := ctrl.NewControllerManagedBy(mgr).
For(&secretsv1alpha1.InfisicalPushSecret{}, builder.WithPredicates( For(&secretsv1alpha1.InfisicalPushSecret{}, builder.WithPredicates(
specChangeOrDelete, specChangeOrDelete,
)). )).
Watches( Watches(
&source.Kind{Type: &corev1.Secret{}}, &source.Kind{Type: &corev1.Secret{}},
handler.EnqueueRequestsFromMapFunc(r.findPushSecretsForSecret), handler.EnqueueRequestsFromMapFunc(r.findPushSecretsForSecret),
). )
Watches(
if !r.IsNamespaceScoped {
r.BaseLogger.Info("Watching ClusterGenerators for non-namespace scoped operator")
controllerManager.Watches(
&source.Kind{Type: &secretsv1alpha1.ClusterGenerator{}}, &source.Kind{Type: &secretsv1alpha1.ClusterGenerator{}},
handler.EnqueueRequestsFromMapFunc(r.findPushSecretsForClusterGenerator), handler.EnqueueRequestsFromMapFunc(r.findPushSecretsForClusterGenerator),
). )
Complete(r) } else {
r.BaseLogger.Info("Not watching ClusterGenerators for namespace scoped operator")
}
return controllerManager.Complete(r)
} }
func (r *InfisicalPushSecretReconciler) findPushSecretsForClusterGenerator(o client.Object) []reconcile.Request { func (r *InfisicalPushSecretReconciler) findPushSecretsForClusterGenerator(o client.Object) []reconcile.Request {
@@ -277,6 +284,7 @@ func (r *InfisicalPushSecretReconciler) findPushSecretsForClusterGenerator(o cli
} }
requests := []reconcile.Request{} requests := []reconcile.Request{}
for _, pushSecret := range pushSecrets.Items { for _, pushSecret := range pushSecrets.Items {
if pushSecret.Spec.Push.Generators != nil { if pushSecret.Spec.Push.Generators != nil {
for _, generator := range pushSecret.Spec.Push.Generators { for _, generator := range pushSecret.Spec.Push.Generators {
+4 -3
View File
@@ -99,9 +99,10 @@ func main() {
} }
if err = (&infisicalPushSecretController.InfisicalPushSecretReconciler{ if err = (&infisicalPushSecretController.InfisicalPushSecretReconciler{
Client: mgr.GetClient(), Client: mgr.GetClient(),
Scheme: mgr.GetScheme(), Scheme: mgr.GetScheme(),
BaseLogger: ctrl.Log, BaseLogger: ctrl.Log,
IsNamespaceScoped: namespace != "",
}).SetupWithManager(mgr); err != nil { }).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "InfisicalPushSecret") setupLog.Error(err, "unable to create controller", "controller", "InfisicalPushSecret")
os.Exit(1) os.Exit(1)