Compare commits
69 Commits
daniel/cli
...
infisical/
Author | SHA1 | Date | |
---|---|---|---|
|
27badad3d7 | ||
|
b5e3af6e7d | ||
|
280fbdfbb9 | ||
|
18fc10aaec | ||
|
b20e04bdeb | ||
|
10d14edc20 | ||
|
4abdd4216b | ||
|
332ed68c13 | ||
|
52feabd786 | ||
|
d7a99db66a | ||
|
fc0bdc25af | ||
|
5ffe45eaf5 | ||
|
8f795100ea | ||
|
8d8a3efd77 | ||
|
677180548b | ||
|
293bea474e | ||
|
bc4fc9a1ca | ||
|
483850441d | ||
|
4355fd09cc | ||
|
1f85d9c486 | ||
|
75d33820b3 | ||
|
074446df1f | ||
|
7ffa0ef8f5 | ||
|
5250e7c3d5 | ||
|
2deaa4eff3 | ||
|
0b6bc4c1f0 | ||
|
abbe7bbd0c | ||
|
565340dc50 | ||
|
36c428f152 | ||
|
f97826ea82 | ||
|
0f5cbf055c | ||
|
1345ff02e3 | ||
|
b960ee61d7 | ||
|
0b98a214a7 | ||
|
599c2226e4 | ||
|
8e24a4d3f8 | ||
|
27486e7600 | ||
|
979e9efbcb | ||
|
e06b5ecd1b | ||
|
1097ec64b2 | ||
|
93fe9929b7 | ||
|
aca654a993 | ||
|
b5cf237a4a | ||
|
6efb630200 | ||
|
151ede6cbf | ||
|
931ee1e8da | ||
|
0401793d38 | ||
|
0613c12508 | ||
|
60d3ffac5d | ||
|
5e192539a1 | ||
|
021a8ddace | ||
|
f92aba14cd | ||
|
fdeefcdfcf | ||
|
645f70f770 | ||
|
923feb81f3 | ||
|
16c51af340 | ||
|
9fd37ca456 | ||
|
92bebf7d84 | ||
|
df053bbae9 | ||
|
42319f01a7 | ||
|
0ea9f9b60d | ||
|
33ce783fda | ||
|
63c48dc095 | ||
|
16eefe5bac | ||
|
b984111a73 | ||
|
ad50cff184 | ||
|
8e43d2a994 | ||
|
ef70de1e0b | ||
|
7e9ee7b5e3 |
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
@@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
@@ -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>;
|
||||
|
@@ -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>;
|
||||
|
@@ -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>;
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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 {
|
||||
|
@@ -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);
|
||||
};
|
||||
|
@@ -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(),
|
||||
|
@@ -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>;
|
||||
|
@@ -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
|
||||
}
|
||||
};
|
||||
|
@@ -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 ?? {
|
||||
|
@@ -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>;
|
||||
|
@@ -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: {
|
||||
|
@@ -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?:\/\//);
|
||||
|
@@ -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({
|
||||
|
@@ -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)
|
||||
})
|
||||
}
|
||||
|
@@ -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),
|
||||
|
@@ -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({
|
||||
|
@@ -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,
|
||||
|
@@ -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({
|
||||
|
@@ -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),
|
||||
|
@@ -189,16 +189,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." });
|
||||
}
|
||||
|
@@ -105,7 +105,7 @@ export const buildCertificateChain = async ({
|
||||
kmsService,
|
||||
kmsId
|
||||
}: TBuildCertificateChainDTO) => {
|
||||
if (!encryptedCertificateChain && (!caCert || !caCertChain)) {
|
||||
if (!encryptedCertificateChain && !caCert) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -79,7 +79,8 @@ export const identityKubernetesAuthServiceFactory = ({
|
||||
|
||||
const callbackResult = await withGatewayProxy(
|
||||
async (port) => {
|
||||
const res = await gatewayCallback("localhost", port);
|
||||
// Needs to be https protocol or the kubernetes API server will fail with "Client sent an HTTP request to an HTTPS server"
|
||||
const res = await gatewayCallback("https://localhost", port);
|
||||
return res;
|
||||
},
|
||||
{
|
||||
@@ -138,11 +139,7 @@ export const identityKubernetesAuthServiceFactory = ({
|
||||
}
|
||||
|
||||
const tokenReviewCallback = async (host: string = identityKubernetesAuth.kubernetesHost, port?: number) => {
|
||||
let baseUrl = `https://${host}`;
|
||||
|
||||
if (port) {
|
||||
baseUrl += `:${port}`;
|
||||
}
|
||||
const baseUrl = port ? `${host}:${port}` : host;
|
||||
|
||||
const res = await axios
|
||||
.post<TCreateTokenReviewResponse>(
|
||||
|
@@ -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);
|
||||
|
@@ -24,5 +24,7 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({
|
||||
kmsProductEnabled: true,
|
||||
sshProductEnabled: true,
|
||||
scannerProductEnabled: true,
|
||||
shareSecretsProductEnabled: true
|
||||
shareSecretsProductEnabled: true,
|
||||
maxSharedSecretLifetime: true,
|
||||
maxSharedSecretViewLimit: true
|
||||
});
|
||||
|
@@ -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;
|
||||
|
@@ -81,6 +81,8 @@ export type TUpdateOrgDTO = {
|
||||
sshProductEnabled: boolean;
|
||||
scannerProductEnabled: boolean;
|
||||
shareSecretsProductEnabled: boolean;
|
||||
maxSharedSecretLifetime: number;
|
||||
maxSharedSecretViewLimit: number | null;
|
||||
}>;
|
||||
} & TOrgPermission;
|
||||
|
||||
|
@@ -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;
|
||||
|
@@ -93,6 +93,7 @@ export type TUpdateProjectDTO = {
|
||||
autoCapitalization?: boolean;
|
||||
hasDeleteProtection?: boolean;
|
||||
slug?: string;
|
||||
secretSharing?: boolean;
|
||||
};
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
|
@@ -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);
|
||||
}
|
||||
|
||||
|
@@ -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 & {
|
||||
|
@@ -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 {
|
||||
|
||||
|
@@ -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.
|
||||
|
@@ -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
|
||||
|
||||

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

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

|
||||
|
||||
@@ -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.
|
||||
|
||||
|
@@ -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**.
|
||||

|
||||
|
||||
- **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 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**.
|
||||

|
||||
|
||||
- **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,
|
||||
|
BIN
docs/images/platform/kms/hsm/fortanix-hsm-setup.png
Normal file
After Width: | Height: | Size: 369 KiB |
Before Width: | Height: | Size: 985 KiB After Width: | Height: | Size: 993 KiB |
Before Width: | Height: | Size: 758 KiB After Width: | Height: | Size: 778 KiB |
Before Width: | Height: | Size: 782 KiB After Width: | Height: | Size: 791 KiB |
@@ -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
|
||||
|
||||
|
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "Kubernetes CSI"
|
||||
description: "How to use Infisical to inject secrets directly into Kubernetes pods."
|
||||
description: "How to use the Infisical Kubernetes CSI provider to inject secrets directly into Kubernetes pods."
|
||||
---
|
||||
|
||||
## Overview
|
||||
@@ -15,9 +15,9 @@ flowchart LR
|
||||
CSP --> CSD(Secrets Store CSI Driver)
|
||||
end
|
||||
|
||||
subgraph Application
|
||||
subgraph Pod
|
||||
CSD --> V(Volume)
|
||||
V <--> P(Pod)
|
||||
V <--> P(Application)
|
||||
end
|
||||
|
||||
```
|
||||
|
317
docs/integrations/platforms/kubernetes-injector.mdx
Normal file
@@ -0,0 +1,317 @@
|
||||
---
|
||||
title: "Kubernetes Agent Injector"
|
||||
description: "How to use the Infisical Kubernetes Agent Injector to inject secrets directly into Kubernetes pods."
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The Infisical Kubernetes Agent Injector allows you to inject secrets directly into your Kubernetes pods. The Injector will create a [Infisical Agent](/integrations/platforms/infisical-agent) container within your pod that syncs secrets from Infisical into a shared volume mount within your pod.
|
||||
|
||||
|
||||
The Infisical Agent Injector will patch and modify your pod's deployment to contain an [Infisical Agent](/integrations/platforms/infisical-agent) container which renders your Infisical secrets into a shared volume mount within your pod.
|
||||
|
||||
The Infisical Agent Injector is built on [Kubernetes Mutating Admission Webhooks](https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers), and will watch for `CREATE` and `UPDATE` events on pods in your cluster.
|
||||
The injector is namespace-agnostic, and will watch for pods in any namespace, but will only patch pods that have the `org.infisical.com/inject` annotation set to `true`.
|
||||
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Secrets Management
|
||||
SS(Infisical) --> INJ(Infisical Injector)
|
||||
end
|
||||
|
||||
subgraph Pod
|
||||
INJ --> INIT(Agent Init Container)
|
||||
INIT --> V(Volume)
|
||||
V <--> P(Application)
|
||||
end
|
||||
|
||||
```
|
||||
|
||||
## Install the Infisical Agent Injector
|
||||
|
||||
To install the Infisical Agent Injector, you will need to install our helm charts using [Helm](https://helm.sh/).
|
||||
|
||||
```bash
|
||||
helm repo add infisical-helm-charts 'https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/'
|
||||
helm repo update
|
||||
helm install --generate-name infisical-helm-charts/infisical-agent-injector
|
||||
```
|
||||
|
||||
After installing the helm chart you can verify that the injector is running and working as intended by checking the logs of the injector pod.
|
||||
```bash
|
||||
$ kubectl logs deployment/infisical-agent-injector
|
||||
2025/05/19 14:20:05 Starting infisical-agent-injector...
|
||||
2025/05/19 14:20:05 Generating self-signed certificate...
|
||||
2025/05/19 14:20:06 Creating directory: /tmp/tls
|
||||
2025/05/19 14:20:06 Writing cert to: /tmp/tls/tls.crt
|
||||
2025/05/19 14:20:06 Writing key to: /tmp/tls/tls.key
|
||||
2025/05/19 14:20:06 Starting HTTPS server on port 8585...
|
||||
2025/05/19 14:20:06 Attempting to update webhook config (attempt 1)...
|
||||
2025/05/19 14:20:06 Successfully updated webhook configuration with CA bundle
|
||||
```
|
||||
|
||||
## Supported annotations
|
||||
|
||||
The Infisical Agent Injector supports the following annotations:
|
||||
|
||||
<Accordion title="org.infisical.com/inject">
|
||||
The inject annotation is used to enable the injector on a pod. Set the value to `true` and the pod will be patched with an Infisical Agent container on update or create.
|
||||
</Accordion>
|
||||
<Accordion title="org.infisical.com/inject-mode">
|
||||
The inject mode annotation is used to specify the mode to use to inject the secrets into the pod. Currently only `init` mode is supported.
|
||||
|
||||
- `init`: The init method will create an init container for the pod that will render the secrets into a shared volume mount within the pod. The agent init container will run before any other containers in the pod runs, including other init containers.
|
||||
</Accordion>
|
||||
<Accordion title="org.infisical.com/agent-config-map">
|
||||
The agent config map annotation is used to specify the name of the config map that contains the configuration for the injector. The config map must be in the same namespace as the pod.
|
||||
</Accordion>
|
||||
|
||||
## ConfigMap Configuration
|
||||
|
||||
### Supported Fields
|
||||
|
||||
When you are configuring a pod to use the injector, you must create a config map in the same namespace as the pod you want to inject secrets into.
|
||||
The entire config needs to be of string format and needs to be assigned to the `config.yaml` key in the config map. You can find a full example of the config at the end of this section.
|
||||
|
||||
<Accordion title="infisical.address">
|
||||
The address of your Infisical instance. This field is optional and will default to `https://app.infisical.com` if not provided.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="infisical.auth.type">
|
||||
The authentication type to use to connect to Infisical. Currently only the `kubernetes` authentication type is supported.
|
||||
You can refer to our [Kubernetes Auth](/documentation/platform/identities/kubernetes-auth) documentation for more information on how to create a machine identity for Kubernetes Auth.
|
||||
Please note that the pod's default service account will be used to authenticate with Infisical.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="infisical.auth.config.identity-id">
|
||||
The ID of the machine identity to use to connect to Infisical. This field is required if the `infisical.auth.type` is set to `kubernetes`.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="templates[]">
|
||||
The templates hold an array of templates that will be rendered and injected into the pod.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="templates[].destination-path">
|
||||
The path to inject the secrets into within the pod.
|
||||
If not specified, this will default to `/shared/infisical-secrets`. If you have multiple templates and don't provide a destination path, the destination paths will default to `/shared/infisical-secrets-1`, `/shared/infisical-secrets-2`, etc.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="templates[].template-content">
|
||||
The content of the template to render.
|
||||
This will be rendered as a [Go Template](https://pkg.go.dev/text/template) and will have access to the following variables.
|
||||
It follows the templating format and supports the same functions as the [Infisical Agent](/integrations/platforms/infisical-agent#quick-start-infisical-agent)
|
||||
</Accordion>
|
||||
|
||||
|
||||
### Authentication
|
||||
The Infisical Agent Injector only supports Machine Identity [Kubernetes Auth](/documentation/platform/identities/kubernetes-auth) authentication at the moment.
|
||||
|
||||
To configure Kubernetes Auth, you need to set the `auth.type` field to `kubernetes` and set the `auth.config.identity-id` to the ID of the machine identity you wish to use for authentication.
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
type: "kubernetes"
|
||||
config:
|
||||
identity-id: "<your-infisical-machine-identity-id>"
|
||||
```
|
||||
|
||||
### Example ConfigMap
|
||||
```yaml config-map.yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: demo-config-map
|
||||
data:
|
||||
config.yaml: |
|
||||
infisical:
|
||||
address: "https://app.infisical.com"
|
||||
auth:
|
||||
type: "kubernetes"
|
||||
config:
|
||||
identity-id: "<your-infisical-machine-identity-id>"
|
||||
templates:
|
||||
- destination-path: "/path/to/save/secrets/file.txt"
|
||||
template-content: |
|
||||
{{- with secret "<your-project-id>" "dev" "/" }}
|
||||
{{- range . }}
|
||||
{{ .Key }}={{ .Value }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
```
|
||||
|
||||
```bash
|
||||
kubectl apply -f config-map.yaml
|
||||
```
|
||||
|
||||
To use the config map in your pod, you will need to add the `org.infisical.com/agent-config-map` annotation to your pod's deployment. The value of the annotation is the name of the config map you created above.
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: demo
|
||||
labels:
|
||||
app: demo
|
||||
annotations:
|
||||
org.infisical.com/inject: "true" # Set to true for the injector to patch the pod on create/update events
|
||||
org.infisical.com/inject-mode: "init" # The mode to use to inject the secrets into the pod. Currently only `init` mode is supported.
|
||||
org.infisical.com/agent-config-map: "name-of-config-map" # The name of the config map that you created above, which contains all the settings for injecting the secrets into the pod
|
||||
spec:
|
||||
# ...
|
||||
```
|
||||
|
||||
|
||||
## Quick Start
|
||||
In this section we'll walk through a full example of how to inject secrets into a pod using the Infisical Agent Injector.
|
||||
In this example we'll create a basic nginx deployment and print a Infisical secret called `API_KEY` to the container logs.
|
||||
|
||||
### Create secrets in Infisical
|
||||
First you'll need to create the secret in Infisical.
|
||||
|
||||
- `API_KEY`: The API key to use for the nginx deployment.
|
||||
|
||||
Once you've created the secret, save your project ID, environment slug, and secret path, as these will be used in the next step.
|
||||
|
||||
### Configuration
|
||||
To use the injector you must create a config map in the same namespace as the pod you want to inject secrets into. In this example we'll create a config map in the `test-namespace` namespace.
|
||||
|
||||
The agent injector will authenticate with Infisical using a [Kubernetes Auth](/documentation/platform/identities/kubernetes-auth) machine identity. Please follow the [instructions](/documentation/platform/identities/kubernetes-auth) to create a machine identity configured for Kubernetes Auth.
|
||||
The agent injector will use the service account token of the pod to authenticate with Infisical.
|
||||
|
||||
The `template-content` will be rendered as a [Go Template](https://pkg.go.dev/text/template) and will have access to the following variables. It follows the templating format and supports the same functions as the [Infisical Agent](/integrations/platforms/infisical-agent#quick-start-infisical-agent)
|
||||
The `destination-path` refers to the path within the pod that the secrets will be injected into. In this case we're injecting the secrets into a file called `/infisical/secrets`.
|
||||
|
||||
|
||||
Replace the `<your-project-id>`, `<your-environment-slug>`, with your project ID and the environment slug of where you created your secrets in Infisical. Replace `<your-infisical-machine-identity-id>` with the ID of your machine identity configured for Kubernetes Auth.
|
||||
```yaml config-map.yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: nginx-infisical-config-map
|
||||
namespace: test-namespace
|
||||
data:
|
||||
config.yaml: |
|
||||
infisical:
|
||||
address: "https://app.infisical.com"
|
||||
auth:
|
||||
type: "kubernetes"
|
||||
config:
|
||||
identity-id: "<your-infisical-machine-identity-id>"
|
||||
templates:
|
||||
- destination-path: "/infisical/secrets"
|
||||
template-content: |
|
||||
{{- with secret "<your-project-id>" "<your-environment-slug>" "/" }}
|
||||
{{- range . }}
|
||||
{{ .Key }}={{ .Value }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
```
|
||||
|
||||
Now apply the config map:
|
||||
```bash
|
||||
kubectl apply -f config-map.yaml
|
||||
```
|
||||
|
||||
### Injecting secrets into your pod
|
||||
|
||||
To inject secrets into your pod, you will need to add the `org.infisical.com/inject: "true"` annotation to your pod's deployment.
|
||||
|
||||
The `org.infisical.com/agent-config-map` annotation will point to the config map we created in the previous step. It's important that the config map is in the same namespace as the pod.
|
||||
|
||||
We are creating a nginx deployment with a PVC to store the database data.
|
||||
|
||||
```yaml nginx.yaml
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: nginx-pod
|
||||
namespace: test-namespace
|
||||
labels:
|
||||
app: nginx
|
||||
annotations:
|
||||
org.infisical.com/inject: "true"
|
||||
org.infisical.com/inject-mode: "init"
|
||||
org.infisical.com/agent-config-map: "nginx-infisical-config-map"
|
||||
spec:
|
||||
containers:
|
||||
- name: simple-app-demo
|
||||
image: nginx:alpine
|
||||
command: ["/bin/sh", "-c"]
|
||||
args:
|
||||
- |
|
||||
export $(cat /infisical/secrets | xargs)
|
||||
echo "API_KEY is set to: $API_KEY"
|
||||
nginx -g "daemon off;"
|
||||
```
|
||||
|
||||
### Applying the deployment
|
||||
|
||||
To apply the deployment, you can use the following command:
|
||||
|
||||
```bash
|
||||
kubectl apply -f nginx.yaml
|
||||
```
|
||||
It may take a few minutes for the pod to be ready and for the Infisical secrets to be injected. You can check the status of the pod by running:
|
||||
|
||||
```bash
|
||||
kubectl get pods -n test-namespace
|
||||
```
|
||||
|
||||
### Verifying the secrets are injected
|
||||
|
||||
To verify the secrets are injected, you can check the pod's logs:
|
||||
|
||||
```bash
|
||||
$ kubectl exec -it pod/nginx-pod -n test-namespace -- cat /infisical/secrets
|
||||
|
||||
Defaulted container "simple-app-demo" out of: simple-app-demo, infisical-agent-init (init)
|
||||
|
||||
API_KEY=sk_api_... # The secret you created in Infisical
|
||||
```
|
||||
|
||||
Additionally you can now check that the `API_KEY` secret is being logged to the nginx container logs:
|
||||
```bash
|
||||
$ kubectl logs pod/nginx-pod -n test-namespace
|
||||
Defaulted container "simple-app-demo" out of: simple-app-demo, infisical-agent-init (init)
|
||||
API_KEY is set to: sk_api_... # The secret you created in Infisical
|
||||
```
|
||||
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
<Accordion title="The pod is stuck in `Init` state">
|
||||
If the pod is stuck in `Init` state, it means the Agent init container is failing to start or is stuck in a restart loop.
|
||||
This could be due to a number of reasons, such as the machine identity not having the correct permissions, or trying to fetch secrets from a non-existent project/environment.
|
||||
|
||||
You can check the logs of the infisical init container by running:
|
||||
```bash
|
||||
# For deployments
|
||||
kubectl logs deployment/your-deployment-name -c infisical-agent-init -n "<namespace>"
|
||||
|
||||
# For pods
|
||||
kubectl logs pod/your-pod-name -c infisical-agent-init -n "<namespace>"
|
||||
```
|
||||
|
||||
You can also check the logs of the pod by running:
|
||||
```bash
|
||||
kubectl logs deployment/postgres-deployment -n test-namespace
|
||||
```
|
||||
|
||||
When checking the logs of the agent init container, you may see something like the following:
|
||||
```bash
|
||||
Starting infisical agent...
|
||||
11:10AM INF starting Infisical agent...
|
||||
11:10AM INF Infisical instance address set to https://daniel1.tunn.dev
|
||||
11:10AM INF template engine started for template 1...
|
||||
11:10AM INF attempting to authenticate...
|
||||
11:10AM INF new access token saved to file at path '/home/infisical/config/identity-access-token'
|
||||
11:10AM ERR unable to process template because template: literalTemplate:1:9: executing "literalTemplate" at <secret "3c0d3ff6-165c-4dc9-b52c-ff3ffaedfce311111" "dev" "/">: error calling secret: CallGetRawSecretsV3: Unsuccessful response [GET https://daniel1.tunn.dev/api/v3/secrets/raw?environment=dev&expandSecretReferences=true&include_imports=true&secretPath=%2F&workspaceId=3c0d3ff6-165c-4dc9-b52c-ff3ffaedfce311111] [status-code=404] [response={"reqId":"req-ljqNq567jchFrK","statusCode":404,"message":"Project with ID '3c0d3ff6-165c-4dc9-b52c-ff3ffaedfce311111' not found during bot lookup. Are you sure you are using the correct project ID?","error":"NotFound"}]
|
||||
+ echo 'Agent failed with exit code 1'
|
||||
+ exit 1
|
||||
Agent failed with exit code 1
|
||||
```
|
||||
|
||||
In the above error, the project ID was invalid in the config map.
|
||||
</Accordion>
|
@@ -441,6 +441,7 @@
|
||||
"integrations/platforms/kubernetes/infisical-dynamic-secret-crd"
|
||||
]
|
||||
},
|
||||
"integrations/platforms/kubernetes-injector",
|
||||
"integrations/platforms/kubernetes-csi",
|
||||
"integrations/platforms/docker-swarm-with-agent",
|
||||
"integrations/platforms/ecs-with-agent"
|
||||
|
@@ -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>
|
||||
|
@@ -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,
|
||||
|
@@ -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'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="-_.~!*"
|
||||
|
@@ -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>
|
||||
</>
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type Props = {
|
||||
label: "Parameters" | "Secrets Mapping";
|
||||
label: "Parameters" | "Secrets Mapping" | "Password Requirements";
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
|
@@ -17,7 +17,7 @@ export const LdapPasswordRotationSecretsMappingFields = () => {
|
||||
|
||||
const items = [
|
||||
{
|
||||
name: "DN",
|
||||
name: "DN/UPN",
|
||||
input: (
|
||||
<Controller
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
|
@@ -6,16 +6,36 @@ import { AzureClientSecretRotationSchema } from "@app/components/secret-rotation
|
||||
import { LdapPasswordRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/ldap-password-rotation-schema";
|
||||
import { 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>>;
|
||||
|
@@ -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);
|
||||
|
@@ -1,5 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export type TPasswordRequirements = z.infer<typeof PasswordRequirementsSchema>;
|
||||
|
||||
export const PasswordRequirementsSchema = z
|
||||
.object({
|
||||
length: z
|
||||
|
@@ -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>{" "}
|
||||
|
@@ -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",
|
||||
|
@@ -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,}$/;
|
||||
|
@@ -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;
|
||||
},
|
||||
|
@@ -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: () => {
|
||||
|
@@ -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 = {
|
||||
|
@@ -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;
|
||||
|
@@ -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
|
||||
}
|
||||
);
|
||||
|
||||
|
@@ -32,6 +32,7 @@ export type TCreateSharedSecretRequest = {
|
||||
expiresAt: Date;
|
||||
expiresAfterViews?: number;
|
||||
accessType?: SecretSharingAccessType;
|
||||
emails?: string[];
|
||||
};
|
||||
|
||||
export type TCreateSecretRequestRequestDTO = {
|
||||
|
@@ -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;
|
||||
|
@@ -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 };
|
||||
|
@@ -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 />
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -30,6 +30,8 @@ export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
allowSecretSharingOutsideOrganization={
|
||||
currentOrg?.allowSecretSharingOutsideOrganization ?? true
|
||||
}
|
||||
maxSharedSecretLifetime={currentOrg?.maxSharedSecretLifetime}
|
||||
maxSharedSecretViewLimit={currentOrg?.maxSharedSecretViewLimit}
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
);
|
||||
};
|
@@ -0,0 +1 @@
|
||||
export { OrgSecretShareLimitSection } from "./OrgSecretShareLimitSection";
|
@@ -1,9 +1,11 @@
|
||||
import { OrgSecretShareLimitSection } from "../OrgSecretShareLimitSection";
|
||||
import { SecretSharingAllowShareToAnyone } from "../SecretSharingAllowShareToAnyone";
|
||||
|
||||
export const SecretSharingSettingsGeneralTab = () => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<SecretSharingAllowShareToAnyone />
|
||||
<OrgSecretShareLimitSection />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@@ -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>
|
||||
|
@@ -96,61 +96,59 @@ export const OrgUserAccessTokenLimitSection = () => {
|
||||
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Settings}>
|
||||
{(isAllowed) => (
|
||||
<form onSubmit={handleSubmit(handleUserTokenExpirationSubmit)} autoComplete="off">
|
||||
<div className="flex max-w-md gap-4">
|
||||
<div className="flex-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="expirationValue"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
label="Expiration value"
|
||||
<div className="flex max-w-sm gap-4">
|
||||
<Controller
|
||||
control={control}
|
||||
name="expirationValue"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
label="Expiration value"
|
||||
className="w-full"
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={field.value}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
|
||||
disabled={!isAllowed}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="expirationUnit"
|
||||
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}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={field.value}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
|
||||
disabled={!isAllowed}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="expirationUnit"
|
||||
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>
|
||||
{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>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||
import { Button, ContentLoader } from "@app/components/v2";
|
||||
import { Button, ContentLoader, EmptyState } from "@app/components/v2";
|
||||
import {
|
||||
OrgPermissionActions,
|
||||
OrgPermissionSubjects,
|
||||
@@ -60,102 +60,108 @@ export const OrgSsoTab = withPermission(
|
||||
const shouldShowCreateIdentityProviderView =
|
||||
!isOidcConfigured && !isSamlConfigured && !isLdapConfigured;
|
||||
|
||||
const createIdentityProviderView = (shouldDisplaySection(LoginMethod.SAML) ||
|
||||
const createIdentityProviderView =
|
||||
shouldDisplaySection(LoginMethod.SAML) ||
|
||||
shouldDisplaySection(LoginMethod.OIDC) ||
|
||||
shouldDisplaySection(LoginMethod.LDAP)) && (
|
||||
<>
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
|
||||
<p className="text-xl font-semibold text-gray-200">Connect an Identity Provider</p>
|
||||
<p className="mb-2 mt-1 text-gray-400">
|
||||
Connect your identity provider to simplify user management
|
||||
</p>
|
||||
{shouldDisplaySection(LoginMethod.SAML) && (
|
||||
<div
|
||||
className={twMerge(
|
||||
"mt-4 flex items-center justify-between",
|
||||
(shouldDisplaySection(LoginMethod.OIDC) ||
|
||||
shouldDisplaySection(LoginMethod.LDAP)) &&
|
||||
"border-b border-mineshaft-500 pb-4"
|
||||
)}
|
||||
>
|
||||
<p className="text-lg text-gray-200">SAML</p>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
onClick={() => {
|
||||
if (!subscription?.samlSSO) {
|
||||
handlePopUpOpen("upgradePlan", { feature: "SAML SSO", plan: "Pro" });
|
||||
return;
|
||||
}
|
||||
|
||||
handlePopUpOpen("addSSO");
|
||||
}}
|
||||
shouldDisplaySection(LoginMethod.LDAP) ? (
|
||||
<>
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
|
||||
<p className="text-xl font-semibold text-gray-200">Connect an Identity Provider</p>
|
||||
<p className="mb-2 mt-1 text-gray-400">
|
||||
Connect your identity provider to simplify user management
|
||||
</p>
|
||||
{shouldDisplaySection(LoginMethod.SAML) && (
|
||||
<div
|
||||
className={twMerge(
|
||||
"mt-4 flex items-center justify-between",
|
||||
(shouldDisplaySection(LoginMethod.OIDC) ||
|
||||
shouldDisplaySection(LoginMethod.LDAP)) &&
|
||||
"border-b border-mineshaft-500 pb-4"
|
||||
)}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{shouldDisplaySection(LoginMethod.OIDC) && (
|
||||
<div
|
||||
className={twMerge(
|
||||
"mt-4 flex items-center justify-between",
|
||||
shouldDisplaySection(LoginMethod.LDAP) && "border-b border-mineshaft-500 pb-4"
|
||||
)}
|
||||
>
|
||||
<p className="text-lg text-gray-200">OIDC</p>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
onClick={() => {
|
||||
if (!subscription?.oidcSSO) {
|
||||
handlePopUpOpen("upgradePlan", { feature: "OIDC SSO", plan: "Pro" });
|
||||
return;
|
||||
}
|
||||
<p className="text-lg text-gray-200">SAML</p>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
onClick={() => {
|
||||
if (!subscription?.samlSSO) {
|
||||
handlePopUpOpen("upgradePlan", { feature: "SAML SSO", plan: "Pro" });
|
||||
return;
|
||||
}
|
||||
|
||||
handlePopUpOpen("addOIDC");
|
||||
}}
|
||||
handlePopUpOpen("addSSO");
|
||||
}}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{shouldDisplaySection(LoginMethod.OIDC) && (
|
||||
<div
|
||||
className={twMerge(
|
||||
"mt-4 flex items-center justify-between",
|
||||
shouldDisplaySection(LoginMethod.LDAP) && "border-b border-mineshaft-500 pb-4"
|
||||
)}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{shouldDisplaySection(LoginMethod.LDAP) && (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<p className="text-lg text-gray-200">LDAP</p>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
onClick={() => {
|
||||
if (!subscription?.ldap) {
|
||||
handlePopUpOpen("upgradePlan", { feature: "LDAP", plan: "Enterprise" });
|
||||
return;
|
||||
}
|
||||
<p className="text-lg text-gray-200">OIDC</p>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
onClick={() => {
|
||||
if (!subscription?.oidcSSO) {
|
||||
handlePopUpOpen("upgradePlan", { feature: "OIDC SSO", plan: "Pro" });
|
||||
return;
|
||||
}
|
||||
|
||||
handlePopUpOpen("addLDAP");
|
||||
}}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SSOModal
|
||||
hideDelete
|
||||
popUp={popUp}
|
||||
handlePopUpClose={handlePopUpClose}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
/>
|
||||
<OIDCModal
|
||||
hideDelete
|
||||
popUp={popUp}
|
||||
handlePopUpClose={handlePopUpClose}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
/>
|
||||
<LDAPModal
|
||||
hideDelete
|
||||
popUp={popUp}
|
||||
handlePopUpClose={handlePopUpClose}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
handlePopUpOpen("addOIDC");
|
||||
}}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{shouldDisplaySection(LoginMethod.LDAP) && (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<p className="text-lg text-gray-200">LDAP</p>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
onClick={() => {
|
||||
if (!subscription?.ldap) {
|
||||
handlePopUpOpen("upgradePlan", { feature: "LDAP", plan: "Enterprise" });
|
||||
return;
|
||||
}
|
||||
|
||||
handlePopUpOpen("addLDAP");
|
||||
}}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SSOModal
|
||||
hideDelete
|
||||
popUp={popUp}
|
||||
handlePopUpClose={handlePopUpClose}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
/>
|
||||
<OIDCModal
|
||||
hideDelete
|
||||
popUp={popUp}
|
||||
handlePopUpClose={handlePopUpClose}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
/>
|
||||
<LDAPModal
|
||||
hideDelete
|
||||
popUp={popUp}
|
||||
handlePopUpClose={handlePopUpClose}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<EmptyState title="" iconSize="2x" className="!pb-10 pt-14">
|
||||
<p className="text-center text-lg">Single Sign-On (SSO) has been disabled</p>
|
||||
<p className="text-center">Contact your server administrator</p>
|
||||
</EmptyState>
|
||||
);
|
||||
|
||||
if (areConfigsLoading) {
|
||||
return <ContentLoader />;
|
||||
|
@@ -91,7 +91,7 @@ export const ShareSecretPage = () => {
|
||||
Infisical
|
||||
</a>
|
||||
<br />
|
||||
156 2nd st, 3rd Floor, San Francisco, California, 94105, United States. 🇺🇸
|
||||
235 2nd st, San Francisco, California, 94105, United States. 🇺🇸
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -6,7 +6,19 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FormControl, IconButton, Input, Select, SelectItem } from "@app/components/v2";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
Button,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
Select,
|
||||
SelectItem,
|
||||
Switch
|
||||
} from "@app/components/v2";
|
||||
import { useTimedReset } from "@app/hooks";
|
||||
import { useCreatePublicSharedSecret, useCreateSharedSecret } from "@app/hooks/api";
|
||||
import { SecretSharingAccessType } from "@app/hooks/api/secretSharing";
|
||||
@@ -33,7 +45,24 @@ const schema = z.object({
|
||||
secret: z.string().min(1),
|
||||
expiresIn: z.string(),
|
||||
viewLimit: z.string(),
|
||||
accessType: z.nativeEnum(SecretSharingAccessType).optional()
|
||||
accessType: z.nativeEnum(SecretSharingAccessType).optional(),
|
||||
emails: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(
|
||||
(val) => {
|
||||
if (!val) return true;
|
||||
const emails = val
|
||||
.split(",")
|
||||
.map((email) => email.trim())
|
||||
.filter((email) => email !== "");
|
||||
if (emails.length > 100) return false;
|
||||
return emails.every((email) => z.string().email().safeParse(email).success);
|
||||
},
|
||||
{
|
||||
message: "Must be a comma-separated list of valid emails (max 100) or empty."
|
||||
}
|
||||
)
|
||||
});
|
||||
|
||||
export type FormData = z.infer<typeof schema>;
|
||||
@@ -42,14 +71,18 @@ type Props = {
|
||||
isPublic: boolean; // whether or not this is a public (non-authenticated) secret sharing form
|
||||
value?: string;
|
||||
allowSecretSharingOutsideOrganization?: boolean;
|
||||
maxSharedSecretLifetime?: number;
|
||||
maxSharedSecretViewLimit?: number | null;
|
||||
};
|
||||
|
||||
export const ShareSecretForm = ({
|
||||
isPublic,
|
||||
value,
|
||||
allowSecretSharingOutsideOrganization = true
|
||||
allowSecretSharingOutsideOrganization = true,
|
||||
maxSharedSecretLifetime,
|
||||
maxSharedSecretViewLimit
|
||||
}: Props) => {
|
||||
const [secretLink, setSecretLink] = useState("");
|
||||
const [secretLink, setSecretLink] = useState<string | null>(null);
|
||||
const [, isCopyingSecret, setCopyTextSecret] = useTimedReset<string>({
|
||||
initialState: "Copy to clipboard"
|
||||
});
|
||||
@@ -58,6 +91,15 @@ export const ShareSecretForm = ({
|
||||
const privateSharedSecretCreator = useCreateSharedSecret();
|
||||
const createSharedSecret = isPublic ? publicSharedSecretCreator : privateSharedSecretCreator;
|
||||
|
||||
// Note: maxSharedSecretLifetime is in seconds
|
||||
const filteredExpiresInOptions = maxSharedSecretLifetime
|
||||
? expiresInOptions.filter((v) => v.value / 1000 <= maxSharedSecretLifetime)
|
||||
: expiresInOptions;
|
||||
|
||||
const filteredViewLimitOptions = maxSharedSecretViewLimit
|
||||
? viewLimitOptions.filter((v) => v.value > 0 && v.value <= maxSharedSecretViewLimit)
|
||||
: viewLimitOptions;
|
||||
|
||||
const {
|
||||
control,
|
||||
reset,
|
||||
@@ -66,7 +108,10 @@ export const ShareSecretForm = ({
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
secret: value || ""
|
||||
secret: value || "",
|
||||
viewLimit: filteredViewLimitOptions[filteredViewLimitOptions.length - 1].value.toString(),
|
||||
expiresIn:
|
||||
filteredExpiresInOptions[Math.min(filteredExpiresInOptions.length - 1, 2)].value.toString()
|
||||
}
|
||||
});
|
||||
|
||||
@@ -76,32 +121,45 @@ export const ShareSecretForm = ({
|
||||
secret,
|
||||
expiresIn,
|
||||
viewLimit,
|
||||
accessType
|
||||
accessType,
|
||||
emails
|
||||
}: FormData) => {
|
||||
try {
|
||||
const expiresAt = new Date(new Date().getTime() + Number(expiresIn));
|
||||
|
||||
const processedEmails = emails ? emails.split(",").map((e) => e.trim()) : undefined;
|
||||
|
||||
const { id } = await createSharedSecret.mutateAsync({
|
||||
name,
|
||||
password,
|
||||
secretValue: secret,
|
||||
expiresAt,
|
||||
expiresAfterViews: viewLimit === "-1" ? undefined : Number(viewLimit),
|
||||
accessType
|
||||
accessType,
|
||||
emails: processedEmails
|
||||
});
|
||||
|
||||
const link = `${window.location.origin}/shared/secret/${id}`;
|
||||
if (processedEmails && processedEmails.length > 0) {
|
||||
setSecretLink("");
|
||||
createNotification({
|
||||
text: `Shared secret link emailed to ${processedEmails.length} user(s).`,
|
||||
type: "success"
|
||||
});
|
||||
} else {
|
||||
const link = `${window.location.origin}/shared/secret/${id}`;
|
||||
|
||||
setSecretLink(link);
|
||||
|
||||
navigator.clipboard.writeText(link);
|
||||
setCopyTextSecret("secret");
|
||||
|
||||
createNotification({
|
||||
text: "Shared secret link copied to clipboard.",
|
||||
type: "success"
|
||||
});
|
||||
}
|
||||
|
||||
setSecretLink(link);
|
||||
reset();
|
||||
|
||||
navigator.clipboard.writeText(link);
|
||||
setCopyTextSecret("secret");
|
||||
|
||||
createNotification({
|
||||
text: "Shared secret link copied to clipboard.",
|
||||
type: "success"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
@@ -111,152 +169,256 @@ export const ShareSecretForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
const hasSecretLink = Boolean(secretLink);
|
||||
|
||||
return !hasSecretLink ? (
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
{!isPublic && (
|
||||
if (secretLink === null)
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
{!isPublic && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Name"
|
||||
isOptional
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="API Key"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
name="secret"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Name (Optional)"
|
||||
label="Your Secret"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
className="mb-2"
|
||||
isRequired
|
||||
>
|
||||
<Input
|
||||
<textarea
|
||||
placeholder="Enter sensitive data to share via an encrypted link..."
|
||||
{...field}
|
||||
placeholder="API Key"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck="false"
|
||||
className="h-40 min-h-[70px] w-full rounded-md border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5 text-bunker-300 outline-none transition-all placeholder:text-mineshaft-400 hover:border-primary-400/30 focus:border-primary-400/50 group-hover:mr-2"
|
||||
disabled={value !== undefined}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name="secret"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Your Secret"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
className="mb-2"
|
||||
isRequired
|
||||
>
|
||||
<textarea
|
||||
placeholder="Enter sensitive data to share via an encrypted link..."
|
||||
{...field}
|
||||
className="h-40 min-h-[70px] w-full rounded-md border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5 text-bunker-300 outline-none transition-all placeholder:text-mineshaft-400 hover:border-primary-400/30 focus:border-primary-400/50 group-hover:mr-2"
|
||||
disabled={value !== undefined}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="password"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Password"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
isOptional
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
autoCorrect="off"
|
||||
spellCheck="false"
|
||||
aria-autocomplete="none"
|
||||
data-form-type="other"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="expiresIn"
|
||||
defaultValue="3600000"
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl label="Expires In" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{expiresInOptions.map(({ label, value: expiresInValue }) => (
|
||||
<SelectItem value={String(expiresInValue || "")} key={label}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="viewLimit"
|
||||
defaultValue="-1"
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl label="Max Views" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{viewLimitOptions.map(({ label, value: viewLimitValue }) => (
|
||||
<SelectItem value={String(viewLimitValue || "")} key={label}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{!isPublic && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="accessType"
|
||||
defaultValue={SecretSharingAccessType.Organization}
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl label="General Access" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
name="password"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Password"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
isOptional
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{allowSecretSharingOutsideOrganization && (
|
||||
<SelectItem value={SecretSharingAccessType.Anyone}>Anyone</SelectItem>
|
||||
)}
|
||||
<SelectItem value={SecretSharingAccessType.Organization}>
|
||||
People within your organization
|
||||
</SelectItem>
|
||||
</Select>
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
autoCorrect="off"
|
||||
spellCheck="false"
|
||||
aria-autocomplete="none"
|
||||
data-form-type="other"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
className="mt-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
Create Secret Link
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
|
||||
{!isPublic && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="accessType"
|
||||
defaultValue={SecretSharingAccessType.Organization}
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
helperText={
|
||||
allowSecretSharingOutsideOrganization ? undefined : (
|
||||
<span className="text-red-500">Feature enforced by organization</span>
|
||||
)
|
||||
}
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Switch
|
||||
className={`ml-0 mr-2 bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-primary ${!allowSecretSharingOutsideOrganization ? "opacity-50" : ""}`}
|
||||
thumbClassName="bg-mineshaft-800"
|
||||
containerClassName="flex-row-reverse w-fit"
|
||||
isChecked={
|
||||
field.value === SecretSharingAccessType.Organization ||
|
||||
!allowSecretSharingOutsideOrganization
|
||||
}
|
||||
isDisabled={!allowSecretSharingOutsideOrganization}
|
||||
onCheckedChange={(v) =>
|
||||
onChange(
|
||||
v ? SecretSharingAccessType.Organization : SecretSharingAccessType.Anyone
|
||||
)
|
||||
}
|
||||
id="org-access-only"
|
||||
>
|
||||
Limit access to people within organization
|
||||
</Switch>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="advance-settings" className="data-[state=open]:border-none">
|
||||
<AccordionTrigger className="h-fit flex-none pl-1 text-sm">
|
||||
<div className="order-1 ml-3">Advanced Settings</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent childrenClassName="p-0">
|
||||
<Controller
|
||||
control={control}
|
||||
name="expiresIn"
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Expires In"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
helperText={
|
||||
expiresInOptions.length !== filteredExpiresInOptions.length ? (
|
||||
<span className="text-yellow-500">
|
||||
Limited to{" "}
|
||||
{filteredExpiresInOptions[filteredExpiresInOptions.length - 1].label} by
|
||||
organization
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{expiresInOptions.map(({ label, value: expiresInValue }) => (
|
||||
<SelectItem
|
||||
value={String(expiresInValue || "")}
|
||||
key={label}
|
||||
isDisabled={!filteredExpiresInOptions.some((v) => v.label === label)}
|
||||
>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="viewLimit"
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Max Views"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
helperText={
|
||||
viewLimitOptions.length !== filteredViewLimitOptions.length ? (
|
||||
<span className="text-yellow-500">
|
||||
Limited to{" "}
|
||||
{filteredViewLimitOptions[filteredViewLimitOptions.length - 1].label} by
|
||||
organization
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{viewLimitOptions.map(({ label, value: viewLimitValue }) => (
|
||||
<SelectItem
|
||||
value={String(viewLimitValue || "")}
|
||||
key={label}
|
||||
isDisabled={!filteredViewLimitOptions.some((v) => v.label === label)}
|
||||
>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!isPublic && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="emails"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Authorized Emails"
|
||||
isOptional
|
||||
tooltipText="Unique secret links will be emailed to each individual. The secret will only be accessible to those links."
|
||||
tooltipClassName="max-w-sm"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="user1@example.com, user2@example.com"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
className="mt-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
Create Secret Link
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
if (secretLink === "")
|
||||
return (
|
||||
<>
|
||||
<div className="mt-1 flex w-full items-center justify-center gap-2">
|
||||
<FontAwesomeIcon icon={faCheck} className="text-green-500" />
|
||||
<span>Shared secret link has been emailed to select users.</span>
|
||||
</div>
|
||||
<Button
|
||||
className="mt-6 w-full bg-mineshaft-700 py-3 text-bunker-200"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
size="sm"
|
||||
onClick={() => setSecretLink(null)}
|
||||
rightIcon={<FontAwesomeIcon icon={faRedo} className="pl-2" />}
|
||||
>
|
||||
Share Another Secret
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mr-2 flex items-center justify-end rounded-md bg-white/[0.05] p-2 text-base text-gray-400">
|
||||
<p className="mr-4 break-all">{secretLink}</p>
|
||||
@@ -265,7 +427,7 @@ export const ShareSecretForm = ({
|
||||
colorSchema="secondary"
|
||||
className="group relative ml-2"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(secretLink);
|
||||
navigator.clipboard.writeText(secretLink || "");
|
||||
setCopyTextSecret("Copied");
|
||||
}}
|
||||
>
|
||||
@@ -277,7 +439,7 @@ export const ShareSecretForm = ({
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
size="sm"
|
||||
onClick={() => setSecretLink("")}
|
||||
onClick={() => setSecretLink(null)}
|
||||
rightIcon={<FontAwesomeIcon icon={faRedo} className="pl-2" />}
|
||||
>
|
||||
Share Another Secret
|
||||
|
@@ -175,7 +175,7 @@ export const ViewSecretRequestByIDPage = () => {
|
||||
Infisical
|
||||
</a>
|
||||
<br />
|
||||
156 2nd st, 3rd Floor, San Francisco, California, 94105, United States. 🇺🇸
|
||||
235 2nd st, San Francisco, California, 94105, United States. 🇺🇸
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -38,6 +38,14 @@ export const ViewSharedSecretByIDPage = () => {
|
||||
from: ROUTE_PATHS.Public.ViewSharedSecretByIDPage.id,
|
||||
select: (el) => el.key
|
||||
});
|
||||
const email = useSearch({
|
||||
from: ROUTE_PATHS.Public.ViewSharedSecretByIDPage.id,
|
||||
select: (el) => el.email
|
||||
});
|
||||
const hash = useSearch({
|
||||
from: ROUTE_PATHS.Public.ViewSharedSecretByIDPage.id,
|
||||
select: (el) => el.hash
|
||||
});
|
||||
const [password, setPassword] = useState<string>();
|
||||
const { hashedHex, key } = extractDetailsFromUrl(urlEncodedKey);
|
||||
|
||||
@@ -49,7 +57,9 @@ export const ViewSharedSecretByIDPage = () => {
|
||||
} = useGetActiveSharedSecretById({
|
||||
sharedSecretId: id,
|
||||
hashedHex,
|
||||
password
|
||||
password,
|
||||
email,
|
||||
hash
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
@@ -57,15 +67,16 @@ export const ViewSharedSecretByIDPage = () => {
|
||||
const isUnauthorized =
|
||||
((error as AxiosError)?.response?.data as { statusCode: number })?.statusCode === 401;
|
||||
|
||||
const isForbidden =
|
||||
((error as AxiosError)?.response?.data as { statusCode: number })?.statusCode === 403;
|
||||
|
||||
const isInvalidCredential =
|
||||
((error as AxiosError)?.response?.data as { message: string })?.message ===
|
||||
"Invalid credentials";
|
||||
|
||||
const isEmailUnauthorized =
|
||||
((error as AxiosError)?.response?.data as { message: string })?.message ===
|
||||
"Email not authorized to view secret";
|
||||
|
||||
useEffect(() => {
|
||||
if (isUnauthorized && !isInvalidCredential) {
|
||||
if (isUnauthorized && !isInvalidCredential && !isEmailUnauthorized) {
|
||||
// persist current URL in session storage so that we can come back to this after successful login
|
||||
sessionStorage.setItem(
|
||||
SessionStorageKeys.ORG_LOGIN_SUCCESS_REDIRECT_URL,
|
||||
@@ -85,10 +96,10 @@ export const ViewSharedSecretByIDPage = () => {
|
||||
});
|
||||
}
|
||||
|
||||
if (isForbidden) {
|
||||
if (error) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "You do not have access to this shared secret."
|
||||
text: ((error as AxiosError)?.response?.data as { message: string })?.message
|
||||
});
|
||||
}
|
||||
}, [error]);
|
||||
@@ -195,7 +206,7 @@ export const ViewSharedSecretByIDPage = () => {
|
||||
Infisical
|
||||
</a>
|
||||
<br />
|
||||
156 2nd st, 3rd Floor, San Francisco, California, 94105, United States. 🇺🇸
|
||||
235 2nd st, San Francisco, California, 94105, United States. 🇺🇸
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -7,7 +7,9 @@ import { authKeys, fetchAuthToken } from "@app/hooks/api/auth/queries";
|
||||
import { ViewSharedSecretByIDPage } from "./ViewSharedSecretByIDPage";
|
||||
|
||||
const SharedSecretByIDPageQuerySchema = z.object({
|
||||
key: z.string().catch("")
|
||||
key: z.string().catch(""),
|
||||
email: z.string().optional(),
|
||||
hash: z.string().optional()
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/shared/secret/$secretId")({
|
||||
|
@@ -95,7 +95,6 @@ export const SecretDetailSidebar = ({
|
||||
handleSecretShare
|
||||
}: Props) => {
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
watch,
|
||||
handleSubmit,
|
||||
@@ -104,7 +103,8 @@ export const SecretDetailSidebar = ({
|
||||
formState: { isDirty, isSubmitting }
|
||||
} = useForm<TFormSchema>({
|
||||
resolver: zodResolver(formSchema),
|
||||
values: secret
|
||||
values: secret,
|
||||
disabled: !secret
|
||||
});
|
||||
|
||||
const { handlePopUpToggle, popUp, handlePopUpOpen } = usePopUp([
|
||||
@@ -398,11 +398,19 @@ export const SecretDetailSidebar = ({
|
||||
autoFocus={false}
|
||||
/>
|
||||
<Tooltip
|
||||
content="You don't have permission to view the secret value."
|
||||
isDisabled={!secret?.secretValueHidden}
|
||||
content={
|
||||
!currentWorkspace.secretSharing
|
||||
? "This project does not allow secret sharing."
|
||||
: "You don't have permission to view the secret value."
|
||||
}
|
||||
isDisabled={
|
||||
!secret?.secretValueHidden && currentWorkspace.secretSharing
|
||||
}
|
||||
>
|
||||
<Button
|
||||
isDisabled={secret?.secretValueHidden}
|
||||
isDisabled={
|
||||
secret?.secretValueHidden || !currentWorkspace.secretSharing
|
||||
}
|
||||
className="px-2 py-[0.43rem] font-normal"
|
||||
variant="outline_bg"
|
||||
leftIcon={<FontAwesomeIcon icon={faShare} />}
|
||||
@@ -705,14 +713,25 @@ export const SecretDetailSidebar = ({
|
||||
</FormControl>
|
||||
</div>
|
||||
</div>
|
||||
<FormControl label="Comments & Notes">
|
||||
<TextArea
|
||||
className="border border-mineshaft-600 bg-bunker-800 text-sm"
|
||||
{...register("comment")}
|
||||
readOnly={isReadOnly}
|
||||
rows={5}
|
||||
/>
|
||||
</FormControl>
|
||||
<Controller
|
||||
control={control}
|
||||
name="comment"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Comments & Notes"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
className="mb-0"
|
||||
>
|
||||
<TextArea
|
||||
className="border border-mineshaft-600 bg-bunker-800 text-sm"
|
||||
readOnly={isReadOnly}
|
||||
rows={5}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<FormControl>
|
||||
{secretReminderRepeatDays && secretReminderRepeatDays > 0 ? (
|
||||
<div className="flex items-center justify-between px-2">
|
||||
@@ -913,7 +932,9 @@ export const SecretDetailSidebar = ({
|
||||
variant="outline_bg"
|
||||
size="sm"
|
||||
className="h-8 w-8 rounded-md"
|
||||
onClick={() => setValue("value", secretValue)}
|
||||
onClick={() =>
|
||||
setValue("value", secretValue, { shouldDirty: true })
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowRotateRight} />
|
||||
</IconButton>
|
||||
|
@@ -589,7 +589,7 @@ export const SecretItem = memo(
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<IconButton
|
||||
isDisabled={secret.secretValueHidden}
|
||||
isDisabled={secret.secretValueHidden || !currentWorkspace.secretSharing}
|
||||
className="w-0 overflow-hidden p-0 group-hover:w-5"
|
||||
variant="plain"
|
||||
size="md"
|
||||
|
@@ -10,6 +10,7 @@ import { DeleteProjectSection } from "../DeleteProjectSection";
|
||||
import { EnvironmentSection } from "../EnvironmentSection";
|
||||
import { PointInTimeVersionLimitSection } from "../PointInTimeVersionLimitSection";
|
||||
import { RebuildSecretIndicesSection } from "../RebuildSecretIndicesSection/RebuildSecretIndicesSection";
|
||||
import { SecretSharingSection } from "../SecretSharingSection";
|
||||
import { SecretTagsSection } from "../SecretTagsSection";
|
||||
|
||||
export const ProjectGeneralTab = () => {
|
||||
@@ -22,6 +23,7 @@ export const ProjectGeneralTab = () => {
|
||||
{isSecretManager && <EnvironmentSection />}
|
||||
{isSecretManager && <SecretTagsSection />}
|
||||
{isSecretManager && <AutoCapitalizationSection />}
|
||||
{isSecretManager && <SecretSharingSection />}
|
||||
{isSecretManager && <PointInTimeVersionLimitSection />}
|
||||
<AuditLogsRetentionSection />
|
||||
{isSecretManager && <BackfillSecretReferenceSecretion />}
|
||||
|
@@ -0,0 +1,64 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { Checkbox } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { useUpdateProject } from "@app/hooks/api/workspace/queries";
|
||||
|
||||
export const SecretSharingSection = () => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { mutateAsync: updateProject } = useUpdateProject();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleToggle = async (state: boolean) => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
if (!currentWorkspace?.id) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await updateProject({
|
||||
projectID: currentWorkspace.id,
|
||||
secretSharing: state
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: `Successfully ${state ? "enabled" : "disabled"} secret sharing for this project`,
|
||||
type: "success"
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to update secret sharing for this project",
|
||||
type: "error"
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<p className="mb-3 text-xl font-semibold">Allow Secret Sharing</p>
|
||||
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Settings}>
|
||||
{(isAllowed) => (
|
||||
<div className="w-max">
|
||||
<Checkbox
|
||||
className="data-[state=checked]:bg-primary"
|
||||
id="secretSharing"
|
||||
isDisabled={!isAllowed || isLoading}
|
||||
isChecked={currentWorkspace?.secretSharing ?? true}
|
||||
onCheckedChange={(state) => handleToggle(state as boolean)}
|
||||
>
|
||||
This feature enables your project members to securely share secrets.
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -0,0 +1 @@
|
||||
export { SecretSharingSection } from "./SecretSharingSection";
|
@@ -13,9 +13,9 @@ type: application
|
||||
# This is the chart version. This version number should be incremented each time you make changes
|
||||
# to the chart and its templates, including the app version.
|
||||
# Versions are expected to follow Semantic Versioning (https://semver.org/)
|
||||
version: v0.9.2
|
||||
version: v0.9.3
|
||||
# This is the version number of the application being deployed. This version number should be
|
||||
# incremented each time you make changes to the application. Versions are not expected to
|
||||
# follow Semantic Versioning. They should reflect the version the application is using.
|
||||
# It is recommended to use it with quotes.
|
||||
appVersion: "v0.9.2"
|
||||
appVersion: "v0.9.3"
|
||||
|
@@ -32,7 +32,7 @@ controllerManager:
|
||||
- ALL
|
||||
image:
|
||||
repository: infisical/kubernetes-operator
|
||||
tag: v0.9.2
|
||||
tag: v0.9.3
|
||||
resources:
|
||||
limits:
|
||||
cpu: 500m
|
||||
|
@@ -30,9 +30,9 @@ import (
|
||||
// InfisicalSecretReconciler reconciles a InfisicalSecret object
|
||||
type InfisicalPushSecretReconciler struct {
|
||||
client.Client
|
||||
|
||||
BaseLogger logr.Logger
|
||||
Scheme *runtime.Scheme
|
||||
IsNamespaceScoped bool
|
||||
BaseLogger logr.Logger
|
||||
Scheme *runtime.Scheme
|
||||
}
|
||||
|
||||
var infisicalPushSecretResourceVariablesMap map[string]util.ResourceVariables = make(map[string]util.ResourceVariables)
|
||||
@@ -51,7 +51,7 @@ func (r *InfisicalPushSecretReconciler) GetLogger(req ctrl.Request) logr.Logger
|
||||
//+kubebuilder:rbac:groups="",resources=pods,verbs=get;list
|
||||
//+kubebuilder:rbac:groups="authentication.k8s.io",resources=tokenreviews,verbs=create
|
||||
//+kubebuilder:rbac:groups="",resources=serviceaccounts/token,verbs=create
|
||||
// +kubebuilder:rbac:groups=secrets.infisical.com,resources=clustergenerators,verbs=get;list;watch;create;update;patch;delete
|
||||
//+kubebuilder:rbac:groups=secrets.infisical.com,resources=clustergenerators,verbs=get;list;watch;create;update;patch;delete
|
||||
// Reconcile is part of the main kubernetes reconciliation loop which aims to
|
||||
// move the current state of the cluster closer to the desired state.
|
||||
// For more details, check Reconcile and its Result here:
|
||||
@@ -249,19 +249,26 @@ func (r *InfisicalPushSecretReconciler) SetupWithManager(mgr ctrl.Manager) error
|
||||
},
|
||||
}
|
||||
|
||||
return ctrl.NewControllerManagedBy(mgr).
|
||||
controllerManager := ctrl.NewControllerManagedBy(mgr).
|
||||
For(&secretsv1alpha1.InfisicalPushSecret{}, builder.WithPredicates(
|
||||
specChangeOrDelete,
|
||||
)).
|
||||
Watches(
|
||||
&source.Kind{Type: &corev1.Secret{}},
|
||||
handler.EnqueueRequestsFromMapFunc(r.findPushSecretsForSecret),
|
||||
).
|
||||
Watches(
|
||||
)
|
||||
|
||||
if !r.IsNamespaceScoped {
|
||||
r.BaseLogger.Info("Watching ClusterGenerators for non-namespace scoped operator")
|
||||
controllerManager.Watches(
|
||||
&source.Kind{Type: &secretsv1alpha1.ClusterGenerator{}},
|
||||
handler.EnqueueRequestsFromMapFunc(r.findPushSecretsForClusterGenerator),
|
||||
).
|
||||
Complete(r)
|
||||
)
|
||||
} else {
|
||||
r.BaseLogger.Info("Not watching ClusterGenerators for namespace scoped operator")
|
||||
}
|
||||
|
||||
return controllerManager.Complete(r)
|
||||
}
|
||||
|
||||
func (r *InfisicalPushSecretReconciler) findPushSecretsForClusterGenerator(o client.Object) []reconcile.Request {
|
||||
@@ -277,6 +284,7 @@ func (r *InfisicalPushSecretReconciler) findPushSecretsForClusterGenerator(o cli
|
||||
}
|
||||
|
||||
requests := []reconcile.Request{}
|
||||
|
||||
for _, pushSecret := range pushSecrets.Items {
|
||||
if pushSecret.Spec.Push.Generators != nil {
|
||||
for _, generator := range pushSecret.Spec.Push.Generators {
|
||||
|
@@ -99,9 +99,10 @@ func main() {
|
||||
}
|
||||
|
||||
if err = (&infisicalPushSecretController.InfisicalPushSecretReconciler{
|
||||
Client: mgr.GetClient(),
|
||||
Scheme: mgr.GetScheme(),
|
||||
BaseLogger: ctrl.Log,
|
||||
Client: mgr.GetClient(),
|
||||
Scheme: mgr.GetScheme(),
|
||||
BaseLogger: ctrl.Log,
|
||||
IsNamespaceScoped: namespace != "",
|
||||
}).SetupWithManager(mgr); err != nil {
|
||||
setupLog.Error(err, "unable to create controller", "controller", "InfisicalPushSecret")
|
||||
os.Exit(1)
|
||||
|