Compare commits

...

74 Commits

Author SHA1 Message Date
Sheen Capadngan
d185dbb7ff misc: add proper error message for bypass failure 2025-05-22 01:00:13 +08:00
Maidul Islam
4292cb2a04 Merge pull request #3518 from akhilmhdh/fix/email-ambigious
fix: email casing conflicts
2025-05-20 21:16:16 -07:00
Maidul Islam
051f53c66e Update bug-bounty.mdx 2025-05-20 18:15:36 -07:00
Maidul Islam
99daa43fc6 delete duplicate accounts 2025-05-20 16:40:21 -07:00
Scott Wilson
27badad3d7 Merge pull request #3614 from Infisical/ldap-target-principal-rotation
feature(secret-rotation): Add support for LDAP target principal self-rotation and UPN
2025-05-20 12:56:52 -07:00
Daniel Hougaard
b5e3af6e7d Merge pull request #3636 from Infisical/helm-update-v0.9.3
Update Helm chart to version v0.9.3
2025-05-20 23:55:21 +04:00
DanielHougaard
280fbdfbb9 Update Helm chart to version v0.9.3 2025-05-20 19:54:55 +00:00
Daniel Hougaard
18fc10aaec Merge pull request #3635 from Infisical/daniel/k8s-generator-fix
fix(k8s): disable clustergenerator watching in namespace scoped installations
2025-05-20 23:52:43 +04:00
Scott Wilson
b20e04bdeb improvements: address feedback 2025-05-20 12:41:37 -07:00
Daniel Hougaard
10d14edc20 Update infisicalpushsecret_controller.go 2025-05-20 23:35:43 +04:00
Maidul Islam
4abdd4216b Merge pull request #3634 from akhilmhdh/feat/license-server-changes
Feat: license server changes
2025-05-20 12:14:43 -07:00
=
332ed68c13 feat: updated message based on feedback 2025-05-21 00:42:06 +05:30
Daniel Hougaard
52feabd786 fix(k8s): disable clustergenerator watching in namespace scoped installation 2025-05-20 23:03:58 +04:00
=
d7a99db66a feat: corrected to small subset of error status code 2025-05-21 00:29:36 +05:30
=
fc0bdc25af feat: corrected text 2025-05-21 00:26:02 +05:30
=
5ffe45eaf5 feat: fixed license server changes in cloud 2025-05-21 00:21:27 +05:30
=
8f795100ea feat: updated cloud functions for quantity change made 2025-05-21 00:21:27 +05:30
Daniel Hougaard
8d8a3efd77 Merge pull request #3631 from Infisical/daniel/password-resets-fix
fix(password-resets): allow password resets when users don't have a password set
2025-05-20 18:14:07 +04:00
Daniel Hougaard
677180548b Update auth-password-service.ts 2025-05-20 17:47:47 +04:00
Daniel Hougaard
293bea474e Merge pull request #3626 from Infisical/daniel/agent-injector-docs
docs: k8s agent injector
2025-05-20 17:33:15 +04:00
Sheen
1f85d9c486 Merge pull request #3629 from Infisical/misc/add-fortanix-hsm
misc: add docs for Fortanix HSM
2025-05-20 20:51:13 +08:00
Daniel Hougaard
75d33820b3 Merge pull request #3630 from Infisical/daniel/agent-exit-code
fix(agent): exit code 1 on fetch secrets error
2025-05-20 14:39:34 +04:00
Daniel Hougaard
074446df1f Update agent.go 2025-05-20 14:32:07 +04:00
Daniel Hougaard
5250e7c3d5 Update docs/documentation/platform/kms/hsm-integration.mdx
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2025-05-20 12:34:57 +04:00
Sheen
2deaa4eff3 misc: final revisions 2025-05-20 06:14:15 +00:00
Maidul Islam
0b6bc4c1f0 update spend 2025-05-19 21:58:19 -07:00
Maidul Islam
abbe7bbd0c Merge pull request #3627 from Infisical/fix-breaking-schema-changes--for-k8s
Allow Hyphens in k8s
2025-05-19 18:26:09 -07:00
Maidul Islam
565340dc50 fix lint 2025-05-19 18:13:45 -07:00
Maidul Islam
36c428f152 allow hyphens in host name 2025-05-19 17:45:12 -07:00
Maidul Islam
f97826ea82 allow hyphens in host name 2025-05-19 17:42:42 -07:00
Maidul Islam
0f5cbf055c remove limit 2025-05-19 17:27:47 -07:00
x032205
b960ee61d7 Merge pull request #3624 from Infisical/product-select-docs
add product select to docs + change the heading
2025-05-19 17:16:38 -04:00
x032205
0b98a214a7 ui tweaks 2025-05-19 17:15:42 -04:00
x032205
599c2226e4 Merge pull request #3615 from Infisical/ENG-2787
feat(org): Shared Secret limits for org
2025-05-19 16:26:10 -04:00
Sheen
8e24a4d3f8 misc: added docs 2025-05-19 20:19:39 +00:00
x032205
27486e7600 Merge pull request #3625 from Infisical/ENG-2795
fix secret rollback not tainting form
2025-05-19 16:17:26 -04:00
x032205
979e9efbcb fix lint issue 2025-05-19 15:52:50 -04:00
Sheen Capadngan
e06b5ecd1b misc: add error handling for already initialized error 2025-05-20 03:44:21 +08:00
x032205
1097ec64b2 ui improvements 2025-05-19 15:40:07 -04:00
x032205
93fe9929b7 fix secret rollback not tainting form 2025-05-19 15:22:24 -04:00
x032205
aca654a993 Update docs/documentation/platform/organization.mdx
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2025-05-19 13:38:34 -04:00
x032205
b5cf237a4a add product select to docs + change the heading 2025-05-19 13:35:35 -04:00
x032205
6efb630200 Moved secret share limits to secret share settings 2025-05-19 12:32:22 -04:00
x032205
151ede6cbf Merge 2025-05-19 12:20:02 -04:00
x032205
931ee1e8da Merge pull request #3616 from Infisical/ENG-2783
feat(secret-sharing): Specify Emails
2025-05-19 12:12:07 -04:00
x032205
0401793d38 Changed "token" param to "hash" and used hex encoding for URL 2025-05-19 10:48:58 -04:00
x032205
0613c12508 Merge pull request #3618 from Infisical/fix-bundle-for-old-certs 2025-05-18 13:29:31 -04:00
Daniel Hougaard
60d3ffac5d Merge pull request #3620 from Infisical/daniel/k8s-auth-fix
fix(identities-auth): fixed kubernetes auth login
2025-05-17 22:18:52 +04:00
x032205
f92aba14cd Merge pull request #3619 from Infisical/fix-padding
Org Products Padding Fix
2025-05-17 13:11:56 -04:00
x032205
fdeefcdfcf padding to match similar container 2025-05-17 13:10:15 -04:00
x032205
645f70f770 tweaks 2025-05-17 13:05:09 -04:00
x032205
923feb81f3 fix bundle endpoint for old certs 2025-05-17 12:44:05 -04:00
x032205
16c51af340 review fixes 2025-05-17 02:17:41 -04:00
x032205
9fd37ca456 greptile review fixes 2025-05-17 01:51:05 -04:00
x032205
92bebf7d84 feat(secret-sharing): Specify Emails 2025-05-17 00:54:40 -04:00
x032205
df053bbae9 Merge pull request #3611 from Infisical/ENG-2782
feat(project): Enable / Disable Secret Sharing
2025-05-16 18:58:39 -04:00
x032205
42319f01a7 greptile review fixes 2025-05-16 18:54:57 -04:00
x032205
0ea9f9b60d feat(org): Shared Secret limits for org 2025-05-16 18:36:02 -04:00
Scott Wilson
33ce783fda improvements: address feedback 2025-05-16 15:16:36 -07:00
Scott Wilson
63c48dc095 feature: add suport for target principal self rotation 2025-05-16 13:15:33 -07:00
Scott Wilson
16eefe5bac Merge pull request #3610 from Infisical/sso-empty-state
improvement(sso-page): Add empty display for SSO general tab if no SSO is enabled
2025-05-16 10:10:16 -07:00
Daniel Hougaard
b984111a73 Merge pull request #3612 from Infisical/daniel/cli-auth-fix
fix(auth): cli auth bug
2025-05-16 17:29:21 +04:00
x032205
ad50cff184 Update frontend/src/pages/secret-manager/SettingsPage/components/SecretSharingSection/SecretSharingSection.tsx
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2025-05-16 00:21:30 -04:00
x032205
8e43d2a994 feat(project): Enable / Disable Secret Sharing 2025-05-16 00:08:55 -04:00
Scott Wilson
ef70de1e0b fix: add noopenner to doc link 2025-05-15 20:05:56 -07:00
Scott Wilson
7e9ee7b5e3 fix: add empty display for sso general tab if no sso is enabled 2025-05-15 20:01:08 -07:00
=
52f8c6adba feat: updated ui 2025-05-15 00:56:53 +05:30
=
3d2b2cbbab feat: updated logic to have login sso 2025-05-15 00:56:53 +05:30
=
1a82809bd5 fix: resolved lint issue 2025-05-15 00:56:53 +05:30
=
c4f994750d feat: removed merge logic as we now have duplicate fix logic 2025-05-15 00:56:53 +05:30
=
fa7020949c feat: resolve alignment issue and fixed sanitization to top level 2025-05-15 00:56:53 +05:30
=
eca2b3ccde feat: rabbit and reptile feedback changes 2025-05-15 00:56:53 +05:30
=
67fc16ecd3 feat: updated frontend for casing deletion process fix 2025-05-15 00:56:53 +05:30
=
f85add7cca feat: implemented backend updates for email casing issue 2025-05-15 00:56:52 +05:30
116 changed files with 2989 additions and 1033 deletions

View File

@@ -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
`);
}
}

View File

@@ -0,0 +1,21 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
const hasSecretSharingColumn = await knex.schema.hasColumn(TableName.Project, "secretSharing");
if (!hasSecretSharingColumn) {
await knex.schema.table(TableName.Project, (table) => {
table.boolean("secretSharing").notNullable().defaultTo(true);
});
}
}
export async function down(knex: Knex): Promise<void> {
const hasSecretSharingColumn = await knex.schema.hasColumn(TableName.Project, "secretSharing");
if (hasSecretSharingColumn) {
await knex.schema.table(TableName.Project, (table) => {
table.dropColumn("secretSharing");
});
}
}

View File

@@ -0,0 +1,35 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
const hasLifetimeColumn = await knex.schema.hasColumn(TableName.Organization, "maxSharedSecretLifetime");
const hasViewLimitColumn = await knex.schema.hasColumn(TableName.Organization, "maxSharedSecretViewLimit");
if (!hasLifetimeColumn || !hasViewLimitColumn) {
await knex.schema.alterTable(TableName.Organization, (t) => {
if (!hasLifetimeColumn) {
t.integer("maxSharedSecretLifetime").nullable().defaultTo(2592000); // 30 days in seconds
}
if (!hasViewLimitColumn) {
t.integer("maxSharedSecretViewLimit").nullable();
}
});
}
}
export async function down(knex: Knex): Promise<void> {
const hasLifetimeColumn = await knex.schema.hasColumn(TableName.Organization, "maxSharedSecretLifetime");
const hasViewLimitColumn = await knex.schema.hasColumn(TableName.Organization, "maxSharedSecretViewLimit");
if (hasLifetimeColumn || hasViewLimitColumn) {
await knex.schema.alterTable(TableName.Organization, (t) => {
if (hasLifetimeColumn) {
t.dropColumn("maxSharedSecretLifetime");
}
if (hasViewLimitColumn) {
t.dropColumn("maxSharedSecretViewLimit");
}
});
}
}

View File

@@ -0,0 +1,43 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.SecretSharing)) {
const hasEncryptedSalt = await knex.schema.hasColumn(TableName.SecretSharing, "encryptedSalt");
const hasAuthorizedEmails = await knex.schema.hasColumn(TableName.SecretSharing, "authorizedEmails");
if (!hasEncryptedSalt || !hasAuthorizedEmails) {
await knex.schema.alterTable(TableName.SecretSharing, (t) => {
// These two columns are only needed when secrets are shared with a specific list of emails
if (!hasEncryptedSalt) {
t.binary("encryptedSalt").nullable();
}
if (!hasAuthorizedEmails) {
t.json("authorizedEmails").nullable();
}
});
}
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.SecretSharing)) {
const hasEncryptedSalt = await knex.schema.hasColumn(TableName.SecretSharing, "encryptedSalt");
const hasAuthorizedEmails = await knex.schema.hasColumn(TableName.SecretSharing, "authorizedEmails");
if (hasEncryptedSalt || hasAuthorizedEmails) {
await knex.schema.alterTable(TableName.SecretSharing, (t) => {
if (hasEncryptedSalt) {
t.dropColumn("encryptedSalt");
}
if (hasAuthorizedEmails) {
t.dropColumn("authorizedEmails");
}
});
}
}
}

View File

@@ -34,7 +34,9 @@ export const OrganizationsSchema = z.object({
kmsProductEnabled: z.boolean().default(true).nullable().optional(),
sshProductEnabled: z.boolean().default(true).nullable().optional(),
scannerProductEnabled: z.boolean().default(true).nullable().optional(),
shareSecretsProductEnabled: z.boolean().default(true).nullable().optional()
shareSecretsProductEnabled: z.boolean().default(true).nullable().optional(),
maxSharedSecretLifetime: z.number().default(2592000).nullable().optional(),
maxSharedSecretViewLimit: z.number().nullable().optional()
});
export type TOrganizations = z.infer<typeof OrganizationsSchema>;

View File

@@ -27,7 +27,8 @@ export const ProjectsSchema = z.object({
description: z.string().nullable().optional(),
type: z.string(),
enforceCapitalization: z.boolean().default(false),
hasDeleteProtection: z.boolean().default(false).nullable().optional()
hasDeleteProtection: z.boolean().default(false).nullable().optional(),
secretSharing: z.boolean().default(true)
});
export type TProjects = z.infer<typeof ProjectsSchema>;

View File

@@ -27,7 +27,9 @@ export const SecretSharingSchema = z.object({
password: z.string().nullable().optional(),
encryptedSecret: zodBuffer.nullable().optional(),
identifier: z.string().nullable().optional(),
type: z.string().default("share")
type: z.string().default("share"),
encryptedSalt: zodBuffer.nullable().optional(),
authorizedEmails: z.unknown().nullable().optional()
});
export type TSecretSharing = z.infer<typeof SecretSharingSchema>;

View File

@@ -145,7 +145,7 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
const { isUserCompleted, providerAuthToken } = await server.services.saml.samlLogin({
externalId: profile.nameID,
email,
email: email.toLowerCase(),
firstName,
lastName: lastName as string,
relayState: (req.body as { RelayState?: string }).RelayState,

View File

@@ -111,9 +111,9 @@ export const groupDALFactory = (db: TDbClient) => {
}
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) {
void query.andWhere(`${TableName.Users}.username`, "ilike", `%${username}%`);
void query.andWhereRaw(`lower("${TableName.Users}"."username") ilike ?`, `%${username}%`);
}
switch (filter) {

View File

@@ -30,7 +30,7 @@ import {
import { TUserGroupMembershipDALFactory } from "./user-group-membership-dal";
type TGroupServiceFactoryDep = {
userDAL: Pick<TUserDALFactory, "find" | "findUserEncKeyByUserIdsBatch" | "transaction" | "findOne">;
userDAL: Pick<TUserDALFactory, "find" | "findUserEncKeyByUserIdsBatch" | "transaction" | "findUserByUsername">;
groupDAL: Pick<
TGroupDALFactory,
"create" | "findOne" | "update" | "delete" | "findAllGroupPossibleMembers" | "findById" | "transaction"
@@ -380,7 +380,10 @@ export const groupServiceFactory = ({
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}` });
const users = await addUsersToGroupByUserIds({
@@ -461,7 +464,10 @@ export const groupServiceFactory = ({
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}` });
const users = await removeUsersFromGroupByUserIds({

View File

@@ -24,9 +24,13 @@ export const initializeHsmModule = (envConfig: Pick<TEnvConfig, "isHsmConfigured
isInitialized = true;
logger.info("PKCS#11 module initialized");
} catch (err) {
logger.error(err, "Failed to initialize PKCS#11 module");
throw err;
} catch (error) {
if (error instanceof pkcs11js.Pkcs11Error && error.code === pkcs11js.CKR_CRYPTOKI_ALREADY_INITIALIZED) {
logger.info("Skipping HSM initialization because it's already initialized.");
} else {
logger.error(error, "Failed to initialize PKCS#11 module");
throw error;
}
}
};

View File

@@ -380,7 +380,7 @@ export const ldapConfigServiceFactory = ({
if (serverCfg.trustLdapEmails) {
newUser = await userDAL.findOne(
{
email,
email: email.toLowerCase(),
isEmailVerified: true
},
tx
@@ -391,8 +391,8 @@ export const ldapConfigServiceFactory = ({
const uniqueUsername = await normalizeUsername(username, userDAL);
newUser = await userDAL.create(
{
username: serverCfg.trustLdapEmails ? email : uniqueUsername,
email,
username: serverCfg.trustLdapEmails ? email.toLowerCase() : uniqueUsername,
email: email.toLowerCase(),
isEmailVerified: serverCfg.trustLdapEmails,
firstName,
lastName,
@@ -429,7 +429,7 @@ export const ldapConfigServiceFactory = ({
await orgMembershipDAL.create(
{
userId: newUser.id,
inviteEmail: email,
inviteEmail: email.toLowerCase(),
orgId,
role,
roleId,

View File

@@ -2,6 +2,7 @@ import axios, { AxiosError } from "axios";
import { getConfig } from "@app/lib/config/env";
import { request } from "@app/lib/config/request";
import { logger } from "@app/lib/logger";
import { TFeatureSet } from "./license-types";
@@ -98,9 +99,10 @@ export const setupLicenseRequestWithStore = (baseURL: string, refreshUrl: string
(response) => response,
async (err) => {
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
if ((err as AxiosError)?.response?.status === 401 && !(originalRequest as any)._retry) {
if ((errStatusCode === 401 || errStatusCode === 403) && !(originalRequest as any)._retry) {
// eslint-disable-next-line
(originalRequest as any)._retry = true; // injected

View File

@@ -348,8 +348,8 @@ export const licenseServiceFactory = ({
} = await licenseServerCloudApi.request.post(
`/api/license-server/v1/customers/${organization.customerId}/billing-details/payment-methods`,
{
success_url: `${appCfg.SITE_URL}/dashboard`,
cancel_url: `${appCfg.SITE_URL}/dashboard`
success_url: `${appCfg.SITE_URL}/organization/billing`,
cancel_url: `${appCfg.SITE_URL}/organization/billing`
}
);
@@ -362,7 +362,7 @@ export const licenseServiceFactory = ({
} = await licenseServerCloudApi.request.post(
`/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`
});
}
if (instanceType !== InstanceType.OnPrem && instanceType !== InstanceType.EnterpriseOnPremOffline) {
if (instanceType === InstanceType.Cloud) {
const { data } = await licenseServerCloudApi.request.get(
`/api/license-server/v1/customers/${organization.customerId}/cloud-plan/billing`
);
@@ -407,11 +407,38 @@ export const licenseServiceFactory = ({
message: `Organization with ID '${orgId}' not found`
});
}
if (instanceType !== InstanceType.OnPrem && instanceType !== InstanceType.EnterpriseOnPremOffline) {
const { data } = await licenseServerCloudApi.request.get(
`/api/license-server/v1/customers/${organization.customerId}/cloud-plan/table`
);
return data;
const orgMembersUsed = await orgDAL.countAllOrgMembers(orgId);
const identityUsed = await identityOrgMembershipDAL.countAllOrgIdentities({ orgId });
const projects = await projectDAL.find({ orgId });
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(
@@ -420,14 +447,11 @@ export const licenseServiceFactory = ({
let used = "-";
if (field === BillingPlanRows.MemberLimit.field) {
const orgMemberships = await orgDAL.countAllOrgMembers(orgId);
used = orgMemberships.toString();
used = orgMembersUsed.toString();
} else if (field === BillingPlanRows.WorkspaceLimit.field) {
const projects = await projectDAL.find({ orgId });
used = projects.length.toString();
used = projectCount.toString();
} else if (field === BillingPlanRows.IdentityLimit.field) {
const identities = await identityOrgMembershipDAL.countAllOrgIdentities({ orgId });
used = identities.toString();
used = identityUsed.toString();
}
return {

View File

@@ -171,8 +171,8 @@ export const oidcConfigServiceFactory = ({
};
const oidcLogin = async ({
externalId,
email,
externalId,
firstName,
lastName,
orgId,
@@ -717,7 +717,7 @@ export const oidcConfigServiceFactory = ({
const groups = typeof claims.groups === "string" ? [claims.groups] : (claims.groups as string[] | undefined);
oidcLogin({
email: claims.email,
email: claims.email.toLowerCase(),
externalId: claims.sub,
firstName: claims.given_name ?? "",
lastName: claims.family_name ?? "",

View File

@@ -342,7 +342,7 @@ export const scimServiceFactory = ({
orgMembership = await orgMembershipDAL.create(
{
userId: userAlias.userId,
inviteEmail: email,
inviteEmail: email.toLowerCase(),
orgId,
role,
roleId,
@@ -364,7 +364,7 @@ export const scimServiceFactory = ({
if (trustScimEmails) {
user = await userDAL.findOne(
{
email,
email: email.toLowerCase(),
isEmailVerified: true
},
tx
@@ -379,8 +379,8 @@ export const scimServiceFactory = ({
);
user = await userDAL.create(
{
username: trustScimEmails ? email : uniqueUsername,
email,
username: trustScimEmails ? email.toLowerCase() : uniqueUsername,
email: email.toLowerCase(),
isEmailVerified: trustScimEmails,
firstName,
lastName,
@@ -396,7 +396,7 @@ export const scimServiceFactory = ({
userId: user.id,
aliasType,
externalId,
emails: email ? [email] : [],
emails: email ? [email.toLowerCase()] : [],
orgId
},
tx
@@ -418,7 +418,7 @@ export const scimServiceFactory = ({
orgMembership = await orgMembershipDAL.create(
{
userId: user.id,
inviteEmail: email,
inviteEmail: email.toLowerCase(),
orgId,
role,
roleId,
@@ -529,7 +529,7 @@ export const scimServiceFactory = ({
membership.userId,
{
firstName: scimUser.name.givenName,
email: scimUser.emails[0].value,
email: scimUser.emails[0].value.toLowerCase(),
lastName: scimUser.name.familyName,
isEmailVerified: hasEmailChanged ? trustScimEmails : undefined
},
@@ -606,7 +606,7 @@ export const scimServiceFactory = ({
membership.userId,
{
firstName,
email,
email: email?.toLowerCase(),
lastName,
isEmailVerified:
org.orgAuthMethod === OrgAuthMethod.OIDC ? serverCfg.trustOidcEmails : serverCfg.trustSamlEmails

View File

@@ -1,4 +1,4 @@
import ldap from "ldapjs";
import ldap, { Client, SearchOptions } from "ldapjs";
import {
TRotationFactory,
@@ -8,26 +8,73 @@ import {
TRotationFactoryRotateCredentials
} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types";
import { logger } from "@app/lib/logger";
import { DistinguishedNameRegex } from "@app/lib/regex";
import { encryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns";
import { getLdapConnectionClient, LdapProvider, TLdapConnection } from "@app/services/app-connection/ldap";
import { generatePassword } from "../shared/utils";
import {
LdapPasswordRotationMethod,
TLdapPasswordRotationGeneratedCredentials,
TLdapPasswordRotationInput,
TLdapPasswordRotationWithConnection
} from "./ldap-password-rotation-types";
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<
TLdapPasswordRotationWithConnection,
TLdapPasswordRotationGeneratedCredentials
TLdapPasswordRotationGeneratedCredentials,
TLdapPasswordRotationInput["temporaryParameters"]
> = (secretRotation, appConnectionDAL, kmsService) => {
const {
connection,
parameters: { dn, passwordRequirements },
secretsMapping
} = secretRotation;
const { connection, parameters, secretsMapping, activeIndex } = secretRotation;
const { dn, passwordRequirements } = parameters;
const $verifyCredentials = async (credentials: Pick<TLdapConnection["credentials"], "dn" | "password">) => {
try {
@@ -40,13 +87,21 @@ export const ldapPasswordRotationFactory: TRotationFactory<
}
};
const $rotatePassword = async () => {
const $rotatePassword = async (currentPassword?: string) => {
const { credentials, orgId } = connection;
if (!credentials.url.startsWith("ldaps")) throw new Error("Password Rotation requires an LDAPS connection");
const client = await getLdapConnectionClient(credentials);
const isPersonalRotation = credentials.dn === dn;
const client = await getLdapConnectionClient(
currentPassword
? {
...credentials,
password: currentPassword,
dn
}
: credentials
);
const isConnectionRotation = credentials.dn === dn;
const password = generatePassword(passwordRequirements);
@@ -58,8 +113,8 @@ export const ldapPasswordRotationFactory: TRotationFactory<
const encodedPassword = getEncodedPassword(password);
// service account vs personal password rotation require different changes
if (isPersonalRotation) {
const currentEncodedPassword = getEncodedPassword(credentials.password);
if (isConnectionRotation || currentPassword) {
const currentEncodedPassword = getEncodedPassword(currentPassword || credentials.password);
changes = [
new ldap.Change({
@@ -93,8 +148,9 @@ export const ldapPasswordRotationFactory: TRotationFactory<
}
try {
const userDn = await getDN(dn, client);
await new Promise((resolve, reject) => {
client.modify(dn, changes, (err) => {
client.modify(userDn, changes, (err) => {
if (err) {
logger.error(err, "LDAP Password Rotation Failed");
reject(new Error(`Provider Modify Error: ${err.message}`));
@@ -110,7 +166,7 @@ export const ldapPasswordRotationFactory: TRotationFactory<
await $verifyCredentials({ dn, password });
if (isPersonalRotation) {
if (isConnectionRotation) {
const updatedCredentials: TLdapConnection["credentials"] = {
...credentials,
password
@@ -128,29 +184,41 @@ export const ldapPasswordRotationFactory: TRotationFactory<
return { dn, password };
};
const issueCredentials: TRotationFactoryIssueCredentials<TLdapPasswordRotationGeneratedCredentials> = async (
callback
) => {
const credentials = await $rotatePassword();
const issueCredentials: TRotationFactoryIssueCredentials<
TLdapPasswordRotationGeneratedCredentials,
TLdapPasswordRotationInput["temporaryParameters"]
> = async (callback, temporaryParameters) => {
const credentials = await $rotatePassword(
parameters.rotationMethod === LdapPasswordRotationMethod.TargetPrincipal
? temporaryParameters?.password
: undefined
);
return callback(credentials);
};
const revokeCredentials: TRotationFactoryRevokeCredentials<TLdapPasswordRotationGeneratedCredentials> = async (
_,
credentialsToRevoke,
callback
) => {
const currentPassword = credentialsToRevoke[activeIndex].password;
// we just rotate to a new password, essentially revoking old credentials
await $rotatePassword();
await $rotatePassword(
parameters.rotationMethod === LdapPasswordRotationMethod.TargetPrincipal ? currentPassword : undefined
);
return callback();
};
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);
};

View File

@@ -1,6 +1,6 @@
import RE2 from "re2";
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 {
BaseCreateSecretRotationSchema,
@@ -9,7 +9,7 @@ import {
} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-schemas";
import { PasswordRequirementsSchema } from "@app/ee/services/secret-rotation-v2/shared/general";
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 { AppConnection } from "@app/services/app-connection/app-connection-enums";
@@ -26,10 +26,16 @@ const LdapPasswordRotationParametersSchema = z.object({
dn: z
.string()
.trim()
.regex(new RE2(DistinguishedNameRegex), "Invalid DN format, ie; CN=user,OU=users,DC=example,DC=com")
.min(1, "Distinguished Name (DN) Required")
.min(1, "DN/UPN required")
.refine((value) => DistinguishedNameRegex.test(value) || UserPrincipalNameRegex.test(value), {
message: "Invalid DN/UPN format"
})
.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({
@@ -50,10 +56,28 @@ export const LdapPasswordRotationSchema = BaseSecretRotationSchema(SecretRotatio
secretsMapping: LdapPasswordRotationSecretsMappingSchema
});
export const CreateLdapPasswordRotationSchema = BaseCreateSecretRotationSchema(SecretRotation.LdapPassword).extend({
parameters: LdapPasswordRotationParametersSchema,
secretsMapping: LdapPasswordRotationSecretsMappingSchema
});
export const CreateLdapPasswordRotationSchema = BaseCreateSecretRotationSchema(SecretRotation.LdapPassword)
.extend({
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({
parameters: LdapPasswordRotationParametersSchema.optional(),

View File

@@ -9,6 +9,11 @@ import {
LdapPasswordRotationSchema
} from "./ldap-password-rotation-schemas";
export enum LdapPasswordRotationMethod {
ConnectionPrincipal = "connection-principal",
TargetPrincipal = "target-principal"
}
export type TLdapPasswordRotation = z.infer<typeof LdapPasswordRotationSchema>;
export type TLdapPasswordRotationInput = z.infer<typeof CreateLdapPasswordRotationSchema>;

View File

@@ -1,12 +1,13 @@
import { AxiosError } from "axios";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError } from "@app/lib/errors";
import { KmsDataKey } from "@app/services/kms/kms-types";
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 { 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 { POSTGRES_CREDENTIALS_ROTATION_LIST_OPTION } from "./postgres-credentials";
import { SecretRotation, SecretRotationStatus } from "./secret-rotation-v2-enums";
@@ -15,7 +16,8 @@ import {
TSecretRotationV2,
TSecretRotationV2GeneratedCredentials,
TSecretRotationV2ListItem,
TSecretRotationV2Raw
TSecretRotationV2Raw,
TUpdateSecretRotationV2DTO
} from "./secret-rotation-v2-types";
const SECRET_ROTATION_LIST_OPTIONS: Record<SecretRotation, TSecretRotationV2ListItem> = {
@@ -228,3 +230,30 @@ export const parseRotationErrorMessage = (err: unknown): string => {
? errorMessage
: `${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
}
};

View File

@@ -25,7 +25,8 @@ import {
getNextUtcRotationInterval,
getSecretRotationRotateSecretJobOptions,
listSecretRotationOptions,
parseRotationErrorMessage
parseRotationErrorMessage,
throwOnImmutableParameterUpdate
} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-fns";
import {
SECRET_ROTATION_CONNECTION_MAP,
@@ -46,6 +47,7 @@ import {
TSecretRotationV2,
TSecretRotationV2GeneratedCredentials,
TSecretRotationV2Raw,
TSecretRotationV2TemporaryParameters,
TSecretRotationV2WithConnection,
TUpdateSecretRotationV2DTO
} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types";
@@ -112,7 +114,8 @@ const MAX_GENERATED_CREDENTIALS_LENGTH = 2;
type TRotationFactoryImplementation = TRotationFactory<
TSecretRotationV2WithConnection,
TSecretRotationV2GeneratedCredentials
TSecretRotationV2GeneratedCredentials,
TSecretRotationV2TemporaryParameters
>;
const SECRET_ROTATION_FACTORY_MAP: Record<SecretRotation, TRotationFactoryImplementation> = {
[SecretRotation.PostgresCredentials]: sqlCredentialsRotationFactory as TRotationFactoryImplementation,
@@ -400,6 +403,7 @@ export const secretRotationV2ServiceFactory = ({
environment,
rotateAtUtc = { hours: 0, minutes: 0 },
secretsMapping,
temporaryParameters,
...payload
}: TCreateSecretRotationV2DTO,
actor: OrgServiceActor
@@ -546,7 +550,7 @@ export const secretRotationV2ServiceFactory = ({
return createdRotation;
});
});
}, temporaryParameters);
await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId);
await snapshotService.performSnapshot(folder.id);
@@ -585,10 +589,7 @@ export const secretRotationV2ServiceFactory = ({
}
};
const updateSecretRotation = async (
{ type, rotationId, ...payload }: TUpdateSecretRotationV2DTO,
actor: OrgServiceActor
) => {
const updateSecretRotation = async (dto: TUpdateSecretRotationV2DTO, actor: OrgServiceActor) => {
const plan = await licenseService.getPlan(actor.orgId);
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."
});
const { type, rotationId, ...payload } = dto;
const secretRotation = await secretRotationV2DAL.findById(rotationId);
if (!secretRotation)
@@ -603,6 +606,8 @@ export const secretRotationV2ServiceFactory = ({
message: `Could not find ${SECRET_ROTATION_NAME_MAP[type]} Rotation with ID ${rotationId}`
});
throwOnImmutableParameterUpdate(dto, secretRotation);
const { folder, environment, projectId, folderId, connection } = secretRotation;
const secretsMapping = secretRotation.secretsMapping as TSecretRotationV2["secretsMapping"];
@@ -877,6 +882,7 @@ export const secretRotationV2ServiceFactory = ({
const inactiveIndex = (activeIndex + 1) % MAX_GENERATED_CREDENTIALS_LENGTH;
const inactiveCredentials = generatedCredentials[inactiveIndex];
const activeCredentials = generatedCredentials[activeIndex];
const rotationFactory = SECRET_ROTATION_FACTORY_MAP[type as SecretRotation](
{
@@ -887,73 +893,77 @@ export const secretRotationV2ServiceFactory = ({
kmsService
);
const updatedRotation = await rotationFactory.rotateCredentials(inactiveCredentials, async (newCredentials) => {
const updatedCredentials = [...generatedCredentials];
updatedCredentials[inactiveIndex] = newCredentials;
const updatedRotation = await rotationFactory.rotateCredentials(
inactiveCredentials,
async (newCredentials) => {
const updatedCredentials = [...generatedCredentials];
updatedCredentials[inactiveIndex] = newCredentials;
const encryptedUpdatedCredentials = await encryptSecretRotationCredentials({
projectId,
generatedCredentials: updatedCredentials as TSecretRotationV2GeneratedCredentials,
kmsService
});
return secretRotationV2DAL.transaction(async (tx) => {
const secretsPayload = rotationFactory.getSecretsPayload(newCredentials);
const { encryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
projectId
const encryptedUpdatedCredentials = await encryptSecretRotationCredentials({
projectId,
generatedCredentials: updatedCredentials as TSecretRotationV2GeneratedCredentials,
kmsService
});
// update mapped secrets with new credential values
await fnSecretBulkUpdate({
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
});
return secretRotationV2DAL.transaction(async (tx) => {
const secretsPayload = rotationFactory.getSecretsPayload(newCredentials);
const currentTime = new Date();
const { encryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
projectId
});
return secretRotationV2DAL.updateById(
secretRotation.id,
{
encryptedGeneratedCredentials: encryptedUpdatedCredentials,
activeIndex: inactiveIndex,
isLastRotationManual: isManualRotation,
lastRotatedAt: currentTime,
lastRotationAttemptedAt: currentTime,
nextRotationAt: calculateNextRotationAt({
...(secretRotation as TSecretRotationV2),
rotationStatus: SecretRotationStatus.Success,
// update mapped secrets with new credential values
await fnSecretBulkUpdate({
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();
return secretRotationV2DAL.updateById(
secretRotation.id,
{
encryptedGeneratedCredentials: encryptedUpdatedCredentials,
activeIndex: inactiveIndex,
isLastRotationManual: isManualRotation,
lastRotatedAt: currentTime,
isManualRotation
}),
rotationStatus: SecretRotationStatus.Success,
lastRotationJobId: jobId,
encryptedLastRotationMessage: null
},
tx
);
});
});
lastRotationAttemptedAt: currentTime,
nextRotationAt: calculateNextRotationAt({
...(secretRotation as TSecretRotationV2),
rotationStatus: SecretRotationStatus.Success,
lastRotatedAt: currentTime,
isManualRotation
}),
rotationStatus: SecretRotationStatus.Success,
lastRotationJobId: jobId,
encryptedLastRotationMessage: null
},
tx
);
});
},
activeCredentials
);
await auditLogService.createAuditLog({
...(auditLogInfo ?? {

View File

@@ -87,6 +87,8 @@ export type TSecretRotationV2ListItem =
| TLdapPasswordRotationListItem
| TAwsIamUserSecretRotationListItem;
export type TSecretRotationV2TemporaryParameters = TLdapPasswordRotationInput["temporaryParameters"] | undefined;
export type TSecretRotationV2Raw = NonNullable<Awaited<ReturnType<TSecretRotationV2DALFactory["findById"]>>>;
export type TListSecretRotationsV2ByProjectId = {
@@ -120,6 +122,7 @@ export type TCreateSecretRotationV2DTO = Pick<
environment: string;
isAutoRotationEnabled?: boolean;
rotateAtUtc?: TRotateAtUtc;
temporaryParameters?: TSecretRotationV2TemporaryParameters;
};
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
// third party credential changes (when supported), preventing credentials getting out of sync
export type TRotationFactoryIssueCredentials<T extends TSecretRotationV2GeneratedCredentials> = (
callback: (newCredentials: T[number]) => Promise<TSecretRotationV2Raw>
export type TRotationFactoryIssueCredentials<
T extends TSecretRotationV2GeneratedCredentials,
P extends TSecretRotationV2TemporaryParameters = undefined
> = (
callback: (newCredentials: T[number]) => Promise<TSecretRotationV2Raw>,
temporaryParameters?: P
) => Promise<TSecretRotationV2Raw>;
export type TRotationFactoryRevokeCredentials<T extends TSecretRotationV2GeneratedCredentials> = (
@@ -197,7 +204,8 @@ export type TRotationFactoryRevokeCredentials<T extends TSecretRotationV2Generat
export type TRotationFactoryRotateCredentials<T extends TSecretRotationV2GeneratedCredentials> = (
credentialsToRevoke: T[number] | undefined,
callback: (newCredentials: T[number]) => Promise<TSecretRotationV2Raw>
callback: (newCredentials: T[number]) => Promise<TSecretRotationV2Raw>,
activeCredentials: T[number]
) => Promise<TSecretRotationV2Raw>;
export type TRotationFactoryGetSecretsPayload<T extends TSecretRotationV2GeneratedCredentials> = (
@@ -206,13 +214,14 @@ export type TRotationFactoryGetSecretsPayload<T extends TSecretRotationV2Generat
export type TRotationFactory<
T extends TSecretRotationV2WithConnection,
C extends TSecretRotationV2GeneratedCredentials
C extends TSecretRotationV2GeneratedCredentials,
P extends TSecretRotationV2TemporaryParameters = undefined
> = (
secretRotation: T,
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "update" | "updateById">,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
) => {
issueCredentials: TRotationFactoryIssueCredentials<C>;
issueCredentials: TRotationFactoryIssueCredentials<C, P>;
revokeCredentials: TRotationFactoryRevokeCredentials<C>;
rotateCredentials: TRotationFactoryRotateCredentials<C>;
getSecretsPayload: TRotationFactoryGetSecretsPayload<C>;

View File

@@ -608,7 +608,8 @@ export const PROJECTS = {
projectDescription: "An optional description label for the project.",
autoCapitalization: "Disable or enable auto-capitalization for the project.",
slug: "An optional slug for the project. (must be unique within the organization)",
hasDeleteProtection: "Enable or disable delete protection for the project."
hasDeleteProtection: "Enable or disable delete protection for the project.",
secretSharing: "Enable or disable secret sharing for the project."
},
GET_KEY: {
workspaceId: "The ID of the project to get the key from."
@@ -2062,7 +2063,7 @@ export const AppConnections = {
LDAP: {
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').",
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.",
sslRejectUnauthorized:
"Whether or not to reject unauthorized SSL certificates (true/false) when using ldaps://. Set to false only in test environments.",
@@ -2307,7 +2308,10 @@ export const SecretRotations = {
clientId: "The client ID of the Azure Application to rotate the client secret for."
},
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: {
PASSWORD_REQUIREMENTS: {
@@ -2341,7 +2345,7 @@ export const SecretRotations = {
clientSecret: "The name of the secret that the rotated client secret will be mapped to."
},
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."
},
AWS_IAM_USER_SECRET: {

View File

@@ -1,6 +1,8 @@
import { Knex } from "knex";
import { Compare, Filter, parse } from "scim2-parse-filter";
import { TableName } from "@app/db/schemas";
const appendParentToGroupingOperator = (parentPath: string, filter: Filter) => {
if (filter.op !== "[]" && filter.op !== "and" && filter.op !== "or" && filter.op !== "not") {
return { ...filter, attrPath: `${parentPath}.${(filter as Compare).attrPath}` };
@@ -27,8 +29,12 @@ const processDynamicQuery = (
const { scimFilterAst, query } = stack.pop()!;
switch (scimFilterAst.op) {
case "eq": {
let sanitizedValue = scimFilterAst.compValue;
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;
}
case "pr": {
@@ -62,18 +68,30 @@ const processDynamicQuery = (
break;
}
case "ew": {
let sanitizedValue = scimFilterAst.compValue;
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;
}
case "co": {
let sanitizedValue = scimFilterAst.compValue;
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;
}
case "ne": {
let sanitizedValue = scimFilterAst.compValue;
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;
}
case "and": {

View File

@@ -1,3 +1,11 @@
import RE2 from "re2";
export const DistinguishedNameRegex =
// 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?:\/\//);

View File

@@ -625,7 +625,6 @@ export const registerRoutes = async (
const userService = userServiceFactory({
userDAL,
userAliasDAL,
orgMembershipDAL,
tokenService,
permissionService,

View File

@@ -261,7 +261,8 @@ export const SanitizedProjectSchema = ProjectsSchema.pick({
pitVersionLimit: true,
kmsCertificateKeyId: true,
auditLogsRetentionDays: true,
hasDeleteProtection: true
hasDeleteProtection: true,
secretSharing: true
});
export const SanitizedTagSchema = SecretTagsSchema.pick({

View File

@@ -131,8 +131,8 @@ export const registerCertRouter = async (server: FastifyZodProvider) => {
response: {
200: z.object({
certificate: z.string().trim().describe(CERTIFICATES.GET_CERT.certificate),
certificateChain: z.string().trim().nullish().describe(CERTIFICATES.GET_CERT.certificateChain),
privateKey: z.string().trim().describe(CERTIFICATES.GET_CERT.privateKey),
certificateChain: z.string().trim().nullable().describe(CERTIFICATES.GET_CERT.certificateChain),
privateKey: z.string().trim().nullable().describe(CERTIFICATES.GET_CERT.privateKey),
serialNumber: z.string().trim().describe(CERTIFICATES.GET_CERT.serialNumberRes)
})
}
@@ -518,7 +518,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => {
response: {
200: z.object({
certificate: z.string().trim().describe(CERTIFICATES.GET_CERT.certificate),
certificateChain: z.string().trim().nullish().describe(CERTIFICATES.GET_CERT.certificateChain),
certificateChain: z.string().trim().nullable().describe(CERTIFICATES.GET_CERT.certificateChain),
serialNumber: z.string().trim().describe(CERTIFICATES.GET_CERT.serialNumberRes)
})
}

View File

@@ -114,10 +114,12 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide
CharacterType.Numbers,
CharacterType.Colon,
CharacterType.Period,
CharacterType.ForwardSlash
CharacterType.ForwardSlash,
CharacterType.Hyphen
])(val),
{
message: "Kubernetes host must only contain alphabets, numbers, colons, periods, and forward slashes."
message:
"Kubernetes host must only contain alphabets, numbers, colons, periods, hyphen, and forward slashes."
}
),
caCert: z.string().trim().default("").describe(KUBERNETES_AUTH.ATTACH.caCert),
@@ -234,11 +236,13 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide
CharacterType.Numbers,
CharacterType.Colon,
CharacterType.Period,
CharacterType.ForwardSlash
CharacterType.ForwardSlash,
CharacterType.Hyphen
])(val);
},
{
message: "Kubernetes host must only contain alphabets, numbers, colons, periods, and forward slashes."
message:
"Kubernetes host must only contain alphabets, numbers, colons, periods, hyphen, and forward slashes."
}
),
caCert: z.string().trim().optional().describe(KUBERNETES_AUTH.UPDATE.caCert),

View File

@@ -16,7 +16,12 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => {
method: "POST",
schema: {
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(),
projects: z
.object({
@@ -115,7 +120,11 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => {
},
schema: {
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(),
code: z.string().trim()
}),

View File

@@ -281,7 +281,18 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
kmsProductEnabled: z.boolean().optional(),
sshProductEnabled: z.boolean().optional(),
scannerProductEnabled: z.boolean().optional(),
shareSecretsProductEnabled: z.boolean().optional()
shareSecretsProductEnabled: z.boolean().optional(),
maxSharedSecretLifetime: z
.number()
.min(300, "Max Shared Secret lifetime cannot be under 5 minutes")
.max(2592000, "Max Shared Secret lifetime cannot exceed 30 days")
.optional(),
maxSharedSecretViewLimit: z
.number()
.min(1, "Max Shared Secret view count cannot be lower than 1")
.max(1000, "Max Shared Secret view count cannot exceed 1000")
.nullable()
.optional()
}),
response: {
200: z.object({

View File

@@ -346,7 +346,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
"Project slug can only contain lowercase letters and numbers, with optional single hyphens (-) or underscores (_) between words. Cannot start or end with a hyphen or underscore."
})
.optional()
.describe(PROJECTS.UPDATE.slug)
.describe(PROJECTS.UPDATE.slug),
secretSharing: z.boolean().optional().describe(PROJECTS.UPDATE.secretSharing)
}),
response: {
200: z.object({
@@ -366,7 +367,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
description: req.body.description,
autoCapitalization: req.body.autoCapitalization,
hasDeleteProtection: req.body.hasDeleteProtection,
slug: req.body.slug
slug: req.body.slug,
secretSharing: req.body.secretSharing
},
actorAuthMethod: req.permission.authMethod,
actorId: req.permission.id,

View File

@@ -62,7 +62,9 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
}),
body: z.object({
hashedHex: z.string().min(1).optional(),
password: z.string().optional()
password: z.string().optional(),
email: z.string().optional(),
hash: z.string().optional()
}),
response: {
200: z.object({
@@ -88,7 +90,9 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
sharedSecretId: req.params.id,
hashedHex: req.body.hashedHex,
password: req.body.password,
orgId: req.permission?.orgId
orgId: req.permission?.orgId,
email: req.body.email,
hash: req.body.hash
});
if (sharedSecret.secret?.orgId) {
@@ -151,7 +155,8 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
secretValue: z.string(),
expiresAt: z.string(),
expiresAfterViews: z.number().min(1).optional(),
accessType: z.nativeEnum(SecretSharingAccessType).default(SecretSharingAccessType.Organization)
accessType: z.nativeEnum(SecretSharingAccessType).default(SecretSharingAccessType.Organization),
emails: z.string().email().array().max(100).optional()
}),
response: {
200: z.object({

View File

@@ -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({
method: "GET",
url: "/private-key",

View File

@@ -27,8 +27,19 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider
projectId: z.string().describe(PROJECT_USERS.INVITE_MEMBER.projectId)
}),
body: z.object({
emails: z.string().email().array().default([]).describe(PROJECT_USERS.INVITE_MEMBER.emails),
usernames: z.string().array().default([]).describe(PROJECT_USERS.INVITE_MEMBER.usernames),
emails: z
.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)
}),
response: {
@@ -92,8 +103,19 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider
projectId: z.string().describe(PROJECT_USERS.REMOVE_MEMBER.projectId)
}),
body: z.object({
emails: z.string().email().array().default([]).describe(PROJECT_USERS.REMOVE_MEMBER.emails),
usernames: z.string().array().default([]).describe(PROJECT_USERS.REMOVE_MEMBER.usernames)
emails: z
.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: {
200: z.object({

View File

@@ -1,8 +1,7 @@
import RE2 from "re2";
import { z } from "zod";
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 {
BaseAppConnectionSchema,
@@ -14,17 +13,14 @@ import { LdapConnectionMethod, LdapProvider } from "./ldap-connection-enums";
export const LdapConnectionSimpleBindCredentialsSchema = z.object({
provider: z.nativeEnum(LdapProvider).describe(AppConnections.CREDENTIALS.LDAP.provider),
url: z
.string()
.trim()
.min(1, "URL required")
.regex(new RE2(/^ldaps?:\/\//))
.describe(AppConnections.CREDENTIALS.LDAP.url),
url: z.string().trim().min(1, "URL required").regex(LdapUrlRegex).describe(AppConnections.CREDENTIALS.LDAP.url),
dn: z
.string()
.trim()
.regex(new RE2(DistinguishedNameRegex), "Invalid DN format, ie; CN=user,OU=users,DC=example,DC=com")
.min(1, "Distinguished Name (DN) required")
.min(1, "DN/UPN required")
.refine((value) => DistinguishedNameRegex.test(value) || UserPrincipalNameRegex.test(value), {
message: "Invalid DN/UPN format"
})
.describe(AppConnections.CREDENTIALS.LDAP.dn),
password: z.string().trim().min(1, "Password required").describe(AppConnections.CREDENTIALS.LDAP.password),
sslRejectUnauthorized: z.boolean().optional().describe(AppConnections.CREDENTIALS.LDAP.sslRejectUnauthorized),

View File

@@ -199,9 +199,12 @@ export const authLoginServiceFactory = ({
providerAuthToken,
clientPublicKey
}: TLoginGenServerPublicKeyDTO) => {
const userEnc = await userDAL.findUserEncKeyByUsername({
// akhilmhdh: case sensitive email resolution
const usersByUsername = await userDAL.findUserEncKeyByUsername({
username: email
});
const userEnc =
usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0];
const serverCfg = await getServerCfg();
@@ -250,9 +253,12 @@ export const authLoginServiceFactory = ({
}: TLoginClientProofDTO) => {
const appCfg = getConfig();
const userEnc = await userDAL.findUserEncKeyByUsername({
// akhilmhdh: case sensitive email resolution
const usersByUsername = await userDAL.findUserEncKeyByUsername({
username: email
});
const userEnc =
usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0];
if (!userEnc) throw new Error("Failed to find user");
const user = await userDAL.findById(userEnc.userId);
const cfg = getConfig();
@@ -649,10 +655,12 @@ export const authLoginServiceFactory = ({
* OAuth2 login for google,github, and other oauth2 provider
* */
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();
if (serverCfg.enabledLoginMethods) {
if (serverCfg.enabledLoginMethods && user) {
switch (authMethod) {
case AuthMethod.GITHUB: {
if (!serverCfg.enabledLoginMethods.includes(LoginMethod.GITHUB)) {
@@ -715,8 +723,8 @@ export const authLoginServiceFactory = ({
}
user = await userDAL.create({
username: email,
email,
username: email.trim().toLowerCase(),
email: email.trim().toLowerCase(),
isEmailVerified: true,
firstName,
lastName,
@@ -814,11 +822,14 @@ export const authLoginServiceFactory = ({
? decodedProviderToken.orgId
: undefined;
const userEnc = await userDAL.findUserEncKeyByUsername({
// akhilmhdh: case sensitive email resolution
const usersByUsername = await userDAL.findUserEncKeyByUsername({
username: email
});
if (!userEnc) throw new BadRequestError({ message: "Invalid token" });
if (!userEnc.serverEncryptedPrivateKey)
const userEnc =
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." });
const token = await generateUserTokens({

View File

@@ -121,7 +121,10 @@ export const authPaswordServiceFactory = ({
*/
const sendPasswordResetEmail = async (email: string) => {
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) {
const cfg = getConfig();
@@ -152,7 +155,10 @@ export const authPaswordServiceFactory = ({
* */
const verifyPasswordResetEmail = async (email: string, code: string) => {
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);
@@ -189,16 +195,15 @@ export const authPaswordServiceFactory = ({
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)) {
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
if (type === ResetPasswordV2Type.LoggedInReset) {
if (!user.hashedPassword) {
throw new BadRequestError({ message: "Unable to change password, no password is set" });
}
if (!oldPassword) {
throw new BadRequestError({ message: "Current password is required." });
}

View File

@@ -73,18 +73,27 @@ export const authSignupServiceFactory = ({
}: TAuthSignupDep) => {
// first step of signup. create user and send email
const beginEmailSignupProcess = async (email: string) => {
const isEmailInvalid = await isDisposableEmail(email);
const sanitizedEmail = email.trim().toLowerCase();
const isEmailInvalid = await isDisposableEmail(sanitizedEmail);
if (isEmailInvalid) {
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) {
// 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) {
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");
@@ -96,7 +105,7 @@ export const authSignupServiceFactory = ({
await smtpService.sendMail({
template: SmtpTemplates.SignupEmailVerification,
subjectLine: "Infisical confirmation code",
recipients: [user.email as string],
recipients: [sanitizedEmail],
substitutions: {
code: token
}
@@ -104,11 +113,15 @@ export const authSignupServiceFactory = ({
};
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)) {
// 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");
}
const appCfg = getConfig();
await tokenService.validateTokenForUser({
type: TokenType.TOKEN_EMAIL_CONFIRMATION,
@@ -153,12 +166,15 @@ export const authSignupServiceFactory = ({
authorization,
useDefaultOrg
}: TCompleteAccountSignupDTO) => {
const sanitizedEmail = email.trim().toLowerCase();
const appCfg = getConfig();
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)) {
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;
@@ -315,7 +331,7 @@ export const authSignupServiceFactory = ({
}
const updatedMembersips = await orgDAL.updateMembership(
{ inviteEmail: email, status: OrgMembershipStatus.Invited },
{ inviteEmail: sanitizedEmail, status: OrgMembershipStatus.Invited },
{ userId: user.id, status: OrgMembershipStatus.Accepted }
);
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
* */
const completeAccountInvite = async ({
email,
ip,
salt,
email,
password,
verifier,
firstName,
@@ -399,7 +415,10 @@ export const authSignupServiceFactory = ({
encryptedPrivateKeyTag,
authorization
}: 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)) {
throw new Error("Failed to complete account for complete user");
}
@@ -407,7 +426,7 @@ export const authSignupServiceFactory = ({
validateSignUpAuthorization(authorization, user.id);
const [orgMembership] = await orgDAL.findMembership({
inviteEmail: email,
inviteEmail: sanitizedEmail,
status: OrgMembershipStatus.Invited
});
if (!orgMembership)
@@ -454,7 +473,7 @@ export const authSignupServiceFactory = ({
const serverGeneratedPrivateKey = await getUserPrivateKey(serverGeneratedPassword, {
...systemGeneratedUserEncryptionKey
});
const encKeys = await generateUserSrpKeys(email, password, {
const encKeys = await generateUserSrpKeys(sanitizedEmail, password, {
publicKey: systemGeneratedUserEncryptionKey.publicKey,
privateKey: serverGeneratedPrivateKey
});
@@ -505,7 +524,7 @@ export const authSignupServiceFactory = ({
}
const updatedMembersips = await orgDAL.updateMembership(
{ inviteEmail: email, status: OrgMembershipStatus.Invited },
{ inviteEmail: sanitizedEmail, status: OrgMembershipStatus.Invited },
{ userId: us.id, status: OrgMembershipStatus.Accepted },
tx
);

View File

@@ -105,7 +105,7 @@ export const buildCertificateChain = async ({
kmsService,
kmsId
}: TBuildCertificateChainDTO) => {
if (!encryptedCertificateChain && (!caCert || !caCertChain)) {
if (!encryptedCertificateChain && !caCert) {
return null;
}

View File

@@ -29,6 +29,7 @@ import {
TGetCertPrivateKeyDTO,
TRevokeCertDTO
} from "./certificate-types";
import { NotFoundError } from "@app/lib/errors";
type TCertificateServiceFactoryDep = {
certificateDAL: Pick<TCertificateDALFactory, "findOne" | "deleteById" | "update" | "find">;
@@ -337,18 +338,27 @@ export const certificateServiceFactory = ({
encryptedCertificateChain: certBody.encryptedCertificateChain || undefined
});
const { certPrivateKey } = await getCertificateCredentials({
certId: cert.id,
projectId: ca.projectId,
certificateSecretDAL,
projectDAL,
kmsService
});
let privateKey: string | null = null;
try {
const { certPrivateKey } = await getCertificateCredentials({
certId: cert.id,
projectId: ca.projectId,
certificateSecretDAL,
projectDAL,
kmsService
});
privateKey = certPrivateKey;
} catch (e) {
// Skip NotFound errors but throw all others
if (!(e instanceof NotFoundError)) {
throw e;
}
}
return {
certificate,
certificateChain,
privateKey: certPrivateKey,
privateKey,
serialNumber,
cert,
ca

View File

@@ -206,7 +206,7 @@ export const orgDALFactory = (db: TDbClient) => {
.where(`${TableName.OrgMembership}.orgId`, orgId)
.count("*")
.join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`)
.where({ isGhost: false })
.where({ isGhost: false, [`${TableName.OrgMembership}.isActive` as "isActive"]: true })
.first();
return parseInt((count as unknown as CountResult).count || "0", 10);

View File

@@ -24,5 +24,7 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({
kmsProductEnabled: true,
sshProductEnabled: true,
scannerProductEnabled: true,
shareSecretsProductEnabled: true
shareSecretsProductEnabled: true,
maxSharedSecretLifetime: true,
maxSharedSecretViewLimit: true
});

View File

@@ -361,7 +361,9 @@ export const orgServiceFactory = ({
kmsProductEnabled,
sshProductEnabled,
scannerProductEnabled,
shareSecretsProductEnabled
shareSecretsProductEnabled,
maxSharedSecretLifetime,
maxSharedSecretViewLimit
}
}: TUpdateOrgDTO) => {
const appCfg = getConfig();
@@ -469,7 +471,9 @@ export const orgServiceFactory = ({
kmsProductEnabled,
sshProductEnabled,
scannerProductEnabled,
shareSecretsProductEnabled
shareSecretsProductEnabled,
maxSharedSecretLifetime,
maxSharedSecretViewLimit
});
if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` });
return org;
@@ -823,7 +827,11 @@ export const orgServiceFactory = ({
const users: Pick<TUsers, "id" | "firstName" | "lastName" | "email" | "username">[] = [];
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 (!inviteeUser) {
@@ -1235,10 +1243,13 @@ export const orgServiceFactory = ({
* magic link and issue a temporary signup token for user to complete setting up their account
*/
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) {
throw new NotFoundError({ message: "User not found" });
}
const [orgMembership] = await orgDAL.findMembership({
[`${TableName.OrgMembership}.userId` as "userId"]: user.id,
status: OrgMembershipStatus.Invited,

View File

@@ -81,6 +81,8 @@ export type TUpdateOrgDTO = {
sshProductEnabled: boolean;
scannerProductEnabled: boolean;
shareSecretsProductEnabled: boolean;
maxSharedSecretLifetime: number;
maxSharedSecretViewLimit: number | null;
}>;
} & TOrgPermission;

View File

@@ -658,7 +658,8 @@ export const projectServiceFactory = ({
autoCapitalization: update.autoCapitalization,
enforceCapitalization: update.autoCapitalization,
hasDeleteProtection: update.hasDeleteProtection,
slug: update.slug
slug: update.slug,
secretSharing: update.secretSharing
});
return updatedProject;

View File

@@ -93,6 +93,7 @@ export type TUpdateProjectDTO = {
autoCapitalization?: boolean;
hasDeleteProtection?: boolean;
slug?: string;
secretSharing?: boolean;
};
} & Omit<TProjectPermission, "projectId">;

View File

@@ -6,6 +6,7 @@ import { TSecretSharing } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
import { logger } from "@app/lib/logger";
import { SecretSharingAccessType } from "@app/lib/types";
import { isUuidV4 } from "@app/lib/validator";
@@ -60,7 +61,9 @@ export const secretSharingServiceFactory = ({
}
const fiveMins = 5 * 60 * 1000;
if (expiryTime - currentTime < fiveMins) {
// 1 second buffer
if (expiryTime - currentTime + 1000 < fiveMins) {
throw new BadRequestError({ message: "Expiration time cannot be less than 5 mins" });
}
};
@@ -76,8 +79,11 @@ export const secretSharingServiceFactory = ({
password,
accessType,
expiresAt,
expiresAfterViews
expiresAfterViews,
emails
}: TCreateSharedSecretDTO) => {
const appCfg = getConfig();
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
if (!permission) throw new ForbiddenRequestError({ name: "User is not a part of the specified organization" });
$validateSharedSecretExpiry(expiresAt);
@@ -93,7 +99,46 @@ export const secretSharingServiceFactory = ({
throw new BadRequestError({ message: "Shared secret value too long" });
}
// Check lifetime is within org allowance
const expiresAtTimestamp = new Date(expiresAt).getTime();
const lifetime = expiresAtTimestamp - new Date().getTime();
// org.maxSharedSecretLifetime is in seconds
if (org.maxSharedSecretLifetime && lifetime / 1000 > org.maxSharedSecretLifetime) {
throw new BadRequestError({ message: "Secret lifetime exceeds organization limit" });
}
// Check max view count is within org allowance
if (org.maxSharedSecretViewLimit && (!expiresAfterViews || expiresAfterViews > org.maxSharedSecretViewLimit)) {
throw new BadRequestError({ message: "Secret max views parameter exceeds organization limit" });
}
const encryptWithRoot = kmsService.encryptWithRootKey();
let salt: string | undefined;
let encryptedSalt: Buffer | undefined;
const orgEmails = [];
if (emails && emails.length > 0) {
const allOrgMembers = await orgDAL.findAllOrgMembers(orgId);
// Check to see that all emails are a part of the organization (if enforced) while also collecting a list of emails which are in the org
for (const email of emails) {
if (allOrgMembers.some((v) => v.user.email === email)) {
orgEmails.push(email);
// If the email is not part of the org, but access type / org settings require it
} else if (!org.allowSecretSharingOutsideOrganization || accessType === SecretSharingAccessType.Organization) {
throw new BadRequestError({
message: "Organization does not allow sharing secrets to members outside of this organization"
});
}
}
// Generate salt for signing email hashes (if emails are provided)
salt = crypto.randomBytes(32).toString("hex");
encryptedSalt = encryptWithRoot(Buffer.from(salt));
}
const encryptedSecret = encryptWithRoot(Buffer.from(secretValue));
const id = crypto.randomBytes(32).toString("hex");
@@ -112,11 +157,45 @@ export const secretSharingServiceFactory = ({
expiresAfterViews,
userId: actorId,
orgId,
accessType
accessType,
authorizedEmails: emails && emails.length > 0 ? JSON.stringify(emails) : undefined,
encryptedSalt
});
const idToReturn = `${Buffer.from(newSharedSecret.identifier!, "hex").toString("base64url")}`;
// Loop through recipients and send out emails with unique access links
if (emails && salt) {
const user = await userDAL.findById(actorId);
if (!user) {
throw new NotFoundError({ message: `User with ID '${actorId}' not found` });
}
for await (const email of emails) {
try {
const hmac = crypto.createHmac("sha256", salt).update(email);
const hash = hmac.digest("hex");
// Only show the username to emails which are part of the organization
const respondentUsername = orgEmails.includes(email) ? user.username : undefined;
await smtpService.sendMail({
recipients: [email],
subjectLine: "A secret has been shared with you",
substitutions: {
name,
respondentUsername,
secretRequestUrl: `${appCfg.SITE_URL}/shared/secret/${idToReturn}?email=${encodeURIComponent(email)}&hash=${hash}`
},
template: SmtpTemplates.SecretRequestCompleted
});
} catch (e) {
logger.error(e, "Failed to send shared secret URL to a recipient's email.");
}
}
}
return { id: idToReturn };
};
@@ -390,8 +469,15 @@ export const secretSharingServiceFactory = ({
});
};
/** Get's password-less secret. validates all secret's requested (must be fresh). */
const getSharedSecretById = async ({ sharedSecretId, hashedHex, orgId, password }: TGetActiveSharedSecretByIdDTO) => {
/** Gets password-less secret. validates all secret's requested (must be fresh). */
const getSharedSecretById = async ({
sharedSecretId,
hashedHex,
orgId,
password,
email,
hash
}: TGetActiveSharedSecretByIdDTO) => {
const sharedSecret = isUuidV4(sharedSecretId)
? await secretSharingDAL.findOne({
id: sharedSecretId,
@@ -438,6 +524,32 @@ export const secretSharingServiceFactory = ({
});
}
const decryptWithRoot = kmsService.decryptWithRootKey();
if (sharedSecret.authorizedEmails && sharedSecret.encryptedSalt) {
// Verify both params were passed
if (!email || !hash) {
throw new BadRequestError({
message: "This secret is email protected. Parameters must include email and hash."
});
// Verify that email is authorized to view shared secret
} else if (!(sharedSecret.authorizedEmails as string[]).includes(email)) {
throw new UnauthorizedError({ message: "Email not authorized to view secret" });
// Verify that hash matches
} else {
const salt = decryptWithRoot(sharedSecret.encryptedSalt).toString();
const hmac = crypto.createHmac("sha256", salt).update(email);
const rebuiltHash = hmac.digest("hex");
if (rebuiltHash !== hash) {
throw new UnauthorizedError({ message: "Email not authorized to view secret" });
}
}
}
// Password checks
const isPasswordProtected = Boolean(sharedSecret.password);
const hasProvidedPassword = Boolean(password);
if (isPasswordProtected) {
@@ -452,7 +564,6 @@ export const secretSharingServiceFactory = ({
// If encryptedSecret is set, we know that this secret has been encrypted using KMS, and we can therefore do server-side decryption.
let decryptedSecretValue: Buffer | undefined;
if (sharedSecret.encryptedSecret) {
const decryptWithRoot = kmsService.decryptWithRootKey();
decryptedSecretValue = decryptWithRoot(sharedSecret.encryptedSecret);
}

View File

@@ -22,6 +22,7 @@ export type TSharedSecretPermission = {
accessType?: SecretSharingAccessType;
name?: string;
password?: string;
emails?: string[];
};
export type TCreatePublicSharedSecretDTO = {
@@ -37,6 +38,10 @@ export type TGetActiveSharedSecretByIdDTO = {
hashedHex?: string;
orgId?: string;
password?: string;
// For secrets shared with specific emails
email?: string;
hash?: string;
};
export type TValidateActiveSharedSecretDTO = TGetActiveSharedSecretByIdDTO & {

View File

@@ -257,8 +257,8 @@ export const superAdminServiceFactory = ({
const adminSignUp = async ({
lastName,
firstName,
salt,
email,
salt,
password,
verifier,
publicKey,
@@ -272,7 +272,8 @@ export const superAdminServiceFactory = ({
userAgent
}: TAdminSignUpDTO) => {
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" });
const privateKey = await getUserPrivateKey(password, {
@@ -292,8 +293,8 @@ export const superAdminServiceFactory = ({
{
firstName,
lastName,
username: email,
email,
username: sanitizedEmail,
email: sanitizedEmail,
superAdmin: true,
isGhost: false,
isAccepted: true,
@@ -348,12 +349,13 @@ export const superAdminServiceFactory = ({
const bootstrapInstance = async ({ email, password, organizationName }: TAdminBootstrapInstanceDTO) => {
const appCfg = getConfig();
const sanitizedEmail = email.trim().toLowerCase();
const serverCfg = await serverCfgDAL.findById(ADMIN_CONFIG_DB_UUID);
if (serverCfg?.initialized) {
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" });
const userInfo = await userDAL.transaction(async (tx) => {
@@ -361,8 +363,8 @@ export const superAdminServiceFactory = ({
{
firstName: "Admin",
lastName: "User",
username: email,
email,
username: sanitizedEmail,
email: sanitizedEmail,
superAdmin: true,
isGhost: false,
isAccepted: true,
@@ -372,7 +374,7 @@ export const superAdminServiceFactory = ({
tx
);
const { tag, encoding, ciphertext, iv } = infisicalSymmetricEncypt(password);
const encKeys = await generateUserSrpKeys(email, password);
const encKeys = await generateUserSrpKeys(sanitizedEmail, password);
const userEnc = await userDAL.createUserEncryption(
{

View File

@@ -8,16 +8,18 @@ import {
TUserEncryptionKeys,
TUserEncryptionKeysInsert,
TUserEncryptionKeysUpdate,
TUsers
TUsers,
UsersSchema
} from "@app/db/schemas";
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 const userDALFactory = (db: TDbClient) => {
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 ({
limit,
@@ -41,7 +43,7 @@ export const userDALFactory = (db: TDbClient) => {
.whereILike("email", `%${searchTerm}%`)
.orWhereILike("firstName", `%${searchTerm}%`)
.orWhereILike("lastName", `%${searchTerm}%`)
.orWhereLike("username", `%${searchTerm}%`);
.orWhereRaw('lower("username") like ?', `%${searchTerm}%`);
});
}
@@ -65,12 +67,11 @@ export const userDALFactory = (db: TDbClient) => {
try {
return await db
.replicaNode()(TableName.Users)
.whereRaw('lower("username") = :username', { username: username.toLowerCase() })
.where({
username,
isGhost: false
})
.join(TableName.UserEncryptionKey, `${TableName.Users}.id`, `${TableName.UserEncryptionKey}.userId`)
.first();
.join(TableName.UserEncryptionKey, `${TableName.Users}.id`, `${TableName.UserEncryptionKey}.userId`);
} catch (error) {
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
// ---------------------
const findOneUserAction = (filter: TUserActionsUpdate, tx?: Knex) => {
@@ -200,6 +233,7 @@ export const userDALFactory = (db: TDbClient) => {
createUserEncryption,
findOneUserAction,
createUserAction,
getUsersByFilter
getUsersByFilter,
findAllMyAccounts
};
};

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 { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal";
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 { TGroupProjectDALFactory } from "../group-project/group-project-dal";
@@ -21,7 +20,7 @@ type TUserServiceFactoryDep = {
userDAL: Pick<
TUserDALFactory,
| "find"
| "findOne"
| "findUserByUsername"
| "findById"
| "transaction"
| "updateById"
@@ -31,8 +30,8 @@ type TUserServiceFactoryDep = {
| "createUserAction"
| "findUserEncKeyByUserId"
| "delete"
| "findAllMyAccounts"
>;
userAliasDAL: Pick<TUserAliasDALFactory, "find" | "insertMany">;
groupProjectDAL: Pick<TGroupProjectDALFactory, "findByUserId">;
orgMembershipDAL: Pick<TOrgMembershipDALFactory, "find" | "insertMany" | "findOne" | "updateById">;
tokenService: Pick<TAuthTokenServiceFactory, "createTokenForUser" | "validateTokenForUser">;
@@ -45,7 +44,6 @@ export type TUserServiceFactory = ReturnType<typeof userServiceFactory>;
export const userServiceFactory = ({
userDAL,
userAliasDAL,
orgMembershipDAL,
projectMembershipDAL,
groupProjectDAL,
@@ -54,8 +52,11 @@ export const userServiceFactory = ({
permissionService
}: TUserServiceFactoryDep) => {
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.email)
throw new BadRequestError({ name: "Failed to send email verification code due to no email on user" });
if (user.isEmailVerified)
@@ -77,7 +78,10 @@ export const userServiceFactory = ({
};
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.email)
throw new BadRequestError({ name: "Failed to verify email verification code due to no email on user" });
@@ -90,84 +94,8 @@ export const userServiceFactory = ({
code
});
const { email } = user;
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
);
}
await userDAL.updateById(user.id, {
isEmailVerified: true
});
};
@@ -212,6 +140,23 @@ export const userServiceFactory = ({
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 user = await userDAL.findUserEncKeyByUserId(userId);
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 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` });
// 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) {
@@ -346,7 +293,9 @@ export const userServiceFactory = ({
getUserAction,
unlockUser,
getUserPrivateKey,
getAllMyAccounts,
getUserProjectFavorites,
removeMyDuplicateAccounts,
updateUserProjectFavorites
};
};

View File

@@ -884,6 +884,12 @@ func (tm *AgentManager) MonitorSecretChanges(secretTemplate Template, templateId
if err != nil {
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 {
if (existingEtag != currentEtag) || firstRun {

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.
<Note>
Please spend money in a way that you think is in the best interest of the company.
</Note>
# Expensing Meals
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
@@ -18,6 +23,10 @@ This means expenses that are:
1. Non-recurring AND less than $75/month in total.
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
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.

View File

@@ -38,7 +38,7 @@ Enabling HSM encryption has a set of key benefits:
### Requirements
- 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.
- 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
@@ -53,14 +53,14 @@ For organizations that work with US government agencies, FIPS compliance is almo
<Steps>
<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.
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.
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 title="Configure HSM on Infisical">
@@ -255,6 +255,78 @@ For organizations that work with US government agencies, FIPS compliance is almo
</Steps>
After following these steps, your Docker setup will be ready to use HSM encryption.
</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>
</Tab>
<Tab title="Kubernetes">
@@ -569,6 +641,173 @@ For organizations that work with US government agencies, FIPS compliance is almo
</Steps>
After following these steps, your Kubernetes setup will be ready to use HSM encryption.
</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>
</Tab>
</Tabs>

View File

@@ -20,6 +20,7 @@ The **Settings** page lets you manage information about your organization includ
- **Slug**: The slug of your organization.
- **Default Organization Member Role**: The role assigned to users when joining your organization unless otherwise specified.
- **Incident Contacts**: Emails that should be alerted if anything abnormal is detected within the organization.
- **Enabled Products**: Products which are enabled for your organization. This setting strictly affects the sidebar UI; disabling a product does not disable its API or routes.
![organization settings general](../../images/platform/organization/organization-settings-general.png)
@@ -43,7 +44,7 @@ In the **Organization Roles** tab, you can edit current or create new custom rol
<Info>
Note that Role-Based Access Management (RBAC) is partly a paid feature.
Infisical provides immutable roles like `admin`, `member`, etc.
at the organization and project level for free.

View File

@@ -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**.
![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>
LDAP Password Rotations require an LDAP Connection that uses ldaps:// protocol.
</Note>
@@ -40,13 +40,20 @@ description: "Learn how to automatically rotate LDAP passwords."
</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 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**.
![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.
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
},
"parameters": {
"rotationMethod": "connection-principal",
"dn": "CN=John,CN=Users,DC=example,DC=com",
"passwordRequirements": {
"length": 48,
@@ -154,6 +162,7 @@ description: "Learn how to automatically rotate LDAP passwords."
"lastRotationMessage": null,
"type": "ldap-password",
"parameters": {
"rotationMethod": "connection-principal",
"dn": "CN=John,CN=Users,DC=example,DC=com",
"passwordRequirements": {
"length": 48,

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 985 KiB

After

Width:  |  Height:  |  Size: 993 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

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:
- **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
- **CA Certificate** - The SSL certificate (PEM format) to use for secure connection when using ldaps:// with a self-signed certificate

View File

@@ -10,8 +10,10 @@ We value reports that help identify vulnerabilities that affect the integrity of
### How to Report
- Send reports to **security@infisical.com** 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.
- **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?

View File

@@ -18,9 +18,7 @@ export const ViewLdapPasswordRotationGeneratedCredentials = ({
<ViewRotationGeneratedCredentialsDisplay
activeCredentials={
<>
<CredentialDisplay label="Distinguished Name (DN)">
{activeCredentials?.dn}
</CredentialDisplay>
<CredentialDisplay label="DN/UPN">{activeCredentials?.dn}</CredentialDisplay>
<CredentialDisplay isSensitive label="Password">
{activeCredentials?.password}
</CredentialDisplay>
@@ -28,9 +26,7 @@ export const ViewLdapPasswordRotationGeneratedCredentials = ({
}
inactiveCredentials={
<>
<CredentialDisplay label="Distinguished Name (DN)">
{inactiveCredentials?.dn}
</CredentialDisplay>
<CredentialDisplay label="DN/UPN">{inactiveCredentials?.dn}</CredentialDisplay>
<CredentialDisplay isSensitive label="Password">
{inactiveCredentials?.password}
</CredentialDisplay>

View File

@@ -48,7 +48,8 @@ const FORM_TABS: { name: string; key: string; fields: (keyof TSecretRotationV2Fo
"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: "Details", key: "details", fields: ["name", "description"] },
{ name: "Review", key: "review", fields: [] }
@@ -75,7 +76,7 @@ export const SecretRotationV2Form = ({
const { rotationOption } = useSecretRotationV2Option(type);
const formMethods = useForm<TSecretRotationV2Form>({
resolver: zodResolver(SecretRotationV2FormSchema),
resolver: zodResolver(SecretRotationV2FormSchema(Boolean(secretRotation))),
defaultValues: secretRotation
? {
...secretRotation,

View File

@@ -2,40 +2,135 @@ import { Controller, useFormContext } from "react-hook-form";
import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas";
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 { LdapPasswordRotationMethod } from "@app/hooks/api/secretRotationsV2/types/ldap-password-rotation";
export const LdapPasswordRotationParametersFields = () => {
const { control } = useFormContext<
const { control, watch, setValue } = useFormContext<
TSecretRotationV2Form & {
type: SecretRotation.LdapPassword;
}
>();
const [id, rotationMethod] = watch(["id", "parameters.rotationMethod"]);
const isUpdate = Boolean(id);
return (
<>
<Controller
name="parameters.dn"
name="parameters.rotationMethod"
control={control}
defaultValue={LdapPasswordRotationMethod.ConnectionPrincipal}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<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}
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}
onChange={onChange}
placeholder="CN=John,OU=Users,DC=example,DC=com"
/>
onValueChange={(val) => {
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>
)}
/>
<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="w-full border-b border-mineshaft-600">
<span className="text-sm text-mineshaft-300">Password Requirements</span>
</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
control={control}
name="parameters.passwordRequirements.length"
@@ -45,7 +140,7 @@ export const LdapPasswordRotationParametersFields = () => {
label="Password Length"
isError={Boolean(error)}
errorText={error?.message}
helperText="The length of the password to generate"
tooltipText="The length of the password to generate"
>
<Input
type="number"
@@ -67,7 +162,7 @@ export const LdapPasswordRotationParametersFields = () => {
label="Digit Count"
isError={Boolean(error)}
errorText={error?.message}
helperText="Minimum number of digits"
tooltipText="Minimum number of digits"
>
<Input
type="number"
@@ -88,7 +183,7 @@ export const LdapPasswordRotationParametersFields = () => {
label="Lowercase Character Count"
isError={Boolean(error)}
errorText={error?.message}
helperText="Minimum number of lowercase characters"
tooltipText="Minimum number of lowercase characters"
>
<Input
type="number"
@@ -109,7 +204,7 @@ export const LdapPasswordRotationParametersFields = () => {
label="Uppercase Character Count"
isError={Boolean(error)}
errorText={error?.message}
helperText="Minimum number of uppercase characters"
tooltipText="Minimum number of uppercase characters"
>
<Input
type="number"
@@ -130,7 +225,7 @@ export const LdapPasswordRotationParametersFields = () => {
label="Symbol Count"
isError={Boolean(error)}
errorText={error?.message}
helperText="Minimum number of symbols"
tooltipText="Minimum number of symbols"
>
<Input
type="number"
@@ -151,7 +246,7 @@ export const LdapPasswordRotationParametersFields = () => {
label="Allowed Symbols"
isError={Boolean(error)}
errorText={error?.message}
helperText="Symbols to use in generated password"
tooltipText="Symbols to use in generated password"
>
<Input
placeholder="-_.~!*"

View File

@@ -15,13 +15,35 @@ export const LdapPasswordRotationReviewFields = () => {
const [parameters, { dn, password }] = watch(["parameters", "secretsMapping"]);
const { passwordRequirements } = parameters;
return (
<>
<SecretRotationReviewSection label="Parameters">
<GenericFieldLabel label="Distinguished Name (DN)">{parameters.dn}</GenericFieldLabel>
<GenericFieldLabel label="DN/UPN">{parameters.dn}</GenericFieldLabel>
</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">
<GenericFieldLabel label="Distinguished Name (DN)">{dn}</GenericFieldLabel>
<GenericFieldLabel label="DN/UPN">{dn}</GenericFieldLabel>
<GenericFieldLabel label="Password">{password}</GenericFieldLabel>
</SecretRotationReviewSection>
</>

View File

@@ -1,7 +1,7 @@
import { ReactNode } from "react";
type Props = {
label: "Parameters" | "Secrets Mapping";
label: "Parameters" | "Secrets Mapping" | "Password Requirements";
children: ReactNode;
};

View File

@@ -17,7 +17,7 @@ export const LdapPasswordRotationSecretsMappingFields = () => {
const items = [
{
name: "DN",
name: "DN/UPN",
input: (
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (

View File

@@ -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 { 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 { SecretRotation } from "@app/hooks/api/secretRotationsV2";
import { LdapPasswordRotationMethod } from "@app/hooks/api/secretRotationsV2/types/ldap-password-rotation";
const SecretRotationUnionSchema = z.discriminatedUnion("type", [
Auth0ClientSecretRotationSchema,
AzureClientSecretRotationSchema,
PostgresCredentialsRotationSchema,
MsSqlCredentialsRotationSchema,
LdapPasswordRotationSchema,
AwsIamUserSecretRotationSchema
]);
export const SecretRotationV2FormSchema = (isUpdate: boolean) =>
z
.intersection(
z.discriminatedUnion("type", [
Auth0ClientSecretRotationSchema,
AzureClientSecretRotationSchema,
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>>;

View File

@@ -2,8 +2,9 @@ import { z } from "zod";
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 { DistinguishedNameRegex } from "@app/helpers/string";
import { DistinguishedNameRegex, UserPrincipalNameRegex } from "@app/helpers/string";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
import { LdapPasswordRotationMethod } from "@app/hooks/api/secretRotationsV2/types/ldap-password-rotation";
export const LdapPasswordRotationSchema = z
.object({
@@ -12,13 +13,24 @@ export const LdapPasswordRotationSchema = z
dn: z
.string()
.trim()
.regex(DistinguishedNameRegex, "Invalid Distinguished Name format")
.min(1, "Distinguished Name (DN) required"),
passwordRequirements: PasswordRequirementsSchema.optional()
.min(1, "DN/UPN required")
.refine(
(value) => DistinguishedNameRegex.test(value) || UserPrincipalNameRegex.test(value),
{
message: "Invalid DN/UPN format"
}
),
passwordRequirements: PasswordRequirementsSchema.optional(),
rotationMethod: z.nativeEnum(LdapPasswordRotationMethod).optional()
}),
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")
})
}),
temporaryParameters: z
.object({
password: z.string().min(1, "Password required")
})
.optional()
})
.merge(BaseSecretRotationSchema);

View File

@@ -1,5 +1,7 @@
import { z } from "zod";
export type TPasswordRequirements = z.infer<typeof PasswordRequirementsSchema>;
export const PasswordRequirementsSchema = z
.object({
length: z

View File

@@ -144,6 +144,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => {
<a
href="https://infisical.com/docs/integrations/secret-syncs/overview#key-schemas"
target="_blank"
rel="noopener noreferrer"
>
Key Schema
</a>{" "}

View File

@@ -123,6 +123,7 @@ export const SelectItem = forwardRef<HTMLDivElement, SelectItemProps>(
return (
<SelectPrimitive.Item
{...props}
disabled={isDisabled}
className={twMerge(
"relative mb-0.5 cursor-pointer select-none items-center overflow-hidden truncate rounded-md py-2 pl-10 pr-4 text-sm outline-none transition-all hover:bg-mineshaft-500 data-[highlighted]:bg-mineshaft-700/80",
isSelected && "bg-primary",

View File

@@ -15,3 +15,5 @@ export const isValidPath = (val: string): boolean => {
export const DistinguishedNameRegex =
/^(?:(?:[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,}$/;

View File

@@ -48,7 +48,7 @@ export const useGetCertBundle = (serialNumber: string) => {
certificate: string;
certificateChain: string;
serialNumber: string;
privateKey: string;
privateKey: string | null;
}>(`/api/v1/pki/certificates/${serialNumber}/bundle`);
return data;
},

View File

@@ -118,7 +118,9 @@ export const useUpdateOrg = () => {
kmsProductEnabled,
sshProductEnabled,
scannerProductEnabled,
shareSecretsProductEnabled
shareSecretsProductEnabled,
maxSharedSecretLifetime,
maxSharedSecretViewLimit
}) => {
return apiRequest.patch(`/api/v1/organization/${orgId}`, {
name,
@@ -136,7 +138,9 @@ export const useUpdateOrg = () => {
kmsProductEnabled,
sshProductEnabled,
scannerProductEnabled,
shareSecretsProductEnabled
shareSecretsProductEnabled,
maxSharedSecretLifetime,
maxSharedSecretViewLimit
});
},
onSuccess: () => {

View File

@@ -26,6 +26,8 @@ export type Organization = {
sshProductEnabled: boolean;
scannerProductEnabled: boolean;
shareSecretsProductEnabled: boolean;
maxSharedSecretLifetime: number;
maxSharedSecretViewLimit: number | null;
};
export type UpdateOrgDTO = {
@@ -46,6 +48,8 @@ export type UpdateOrgDTO = {
sshProductEnabled?: boolean;
scannerProductEnabled?: boolean;
shareSecretsProductEnabled?: boolean;
maxSharedSecretViewLimit?: number | null;
maxSharedSecretLifetime?: number;
};
export type BillingDetails = {

View File

@@ -1,3 +1,4 @@
import { TPasswordRequirements } from "@app/components/secret-rotations-v2/forms/schemas/shared";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
import {
@@ -5,10 +6,17 @@ import {
TSecretRotationV2GeneratedCredentialsResponseBase
} from "@app/hooks/api/secretRotationsV2/types/shared";
export enum LdapPasswordRotationMethod {
ConnectionPrincipal = "connection-principal",
TargetPrincipal = "target-principal"
}
export type TLdapPasswordRotation = TSecretRotationV2Base & {
type: SecretRotation.LdapPassword;
parameters: {
dn: string;
rotationMethod?: LdapPasswordRotationMethod;
passwordRequirements?: TPasswordRequirements;
};
secretsMapping: {
dn: string;

View File

@@ -11,10 +11,13 @@ export const secretSharingKeys = {
allSecretRequests: () => ["secretRequests"] as const,
specificSecretRequests: ({ offset, limit }: { offset: number; limit: number }) =>
[...secretSharingKeys.allSecretRequests(), { offset, limit }] as const,
getSecretById: (arg: { id: string; hashedHex: string | null; password?: string }) => [
"shared-secret",
arg
],
getSecretById: (arg: {
id: string;
hashedHex: string | null;
password?: string;
email?: string;
hash?: string;
}) => ["shared-secret", arg],
getSecretRequestById: (arg: { id: string }) => ["secret-request", arg] as const
};
@@ -70,20 +73,34 @@ export const useGetSecretRequests = ({
export const useGetActiveSharedSecretById = ({
sharedSecretId,
hashedHex,
password
password,
email,
hash
}: {
sharedSecretId: string;
hashedHex: string | null;
password?: string;
// For secrets shared to specific emails (optional)
email?: string;
hash?: string;
}) => {
return useQuery({
queryKey: secretSharingKeys.getSecretById({ id: sharedSecretId, hashedHex, password }),
queryKey: secretSharingKeys.getSecretById({
id: sharedSecretId,
hashedHex,
password,
email,
hash
}),
queryFn: async () => {
const { data } = await apiRequest.post<TViewSharedSecretResponse>(
`/api/v1/secret-sharing/shared/public/${sharedSecretId}`,
{
...(hashedHex && { hashedHex }),
password
password,
email,
hash
}
);

View File

@@ -32,6 +32,7 @@ export type TCreateSharedSecretRequest = {
expiresAt: Date;
expiresAfterViews?: number;
accessType?: SecretSharingAccessType;
emails?: string[];
};
export type TCreateSecretRequestRequestDTO = {

View File

@@ -1,6 +1,7 @@
export {
useAddUserToWsE2EE,
useAddUserToWsNonE2EE,
useRemoveMyDuplicateAccounts,
useRevokeMySessionById,
useSendEmailVerificationCode,
useVerifyEmailVerificationCode
@@ -14,6 +15,7 @@ export {
useDeleteOrgMembership,
useGetMyAPIKeys,
useGetMyAPIKeysV2,
useGetMyDuplicateAccount,
useGetMyIp,
useGetMyOrganizationProjects,
useGetMySessions,

View File

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

View File

@@ -37,6 +37,33 @@ export const useGetUser = () =>
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 = () => {
const queryClient = useQueryClient();

View File

@@ -1,5 +1,6 @@
export const userKeys = {
getUser: ["user"] as const,
getMyDuplicateAccount: ["user-duplicate-account"] as const,
getPrivateKey: ["user"] as const,
userAction: ["user-action"] as const,
userProjectFavorites: (orgId: string) => [{ orgId }, "user-project-favorites"] as const,

View File

@@ -277,13 +277,20 @@ export const useUpdateProject = () => {
const queryClient = useQueryClient();
return useMutation<Workspace, object, UpdateProjectDTO>({
mutationFn: async ({ projectID, newProjectName, newProjectDescription, newSlug }) => {
mutationFn: async ({
projectID,
newProjectName,
newProjectDescription,
newSlug,
secretSharing
}) => {
const { data } = await apiRequest.patch<{ workspace: Workspace }>(
`/api/v1/workspace/${projectID}`,
{
name: newProjectName,
description: newProjectDescription,
slug: newSlug
slug: newSlug,
secretSharing
}
);
return data.workspace;

View File

@@ -37,6 +37,7 @@ export type Workspace = {
createdAt: string;
roles?: TProjectRole[];
hasDeleteProtection: boolean;
secretSharing: boolean;
};
export type WorkspaceEnv = {
@@ -73,9 +74,10 @@ export type CreateWorkspaceDTO = {
export type UpdateProjectDTO = {
projectID: string;
newProjectName: string;
newProjectName?: string;
newProjectDescription?: string;
newSlug?: string;
secretSharing?: boolean;
};
export type UpdatePitVersionLimitDTO = { projectSlug: string; pitVersionLimit: number };

View File

@@ -18,7 +18,8 @@ import { useToggle } from "@app/hooks";
import { useOauthTokenExchange, useSelectOrganization } from "@app/hooks/api";
import { MfaMethod } from "@app/hooks/api/auth/types";
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";
@@ -40,6 +41,7 @@ export const PasswordStep = ({
const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslation();
const navigate = useNavigate();
const [removeDuplicateLater, setRemoveDuplicateLater] = useState(true);
const { mutateAsync: selectOrganization } = useSelectOrganization();
const { mutateAsync: oauthTokenExchange } = useOauthTokenExchange();
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
@@ -109,6 +111,13 @@ export const PasswordStep = ({
return;
}
const userDuplicateAccount = await fetchUserDuplicateAccounts();
const hasDuplicate = userDuplicateAccount?.length > 1;
if (hasDuplicate) {
setRemoveDuplicateLater(false);
return;
}
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) {
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">

View File

@@ -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'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={`Youre 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>
);
};

View File

@@ -1,33 +1,10 @@
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 { useState } from "react";
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 { Spinner } from "@app/components/v2";
import { useGetMyDuplicateAccount } from "@app/hooks/api";
import { navigateUserToOrg } from "../LoginPage/Login.utils";
import { EmailDuplicationConfirmation } from "./EmailDuplicationConfirmation";
import { SelectOrganizationSection } from "./SelectOrgSection";
const LoadingScreen = () => {
return (
@@ -39,253 +16,18 @@ const LoadingScreen = () => {
};
export const SelectOrganizationPage = () => {
const navigate = useNavigate();
const { t } = useTranslation();
const duplicateAccounts = useGetMyDuplicateAccount();
const [removeDuplicateLater, setRemoveDuplicateLater] = useState(false);
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 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)
) {
if (duplicateAccounts.isPending) {
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>
if (duplicateAccounts.data?.duplicateAccounts?.length && !removeDuplicateLater) {
return (
<EmailDuplicationConfirmation onRemoveDuplicateLater={() => setRemoveDuplicateLater(true)} />
);
}
<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>
);
return <SelectOrganizationSection />;
};

View File

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

View File

@@ -35,7 +35,7 @@ export const CertificateCertModal = ({ popUp, handlePopUpToggle }: Props) => {
certificate: string;
certificateChain: string;
serialNumber: string;
privateKey?: string;
privateKey?: string | null;
}
| undefined = canReadPrivateKey ? bundleData : bodyData;
@@ -52,7 +52,7 @@ export const CertificateCertModal = ({ popUp, handlePopUpToggle }: Props) => {
serialNumber={data.serialNumber}
certificate={data.certificate}
certificateChain={data.certificateChain}
privateKey={data.privateKey}
privateKey={data.privateKey || undefined}
/>
) : (
<div />

View File

@@ -19,7 +19,7 @@ import {
Tooltip
} from "@app/components/v2";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import { DistinguishedNameRegex } from "@app/helpers/string";
import { DistinguishedNameRegex, UserPrincipalNameRegex } from "@app/helpers/string";
import {
LdapConnectionMethod,
LdapConnectionProvider,
@@ -55,8 +55,13 @@ const formSchema = z.discriminatedUnion("method", [
dn: z
.string()
.trim()
.regex(DistinguishedNameRegex, "Invalid Distinguished Name format")
.min(1, "Distinguished Name (DN) required"),
.min(1, "DN/UPN required")
.refine(
(value) => DistinguishedNameRegex.test(value) || UserPrincipalNameRegex.test(value),
{
message: "Invalid DN/UPN format"
}
),
password: z.string().trim().min(1, "Password required"),
sslRejectUnauthorized: z.boolean(),
sslCertificate: z
@@ -223,7 +228,7 @@ export const LdapConnectionForm = ({ appConnection, onSubmit }: Props) => {
<FormControl
errorText={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" />
</FormControl>

View File

@@ -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 {
@@ -10,6 +15,7 @@ import {
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { useOrganization } from "@app/context";
@@ -48,9 +54,26 @@ export const CurrentPlanSection = () => {
data &&
data?.rows?.length > 0 &&
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 (
<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>{used}</Td>
</Tr>

View File

@@ -30,6 +30,8 @@ export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => {
allowSecretSharingOutsideOrganization={
currentOrg?.allowSecretSharingOutsideOrganization ?? true
}
maxSharedSecretLifetime={currentOrg?.maxSharedSecretLifetime}
maxSharedSecretViewLimit={currentOrg?.maxSharedSecretViewLimit}
/>
</ModalContent>
</Modal>

View File

@@ -17,11 +17,11 @@ export const SecretSharingSettingsPage = withPermission(
return (
<>
<Helmet>
<title>{t("common.head-title", { title: t("settings.org.title") })}</title>
<title>{t("common.head-title", { title: "Secret Share Settings" })}</title>
</Helmet>
<div className="flex w-full justify-center bg-bunker-800 text-white">
<div className="w-full max-w-7xl">
<PageHeader title={t("settings.org.title")} />
<PageHeader title="Secret Share Settings" />
<SecretSharingSettingsTabGroup />
</div>
</div>

View File

@@ -0,0 +1,294 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useEffect } from "react";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button, FormControl, Input, Select, SelectItem } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { useUpdateOrg } from "@app/hooks/api";
const MAX_SHARED_SECRET_LIFETIME_SECONDS = 30 * 24 * 60 * 60; // 30 days in seconds
const MIN_SHARED_SECRET_LIFETIME_SECONDS = 5 * 60; // 5 minutes in seconds
// Helper function to convert duration to seconds
const durationToSeconds = (value: number, unit: "m" | "h" | "d"): number => {
switch (unit) {
case "m":
return value * 60;
case "h":
return value * 60 * 60;
case "d":
return value * 60 * 60 * 24;
default:
return 0;
}
};
// Helper function to convert seconds to form lifetime value and unit
const getFormLifetimeFromSeconds = (
totalSeconds: number | null | undefined
): { maxLifetimeValue: number; maxLifetimeUnit: "m" | "h" | "d" } => {
const DEFAULT_LIFETIME_VALUE = 30;
const DEFAULT_LIFETIME_UNIT = "d" as "m" | "h" | "d";
if (totalSeconds == null || totalSeconds <= 0) {
return {
maxLifetimeValue: DEFAULT_LIFETIME_VALUE,
maxLifetimeUnit: DEFAULT_LIFETIME_UNIT
};
}
const secondsInDay = 24 * 60 * 60;
const secondsInHour = 60 * 60;
const secondsInMinute = 60;
if (totalSeconds % secondsInDay === 0) {
const value = totalSeconds / secondsInDay;
if (value >= 1) return { maxLifetimeValue: value, maxLifetimeUnit: "d" };
}
if (totalSeconds % secondsInHour === 0) {
const value = totalSeconds / secondsInHour;
if (value >= 1) return { maxLifetimeValue: value, maxLifetimeUnit: "h" };
}
if (totalSeconds % secondsInMinute === 0) {
const value = totalSeconds / secondsInMinute;
if (value >= 1) return { maxLifetimeValue: value, maxLifetimeUnit: "m" };
}
return {
maxLifetimeValue: DEFAULT_LIFETIME_VALUE,
maxLifetimeUnit: DEFAULT_LIFETIME_UNIT
};
};
const formSchema = z
.object({
maxLifetimeValue: z.number().min(1, "Value must be at least 1"),
maxLifetimeUnit: z.enum(["m", "h", "d"], {
invalid_type_error: "Please select a valid time unit"
}),
maxViewLimit: z.string()
})
.superRefine((data, ctx) => {
const { maxLifetimeValue, maxLifetimeUnit } = data;
const durationInSeconds = durationToSeconds(maxLifetimeValue, maxLifetimeUnit);
// Check max limit
if (durationInSeconds > MAX_SHARED_SECRET_LIFETIME_SECONDS) {
let message = "Duration exceeds maximum allowed limit";
if (maxLifetimeUnit === "m") {
message = `Maximum allowed minutes is ${MAX_SHARED_SECRET_LIFETIME_SECONDS / 60} (30 days)`;
} else if (maxLifetimeUnit === "h") {
message = `Maximum allowed hours is ${MAX_SHARED_SECRET_LIFETIME_SECONDS / (60 * 60)} (30 days)`;
} else if (maxLifetimeUnit === "d") {
message = `Maximum allowed days is ${MAX_SHARED_SECRET_LIFETIME_SECONDS / (24 * 60 * 60)}`;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message,
path: ["maxLifetimeValue"]
});
}
// Check min limit
if (durationInSeconds < MIN_SHARED_SECRET_LIFETIME_SECONDS) {
const message = `Duration must be at least ${MIN_SHARED_SECRET_LIFETIME_SECONDS / 60} minutes`; // 5 minutes
ctx.addIssue({
code: z.ZodIssueCode.custom,
message,
path: ["maxLifetimeValue"]
});
}
});
type TForm = z.infer<typeof formSchema>;
const viewLimitOptions = [
{ label: "1", value: 1 },
{ label: "Unlimited", value: -1 }
];
export const OrgSecretShareLimitSection = () => {
const { mutateAsync } = useUpdateOrg();
const { currentOrg } = useOrganization();
const getDefaultFormValues = () => {
const initialLifetime = getFormLifetimeFromSeconds(currentOrg?.maxSharedSecretLifetime);
return {
maxLifetimeValue: initialLifetime.maxLifetimeValue,
maxLifetimeUnit: initialLifetime.maxLifetimeUnit,
maxViewLimit: currentOrg?.maxSharedSecretViewLimit?.toString() || "-1"
};
};
const {
control,
formState: { isSubmitting, isDirty },
handleSubmit,
reset
} = useForm<TForm>({
resolver: zodResolver(formSchema),
defaultValues: getDefaultFormValues()
});
useEffect(() => {
if (currentOrg) {
reset(getDefaultFormValues());
}
}, [currentOrg, reset]);
const handleFormSubmit = async (formData: TForm) => {
try {
const maxSharedSecretLifetimeSeconds = durationToSeconds(
formData.maxLifetimeValue,
formData.maxLifetimeUnit
);
await mutateAsync({
orgId: currentOrg.id,
maxSharedSecretViewLimit:
formData.maxViewLimit === "-1" ? null : Number(formData.maxViewLimit),
maxSharedSecretLifetime: maxSharedSecretLifetimeSeconds
});
createNotification({
text: "Successfully updated secret share limits",
type: "success"
});
reset(formData);
} catch {
createNotification({
text: "Failed to update secret share limits",
type: "error"
});
}
};
// Units for the dropdown with readable labels
const timeUnits = [
{ value: "m", label: "Minutes" },
{ value: "h", label: "Hours" },
{ value: "d", label: "Days" }
];
return (
<div className="mb-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex w-full items-center justify-between">
<p className="text-xl font-semibold">Secret Share Limits</p>
</div>
<p className="mb-4 mt-2 text-sm text-gray-400">
These settings establish the maximum limits for all Shared Secret parameters within this
organization. Shared secrets cannot be created with values exceeding these limits.
</p>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Settings}>
{(isAllowed) => (
<form onSubmit={handleSubmit(handleFormSubmit)} autoComplete="off">
<div className="flex max-w-sm gap-4">
<Controller
control={control}
name="maxLifetimeValue"
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
tooltipText="The max amount of time that can be set before the secret share link expires."
label="Max Lifetime"
className="w-full"
>
<Input
{...field}
type="number"
min={1}
step={1}
value={field.value}
onChange={(e) => {
const val = e.target.value;
field.onChange(val === "" ? "" : parseInt(val, 10));
}}
disabled={!isAllowed}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="maxLifetimeUnit"
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Time unit"
>
<Select
value={field.value}
className="pr-2"
onValueChange={field.onChange}
placeholder="Select time unit"
isDisabled={!isAllowed}
>
{timeUnits.map(({ value, label }) => (
<SelectItem
key={value}
value={value}
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">{label}</div>
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
<div className="flex max-w-sm">
<Controller
control={control}
name="maxViewLimit"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Max Views"
errorText={error?.message}
isError={Boolean(error)}
className="w-full"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
isDisabled={!isAllowed}
>
{viewLimitOptions.map(({ label, value: viewLimitValue }) => (
<SelectItem value={String(viewLimitValue || "")} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
<Button
colorSchema="secondary"
type="submit"
isLoading={isSubmitting}
disabled={!isDirty || !isAllowed}
className="mt-4"
>
Save
</Button>
</form>
)}
</OrgPermissionCan>
</div>
);
};

View File

@@ -0,0 +1 @@
export { OrgSecretShareLimitSection } from "./OrgSecretShareLimitSection";

View File

@@ -1,9 +1,11 @@
import { OrgSecretShareLimitSection } from "../OrgSecretShareLimitSection";
import { SecretSharingAllowShareToAnyone } from "../SecretSharingAllowShareToAnyone";
export const SecretSharingSettingsGeneralTab = () => {
return (
<div className="w-full">
<SecretSharingAllowShareToAnyone />
<OrgSecretShareLimitSection />
</div>
);
};

View File

@@ -1,10 +1,10 @@
import { useEffect, useState } from "react";
import axios from "axios";
import { createNotification } from "@app/components/notifications";
import { Switch } from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useUpdateOrg } from "@app/hooks/api";
import axios from "axios";
import { createNotification } from "@app/components/notifications";
export const OrgProductSelectSection = () => {
const [toggledProducts, setToggledProducts] = useState<{
@@ -79,8 +79,8 @@ export const OrgProductSelectSection = () => {
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<h2 className="text-xl font-semibold text-mineshaft-100">Organization Products</h2>
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-6 py-5">
<h2 className="text-xl font-semibold text-mineshaft-100">Enabled Products</h2>
<p className="mb-4 text-gray-400">
Select which products are available for your organization.
</p>

Some files were not shown because too many files have changed in this diff Show More