diff --git a/.infisicalignore b/.infisicalignore index 4ccf734b6..85883e646 100644 --- a/.infisicalignore +++ b/.infisicalignore @@ -24,3 +24,4 @@ frontend/src/hooks/api/secretRotationsV2/types/index.ts:generic-api-key:65 frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView/SecretRotationItem.tsx:generic-api-key:26 docs/documentation/platform/kms/overview.mdx:generic-api-key:281 docs/documentation/platform/kms/overview.mdx:generic-api-key:344 +frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx:generic-api-key:85 diff --git a/backend/package-lock.json b/backend/package-lock.json index 165323a08..eaf32ae4c 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -92,10 +92,10 @@ "ora": "^7.0.1", "oracledb": "^6.4.0", "otplib": "^12.0.1", - "passport-github": "^1.1.0", "passport-gitlab2": "^5.0.0", "passport-google-oauth20": "^2.0.0", "passport-ldapauth": "^3.0.1", + "passport-oauth2": "^1.8.0", "pg": "^8.11.3", "pg-boss": "^10.1.5", "pg-query-stream": "^4.5.3", @@ -136,7 +136,6 @@ "@types/lodash.isequal": "^4.5.8", "@types/node": "^20.17.30", "@types/nodemailer": "^6.4.14", - "@types/passport-github": "^1.1.12", "@types/passport-google-oauth20": "^2.0.14", "@types/pg": "^8.10.9", "@types/picomatch": "^2.3.3", @@ -10124,17 +10123,6 @@ "@types/express": "*" } }, - "node_modules/@types/passport-github": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@types/passport-github/-/passport-github-1.1.12.tgz", - "integrity": "sha512-VJpMEIH+cOoXB694QgcxuvWy2wPd1Oq3gqrg2Y9DMVBYs9TmH9L14qnqPDZsNMZKBDH+SvqRsGZj9SgHYeDgcA==", - "dev": true, - "dependencies": { - "@types/express": "*", - "@types/passport": "*", - "@types/passport-oauth2": "*" - } - }, "node_modules/@types/passport-google-oauth20": { "version": "2.0.14", "resolved": "https://registry.npmjs.org/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.14.tgz", @@ -18397,9 +18385,10 @@ "integrity": "sha512-p1TRH/edngVEHVbwqWnxUViEmq5znDvyB+Sik5cmuLpGOIfDf/39zLiq3swPF8Vakqn+gvNiOQAZu8djYlQILA==" }, "node_modules/oauth": { - "version": "0.9.15", - "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", - "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==" + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", + "integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==", + "license": "MIT" }, "node_modules/object-assign": { "version": "4.1.1", @@ -19082,17 +19071,6 @@ "url": "https://github.com/sponsors/jaredhanson" } }, - "node_modules/passport-github": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/passport-github/-/passport-github-1.1.0.tgz", - "integrity": "sha512-XARXJycE6fFh/dxF+Uut8OjlwbFEXgbPVj/+V+K7cvriRK7VcAOm+NgBmbiLM9Qv3SSxEAV+V6fIk89nYHXa8A==", - "dependencies": { - "passport-oauth2": "1.x.x" - }, - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/passport-gitlab2": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/passport-gitlab2/-/passport-gitlab2-5.0.0.tgz", @@ -19128,12 +19106,13 @@ } }, "node_modules/passport-oauth2": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.7.0.tgz", - "integrity": "sha512-j2gf34szdTF2Onw3+76alNnaAExlUmHvkc7cL+cmaS5NzHzDP/BvFHJruueQ9XAeNOdpI+CH+PWid8RA7KCwAQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==", + "license": "MIT", "dependencies": { "base64url": "3.x.x", - "oauth": "0.9.x", + "oauth": "0.10.x", "passport-strategy": "1.x.x", "uid2": "0.0.x", "utils-merge": "1.x.x" diff --git a/backend/package.json b/backend/package.json index 680cf40aa..5db1dffe0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -91,7 +91,6 @@ "@types/lodash.isequal": "^4.5.8", "@types/node": "^20.17.30", "@types/nodemailer": "^6.4.14", - "@types/passport-github": "^1.1.12", "@types/passport-google-oauth20": "^2.0.14", "@types/pg": "^8.10.9", "@types/picomatch": "^2.3.3", @@ -209,10 +208,10 @@ "ora": "^7.0.1", "oracledb": "^6.4.0", "otplib": "^12.0.1", - "passport-github": "^1.1.0", "passport-gitlab2": "^5.0.0", "passport-google-oauth20": "^2.0.0", "passport-ldapauth": "^3.0.1", + "passport-oauth2": "^1.8.0", "pg": "^8.11.3", "pg-boss": "^10.1.5", "pg-query-stream": "^4.5.3", diff --git a/backend/src/db/migrations/20250428134716_add-org-user-token-expiration-setting.ts b/backend/src/db/migrations/20250428134716_add-org-user-token-expiration-setting.ts new file mode 100644 index 000000000..3f24e4f2c --- /dev/null +++ b/backend/src/db/migrations/20250428134716_add-org-user-token-expiration-setting.ts @@ -0,0 +1,27 @@ +import { Knex } from "knex"; + +import { getConfig } from "@app/lib/config/env"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const appCfg = getConfig(); + const tokenDuration = appCfg?.JWT_REFRESH_LIFETIME; + + if (!(await knex.schema.hasColumn(TableName.Organization, "userTokenExpiration"))) { + await knex.schema.alterTable(TableName.Organization, (t) => { + t.string("userTokenExpiration"); + }); + if (tokenDuration) { + await knex(TableName.Organization).update({ userTokenExpiration: tokenDuration }); + } + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.Organization, "userTokenExpiration")) { + await knex.schema.alterTable(TableName.Organization, (t) => { + t.dropColumn("userTokenExpiration"); + }); + } +} diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts index eea1808e0..8d8279802 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -27,7 +27,8 @@ export const OrganizationsSchema = z.object({ shouldUseNewPrivilegeSystem: z.boolean().default(true), privilegeUpgradeInitiatedByUsername: z.string().nullable().optional(), privilegeUpgradeInitiatedAt: z.date().nullable().optional(), - bypassOrgAuthEnabled: z.boolean().default(false) + bypassOrgAuthEnabled: z.boolean().default(false), + userTokenExpiration: z.string().nullable().optional() }); export type TOrganizations = z.infer; diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/azure-client-secret-rotation-router.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/azure-client-secret-rotation-router.ts new file mode 100644 index 000000000..d8ccbc12c --- /dev/null +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/azure-client-secret-rotation-router.ts @@ -0,0 +1,19 @@ +import { + AzureClientSecretRotationGeneratedCredentialsSchema, + AzureClientSecretRotationSchema, + CreateAzureClientSecretRotationSchema, + UpdateAzureClientSecretRotationSchema +} from "@app/ee/services/secret-rotation-v2/azure-client-secret"; +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; + +import { registerSecretRotationEndpoints } from "./secret-rotation-v2-endpoints"; + +export const registerAzureClientSecretRotationRouter = async (server: FastifyZodProvider) => + registerSecretRotationEndpoints({ + type: SecretRotation.AzureClientSecret, + server, + responseSchema: AzureClientSecretRotationSchema, + createSchema: CreateAzureClientSecretRotationSchema, + updateSchema: UpdateAzureClientSecretRotationSchema, + generatedCredentialsSchema: AzureClientSecretRotationGeneratedCredentialsSchema + }); diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts index 3dcdac30b..90edc1306 100644 --- a/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts @@ -2,6 +2,7 @@ import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotat import { registerAuth0ClientSecretRotationRouter } from "./auth0-client-secret-rotation-router"; import { registerAwsIamUserSecretRotationRouter } from "./aws-iam-user-secret-rotation-router"; +import { registerAzureClientSecretRotationRouter } from "./azure-client-secret-rotation-router"; import { registerLdapPasswordRotationRouter } from "./ldap-password-rotation-router"; import { registerMsSqlCredentialsRotationRouter } from "./mssql-credentials-rotation-router"; import { registerPostgresCredentialsRotationRouter } from "./postgres-credentials-rotation-router"; @@ -15,6 +16,7 @@ export const SECRET_ROTATION_REGISTER_ROUTER_MAP: Record< [SecretRotation.PostgresCredentials]: registerPostgresCredentialsRotationRouter, [SecretRotation.MsSqlCredentials]: registerMsSqlCredentialsRotationRouter, [SecretRotation.Auth0ClientSecret]: registerAuth0ClientSecretRotationRouter, - [SecretRotation.LdapPassword]: registerLdapPasswordRotationRouter, - [SecretRotation.AwsIamUserSecret]: registerAwsIamUserSecretRotationRouter + [SecretRotation.AzureClientSecret]: registerAzureClientSecretRotationRouter, + [SecretRotation.AwsIamUserSecret]: registerAwsIamUserSecretRotationRouter, + [SecretRotation.LdapPassword]: registerLdapPasswordRotationRouter }; diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts index 772f70035..298f2c412 100644 --- a/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { Auth0ClientSecretRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/auth0-client-secret"; import { AwsIamUserSecretRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/aws-iam-user-secret"; +import { AzureClientSecretRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/azure-client-secret"; import { LdapPasswordRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/ldap-password"; import { MsSqlCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/mssql-credentials"; import { PostgresCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/postgres-credentials"; @@ -16,8 +17,9 @@ const SecretRotationV2OptionsSchema = z.discriminatedUnion("type", [ PostgresCredentialsRotationListItemSchema, MsSqlCredentialsRotationListItemSchema, Auth0ClientSecretRotationListItemSchema, - LdapPasswordRotationListItemSchema, - AwsIamUserSecretRotationListItemSchema + AzureClientSecretRotationListItemSchema, + AwsIamUserSecretRotationListItemSchema, + LdapPasswordRotationListItemSchema ]); export const registerSecretRotationV2Router = async (server: FastifyZodProvider) => { diff --git a/backend/src/ee/services/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts index adfe92341..bc60dff25 100644 --- a/backend/src/ee/services/oidc/oidc-config-service.ts +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -685,10 +685,16 @@ export const oidcConfigServiceFactory = ({ id_token_signed_response_alg: oidcCfg.jwtSignatureAlgorithm }); + // Check if the OIDC provider supports PKCE + const codeChallengeMethods = client.issuer.metadata.code_challenge_methods_supported; + const supportsPKCE = Array.isArray(codeChallengeMethods) && codeChallengeMethods.includes("S256"); + const strategy = new OpenIdStrategy( { client, - passReqToCallback: true + passReqToCallback: true, + usePKCE: supportsPKCE, + params: supportsPKCE ? { code_challenge_method: "S256" } : undefined }, // eslint-disable-next-line @typescript-eslint/no-explicit-any (_req: any, tokenSet: TokenSet, cb: any) => { diff --git a/backend/src/ee/services/oidc/oidc-config-types.ts b/backend/src/ee/services/oidc/oidc-config-types.ts index 3b2194375..c56427e63 100644 --- a/backend/src/ee/services/oidc/oidc-config-types.ts +++ b/backend/src/ee/services/oidc/oidc-config-types.ts @@ -8,7 +8,8 @@ export enum OIDCConfigurationType { export enum OIDCJWTSignatureAlgorithm { RS256 = "RS256", HS256 = "HS256", - RS512 = "RS512" + RS512 = "RS512", + EDDSA = "EdDSA" } export type TOidcLoginDTO = { diff --git a/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-constants.ts b/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-constants.ts new file mode 100644 index 000000000..3e25da403 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-constants.ts @@ -0,0 +1,15 @@ +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { TSecretRotationV2ListItem } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const AZURE_CLIENT_SECRET_ROTATION_LIST_OPTION: TSecretRotationV2ListItem = { + name: "Azure Client Secret", + type: SecretRotation.AzureClientSecret, + connection: AppConnection.AzureClientSecrets, + template: { + secretsMapping: { + clientId: "AZURE_CLIENT_ID", + clientSecret: "AZURE_CLIENT_SECRET" + } + } +}; diff --git a/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-fns.ts b/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-fns.ts new file mode 100644 index 000000000..037df50ac --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-fns.ts @@ -0,0 +1,202 @@ +/* eslint-disable no-await-in-loop */ +import { AxiosError } from "axios"; + +import { + AzureAddPasswordResponse, + TAzureClientSecretRotationGeneratedCredentials, + TAzureClientSecretRotationWithConnection +} from "@app/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-types"; +import { + TRotationFactory, + TRotationFactoryGetSecretsPayload, + TRotationFactoryIssueCredentials, + TRotationFactoryRevokeCredentials, + TRotationFactoryRotateCredentials +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; +import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; +import { getAzureConnectionAccessToken } from "@app/services/app-connection/azure-client-secrets"; + +const GRAPH_API_BASE = "https://graph.microsoft.com/v1.0"; + +type AzureErrorResponse = { error: { message: string } }; + +const sleep = async () => + new Promise((resolve) => { + setTimeout(resolve, 1000); + }); + +export const azureClientSecretRotationFactory: TRotationFactory< + TAzureClientSecretRotationWithConnection, + TAzureClientSecretRotationGeneratedCredentials +> = (secretRotation, appConnectionDAL, kmsService) => { + const { + connection, + parameters: { objectId, clientId: clientIdParam }, + secretsMapping + } = secretRotation; + + /** + * Creates a new client secret for the Azure app. + */ + const $rotateClientSecret = async () => { + const accessToken = await getAzureConnectionAccessToken(connection.id, appConnectionDAL, kmsService); + const endpoint = `${GRAPH_API_BASE}/applications/${objectId}/addPassword`; + + const now = new Date(); + const formattedDate = `${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart( + 2, + "0" + )}-${now.getFullYear()}`; + + const endDateTime = new Date(); + endDateTime.setFullYear(now.getFullYear() + 5); + + try { + const { data } = await request.post( + endpoint, + { + passwordCredential: { + displayName: `Infisical Rotated Secret (${formattedDate})`, + endDateTime: endDateTime.toISOString() + } + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json" + } + } + ); + + if (!data?.secretText || !data?.keyId) { + throw new Error("Invalid response from Azure: missing secretText or keyId."); + } + + return { + clientSecret: data.secretText, + keyId: data.keyId, + clientId: clientIdParam + }; + } catch (error: unknown) { + if (error instanceof AxiosError) { + let message; + if ( + error.response?.data && + typeof error.response.data === "object" && + "error" in error.response.data && + typeof (error.response.data as AzureErrorResponse).error.message === "string" + ) { + message = (error.response.data as AzureErrorResponse).error.message; + } + throw new BadRequestError({ + message: `Failed to add client secret to Azure app ${objectId}: ${ + message || error.message || "Unknown error" + }` + }); + } + throw new BadRequestError({ + message: "Unable to validate connection: verify credentials" + }); + } + }; + + /** + * Revokes a client secret from the Azure app using its keyId. + */ + const revokeCredential = async (keyId: string) => { + const accessToken = await getAzureConnectionAccessToken(connection.id, appConnectionDAL, kmsService); + const endpoint = `${GRAPH_API_BASE}/applications/${objectId}/removePassword`; + + try { + await request.post( + endpoint, + { keyId }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json" + } + } + ); + } catch (error: unknown) { + if (error instanceof AxiosError) { + let message; + if ( + error.response?.data && + typeof error.response.data === "object" && + "error" in error.response.data && + typeof (error.response.data as AzureErrorResponse).error.message === "string" + ) { + message = (error.response.data as AzureErrorResponse).error.message; + } + throw new BadRequestError({ + message: `Failed to remove client secret with keyId ${keyId} from app ${objectId}: ${ + message || error.message || "Unknown error" + }` + }); + } + throw new BadRequestError({ + message: "Unable to validate connection: verify credentials" + }); + } + }; + + /** + * Issues a new set of credentials. + */ + const issueCredentials: TRotationFactoryIssueCredentials = async ( + callback + ) => { + const credentials = await $rotateClientSecret(); + return callback(credentials); + }; + + /** + * Revokes a list of credentials. + */ + const revokeCredentials: TRotationFactoryRevokeCredentials = async ( + credentials, + callback + ) => { + if (!credentials?.length) return callback(); + + for (const { keyId } of credentials) { + await revokeCredential(keyId); + await sleep(); + } + return callback(); + }; + + /** + * Rotates credentials by issuing new ones and revoking the old. + */ + const rotateCredentials: TRotationFactoryRotateCredentials = async ( + oldCredentials, + callback + ) => { + const newCredentials = await $rotateClientSecret(); + if (oldCredentials?.keyId) { + await revokeCredential(oldCredentials.keyId); + } + + return callback(newCredentials); + }; + + /** + * Maps the generated credentials into the secret payload format. + */ + const getSecretsPayload: TRotationFactoryGetSecretsPayload = ({ + clientSecret + }) => [ + { key: secretsMapping.clientSecret, value: clientSecret }, + { key: secretsMapping.clientId, value: clientIdParam } + ]; + + return { + issueCredentials, + revokeCredentials, + rotateCredentials, + getSecretsPayload + }; +}; diff --git a/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-schemas.ts b/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-schemas.ts new file mode 100644 index 000000000..9d98cac49 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-schemas.ts @@ -0,0 +1,74 @@ +import { z } from "zod"; + +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { + BaseCreateSecretRotationSchema, + BaseSecretRotationSchema, + BaseUpdateSecretRotationSchema +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-schemas"; +import { SecretRotations } from "@app/lib/api-docs"; +import { SecretNameSchema } from "@app/server/lib/schemas"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const AzureClientSecretRotationGeneratedCredentialsSchema = z + .object({ + clientId: z.string(), + clientSecret: z.string(), + keyId: z.string() + }) + .array() + .min(1) + .max(2); + +const AzureClientSecretRotationParametersSchema = z.object({ + objectId: z + .string() + .trim() + .min(1, "Object ID Required") + .describe(SecretRotations.PARAMETERS.AZURE_CLIENT_SECRET.objectId), + appName: z.string().trim().describe(SecretRotations.PARAMETERS.AZURE_CLIENT_SECRET.appName).optional(), + clientId: z + .string() + .trim() + .min(1, "Client ID Required") + .describe(SecretRotations.PARAMETERS.AZURE_CLIENT_SECRET.clientId) +}); + +const AzureClientSecretRotationSecretsMappingSchema = z.object({ + clientId: SecretNameSchema.describe(SecretRotations.SECRETS_MAPPING.AZURE_CLIENT_SECRET.clientId), + clientSecret: SecretNameSchema.describe(SecretRotations.SECRETS_MAPPING.AZURE_CLIENT_SECRET.clientSecret) +}); + +export const AzureClientSecretRotationTemplateSchema = z.object({ + secretsMapping: z.object({ + clientId: z.string(), + clientSecret: z.string() + }) +}); + +export const AzureClientSecretRotationSchema = BaseSecretRotationSchema(SecretRotation.AzureClientSecret).extend({ + type: z.literal(SecretRotation.AzureClientSecret), + parameters: AzureClientSecretRotationParametersSchema, + secretsMapping: AzureClientSecretRotationSecretsMappingSchema +}); + +export const CreateAzureClientSecretRotationSchema = BaseCreateSecretRotationSchema( + SecretRotation.AzureClientSecret +).extend({ + parameters: AzureClientSecretRotationParametersSchema, + secretsMapping: AzureClientSecretRotationSecretsMappingSchema +}); + +export const UpdateAzureClientSecretRotationSchema = BaseUpdateSecretRotationSchema( + SecretRotation.AzureClientSecret +).extend({ + parameters: AzureClientSecretRotationParametersSchema.optional(), + secretsMapping: AzureClientSecretRotationSecretsMappingSchema.optional() +}); + +export const AzureClientSecretRotationListItemSchema = z.object({ + name: z.literal("Azure Client Secret"), + connection: z.literal(AppConnection.AzureClientSecrets), + type: z.literal(SecretRotation.AzureClientSecret), + template: AzureClientSecretRotationTemplateSchema +}); diff --git a/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-types.ts b/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-types.ts new file mode 100644 index 000000000..91f66a883 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-types.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; + +import { TAzureClientSecretsConnection } from "@app/services/app-connection/azure-client-secrets"; + +import { + AzureClientSecretRotationGeneratedCredentialsSchema, + AzureClientSecretRotationListItemSchema, + AzureClientSecretRotationSchema, + CreateAzureClientSecretRotationSchema +} from "./azure-client-secret-rotation-schemas"; + +export type TAzureClientSecretRotation = z.infer; + +export type TAzureClientSecretRotationInput = z.infer; + +export type TAzureClientSecretRotationListItem = z.infer; + +export type TAzureClientSecretRotationWithConnection = TAzureClientSecretRotation & { + connection: TAzureClientSecretsConnection; +}; + +export type TAzureClientSecretRotationGeneratedCredentials = z.infer< + typeof AzureClientSecretRotationGeneratedCredentialsSchema +>; + +export interface TAzureClientSecretRotationParameters { + appId: string; + keyId?: string; + displayName?: string; +} + +export interface TAzureClientSecretRotationSecretsMapping { + appId: string; + clientSecret: string; + keyId: string; +} + +export interface AzureAddPasswordResponse { + secretText: string; + keyId: string; +} diff --git a/backend/src/ee/services/secret-rotation-v2/azure-client-secret/index.ts b/backend/src/ee/services/secret-rotation-v2/azure-client-secret/index.ts new file mode 100644 index 000000000..8c741bdc6 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/azure-client-secret/index.ts @@ -0,0 +1,3 @@ +export * from "./azure-client-secret-rotation-constants"; +export * from "./azure-client-secret-rotation-schemas"; +export * from "./azure-client-secret-rotation-types"; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts index 4ddf4ee0c..d67abea2b 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts @@ -2,8 +2,9 @@ export enum SecretRotation { PostgresCredentials = "postgres-credentials", MsSqlCredentials = "mssql-credentials", Auth0ClientSecret = "auth0-client-secret", - LdapPassword = "ldap-password", - AwsIamUserSecret = "aws-iam-user-secret" + AzureClientSecret = "azure-client-secret", + AwsIamUserSecret = "aws-iam-user-secret", + LdapPassword = "ldap-password" } export enum SecretRotationStatus { diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts index 23452d7d4..5c0d97ee8 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts @@ -5,6 +5,7 @@ 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 { MSSQL_CREDENTIALS_ROTATION_LIST_OPTION } from "./mssql-credentials"; import { POSTGRES_CREDENTIALS_ROTATION_LIST_OPTION } from "./postgres-credentials"; @@ -21,8 +22,9 @@ const SECRET_ROTATION_LIST_OPTIONS: Record { diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts index 134aaeafc..f4ea75558 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts @@ -5,14 +5,16 @@ export const SECRET_ROTATION_NAME_MAP: Record = { [SecretRotation.PostgresCredentials]: "PostgreSQL Credentials", [SecretRotation.MsSqlCredentials]: "Microsoft SQL Server Credentials", [SecretRotation.Auth0ClientSecret]: "Auth0 Client Secret", - [SecretRotation.LdapPassword]: "LDAP Password", - [SecretRotation.AwsIamUserSecret]: "AWS IAM User Secret" + [SecretRotation.AzureClientSecret]: "Azure Client Secret", + [SecretRotation.AwsIamUserSecret]: "AWS IAM User Secret", + [SecretRotation.LdapPassword]: "LDAP Password" }; export const SECRET_ROTATION_CONNECTION_MAP: Record = { [SecretRotation.PostgresCredentials]: AppConnection.Postgres, [SecretRotation.MsSqlCredentials]: AppConnection.MsSql, [SecretRotation.Auth0ClientSecret]: AppConnection.Auth0, - [SecretRotation.LdapPassword]: AppConnection.LDAP, - [SecretRotation.AwsIamUserSecret]: AppConnection.AWS + [SecretRotation.AzureClientSecret]: AppConnection.AzureClientSecrets, + [SecretRotation.AwsIamUserSecret]: AppConnection.AWS, + [SecretRotation.LdapPassword]: AppConnection.LDAP }; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts index 70543c9b4..69743f133 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts @@ -14,6 +14,7 @@ import { ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { auth0ClientSecretRotationFactory } from "@app/ee/services/secret-rotation-v2/auth0-client-secret/auth0-client-secret-rotation-fns"; +import { azureClientSecretRotationFactory } from "@app/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-fns"; import { ldapPasswordRotationFactory } from "@app/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-fns"; import { SecretRotation, SecretRotationStatus } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; import { @@ -102,7 +103,7 @@ export type TSecretRotationV2ServiceFactoryDep = { secretQueueService: Pick; snapshotService: Pick; queueService: Pick; - appConnectionDAL: Pick; + appConnectionDAL: Pick; }; export type TSecretRotationV2ServiceFactory = ReturnType; @@ -117,8 +118,9 @@ const SECRET_ROTATION_FACTORY_MAP: Record = ( secretRotation: T, - appConnectionDAL: Pick, + appConnectionDAL: Pick, kmsService: Pick ) => { issueCredentials: TRotationFactoryIssueCredentials; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema.ts index 4d51a23c3..f6fdafe1d 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { Auth0ClientSecretRotationSchema } from "@app/ee/services/secret-rotation-v2/auth0-client-secret"; +import { AzureClientSecretRotationSchema } from "@app/ee/services/secret-rotation-v2/azure-client-secret"; import { LdapPasswordRotationSchema } from "@app/ee/services/secret-rotation-v2/ldap-password"; import { MsSqlCredentialsRotationSchema } from "@app/ee/services/secret-rotation-v2/mssql-credentials"; import { PostgresCredentialsRotationSchema } from "@app/ee/services/secret-rotation-v2/postgres-credentials"; @@ -11,6 +12,7 @@ export const SecretRotationV2Schema = z.discriminatedUnion("type", [ PostgresCredentialsRotationSchema, MsSqlCredentialsRotationSchema, Auth0ClientSecretRotationSchema, + AzureClientSecretRotationSchema, LdapPasswordRotationSchema, AwsIamUserSecretRotationSchema ]); diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 6cfa9a1e8..2e6d15ffb 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1912,6 +1912,10 @@ export const AppConnections = { TEAMCITY: { instanceUrl: "The TeamCity instance URL to connect with.", accessToken: "The access token to use to connect with TeamCity." + }, + AZURE_CLIENT_SECRETS: { + code: "The OAuth code to use to connect with Azure Client Secrets.", + tenantId: "The Tenant ID to use to connect with Azure Client Secrets." } } }; @@ -2120,6 +2124,11 @@ export const SecretRotations = { AUTH0_CLIENT_SECRET: { clientId: "The client ID of the Auth0 Application to rotate the client secret for." }, + AZURE_CLIENT_SECRET: { + objectId: "The ID of the Azure Application to rotate the client secret for.", + appName: "The name of the Azure Application to rotate the client secret for.", + clientId: "The client ID of the Azure Application to rotate the client secret for." + }, LDAP_PASSWORD: { dn: "The Distinguished Name (DN) of the principal to rotate the password for." }, @@ -2150,6 +2159,10 @@ export const SecretRotations = { clientId: "The name of the secret that the client ID will be mapped to.", clientSecret: "The name of the secret that the rotated client secret will be mapped to." }, + AZURE_CLIENT_SECRET: { + clientId: "The name of the secret that the client ID will be mapped to.", + 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.", password: "The name of the secret that the rotated password will be mapped to." diff --git a/backend/src/lib/dates/index.ts b/backend/src/lib/dates/index.ts index 1b6e5dec0..369e289cd 100644 --- a/backend/src/lib/dates/index.ts +++ b/backend/src/lib/dates/index.ts @@ -2,7 +2,7 @@ export const daysToMillisecond = (days: number) => days * 24 * 60 * 60 * 1000; export const secondsToMillis = (seconds: number) => seconds * 1000; -export const applyJitter = (delayMs: number, jitterMs: number) => { - const jitter = Math.floor(Math.random() * (2 * jitterMs)) - jitterMs; - return delayMs + jitter; +export const applyJitter = (delay: number, jitter: number) => { + const jitterTime = Math.floor(Math.random() * (2 * jitter)) - jitter; + return delay + jitterTime; }; diff --git a/backend/src/lib/fn/index.ts b/backend/src/lib/fn/index.ts index 82a4c4914..ae704cf9f 100644 --- a/backend/src/lib/fn/index.ts +++ b/backend/src/lib/fn/index.ts @@ -6,4 +6,5 @@ export * from "./array"; export * from "./dates"; export * from "./object"; export * from "./string"; +export * from "./time"; export * from "./undefined"; diff --git a/backend/src/lib/fn/time.ts b/backend/src/lib/fn/time.ts new file mode 100644 index 000000000..27bd8f8a6 --- /dev/null +++ b/backend/src/lib/fn/time.ts @@ -0,0 +1,21 @@ +import ms, { StringValue } from "ms"; + +const convertToMilliseconds = (exp: string | number): number => { + if (typeof exp === "number") { + return exp * 1000; + } + + const result = ms(exp as StringValue); + if (typeof result !== "number") { + throw new Error(`Invalid expiration format: ${exp}`); + } + + return result; +}; + +export const getMinExpiresIn = (exp1: string | number, exp2: string | number): string | number => { + const ms1 = convertToMilliseconds(exp1); + const ms2 = convertToMilliseconds(exp2); + + return ms1 <= ms2 ? exp1 : exp2; +}; diff --git a/backend/src/lib/knex/index.ts b/backend/src/lib/knex/index.ts index d43d2af8e..b1e011709 100644 --- a/backend/src/lib/knex/index.ts +++ b/backend/src/lib/knex/index.ts @@ -2,6 +2,8 @@ import { Knex } from "knex"; import { Tables } from "knex/types/tables"; +import { TableName } from "@app/db/schemas"; + import { DatabaseError } from "../errors"; import { buildDynamicKnexQuery, TKnexDynamicOperator } from "./dynamic"; @@ -25,28 +27,41 @@ export type TFindFilter = Partial & { $search?: Partial<{ [k in keyof R]: R[k] }>; $complex?: TKnexDynamicOperator; }; + export const buildFindFilter = - ({ $in, $notNull, $search, $complex, ...filter }: TFindFilter) => + ( + { $in, $notNull, $search, $complex, ...filter }: TFindFilter, + tableName?: TableName, + excludeKeys?: Array + ) => (bd: Knex.QueryBuilder) => { - void bd.where(filter); + const processedFilter = tableName + ? Object.fromEntries( + Object.entries(filter) + .filter(([key]) => !excludeKeys || !excludeKeys.includes(key as keyof R)) + .map(([key, value]) => [`${tableName}.${key}`, value]) + ) + : filter; + + void bd.where(processedFilter); if ($in) { Object.entries($in).forEach(([key, val]) => { if (val) { - void bd.whereIn(key as never, val as never); + void bd.whereIn(`${tableName ? `${tableName}.` : ""}${key}`, val as never); } }); } if ($notNull?.length) { $notNull.forEach((key) => { - void bd.whereNotNull(key as never); + void bd.whereNotNull(`${tableName ? `${tableName}.` : ""}${key as string}`); }); } if ($search) { Object.entries($search).forEach(([key, val]) => { if (val) { - void bd.whereILike(key as never, val as never); + void bd.whereILike(`${tableName ? `${tableName}.` : ""}${key}`, val as never); } }); } diff --git a/backend/src/lib/requests/github.ts b/backend/src/lib/requests/github.ts index 723e4957a..f25e46af5 100644 --- a/backend/src/lib/requests/github.ts +++ b/backend/src/lib/requests/github.ts @@ -16,3 +16,17 @@ export const fetchGithubEmails = async (accessToken: string) => { }); return data; }; + +type TGithubUser = { + name?: string; + login: string; +}; + +export const fetchGithubUser = async (accessToken: string) => { + const { data } = await request.get(`${INTEGRATION_GITHUB_API_URL}/user`, { + headers: { + Authorization: `Bearer ${accessToken}` + } + }); + return data; +}; diff --git a/backend/src/lib/validator/validate-url.ts b/backend/src/lib/validator/validate-url.ts index b555869d7..8f195e0b5 100644 --- a/backend/src/lib/validator/validate-url.ts +++ b/backend/src/lib/validator/validate-url.ts @@ -15,13 +15,13 @@ export const blockLocalAndPrivateIpAddresses = async (url: string) => { const validUrl = new URL(url); const inputHostIps: string[] = []; - if (isIPv4(validUrl.host)) { - inputHostIps.push(validUrl.host); + if (isIPv4(validUrl.hostname)) { + inputHostIps.push(validUrl.hostname); } else { - if (validUrl.host === "localhost" || validUrl.host === "host.docker.internal") { + if (validUrl.hostname === "localhost" || validUrl.hostname === "host.docker.internal") { throw new BadRequestError({ message: "Local IPs not allowed as URL" }); } - const resolvedIps = await dns.resolve4(validUrl.host); + const resolvedIps = await dns.resolve4(validUrl.hostname); inputHostIps.push(...resolvedIps); } const isInternalIp = inputHostIps.some((el) => isPrivateIp(el)); diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 04c9482a5..c4fceb90e 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1559,6 +1559,7 @@ export const registerRoutes = async ( const secretSyncService = secretSyncServiceFactory({ secretSyncDAL, + secretImportDAL, permissionService, appConnectionService, folderDAL, diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts index 0b565f7d8..f6180b9b6 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts @@ -10,6 +10,10 @@ import { AzureAppConfigurationConnectionListItemSchema, SanitizedAzureAppConfigurationConnectionSchema } from "@app/services/app-connection/azure-app-configuration"; +import { + AzureClientSecretsConnectionListItemSchema, + SanitizedAzureClientSecretsConnectionSchema +} from "@app/services/app-connection/azure-client-secrets"; import { AzureKeyVaultConnectionListItemSchema, SanitizedAzureKeyVaultConnectionSchema @@ -63,8 +67,9 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedPostgresConnectionSchema.options, ...SanitizedMsSqlConnectionSchema.options, ...SanitizedCamundaConnectionSchema.options, - ...SanitizedWindmillConnectionSchema.options, ...SanitizedAuth0ConnectionSchema.options, + ...SanitizedAzureClientSecretsConnectionSchema.options, + ...SanitizedWindmillConnectionSchema.options, ...SanitizedLdapConnectionSchema.options, ...SanitizedTeamCityConnectionSchema.options ]); @@ -82,8 +87,9 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ PostgresConnectionListItemSchema, MsSqlConnectionListItemSchema, CamundaConnectionListItemSchema, - WindmillConnectionListItemSchema, Auth0ConnectionListItemSchema, + AzureClientSecretsConnectionListItemSchema, + WindmillConnectionListItemSchema, LdapConnectionListItemSchema, TeamCityConnectionListItemSchema ]); diff --git a/backend/src/server/routes/v1/app-connection-routers/azure-client-secrets-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/azure-client-secrets-connection-router.ts new file mode 100644 index 000000000..f699e60f1 --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/azure-client-secrets-connection-router.ts @@ -0,0 +1,49 @@ +import { z } from "zod"; + +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + CreateAzureClientSecretsConnectionSchema, + SanitizedAzureClientSecretsConnectionSchema, + UpdateAzureClientSecretsConnectionSchema +} from "@app/services/app-connection/azure-client-secrets"; +import { AuthMode } from "@app/services/auth/auth-type"; + +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerAzureClientSecretsConnectionRouter = async (server: FastifyZodProvider) => { + registerAppConnectionEndpoints({ + app: AppConnection.AzureClientSecrets, + server, + sanitizedResponseSchema: SanitizedAzureClientSecretsConnectionSchema, + createSchema: CreateAzureClientSecretsConnectionSchema, + updateSchema: UpdateAzureClientSecretsConnectionSchema + }); + + server.route({ + method: "GET", + url: `/:connectionId/clients`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z.object({ + clients: z.object({ name: z.string(), id: z.string(), appId: z.string() }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + + const clients = await server.services.appConnection.azureClientSecrets.listApps(connectionId, req.permission); + + return { clients }; + } + }); +}; diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts index c2398fd78..194f32290 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -3,6 +3,7 @@ import { AppConnection } from "@app/services/app-connection/app-connection-enums import { registerAuth0ConnectionRouter } from "./auth0-connection-router"; import { registerAwsConnectionRouter } from "./aws-connection-router"; import { registerAzureAppConfigurationConnectionRouter } from "./azure-app-configuration-connection-router"; +import { registerAzureClientSecretsConnectionRouter } from "./azure-client-secrets-connection-router"; import { registerAzureKeyVaultConnectionRouter } from "./azure-key-vault-connection-router"; import { registerCamundaConnectionRouter } from "./camunda-connection-router"; import { registerDatabricksConnectionRouter } from "./databricks-connection-router"; @@ -26,6 +27,7 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { handler: async (req) => { const { decodedToken, tokenVersion } = await server.services.authToken.validateRefreshToken(req.cookies.jid); const appCfg = getConfig(); + let expiresIn: string | number = appCfg.JWT_AUTH_LIFETIME; + if (decodedToken.organizationId) { + const org = await server.services.org.findOrganizationById( + decodedToken.userId, + decodedToken.organizationId, + decodedToken.authMethod, + decodedToken.organizationId + ); + if (org && org.userTokenExpiration) { + expiresIn = getMinExpiresIn(appCfg.JWT_AUTH_LIFETIME, org.userTokenExpiration); + } + } const token = jwt.sign( { @@ -92,7 +105,7 @@ export const registerAuthRoutes = async (server: FastifyZodProvider) => { mfaMethod: decodedToken.mfaMethod }, appCfg.AUTH_SECRET, - { expiresIn: appCfg.JWT_AUTH_LIFETIME } + { expiresIn } ); return { token, organizationId: decodedToken.organizationId }; diff --git a/backend/src/server/routes/v1/dashboard-router.ts b/backend/src/server/routes/v1/dashboard-router.ts index 54da97682..373e2d51f 100644 --- a/backend/src/server/routes/v1/dashboard-router.ts +++ b/backend/src/server/routes/v1/dashboard-router.ts @@ -154,7 +154,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { secrets: z .object({ secretId: z.string(), - referencedSecretKey: z.string() + referencedSecretKey: z.string(), + referencedSecretEnv: z.string() }) .array() .optional() @@ -166,6 +167,16 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { }) .array() .optional(), + usedBySecretSyncs: z + .object({ + name: z.string(), + destination: z.string(), + environment: z.string(), + id: z.string(), + path: z.string() + }) + .array() + .optional(), totalFolderCount: z.number().optional(), totalDynamicSecretCount: z.number().optional(), totalSecretCount: z.number().optional(), @@ -500,6 +511,24 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { } } + const usedBySecretSyncs: { name: string; destination: string; environment: string; id: string; path: string }[] = + []; + for await (const environment of environments) { + const secretSyncs = await server.services.secretSync.listSecretSyncsBySecretPath( + { projectId, secretPath, environment }, + req.permission + ); + secretSyncs.forEach((sync) => { + usedBySecretSyncs.push({ + name: sync.name, + destination: sync.destination, + environment, + id: sync.id, + path: sync.folder?.path || "/" + }); + }); + } + return { folders, dynamicSecrets, @@ -512,6 +541,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { totalSecretCount, totalSecretRotationCount, importedByEnvs, + usedBySecretSyncs, totalCount: (totalFolderCount ?? 0) + (totalDynamicSecretCount ?? 0) + @@ -611,6 +641,16 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { totalFolderCount: z.number().optional(), totalDynamicSecretCount: z.number().optional(), totalSecretCount: z.number().optional(), + usedBySecretSyncs: z + .object({ + name: z.string(), + destination: z.string(), + environment: z.string(), + id: z.string(), + path: z.string() + }) + .array() + .optional(), importedBy: z .object({ environment: z.object({ @@ -624,7 +664,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { secrets: z .object({ secretId: z.string(), - referencedSecretKey: z.string() + referencedSecretKey: z.string(), + referencedSecretEnv: z.string() }) .array() .optional() @@ -904,6 +945,18 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { secrets }); + const secretSyncs = await server.services.secretSync.listSecretSyncsBySecretPath( + { projectId, secretPath, environment }, + req.permission + ); + const usedBySecretSyncs = secretSyncs.map((sync) => ({ + name: sync.name, + destination: sync.destination, + environment: sync.environment?.name || environment, + id: sync.id, + path: sync.folder?.path || "/" + })); + if (secrets?.length || secretRotations?.length) { const secretCount = (secrets?.length ?? 0) + @@ -950,6 +1003,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { totalSecretCount, totalSecretRotationCount, importedBy, + usedBySecretSyncs, totalCount: (totalImportCount ?? 0) + (totalFolderCount ?? 0) + diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index 22314b54b..da1a251ff 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -1,3 +1,4 @@ +import RE2 from "re2"; import { z } from "zod"; import { @@ -263,7 +264,18 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { enforceMfa: z.boolean().optional(), selectedMfaMethod: z.nativeEnum(MfaMethod).optional(), allowSecretSharingOutsideOrganization: z.boolean().optional(), - bypassOrgAuthEnabled: z.boolean().optional() + bypassOrgAuthEnabled: z.boolean().optional(), + userTokenExpiration: z + .string() + .refine((val) => new RE2(/^\d+[mhdw]$/).test(val), "Must be a number followed by m, h, d, or w") + .refine( + (val) => { + const numericPart = val.slice(0, -1); + return parseInt(numericPart, 10) >= 1; + }, + { message: "Duration value must be at least 1" } + ) + .optional() }), response: { 200: z.object({ diff --git a/backend/src/server/routes/v1/sso-router.ts b/backend/src/server/routes/v1/sso-router.ts index 09d7cb5bd..f7a1b973a 100644 --- a/backend/src/server/routes/v1/sso-router.ts +++ b/backend/src/server/routes/v1/sso-router.ts @@ -9,9 +9,9 @@ import { Authenticator } from "@fastify/passport"; import fastifySession from "@fastify/session"; import RedisStore from "connect-redis"; -import { Strategy as GitHubStrategy } from "passport-github"; import { Strategy as GitLabStrategy } from "passport-gitlab2"; import { Strategy as GoogleStrategy } from "passport-google-oauth20"; +import { Strategy as OAuth2Strategy } from "passport-oauth2"; import { z } from "zod"; import { INFISICAL_PROVIDER_GITHUB_ACCESS_TOKEN } from "@app/lib/config/const"; @@ -19,7 +19,7 @@ import { getConfig } from "@app/lib/config/env"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { ms } from "@app/lib/ms"; -import { fetchGithubEmails } from "@app/lib/requests/github"; +import { fetchGithubEmails, fetchGithubUser } from "@app/lib/requests/github"; import { authRateLimit } from "@app/server/config/rateLimiter"; import { AuthMethod } from "@app/services/auth/auth-type"; import { OrgAuthMethod } from "@app/services/org/org-types"; @@ -44,6 +44,7 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { }); await server.register(passport.initialize()); await server.register(passport.secureSession()); + // passport oauth strategy for Google const isGoogleOauthActive = Boolean(appCfg.CLIENT_ID_GOOGLE_LOGIN && appCfg.CLIENT_SECRET_GOOGLE_LOGIN); if (isGoogleOauthActive) { @@ -54,8 +55,9 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { clientID: appCfg.CLIENT_ID_GOOGLE_LOGIN as string, clientSecret: appCfg.CLIENT_SECRET_GOOGLE_LOGIN as string, callbackURL: `${appCfg.SITE_URL}/api/v1/sso/google`, - scope: ["profile", " email"], - state: true + scope: ["profile", "email"], + state: true, + pkce: true }, // eslint-disable-next-line async (req, _accessToken, _refreshToken, profile, cb) => { @@ -91,34 +93,44 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { const isGithubOauthActive = Boolean(appCfg.CLIENT_SECRET_GITHUB_LOGIN && appCfg.CLIENT_ID_GITHUB_LOGIN); if (isGithubOauthActive) { passport.use( - new GitHubStrategy( + "github", + new OAuth2Strategy( { - passReqToCallback: true, - clientID: appCfg.CLIENT_ID_GITHUB_LOGIN as string, - clientSecret: appCfg.CLIENT_SECRET_GITHUB_LOGIN as string, + authorizationURL: "https://github.com/login/oauth/authorize", + tokenURL: "https://github.com/login/oauth/access_token", + clientID: appCfg.CLIENT_ID_GITHUB_LOGIN!, + clientSecret: appCfg.CLIENT_SECRET_GITHUB_LOGIN!, callbackURL: `${appCfg.SITE_URL}/api/v1/sso/github`, scope: ["user:email", "read:org"], - // akhilmhdh: because the ts type for this is outdated by the maintainer - state: true as unknown as string + state: true, + pkce: true, + passReqToCallback: true }, // eslint-disable-next-line - async (req, accessToken, _refreshToken, profile, cb) => { - // @ts-expect-error this is because this is express type and not fastify - const callbackPort = req.session.get("callbackPort"); + async (req: any, accessToken: string, _refreshToken: string, _profile: any, done: Function) => { try { const ghEmails = await fetchGithubEmails(accessToken); const { email } = ghEmails.filter((gitHubEmail) => gitHubEmail.primary)[0]; + + if (!email) throw new Error("No primary email found"); + + // profile does not get automatically populated so we need to manually fetch user info + const user = await fetchGithubUser(accessToken); + + const callbackPort = req.session.get("callbackPort"); + const { isUserCompleted, providerAuthToken } = await server.services.login.oauth2Login({ email, - firstName: profile.displayName || profile.username || "", + firstName: user.name || user.login, lastName: "", authMethod: AuthMethod.GITHUB, callbackPort }); - return cb(null, { isUserCompleted, providerAuthToken, externalProviderAccessToken: accessToken }); - } catch (error) { - logger.error(error); - cb(error as Error, false); + + done(null, { isUserCompleted, providerAuthToken, externalProviderAccessToken: accessToken }); + } catch (err) { + logger.error(err); + done(err as Error, false); } } ) @@ -138,7 +150,8 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { clientSecret: appCfg.CLIENT_SECRET_GITLAB_LOGIN, callbackURL: `${appCfg.SITE_URL}/api/v1/sso/gitlab`, baseURL: appCfg.CLIENT_GITLAB_LOGIN_URL, - state: true + state: true, + pkce: true }, async (req: any, _accessToken: string, _refreshToken: string, profile: any, cb: any) => { try { diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index de20f3f64..82723a138 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -5,6 +5,7 @@ export enum AppConnection { GCP = "gcp", AzureKeyVault = "azure-key-vault", AzureAppConfiguration = "azure-app-configuration", + AzureClientSecrets = "azure-client-secrets", Humanitec = "humanitec", TerraformCloud = "terraform-cloud", Vercel = "vercel", diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index 92595619c..3b020fb06 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -23,6 +23,11 @@ import { getAzureAppConfigurationConnectionListItem, validateAzureAppConfigurationConnectionCredentials } from "./azure-app-configuration"; +import { + AzureClientSecretsConnectionMethod, + getAzureClientSecretsConnectionListItem, + validateAzureClientSecretsConnectionCredentials +} from "./azure-client-secrets"; import { AzureKeyVaultConnectionMethod, getAzureKeyVaultConnectionListItem, @@ -76,6 +81,7 @@ export const listAppConnectionOptions = () => { getPostgresConnectionListItem(), getMsSqlConnectionListItem(), getCamundaConnectionListItem(), + getAzureClientSecretsConnectionListItem(), getWindmillConnectionListItem(), getAuth0ConnectionListItem(), getLdapConnectionListItem(), @@ -136,6 +142,8 @@ export const validateAppConnectionCredentials = async ( [AppConnection.AzureKeyVault]: validateAzureKeyVaultConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.AzureAppConfiguration]: validateAzureAppConfigurationConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.AzureClientSecrets]: + validateAzureClientSecretsConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Humanitec]: validateHumanitecConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Postgres]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.MsSql]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator, @@ -157,6 +165,7 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => return "GitHub App"; case AzureKeyVaultConnectionMethod.OAuth: case AzureAppConfigurationConnectionMethod.OAuth: + case AzureClientSecretsConnectionMethod.OAuth: case GitHubConnectionMethod.OAuth: return "OAuth"; case AwsConnectionMethod.AccessKey: @@ -226,6 +235,7 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.TerraformCloud]: platformManagedCredentialsNotSupported, [AppConnection.Camunda]: platformManagedCredentialsNotSupported, [AppConnection.Vercel]: platformManagedCredentialsNotSupported, + [AppConnection.AzureClientSecrets]: platformManagedCredentialsNotSupported, [AppConnection.Windmill]: platformManagedCredentialsNotSupported, [AppConnection.Auth0]: platformManagedCredentialsNotSupported, [AppConnection.LDAP]: platformManagedCredentialsNotSupported, // we could support this in the future diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index 524993b23..594c4c734 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -6,6 +6,7 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.GCP]: "GCP", [AppConnection.AzureKeyVault]: "Azure Key Vault", [AppConnection.AzureAppConfiguration]: "Azure App Configuration", + [AppConnection.AzureClientSecrets]: "Azure Client Secrets", [AppConnection.Databricks]: "Databricks", [AppConnection.Humanitec]: "Humanitec", [AppConnection.TerraformCloud]: "Terraform Cloud", diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts index e872c07e4..9669de0c8 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -32,6 +32,8 @@ import { ValidateAuth0ConnectionCredentialsSchema } from "./auth0"; import { ValidateAwsConnectionCredentialsSchema } from "./aws"; import { awsConnectionService } from "./aws/aws-connection-service"; import { ValidateAzureAppConfigurationConnectionCredentialsSchema } from "./azure-app-configuration"; +import { ValidateAzureClientSecretsConnectionCredentialsSchema } from "./azure-client-secrets"; +import { azureClientSecretsConnectionService } from "./azure-client-secrets/azure-client-secrets-service"; import { ValidateAzureKeyVaultConnectionCredentialsSchema } from "./azure-key-vault"; import { ValidateCamundaConnectionCredentialsSchema } from "./camunda"; import { camundaConnectionService } from "./camunda/camunda-connection-service"; @@ -76,6 +78,7 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record { + const { INF_APP_CONNECTION_AZURE_CLIENT_ID } = getConfig(); + + return { + name: "Azure Client Secrets" as const, + app: AppConnection.AzureClientSecrets as const, + methods: Object.values(AzureClientSecretsConnectionMethod) as [AzureClientSecretsConnectionMethod.OAuth], + oauthClientId: INF_APP_CONNECTION_AZURE_CLIENT_ID + }; +}; + +export const getAzureConnectionAccessToken = async ( + connectionId: string, + appConnectionDAL: Pick, + kmsService: Pick +) => { + const appCfg = getConfig(); + if (!appCfg.INF_APP_CONNECTION_AZURE_CLIENT_ID || !appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRET) { + throw new BadRequestError({ + message: `Azure environment variables have not been configured` + }); + } + + const appConnection = await appConnectionDAL.findById(connectionId); + + if (!appConnection) { + throw new NotFoundError({ message: `Connection with ID '${connectionId}' not found` }); + } + + if (appConnection.app !== AppConnection.AzureClientSecrets) { + throw new BadRequestError({ + message: `Connection with ID '${connectionId}' is not an Azure Client Secrets connection` + }); + } + + const credentials = (await decryptAppConnectionCredentials({ + orgId: appConnection.orgId, + kmsService, + encryptedCredentials: appConnection.encryptedCredentials + })) as TAzureClientSecretsConnectionCredentials; + + const { refreshToken } = credentials; + const currentTime = Date.now(); + + const { data } = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", credentials.tenantId || "common"), + new URLSearchParams({ + grant_type: "refresh_token", + scope: `openid offline_access https://graph.microsoft.com/.default`, + client_id: appCfg.INF_APP_CONNECTION_AZURE_CLIENT_ID, + client_secret: appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRET, + refresh_token: refreshToken + }) + ); + + const updatedCredentials = { + ...credentials, + accessToken: data.access_token, + expiresAt: currentTime + data.expires_in * 1000, + refreshToken: data.refresh_token + }; + + const encryptedCredentials = await encryptAppConnectionCredentials({ + credentials: updatedCredentials, + orgId: appConnection.orgId, + kmsService + }); + + await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials }); + + return data.access_token; +}; + +export const validateAzureClientSecretsConnectionCredentials = async (config: TAzureClientSecretsConnectionConfig) => { + const { credentials: inputCredentials, method } = config; + + const { INF_APP_CONNECTION_AZURE_CLIENT_ID, INF_APP_CONNECTION_AZURE_CLIENT_SECRET, SITE_URL } = getConfig(); + + if (!SITE_URL) { + throw new InternalServerError({ message: "SITE_URL env var is required to complete Azure OAuth flow" }); + } + + if (!INF_APP_CONNECTION_AZURE_CLIENT_ID || !INF_APP_CONNECTION_AZURE_CLIENT_SECRET) { + throw new InternalServerError({ + message: `Azure ${getAppConnectionMethodName(method)} environment variables have not been configured` + }); + } + + let tokenResp: AxiosResponse | null = null; + let tokenError: AxiosError | null = null; + + try { + tokenResp = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", inputCredentials.tenantId || "common"), + new URLSearchParams({ + grant_type: "authorization_code", + code: inputCredentials.code, + scope: `openid offline_access https://graph.microsoft.com/.default`, + client_id: INF_APP_CONNECTION_AZURE_CLIENT_ID, + client_secret: INF_APP_CONNECTION_AZURE_CLIENT_SECRET, + redirect_uri: `${SITE_URL}/organization/app-connections/azure/oauth/callback` + }) + ); + } catch (e: unknown) { + if (e instanceof AxiosError) { + tokenError = e; + } else { + throw new BadRequestError({ + message: `Unable to validate connection: verify credentials` + }); + } + } + + if (tokenError) { + if (tokenError instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to get access token: ${ + (tokenError?.response?.data as { error_description?: string })?.error_description || "Unknown error" + }` + }); + } else { + throw new InternalServerError({ + message: "Failed to get access token" + }); + } + } + + if (!tokenResp) { + throw new InternalServerError({ + message: `Failed to get access token: Token was empty with no error` + }); + } + + switch (method) { + case AzureClientSecretsConnectionMethod.OAuth: + return { + tenantId: inputCredentials.tenantId, + accessToken: tokenResp.data.access_token, + refreshToken: tokenResp.data.refresh_token, + expiresAt: Date.now() + tokenResp.data.expires_in * 1000 + }; + default: + throw new InternalServerError({ + message: `Unhandled Azure connection method: ${method as AzureClientSecretsConnectionMethod}` + }); + } +}; diff --git a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-schemas.ts b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-schemas.ts new file mode 100644 index 000000000..2b4e65a13 --- /dev/null +++ b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-schemas.ts @@ -0,0 +1,80 @@ +import { z } from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { AzureClientSecretsConnectionMethod } from "./azure-client-secrets-connection-enums"; + +export const AzureClientSecretsConnectionOAuthInputCredentialsSchema = z.object({ + code: z.string().trim().min(1, "OAuth code required").describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.code), + tenantId: z + .string() + .trim() + .min(1, "Tenant ID required") + .describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.tenantId) +}); + +export const AzureClientSecretsConnectionOAuthOutputCredentialsSchema = z.object({ + tenantId: z.string(), + accessToken: z.string(), + refreshToken: z.string(), + expiresAt: z.number() +}); + +export const ValidateAzureClientSecretsConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(AzureClientSecretsConnectionMethod.OAuth) + .describe(AppConnections.CREATE(AppConnection.AzureClientSecrets).method), + credentials: AzureClientSecretsConnectionOAuthInputCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.AzureClientSecrets).credentials + ) + }) +]); + +export const CreateAzureClientSecretsConnectionSchema = ValidateAzureClientSecretsConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.AzureClientSecrets) +); + +export const UpdateAzureClientSecretsConnectionSchema = z + .object({ + credentials: AzureClientSecretsConnectionOAuthInputCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.AzureClientSecrets).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.AzureClientSecrets)); + +const BaseAzureClientSecretsConnectionSchema = BaseAppConnectionSchema.extend({ + app: z.literal(AppConnection.AzureClientSecrets) +}); + +export const AzureClientSecretsConnectionSchema = z.intersection( + BaseAzureClientSecretsConnectionSchema, + z.discriminatedUnion("method", [ + z.object({ + method: z.literal(AzureClientSecretsConnectionMethod.OAuth), + credentials: AzureClientSecretsConnectionOAuthOutputCredentialsSchema + }) + ]) +); + +export const SanitizedAzureClientSecretsConnectionSchema = z.discriminatedUnion("method", [ + BaseAzureClientSecretsConnectionSchema.extend({ + method: z.literal(AzureClientSecretsConnectionMethod.OAuth), + credentials: AzureClientSecretsConnectionOAuthOutputCredentialsSchema.pick({ + tenantId: true + }) + }) +]); + +export const AzureClientSecretsConnectionListItemSchema = z.object({ + name: z.literal("Azure Client Secrets"), + app: z.literal(AppConnection.AzureClientSecrets), + methods: z.nativeEnum(AzureClientSecretsConnectionMethod).array(), + oauthClientId: z.string().optional() +}); diff --git a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-types.ts b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-types.ts new file mode 100644 index 000000000..fb20fbadd --- /dev/null +++ b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-types.ts @@ -0,0 +1,65 @@ +import { z } from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + AzureClientSecretsConnectionOAuthOutputCredentialsSchema, + AzureClientSecretsConnectionSchema, + CreateAzureClientSecretsConnectionSchema, + ValidateAzureClientSecretsConnectionCredentialsSchema +} from "./azure-client-secrets-connection-schemas"; + +export type TAzureClientSecretsConnection = z.infer; + +export type TAzureClientSecretsConnectionInput = z.infer & { + app: AppConnection.AzureClientSecrets; +}; + +export type TValidateAzureClientSecretsConnectionCredentialsSchema = + typeof ValidateAzureClientSecretsConnectionCredentialsSchema; + +export type TAzureClientSecretsConnectionConfig = DiscriminativePick< + TAzureClientSecretsConnectionInput, + "method" | "app" | "credentials" +> & { + orgId: string; +}; + +export type TAzureClientSecretsConnectionCredentials = z.infer< + typeof AzureClientSecretsConnectionOAuthOutputCredentialsSchema +>; + +export interface ExchangeCodeAzureResponse { + token_type: string; + scope: string; + expires_in: number; + ext_expires_in: number; + access_token: string; + refresh_token: string; + id_token: string; +} + +export interface TAzureRegisteredApp { + id: string; + appId: string; + displayName: string; + description?: string; + createdDateTime: string; + identifierUris?: string[]; + signInAudience?: string; +} + +export interface TAzureListRegisteredAppsResponse { + "@odata.context": string; + "@odata.nextLink"?: string; + value: TAzureRegisteredApp[]; +} + +export interface TAzureClientSecret { + keyId: string; + displayName?: string; + startDateTime: string; + endDateTime: string; + secretText?: string; +} diff --git a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-service.ts b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-service.ts new file mode 100644 index 000000000..336c48d58 --- /dev/null +++ b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-service.ts @@ -0,0 +1,68 @@ +import { request } from "@app/lib/config/request"; +import { OrgServiceActor } from "@app/lib/types"; +import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { getAzureConnectionAccessToken } from "@app/services/app-connection/azure-client-secrets/azure-client-secrets-connection-fns"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; + +import { + TAzureClientSecretsConnection, + TAzureListRegisteredAppsResponse, + TAzureRegisteredApp +} from "./azure-client-secrets-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +const listAzureRegisteredApps = async ( + appConnection: TAzureClientSecretsConnection, + appConnectionDAL: Pick, + kmsService: Pick +) => { + const accessToken = await getAzureConnectionAccessToken(appConnection.id, appConnectionDAL, kmsService); + + const graphEndpoint = `https://graph.microsoft.com/v1.0/applications`; + + const apps: TAzureRegisteredApp[] = []; + let nextLink = graphEndpoint; + + while (nextLink) { + // eslint-disable-next-line no-await-in-loop + const { data: appsPage } = await request.get(nextLink, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + }); + + apps.push(...appsPage.value); + nextLink = appsPage["@odata.nextLink"] || ""; + } + + return apps; +}; + +export const azureClientSecretsConnectionService = ( + getAppConnection: TGetAppConnectionFunc, + appConnectionDAL: Pick, + kmsService: Pick +) => { + const listApps = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.AzureClientSecrets, connectionId, actor); + + const apps = await listAzureRegisteredApps(appConnection, appConnectionDAL, kmsService); + + return apps.map((app) => ({ + id: app.id, + name: app.displayName, + appId: app.appId + })); + }; + + return { + listApps + }; +}; diff --git a/backend/src/services/app-connection/azure-client-secrets/index.ts b/backend/src/services/app-connection/azure-client-secrets/index.ts new file mode 100644 index 000000000..60177973e --- /dev/null +++ b/backend/src/services/app-connection/azure-client-secrets/index.ts @@ -0,0 +1,4 @@ +export * from "./azure-client-secrets-connection-enums"; +export * from "./azure-client-secrets-connection-fns"; +export * from "./azure-client-secrets-connection-schemas"; +export * from "./azure-client-secrets-connection-types"; diff --git a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts index 8e8a6b2a7..116597ec4 100644 --- a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts +++ b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts @@ -38,8 +38,12 @@ export const getAzureConnectionAccessToken = async ( throw new NotFoundError({ message: `Connection with ID '${connectionId}' not found` }); } - if (appConnection.app !== AppConnection.AzureKeyVault && appConnection.app !== AppConnection.AzureAppConfiguration) { - throw new BadRequestError({ message: `Connection with ID '${connectionId}' is not an Azure Key Vault connection` }); + if ( + appConnection.app !== AppConnection.AzureKeyVault && + appConnection.app !== AppConnection.AzureAppConfiguration && + appConnection.app !== AppConnection.AzureClientSecrets + ) { + throw new BadRequestError({ message: `Connection with ID '${connectionId}' is not a valid Azure connection` }); } const credentials = (await decryptAppConnectionCredentials({ diff --git a/backend/src/services/app-connection/teamcity/teamcity-connection-fns.ts b/backend/src/services/app-connection/teamcity/teamcity-connection-fns.ts index c87eb06d2..645be988f 100644 --- a/backend/src/services/app-connection/teamcity/teamcity-connection-fns.ts +++ b/backend/src/services/app-connection/teamcity/teamcity-connection-fns.ts @@ -69,6 +69,5 @@ export const listTeamCityProjects = async (appConnection: TTeamCityConnection) = } ); - // Filter out the root project. Should not be seen by users. - return resp.data.project.filter((proj) => proj.id !== "_Root"); + return resp.data.project; }; diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index 0f8ba5176..d1b0a550d 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -12,7 +12,7 @@ import { generateSrpServerKey, srpCheckClientProof } from "@app/lib/crypto"; import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { getUserPrivateKey } from "@app/lib/crypto/srp"; import { BadRequestError, DatabaseError, ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors"; -import { removeTrailingSlash } from "@app/lib/fn"; +import { getMinExpiresIn, removeTrailingSlash } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; import { getUserAgentType } from "@app/server/plugins/audit-log"; import { getServerCfg } from "@app/services/super-admin/super-admin-service"; @@ -143,6 +143,17 @@ export const authLoginServiceFactory = ({ ); if (!tokenSession) throw new Error("Failed to create token"); + let tokenSessionExpiresIn: string | number = cfg.JWT_AUTH_LIFETIME; + let refreshTokenExpiresIn: string | number = cfg.JWT_REFRESH_LIFETIME; + + if (organizationId) { + const org = await orgDAL.findById(organizationId); + if (org && org.userTokenExpiration) { + tokenSessionExpiresIn = getMinExpiresIn(cfg.JWT_AUTH_LIFETIME, org.userTokenExpiration); + refreshTokenExpiresIn = org.userTokenExpiration; + } + } + const accessToken = jwt.sign( { authMethod, @@ -155,7 +166,7 @@ export const authLoginServiceFactory = ({ mfaMethod }, cfg.AUTH_SECRET, - { expiresIn: cfg.JWT_AUTH_LIFETIME } + { expiresIn: tokenSessionExpiresIn } ); const refreshToken = jwt.sign( @@ -170,7 +181,7 @@ export const authLoginServiceFactory = ({ mfaMethod }, cfg.AUTH_SECRET, - { expiresIn: cfg.JWT_REFRESH_LIFETIME } + { expiresIn: refreshTokenExpiresIn } ); return { access: accessToken, refresh: refreshToken }; diff --git a/backend/src/services/auth/auth-signup-service.ts b/backend/src/services/auth/auth-signup-service.ts index a652c2a5b..58ba9186e 100644 --- a/backend/src/services/auth/auth-signup-service.ts +++ b/backend/src/services/auth/auth-signup-service.ts @@ -10,6 +10,7 @@ import { getConfig } from "@app/lib/config/env"; import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { generateUserSrpKeys, getUserPrivateKey } from "@app/lib/crypto/srp"; import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { getMinExpiresIn } from "@app/lib/fn"; import { isDisposableEmail } from "@app/lib/validator"; import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; @@ -46,7 +47,7 @@ type TAuthSignupDep = { projectDAL: Pick; projectBotDAL: Pick; groupProjectDAL: Pick; - orgService: Pick; + orgService: Pick; orgDAL: TOrgDALFactory; tokenService: TAuthTokenServiceFactory; smtpService: TSmtpService; @@ -320,6 +321,17 @@ export const authSignupServiceFactory = ({ projectBotDAL }); + let tokenSessionExpiresIn: string | number = appCfg.JWT_AUTH_LIFETIME; + let refreshTokenExpiresIn: string | number = appCfg.JWT_REFRESH_LIFETIME; + + if (organizationId) { + const org = await orgService.findOrganizationById(user.id, organizationId, authMethod, organizationId); + if (org && org.userTokenExpiration) { + tokenSessionExpiresIn = getMinExpiresIn(appCfg.JWT_AUTH_LIFETIME, org.userTokenExpiration); + refreshTokenExpiresIn = org.userTokenExpiration; + } + } + const tokenSession = await tokenService.getUserTokenSession({ userAgent, ip, @@ -337,7 +349,7 @@ export const authSignupServiceFactory = ({ organizationId }, appCfg.AUTH_SECRET, - { expiresIn: appCfg.JWT_AUTH_LIFETIME } + { expiresIn: tokenSessionExpiresIn } ); const refreshToken = jwt.sign( @@ -350,7 +362,7 @@ export const authSignupServiceFactory = ({ organizationId }, appCfg.AUTH_SECRET, - { expiresIn: appCfg.JWT_REFRESH_LIFETIME } + { expiresIn: refreshTokenExpiresIn } ); return { user: updateduser.info, accessToken, refreshToken, organizationId }; diff --git a/backend/src/services/org/org-schema.ts b/backend/src/services/org/org-schema.ts index 2aa793c04..5a1a4c333 100644 --- a/backend/src/services/org/org-schema.ts +++ b/backend/src/services/org/org-schema.ts @@ -17,5 +17,6 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({ shouldUseNewPrivilegeSystem: true, privilegeUpgradeInitiatedByUsername: true, privilegeUpgradeInitiatedAt: true, - bypassOrgAuthEnabled: true + bypassOrgAuthEnabled: true, + userTokenExpiration: true }); diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 3a6373575..060a01634 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -170,8 +170,12 @@ export const orgServiceFactory = ({ actorOrgId: string | undefined ) => { await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); + const appCfg = getConfig(); const org = await orgDAL.findOrgById(orgId); if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` }); + if (!org.userTokenExpiration) { + return { ...org, userTokenExpiration: appCfg.JWT_REFRESH_LIFETIME }; + } return org; }; /* @@ -350,7 +354,8 @@ export const orgServiceFactory = ({ enforceMfa, selectedMfaMethod, allowSecretSharingOutsideOrganization, - bypassOrgAuthEnabled + bypassOrgAuthEnabled, + userTokenExpiration } }: TUpdateOrgDTO) => { const appCfg = getConfig(); @@ -451,7 +456,8 @@ export const orgServiceFactory = ({ enforceMfa, selectedMfaMethod, allowSecretSharingOutsideOrganization, - bypassOrgAuthEnabled + bypassOrgAuthEnabled, + userTokenExpiration }); if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` }); return org; diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index 8a1698015..702cd25bf 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -74,6 +74,7 @@ export type TUpdateOrgDTO = { selectedMfaMethod: MfaMethod; allowSecretSharingOutsideOrganization: boolean; bypassOrgAuthEnabled: boolean; + userTokenExpiration: string; }>; } & TOrgPermission; diff --git a/backend/src/services/secret-import/secret-import-dal.ts b/backend/src/services/secret-import/secret-import-dal.ts index 1a171aa2e..dbe2f6a84 100644 --- a/backend/src/services/secret-import/secret-import-dal.ts +++ b/backend/src/services/secret-import/secret-import-dal.ts @@ -171,6 +171,19 @@ export const secretImportDALFactory = (db: TDbClient) => { } }; + const getFolderImports = async (secretPath: string, environmentId: string, tx?: Knex) => { + try { + const folderImports = await (tx || db.replicaNode())(TableName.SecretImport) + .where({ importPath: secretPath, importEnv: environmentId }) + .join(TableName.SecretFolder, `${TableName.SecretImport}.folderId`, `${TableName.SecretFolder}.id`) + .join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`) + .select(db.ref("id").withSchema(TableName.SecretFolder).as("folderId")); + return folderImports; + } catch (error) { + throw new DatabaseError({ error, name: "get secret imports" }); + } + }; + const getFolderIsImportedBy = async ( secretPath: string, environmentId: string, @@ -203,7 +216,8 @@ export const secretImportDALFactory = (db: TDbClient) => { db.ref("name").withSchema(TableName.Environment).as("envName"), db.ref("slug").withSchema(TableName.Environment).as("envSlug"), db.ref("id").withSchema(TableName.SecretFolder).as("folderId"), - db.ref("secretKey").withSchema(TableName.SecretReferenceV2).as("referencedSecretKey") + db.ref("secretKey").withSchema(TableName.SecretReferenceV2).as("referencedSecretKey"), + db.ref("environment").withSchema(TableName.SecretReferenceV2).as("referencedSecretEnv") ); const folderResults = folderImports.map(({ envName, envSlug, folderName, folderId }) => ({ @@ -214,13 +228,14 @@ export const secretImportDALFactory = (db: TDbClient) => { })); const secretResults = secretReferences.map( - ({ envName, envSlug, secretId, folderName, folderId, referencedSecretKey }) => ({ + ({ envName, envSlug, secretId, folderName, folderId, referencedSecretKey, referencedSecretEnv }) => ({ envName, envSlug, secretId, folderName, folderId, - referencedSecretKey + referencedSecretKey, + referencedSecretEnv }) ); @@ -235,6 +250,7 @@ export const secretImportDALFactory = (db: TDbClient) => { secrets: { secretId: string; referencedSecretKey: string; + referencedSecretEnv: string; }[]; folderId: string; folderImported: boolean; @@ -264,7 +280,11 @@ export const secretImportDALFactory = (db: TDbClient) => { if ("secretId" in item && item.secretId) { updatedAcc[env].folders[folder].secrets = [ ...updatedAcc[env].folders[folder].secrets, - { secretId: item.secretId, referencedSecretKey: item.referencedSecretKey } + { + secretId: item.secretId, + referencedSecretKey: item.referencedSecretKey, + referencedSecretEnv: item.referencedSecretEnv + } ]; } else { updatedAcc[env].folders[folder].folderImported = true; @@ -309,6 +329,7 @@ export const secretImportDALFactory = (db: TDbClient) => { findLastImportPosition, updateAllPosition, getProjectImportCount, - getFolderIsImportedBy + getFolderIsImportedBy, + getFolderImports }; }; diff --git a/backend/src/services/secret-import/secret-import-service.ts b/backend/src/services/secret-import/secret-import-service.ts index 2015516f5..5078496d6 100644 --- a/backend/src/services/secret-import/secret-import-service.ts +++ b/backend/src/services/secret-import/secret-import-service.ts @@ -808,7 +808,7 @@ export const secretImportServiceFactory = ({ actorOrgId, secrets }: TGetSecretImportsDTO & { - secrets: { secretKey: string; secretValue: string }[] | undefined; + secrets: { secretKey: string; secretValue: string; id: string }[] | undefined; }) => { const { permission } = await permissionService.getProjectPermission({ actor, @@ -877,7 +877,8 @@ export const secretImportServiceFactory = ({ ) .map((otherSecret) => ({ secretId: secret.secretKey, - referencedSecretKey: otherSecret.secretKey + referencedSecretKey: otherSecret.secretKey, + referencedSecretEnv: environment })); }) || []; if (locallyReferenced.length > 0) { diff --git a/backend/src/services/secret-import/secret-import-types.ts b/backend/src/services/secret-import/secret-import-types.ts index e4490e715..41ddbc9e2 100644 --- a/backend/src/services/secret-import/secret-import-types.ts +++ b/backend/src/services/secret-import/secret-import-types.ts @@ -56,11 +56,12 @@ export type FolderResult = { export type SecretResult = { secretId: string; referencedSecretKey: string; + referencedSecretEnv: string; } & FolderResult; export type FolderInfo = { folderName: string; - secrets?: { secretId: string; referencedSecretKey: string }[]; + secrets?: { secretId: string; referencedSecretKey: string; referencedSecretEnv: string }[]; folderId: string; folderImported: boolean; envSlug?: string; diff --git a/backend/src/services/secret-sync/secret-sync-service.ts b/backend/src/services/secret-sync/secret-sync-service.ts index 14a1a1cf0..db350f785 100644 --- a/backend/src/services/secret-sync/secret-sync-service.ts +++ b/backend/src/services/secret-sync/secret-sync-service.ts @@ -23,6 +23,7 @@ import { TDeleteSecretSyncDTO, TFindSecretSyncByIdDTO, TFindSecretSyncByNameDTO, + TListSecretSyncsByFolderId, TListSecretSyncsByProjectId, TSecretSync, TTriggerSecretSyncImportSecretsByIdDTO, @@ -31,12 +32,14 @@ import { TUpdateSecretSyncDTO } from "@app/services/secret-sync/secret-sync-types"; +import { TSecretImportDALFactory } from "../secret-import/secret-import-dal"; import { TSecretSyncDALFactory } from "./secret-sync-dal"; import { SECRET_SYNC_CONNECTION_MAP, SECRET_SYNC_NAME_MAP } from "./secret-sync-maps"; import { TSecretSyncQueueFactory } from "./secret-sync-queue"; type TSecretSyncServiceFactoryDep = { secretSyncDAL: TSecretSyncDALFactory; + secretImportDAL: TSecretImportDALFactory; appConnectionService: Pick; permissionService: Pick; projectBotService: Pick; @@ -53,6 +56,7 @@ export type TSecretSyncServiceFactory = ReturnType { + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId + }); + + if (permission.cannot(ProjectPermissionSecretSyncActions.Read, ProjectPermissionSub.SecretSyncs)) { + return []; + } + + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); + if (!folder) return []; + + const folderImports = await secretImportDAL.getFolderImports(secretPath, folder.envId); + + const secretSyncs = await secretSyncDAL.find({ + $in: { + folderId: folderImports.map((folderImport) => folderImport.folderId).concat(folder.id) + } + }); + + return secretSyncs as TSecretSync[]; + }; + const findSecretSyncById = async ({ destination, syncId }: TFindSecretSyncByIdDTO, actor: OrgServiceActor) => { const secretSync = await secretSyncDAL.findById(syncId); @@ -518,6 +553,7 @@ export const secretSyncServiceFactory = ({ return { listSecretSyncOptions, listSecretSyncsByProjectId, + listSecretSyncsBySecretPath, findSecretSyncById, findSecretSyncByName, createSecretSync, diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index 716c9b44f..e99b31c20 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -144,6 +144,13 @@ export type TListSecretSyncsByProjectId = { destination?: SecretSync; }; +export type TListSecretSyncsByFolderId = { + projectId: string; + secretPath: string; + environment: string; + destination?: SecretSync; +}; + export type TFindSecretSyncByIdDTO = { syncId: string; destination: SecretSync; diff --git a/backend/src/services/secret-sync/teamcity/teamcity-sync-fns.ts b/backend/src/services/secret-sync/teamcity/teamcity-sync-fns.ts index 323f59851..6dbd9bdd7 100644 --- a/backend/src/services/secret-sync/teamcity/teamcity-sync-fns.ts +++ b/backend/src/services/secret-sync/teamcity/teamcity-sync-fns.ts @@ -10,7 +10,7 @@ import { TTeamCitySyncWithCredentials } from "@app/services/secret-sync/teamcity/teamcity-sync-types"; -// Note: Most variables won't be returned with a value due to them being a "password" type (starting with "env."). +// Note: Most variables won't be returned with a value due to them being a "password" type. // TeamCity API returns empty string for password-type variables for security reasons. const listTeamCityVariables = async ({ instanceUrl, accessToken, project, buildConfig }: TTeamCityListVariables) => { const { data } = await request.get( @@ -25,12 +25,16 @@ const listTeamCityVariables = async ({ instanceUrl, accessToken, project, buildC } ); + // Filters for only non-inherited environment variables // Strips out "env." from map key, but the "name" field still has the original unaltered key. return Object.fromEntries( - data.property.map((variable) => [ - variable.name.startsWith("env.") ? variable.name.substring(4) : variable.name, - { ...variable, value: variable.value || "" } // Password values will be empty strings from the API for security - ]) + data.property + .filter((variable) => !variable.inherited) + .filter((variable) => variable.name.startsWith("env.")) + .map((variable) => [ + variable.name.substring(4), + { ...variable, value: variable.value || "" } // Password values will be empty strings from the API for security + ]) ); }; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts index cf389ac2a..6ab348520 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts @@ -22,6 +22,7 @@ import type { TFindSecretsByFolderIdsFilter, TGetSecretsDTO } from "@app/services/secret-v2-bridge/secret-v2-bridge-types"; +import { applyJitter } from "@app/lib/dates"; export const SecretServiceCacheKeys = { get productKey() { @@ -48,7 +49,7 @@ interface TSecretV2DalArg { keyStore: TKeyStoreFactory; } -export const SECRET_DAL_TTL = 5 * 60; +export const SECRET_DAL_TTL = () => applyJitter(10 * 60, 2 * 60); export const SECRET_DAL_VERSION_TTL = 15 * 60; export const MAX_SECRET_CACHE_BYTES = 25 * 1024 * 1024; export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { @@ -63,7 +64,8 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { const findOne = async (filter: Partial, tx?: Knex) => { try { const docs = await (tx || db)(TableName.SecretV2) - .where(filter) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .where(buildFindFilter(filter, TableName.SecretV2)) .leftJoin( TableName.SecretV2JnTag, `${TableName.SecretV2}.id`, diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts index 5c2f6a2f0..6fdcadeff 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts @@ -2,7 +2,7 @@ import path from "node:path"; import RE2 from "re2"; -import { TableName, TSecretFolders, TSecretsV2 } from "@app/db/schemas"; +import { SecretType, TableName, TSecretFolders, TSecretsV2 } from "@app/db/schemas"; import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; @@ -720,7 +720,7 @@ export const reshapeBridgeSecret = ( secretReminderRecipients: secret.secretReminderRecipients || [], ...(secretValueHidden ? { - secretValue: INFISICAL_SECRET_VALUE_HIDDEN_MASK, + secretValue: secret.type === SecretType.Personal ? secret.value : INFISICAL_SECRET_VALUE_HIDDEN_MASK, secretValueHidden: true } : { diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index 010f5beb1..1ef4a2d41 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -962,7 +962,7 @@ export const secretV2BridgeServiceFactory = ({ const encryptedCachedSecrets = await keyStore.getItem(cacheKey); if (encryptedCachedSecrets) { try { - await keyStore.setExpiry(cacheKey, SECRET_DAL_TTL); + await keyStore.setExpiry(cacheKey, SECRET_DAL_TTL()); const cachedSecrets = secretManagerDecryptor({ cipherTextBlob: Buffer.from(encryptedCachedSecrets, "base64") }); const { secrets, imports = [] } = JSON.parse(cachedSecrets.toString("utf8")) as { secrets: typeof decryptedSecrets; @@ -1132,7 +1132,7 @@ export const secretV2BridgeServiceFactory = ({ plainText: Buffer.from(JSON.stringify(payload)) }).cipherTextBlob; if (encryptedUpdatedCachedSecrets.byteLength < MAX_SECRET_CACHE_BYTES) { - await keyStore.setItemWithExpiry(cacheKey, SECRET_DAL_TTL, encryptedUpdatedCachedSecrets.toString("base64")); + await keyStore.setItemWithExpiry(cacheKey, SECRET_DAL_TTL(), encryptedUpdatedCachedSecrets.toString("base64")); } return payload; } @@ -1179,7 +1179,7 @@ export const secretV2BridgeServiceFactory = ({ plainText: Buffer.from(JSON.stringify(payload)) }).cipherTextBlob; if (encryptedUpdatedCachedSecrets.byteLength < MAX_SECRET_CACHE_BYTES) { - await keyStore.setItemWithExpiry(cacheKey, SECRET_DAL_TTL, encryptedUpdatedCachedSecrets.toString("base64")); + await keyStore.setItemWithExpiry(cacheKey, SECRET_DAL_TTL(), encryptedUpdatedCachedSecrets.toString("base64")); } return payload; }; diff --git a/docs/api-reference/endpoints/app-connections/azure-client-secret/available.mdx b/docs/api-reference/endpoints/app-connections/azure-client-secret/available.mdx new file mode 100644 index 000000000..238f8ae18 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-client-secret/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/azure-client-secrets/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/azure-client-secret/create.mdx b/docs/api-reference/endpoints/app-connections/azure-client-secret/create.mdx new file mode 100644 index 000000000..c1c6bd6a8 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-client-secret/create.mdx @@ -0,0 +1,10 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/azure-client-secrets" +--- + + + Azure Client Secret Connections must be created through the Infisical UI. + Check out the configuration docs for [Azure Client Secret Connections](/integrations/app-connections/azure-client-secrets) for a step-by-step + guide. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/azure-client-secret/delete.mdx b/docs/api-reference/endpoints/app-connections/azure-client-secret/delete.mdx new file mode 100644 index 000000000..8482e2ad1 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-client-secret/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/azure-client-secrets/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/azure-client-secret/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/azure-client-secret/get-by-id.mdx new file mode 100644 index 000000000..555ae20f3 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-client-secret/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/azure-client-secrets/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/azure-client-secret/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/azure-client-secret/get-by-name.mdx new file mode 100644 index 000000000..f6c23483e --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-client-secret/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/azure-client-secrets/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/azure-client-secret/list.mdx b/docs/api-reference/endpoints/app-connections/azure-client-secret/list.mdx new file mode 100644 index 000000000..795906cfd --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-client-secret/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/azure-client-secrets" +--- diff --git a/docs/api-reference/endpoints/app-connections/azure-client-secret/update.mdx b/docs/api-reference/endpoints/app-connections/azure-client-secret/update.mdx new file mode 100644 index 000000000..f60993285 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-client-secret/update.mdx @@ -0,0 +1,10 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/azure-client-secrets/{connectionId}" +--- + + + Azure Client Secret Connections must be updated through the Infisical UI. + Check out the configuration docs for [Azure Client Secret Connections](/integrations/app-connections/azure-client-secrets) for a step-by-step + guide. + diff --git a/docs/api-reference/endpoints/secret-rotations/azure-client-secret/create.mdx b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/create.mdx new file mode 100644 index 000000000..eb998767b --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/create.mdx @@ -0,0 +1,9 @@ +--- +title: "Create" +openapi: "POST /api/v2/secret-rotations/azure-client-secret" +--- + + + Check out the configuration docs for [Azure Client Secret Rotations](/documentation/platform/secret-rotation/azure-client-secret) to learn how to obtain the + required parameters. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/secret-rotations/azure-client-secret/delete.mdx b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/delete.mdx new file mode 100644 index 000000000..31e4a5697 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v2/secret-rotations/azure-client-secret/{rotationId}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/azure-client-secret/get-by-id.mdx b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/get-by-id.mdx new file mode 100644 index 000000000..db5ec7a2a --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v2/secret-rotations/azure-client-secret/{rotationId}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/azure-client-secret/get-by-name.mdx b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/get-by-name.mdx new file mode 100644 index 000000000..c1ca2d958 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v2/secret-rotations/azure-client-secret/rotation-name/{rotationName}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/azure-client-secret/get-generated-credentials-by-id.mdx b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/get-generated-credentials-by-id.mdx new file mode 100644 index 000000000..c6da0709c --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/get-generated-credentials-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Credentials by ID" +openapi: "GET /api/v2/secret-rotations/azure-client-secret/{rotationId}/generated-credentials" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/azure-client-secret/list.mdx b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/list.mdx new file mode 100644 index 000000000..da970a16f --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/secret-rotations/azure-client-secret" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/azure-client-secret/rotate-secrets.mdx b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/rotate-secrets.mdx new file mode 100644 index 000000000..b824178da --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/rotate-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Rotate Secrets" +openapi: "POST /api/v2/secret-rotations/azure-client-secret/{rotationId}/rotate-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/azure-client-secret/update.mdx b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/update.mdx new file mode 100644 index 000000000..abb00bba0 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/update.mdx @@ -0,0 +1,9 @@ +--- +title: "Update" +openapi: "PATCH /api/v2/secret-rotations/azure-client-secret/{rotationId}" +--- + + + Check out the configuration docs for [Azure Client Secret Rotations](/documentation/platform/secret-rotation/azure-client-secret) to learn how to obtain the + required parameters. + \ No newline at end of file diff --git a/docs/documentation/platform/access-controls/assume-privilege.mdx b/docs/documentation/platform/access-controls/assume-privilege.mdx new file mode 100644 index 000000000..a38fd65f0 --- /dev/null +++ b/docs/documentation/platform/access-controls/assume-privilege.mdx @@ -0,0 +1,40 @@ +--- +title: "Assume Privileges" +description: "Learn how to temporarily assume the privileges of a user or machine identity within a project." +--- + +This feature allows authorized users to temporarily take on the permissions of another user or identity. It helps administrators and access managers test and verify permissions before granting access, ensuring everything is set up correctly. +It also reduces back-and-forth with end users when troubleshooting permission-related issues. + +## How It Works + +When an authorized user activates assume privileges mode, they temporarily inherit the target user or identity’s permissions for up to one hour. +During this time, they can perform actions within the system with the same level of access as the target user. + +- **Permission-based**: Only permissions are inherited, not the full identity +- **Time-limited**: Access automatically expires after one hour +- **Audited**: All actions are logged under the original user's account. This means any action taken during the session will be recorded under the entity assuming the privileges, not the target entity. +- **Authorization required**: Only users with the specific **assume privilege** permission can use this feature +- **Scoped to a single project**: You can only assume privileges for one project at a time + +## How to Assume Privileges + + + + Click on the user or identity you want to assume. + + ![Access control page](/images/platform/access-controls/assume-privileges/access-control.png) + + + + Click **Assume Privilege**, then type `assume` to confirm and start your session. + + ![Access control detail page](/images/platform/access-controls/assume-privileges/access-control-detail.png) + + + + You will see a yellow banner indicating that your assume privilege session is active. You can exit at any time by clicking **Exit**. + + ![session start](/images/platform/access-controls/assume-privileges/session-start.png) + + \ No newline at end of file diff --git a/docs/documentation/platform/github-org-sync.mdx b/docs/documentation/platform/github-org-sync.mdx index 426c51060..00c9bf4c4 100644 --- a/docs/documentation/platform/github-org-sync.mdx +++ b/docs/documentation/platform/github-org-sync.mdx @@ -1,5 +1,5 @@ --- -title: "GitHub Organization Sync" +title: "GitHub Team Sync" description: "Learn how to automatically synchronize your GitHub teams with Infisical Groups." --- diff --git a/docs/documentation/platform/kms-configuration/aws-kms.mdx b/docs/documentation/platform/kms-configuration/aws-kms.mdx index 3fc5404ae..b4631b1c3 100644 --- a/docs/documentation/platform/kms-configuration/aws-kms.mdx +++ b/docs/documentation/platform/kms-configuration/aws-kms.mdx @@ -9,6 +9,9 @@ This guide will walk you through the steps needed to configure external KMS supp ## Prerequisites +- An AWS KMS Key configured as a `Symmetric` key and with `Encrypt and Decrypt` key usage. + ![Create AWS KMS Key](/images/platform/kms/aws/aws-kms-key-create.png) + Before you begin, you'll first need to choose a method of authentication with AWS from below. diff --git a/docs/documentation/platform/kms/hsm-integration.mdx b/docs/documentation/platform/kms/hsm-integration.mdx index 633377b3d..a9ab2c832 100644 --- a/docs/documentation/platform/kms/hsm-integration.mdx +++ b/docs/documentation/platform/kms/hsm-integration.mdx @@ -268,11 +268,11 @@ For organizations that work with US government agencies, FIPS compliance is almo - When using Kubernetes, you need to mount the path containing the HSM client files. This section covers how to configure your Infisical instance to use an HSM with Kubernetes. + When using Kubernetes, you need to mount the path containing the HSM client files. This section covers how to configure your Infisical instance to use an HSM with Kubernetes. In this example, we are going to be using `/etc/luna-docker`. ```bash - mkdir /etc/hsm-client + mkdir /etc/luna-docker ``` After [setting up your Luna Cloud HSM client](https://thalesdocs.com/gphsm/luna/7/docs/network/Content/install/client_install/add_dpod.htm), you should have a set of files, referred to as the HSM client. You don't need all the files, but for simplicity we recommend copying all the files from the client. @@ -306,20 +306,60 @@ For organizations that work with US government agencies, FIPS compliance is almo The most important parts of the client folder is the `Chrystoki.conf` file, and the `libs`, `plugins`, and `jsp` folders. You need to copy these files to the folder you created in the first step. ```bash - cp -r / /etc/hsm-client + cp -r //* /etc/luna-docker ``` + + + The `/*` wildcard will copy all files and folders within the HSM client. The wildcard is important to ensure that the file structure is inline with the rest of this guide. + + + After copying the files, the `/etc/luna-docker` directory should have the following file structure: + ```bash + $ ls -R /etc/luna-docker + Chrystoki.conf etc lock server-certificate.pem + Chrystoki.conf.tmp2E jsp partition-ca-certificate.pem setenv + lch-support-linux-64bit partition-certificate.pem + bin libs plugins + + /etc/luna-docker/bin: + 64 + + /etc/luna-docker/bin/64: + ckdemo cmu lunacm multitoken vtl + + /etc/luna-docker/etc: + openssl.cnf + + /etc/luna-docker/jsp: + 64 LunaProvider.jar + + /etc/luna-docker/jsp/64: + libLunaAPI.so + + /etc/luna-docker/libs: + 64 + + /etc/luna-docker/libs/64: + libCryptoki2.so + + /etc/luna-docker/lock: + + /etc/luna-docker/plugins: + libcloud.plugin + ``` + The `Chrystoki.conf` file is used to configure the HSM client. You need to update the `Chrystoki.conf` file to point to the correct file paths. - In this example, we will be mounting the `/etc/hsm-client` folder from the host to containers in our deployment's pods at the path `/hsm-client`. This means the contents of `/etc/hsm-client` on the host will be accessible at `/hsm-client` within the containers. + In this example, we will be mounting the `/etc/luna-docker` folder from the host to containers in our deployment's pods at the path `/usr/safenet/lunaclient`. This means the contents of `/etc/luna-docker` on the host will be accessible at `/usr/safenet/lunaclient` within the containers. An example config file will look like this: ```Chrystoki.conf Chrystoki2 = { - # This path points to the mounted path, /hsm-client - LibUNIX64 = /hsm-client/libs/64/libCryptoki2.so; + # This path points to the mounted path, /usr/safenet/lunaclient + LibUNIX64 = /usr/safenet/lunaclient/libs/64/libCryptoki2.so; } Luna = { @@ -339,8 +379,8 @@ For organizations that work with US government agencies, FIPS compliance is almo Misc = { # Update the paths to point to the mounted path if your folder structure is different from the one mentioned in the previous step. - PluginModuleDir = /hsm-client/plugins; - MutexFolder = /hsm-client/lock; + PluginModuleDir = /usr/safenet/lunaclient/plugins; + MutexFolder = /usr/safenet/lunaclient/lock; PE1746Enabled = 1; ToolsDir = /usr/bin; @@ -353,7 +393,7 @@ For organizations that work with US government agencies, FIPS compliance is almo LunaSA Client = { ReceiveTimeout = 20000; # Update the paths to point to the mounted path if your folder structure is different from the one mentioned in the previous step. - SSLConfigFile = /hsm-client/etc/openssl.cnf; + SSLConfigFile = /usr/safenet/lunaclient/etc/openssl.cnf; ClientPrivKeyFile = ./etc/ClientNameKey.pem; ClientCertFile = ./etc/ClientNameCert.pem; ServerCAFile = ./etc/CAFile.pem; @@ -441,7 +481,7 @@ For organizations that work with US government agencies, FIPS compliance is almo ```bash kubectl exec hsm-setup-pod -- mkdir -p /data/ # Create the data directory - kubectl cp ./hsm-client/ hsm-setup-pod:/data/ # Copy the HSM client files into the PVC + kubectl cp /etc/luna-docker/. hsm-setup-pod:/data/ # Copy the HSM client files into the PVC kubectl exec hsm-setup-pod -- chmod -R 755 /data/ # Set the correct permissions for the HSM client files ``` @@ -456,7 +496,7 @@ For organizations that work with US government agencies, FIPS compliance is almo Next we need to update the environment variables used for the deployment. If you followed the [setup instructions for Kubernetes deployments](/self-hosting/deployment-options/kubernetes-helm), you should have a Kubernetes secret called `infisical-secrets`. We need to update the secret with the following environment variables: - - `HSM_LIB_PATH` - The path to the HSM client library _(mapped to `/hsm-client/libs/64/libCryptoki2.so`)_ + - `HSM_LIB_PATH` - The path to the HSM client library _(mapped to `/usr/safenet/lunaclient/libs/64/libCryptoki2.so`)_ - `HSM_PIN` - The PIN for the HSM device that you created when setting up your Luna Cloud HSM client - `HSM_SLOT` - The slot number for the HSM device that you selected when setting up your Luna Cloud HSM client - `HSM_KEY_LABEL` - The label for the HSM key. If no key is found with the provided key label, the HSM will create a new key with the provided label. @@ -471,7 +511,7 @@ For organizations that work with US government agencies, FIPS compliance is almo type: Opaque stringData: # ... Other environment variables ... - HSM_LIB_PATH: "/hsm-client/libs/64/libCryptoki2.so" # If you followed this guide, this will be the path of the Luna Cloud HSM client + HSM_LIB_PATH: "/usr/safenet/lunaclient/libs/64/libCryptoki2.so" # If you followed this guide, this will be the path of the Luna Cloud HSM client HSM_PIN: "" HSM_SLOT: "" HSM_KEY_LABEL: "" @@ -487,7 +527,7 @@ For organizations that work with US government agencies, FIPS compliance is almo After we've successfully configured the PVC and updated our environment variables, we are ready to update the deployment configuration so that the pods it creates can access the HSM client files. - We need to update the Docker image of the deployment to use `infisical/infisical-fips`. The `infisical/infisical-fips` image is a functionally identical image to the `infisical/infisical` image, but it is built with support for HSM encryption. + We need to update the Docker image of the deployment to use `infisical/infisical-fips`. The `infisical/infisical-fips` image is a functionally identical image to the `infisical/infisical` image, but it is built with HSM support. ```yaml # ... The rest of the values.yaml file ... @@ -499,8 +539,7 @@ For organizations that work with US government agencies, FIPS compliance is almo extraVolumeMounts: - name: hsm-data - mountPath: /hsm-client # The path we will mount the HSM client files to - subPath: ./hsm-client + mountPath: /usr/safenet/lunaclient # The path we will mount the HSM client files to extraVolumes: - name: hsm-data diff --git a/docs/documentation/platform/organization.mdx b/docs/documentation/platform/organization.mdx index f1c62ff37..3a53484fb 100644 --- a/docs/documentation/platform/organization.mdx +++ b/docs/documentation/platform/organization.mdx @@ -27,6 +27,10 @@ The **Settings** page lets you manage information about your organization includ ![organization settings auth](../../images/platform/organization/organization-settings-auth.png) + + You can adjust the maximum time a user token will remain valid for your organization. After this period, users will be required to re-authenticate. This helps improve security by enforcing regular sign-ins. + + ## Access Control The **Access Control** page is where you can manage identities (both people and machines) that are part of your organization. diff --git a/docs/documentation/platform/secret-rotation/azure-client-secret.mdx b/docs/documentation/platform/secret-rotation/azure-client-secret.mdx new file mode 100644 index 000000000..046b772f6 --- /dev/null +++ b/docs/documentation/platform/secret-rotation/azure-client-secret.mdx @@ -0,0 +1,142 @@ +--- +title: "Azure Client Secret" +description: "Learn how to automatically rotate Azure Client Secrets." +--- + +## Prerequisites + +- Create an [Azure Client Secret Connection](/integrations/app-connections/azure-client-secrets). + +## Create an Azure Client Secret Rotation in Infisical + + + + 1. Navigate to your Secret Manager Project's Dashboard and select **Add Secret Rotation** from the actions dropdown. + ![Secret Manager Dashboard](/images/secret-rotations-v2/generic/add-secret-rotation.png) + + 2. Select the **Azure Client Secret** option. + ![Select Azure Client Secret](/images/secret-rotations-v2/azure-client-secret/azure-client-secret-option.png) + + 3. Select the **Azure Connection** to use and configure the rotation behavior. Then click **Next**. + ![Rotation Configuration](/images/secret-rotations-v2/azure-client-secret/azure-client-secret-configuration.png) + + - **Azure Connection** - the connection that will perform the rotation of the specified application's Client Secret. + - **Rotation Interval** - the interval, in days, that once elapsed will trigger a rotation. + - **Rotate At** - the local time of day when rotation should occur once the interval has elapsed. + - **Auto-Rotation Enabled** - whether secrets should automatically be rotated once the rotation interval has elapsed. Disable this option to manually rotate secrets or pause secret rotation. + + 4. Select the Azure application whose Client Secret you want to rotate. Then click **Next**. + ![Rotation Parameters](/images/secret-rotations-v2/azure-client-secret/azure-client-secret-parameters.png) + + 5. Specify the secret names that the client credentials should be mapped to. Then click **Next**. + ![Rotation Secrets Mapping](/images/secret-rotations-v2/azure-client-secret/azure-client-secret-mapping.png) + + - **Client ID** - the name of the secret that the application Client ID will be mapped to. + - **Client Secret** - the name of the secret that the rotated Client Secret will be mapped to. + + 6. Give your rotation a name and description (optional). Then click **Next**. + ![Rotation Details](/images/secret-rotations-v2/azure-client-secret/azure-client-secret-details.png) + + - **Name** - the name of the secret rotation configuration. Must be slug-friendly. + - **Description** (optional) - a description of this rotation configuration. + + 7. Review your configuration, then click **Create Secret Rotation**. + ![Rotation Review](/images/secret-rotations-v2/azure-client-secret/azure-client-secret-review.png) + + 8. Your **Azure Client Secret** credentials are now available for use via the mapped secrets. + ![Rotation Created](/images/secret-rotations-v2/azure-client-secret/azure-client-secret-created.png) + + + To create an Azure Client Secret Rotation, make an API request to the [Create Azure + Client Secret Rotation](/api-reference/endpoints/secret-rotations/azure-client-secret/create) API endpoint. + + You will first need the **Client ID** and **Object ID** of the Azure application you want to rotate the secret for. This can be obtained from the Applications dashboard. + ![Azure Client ID](/images/secret-rotations-v2/azure-client-secret/azure-app-client-id.png) + + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://us.infisical.com/api/v2/secret-rotations/azure-client-secret \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-azure-rotation", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "my client secret rotation", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/", + "isAutoRotationEnabled": true, + "rotationInterval": 30, + "rotateAtUtc": { + "hours": 0, + "minutes": 0 + }, + "parameters": { + "objectId": "...", + "clientId": "...", + "appName": "..." + }, + "secretsMapping": { + "clientId": "AZURE_CLIENT_ID", + "clientSecret": "AZURE_CLIENT_SECRET" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretRotation": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-azure-rotation", + "description": "my client secret rotation", + "secretsMapping": { + "clientId": "AZURE_CLIENT_ID", + "clientSecret": "AZURE_CLIENT_SECRET" + }, + "isAutoRotationEnabled": true, + "activeIndex": 0, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "rotationInterval": 30, + "rotationStatus": "success", + "lastRotationAttemptedAt": "2023-11-07T05:31:56Z", + "lastRotatedAt": "2023-11-07T05:31:56Z", + "lastRotationJobId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "nextRotationAt": "2023-11-07T05:31:56Z", + "connection": { + "app": "azure", + "name": "my-azure-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/" + }, + "rotateAtUtc": { + "hours": 0, + "minutes": 0 + }, + "lastRotationMessage": null, + "type": "azure-client-secret", + "parameters": { + "objectId": "...", + "appName": "...", + "clientId": "..." + } + } + } + ``` + + diff --git a/docs/images/app-connections/azure/client-secrets/config-credentials-1.png b/docs/images/app-connections/azure/client-secrets/config-credentials-1.png new file mode 100644 index 000000000..954f8aeb9 Binary files /dev/null and b/docs/images/app-connections/azure/client-secrets/config-credentials-1.png differ diff --git a/docs/images/app-connections/azure/client-secrets/create-oauth-method.png b/docs/images/app-connections/azure/client-secrets/create-oauth-method.png new file mode 100644 index 000000000..e38707ea1 Binary files /dev/null and b/docs/images/app-connections/azure/client-secrets/create-oauth-method.png differ diff --git a/docs/images/app-connections/azure/client-secrets/oauth-connection.png b/docs/images/app-connections/azure/client-secrets/oauth-connection.png new file mode 100644 index 000000000..5bc8e479a Binary files /dev/null and b/docs/images/app-connections/azure/client-secrets/oauth-connection.png differ diff --git a/docs/images/app-connections/azure/client-secrets/select-connection.png b/docs/images/app-connections/azure/client-secrets/select-connection.png new file mode 100644 index 000000000..84091ef25 Binary files /dev/null and b/docs/images/app-connections/azure/client-secrets/select-connection.png differ diff --git a/docs/images/integrations/azure-client-secrets/app-api-permissions.png b/docs/images/integrations/azure-client-secrets/app-api-permissions.png new file mode 100644 index 000000000..f64d8b50f Binary files /dev/null and b/docs/images/integrations/azure-client-secrets/app-api-permissions.png differ diff --git a/docs/images/platform/access-controls/assume-privileges/access-control-detail.png b/docs/images/platform/access-controls/assume-privileges/access-control-detail.png new file mode 100644 index 000000000..e0844b8f4 Binary files /dev/null and b/docs/images/platform/access-controls/assume-privileges/access-control-detail.png differ diff --git a/docs/images/platform/access-controls/assume-privileges/access-control.png b/docs/images/platform/access-controls/assume-privileges/access-control.png new file mode 100644 index 000000000..aa6974cdd Binary files /dev/null and b/docs/images/platform/access-controls/assume-privileges/access-control.png differ diff --git a/docs/images/platform/access-controls/assume-privileges/session-start.png b/docs/images/platform/access-controls/assume-privileges/session-start.png new file mode 100644 index 000000000..1aab112c4 Binary files /dev/null and b/docs/images/platform/access-controls/assume-privileges/session-start.png differ diff --git a/docs/images/platform/kms/aws/aws-kms-key-create.png b/docs/images/platform/kms/aws/aws-kms-key-create.png new file mode 100644 index 000000000..7d8466538 Binary files /dev/null and b/docs/images/platform/kms/aws/aws-kms-key-create.png differ diff --git a/docs/images/platform/organization/organization-settings-auth.png b/docs/images/platform/organization/organization-settings-auth.png index ca2340e9f..fd7a946e0 100644 Binary files a/docs/images/platform/organization/organization-settings-auth.png and b/docs/images/platform/organization/organization-settings-auth.png differ diff --git a/docs/images/secret-rotations-v2/azure-client-secret/azure-app-client-id.png b/docs/images/secret-rotations-v2/azure-client-secret/azure-app-client-id.png new file mode 100644 index 000000000..7c040f758 Binary files /dev/null and b/docs/images/secret-rotations-v2/azure-client-secret/azure-app-client-id.png differ diff --git a/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-configuration.png b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-configuration.png new file mode 100644 index 000000000..043d51bcf Binary files /dev/null and b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-configuration.png differ diff --git a/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-created.png b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-created.png new file mode 100644 index 000000000..c4208d14c Binary files /dev/null and b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-created.png differ diff --git a/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-details.png b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-details.png new file mode 100644 index 000000000..68c58d795 Binary files /dev/null and b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-details.png differ diff --git a/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-mapping.png b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-mapping.png new file mode 100644 index 000000000..ee8a3b948 Binary files /dev/null and b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-mapping.png differ diff --git a/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-option.png b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-option.png new file mode 100644 index 000000000..6ff3f3f00 Binary files /dev/null and b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-option.png differ diff --git a/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-parameters.png b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-parameters.png new file mode 100644 index 000000000..a3917965d Binary files /dev/null and b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-parameters.png differ diff --git a/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-review.png b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-review.png new file mode 100644 index 000000000..fca3a4c4c Binary files /dev/null and b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-review.png differ diff --git a/docs/integrations/app-connections/azure-client-secrets.mdx b/docs/integrations/app-connections/azure-client-secrets.mdx new file mode 100644 index 000000000..55416303a --- /dev/null +++ b/docs/integrations/app-connections/azure-client-secrets.mdx @@ -0,0 +1,103 @@ +--- +title: "Azure Client Secrets Connection" +description: "Learn how to configure an Azure Client Secrets Connection for Infisical." +--- + +Infisical currently only supports one method for connecting to Azure, which is OAuth. + + + Using the Azure Client Secrets connection on a self-hosted instance of Infisical requires configuring an application in Azure + and registering your instance with it. + + **Prerequisites:** + + - Set up Azure. + + + + Navigate to Azure Active Directory > App registrations to create a new application. + + + Azure Active Directory is now Microsoft Entra ID. + + ![Azure client secrets](/images/integrations/azure-app-configuration/config-aad.png) + ![Azure client secrets](/images/integrations/azure-app-configuration/config-new-app.png) + + Create the application. As part of the form, set the **Redirect URI** to `https://your-domain.com/organization/app-connections/azure/oauth/callback`. + + The domain you defined in the Redirect URI should be equivalent to the `SITE_URL` configured in your Infisical instance. + + + ![Azure client secrets](/images/app-connections/azure/register-callback.png) + + + + For the Azure Connection to work with Client Secrets, you need to assign the following permission to the application. + + #### Azure Client Secrets permissions + + Set the API permissions of the Azure application to include the following permissions: + - Microsoft Graph + - `Application.ReadWrite.All` + - `Application.ReadWrite.OwnedBy` + - `Application.ReadWrite.All` (Delegated) + - `Directory.ReadWrite.All` (Delegated) + - `User.Read` (Delegated) + - Azure App Configuration + - `KeyValue.Delete` (Delegated) + - `KeyValue.Read` (Delegated) + - `KeyValue.Write` (Delegated) + - Access Key Vault + - `user_impersonation` (Delegated) + + ![Azure client secrets](/images/integrations/azure-client-secrets/app-api-permissions.png) + + + + + Obtain the **Application (Client) ID** and **Directory (Tenant) ID** (this will be used later in the Infisical connection) in Overview and generate a **Client Secret** in Certificate & secrets for your Azure application. + + ![Azure client secrets](../../images/app-connections/azure/client-secrets/config-credentials-1.png) + ![Azure client secrets](../../images/integrations/azure-app-configuration/config-credentials-2.png) + ![Azure client secrets](../../images/integrations/azure-app-configuration/config-credentials-3.png) + + Back in your Infisical instance, add two new environment variables for the credentials of your Azure application. + + - `INF_APP_CONNECTION_AZURE_CLIENT_ID`: The **Application (Client) ID** of your Azure application. + - `INF_APP_CONNECTION_AZURE_CLIENT_SECRET`: The **Client Secret** of your Azure application. + + Once added, restart your Infisical instance and use the Azure Client Secrets connection. + + + + + +## Setup Azure Connection in Infisical + + + + Navigate to the **App Connections** tab on the **Organization Settings** page. ![App Connections + Tab](/images/app-connections/general/add-connection.png) + + + Select the **Azure Connection** option from the connection options modal. ![Select Azure Connection](/images/app-connections/azure/client-secrets/select-connection.png) + + + Fill in the **Tenant ID** field with the Directory (Tenant) ID you obtained in the previous step. + + Now select the **OAuth** method and click **Connect to Azure**. + + ![Connect via Azure OAUth](/images/app-connections/azure/client-secrets/create-oauth-method.png) + + + + + + You will then be redirected to Azure to grant Infisical access to your Azure account. Once granted, + you will be redirected back to Infisical's App Connections page. ![Azure Client Secrets + Authorization](/images/app-connections/azure/grant-access.png) + + + Your **Azure Client Secrets Connection** is now available for use. ![Azure Client Secrets](/images/app-connections/azure/client-secrets/oauth-connection.png) + + diff --git a/docs/integrations/secret-syncs/teamcity.mdx b/docs/integrations/secret-syncs/teamcity.mdx index e79fc0f0c..af4c8d76a 100644 --- a/docs/integrations/secret-syncs/teamcity.mdx +++ b/docs/integrations/secret-syncs/teamcity.mdx @@ -34,7 +34,7 @@ description: "Learn how to configure a TeamCity Sync for Infisical." - **Build Configuration**: The build configuration to sync secrets to. - Not including a Build Configuration will sync secrets to the entire project. + Not including a Build Configuration will sync secrets to the project. 5. Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. @@ -44,6 +44,11 @@ description: "Learn how to configure a TeamCity Sync for Infisical." - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over TeamCity when keys conflict. - **Import Secrets (Prioritize TeamCity)**: Imports secrets from the destination endpoint before syncing, prioritizing values from TeamCity over Infisical when keys conflict. + + + Infisical only syncs secrets from within the target scope; inherited secrets will not be imported. + + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. diff --git a/docs/mint.json b/docs/mint.json index 6b23fe70e..f629baf46 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -166,6 +166,7 @@ }, "documentation/platform/access-controls/additional-privileges", "documentation/platform/access-controls/temporary-access", + "documentation/platform/access-controls/assume-privilege", "documentation/platform/access-controls/access-requests", "documentation/platform/access-controls/project-access-requests", "documentation/platform/pr-workflows", @@ -186,6 +187,7 @@ "documentation/platform/secret-rotation/overview", "documentation/platform/secret-rotation/auth0-client-secret", "documentation/platform/secret-rotation/aws-iam-user-secret", + "documentation/platform/secret-rotation/azure-client-secret", "documentation/platform/secret-rotation/ldap-password", "documentation/platform/secret-rotation/mssql-credentials", "documentation/platform/secret-rotation/postgres-credentials" @@ -435,6 +437,7 @@ "integrations/app-connections/auth0", "integrations/app-connections/aws", "integrations/app-connections/azure-app-configuration", + "integrations/app-connections/azure-client-secrets", "integrations/app-connections/azure-key-vault", "integrations/app-connections/camunda", "integrations/app-connections/databricks", @@ -892,6 +895,19 @@ "api-reference/endpoints/secret-rotations/aws-iam-user-secret/update" ] }, + { + "group": "Azure Client Secret", + "pages": [ + "api-reference/endpoints/secret-rotations/azure-client-secret/create", + "api-reference/endpoints/secret-rotations/azure-client-secret/delete", + "api-reference/endpoints/secret-rotations/azure-client-secret/get-by-id", + "api-reference/endpoints/secret-rotations/azure-client-secret/get-by-name", + "api-reference/endpoints/secret-rotations/azure-client-secret/get-generated-credentials-by-id", + "api-reference/endpoints/secret-rotations/azure-client-secret/list", + "api-reference/endpoints/secret-rotations/azure-client-secret/rotate-secrets", + "api-reference/endpoints/secret-rotations/azure-client-secret/update" + ] + }, { "group": "LDAP Password", "pages": [ @@ -985,6 +1001,18 @@ "api-reference/endpoints/app-connections/azure-app-configuration/delete" ] }, + { + "group": "Azure Client Secret", + "pages": [ + "api-reference/endpoints/app-connections/azure-client-secret/list", + "api-reference/endpoints/app-connections/azure-client-secret/available", + "api-reference/endpoints/app-connections/azure-client-secret/get-by-id", + "api-reference/endpoints/app-connections/azure-client-secret/get-by-name", + "api-reference/endpoints/app-connections/azure-client-secret/create", + "api-reference/endpoints/app-connections/azure-client-secret/update", + "api-reference/endpoints/app-connections/azure-client-secret/delete" + ] + }, { "group": "Azure Key Vault", "pages": [ diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index 8318869f2..d9eef9cb0 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -73,7 +73,8 @@ The platform utilizes Postgres to persist all of its data and Redis for caching ### PostgreSQL - Please note that the database user must have **CREATE** privileges along with ability to create and modify tables. This is needed for Infisical to run schema migrations. + Please note that the database user you create must be granted all privileges on the Infisical database. + This includes the ability to create new schemas, create, update, delete, modify tables and indexes, etc. diff --git a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewAzureClientSecretRotationGeneratedCredentials.tsx b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewAzureClientSecretRotationGeneratedCredentials.tsx new file mode 100644 index 000000000..7dd94b6c5 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewAzureClientSecretRotationGeneratedCredentials.tsx @@ -0,0 +1,38 @@ +import { CredentialDisplay } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/CredentialDisplay"; +import { TAzureClientSecretRotationGeneratedCredentialsResponse } from "@app/hooks/api/secretRotationsV2/types/azure-client-secret-rotation"; + +import { ViewRotationGeneratedCredentialsDisplay } from "./shared"; + +type Props = { + generatedCredentialsResponse: TAzureClientSecretRotationGeneratedCredentialsResponse; +}; + +export const ViewAzureClientSecretRotationGeneratedCredentials = ({ + generatedCredentialsResponse: { generatedCredentials, activeIndex } +}: Props) => { + const inactiveIndex = activeIndex === 0 ? 1 : 0; + + const activeCredentials = generatedCredentials[activeIndex]; + const inactiveCredentials = generatedCredentials[inactiveIndex]; + + return ( + + {activeCredentials?.clientId} + + {activeCredentials?.clientSecret} + + + } + inactiveCredentials={ + <> + {inactiveCredentials?.clientId} + + {inactiveCredentials?.clientSecret} + + + } + /> + ); +}; diff --git a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx index d5af51eef..a8a00e17f 100644 --- a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx +++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx @@ -4,6 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { format } from "date-fns"; import { ViewAuth0ClientSecretRotationGeneratedCredentials } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewAuth0ClientSecretRotationGeneratedCredentials"; +import { ViewAzureClientSecretRotationGeneratedCredentials } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewAzureClientSecretRotationGeneratedCredentials"; import { ViewLdapPasswordRotationGeneratedCredentials } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewLdapPasswordRotationGeneratedCredentials"; import { Modal, ModalContent, Spinner } from "@app/components/v2"; import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2"; @@ -75,6 +76,13 @@ const Content = ({ secretRotation }: ContentProps) => { /> ); break; + case SecretRotation.AzureClientSecret: + Component = ( + + ); + break; case SecretRotation.LdapPassword: Component = ( { + const { control, watch, setValue } = useFormContext< + TSecretRotationV2Form & { + type: SecretRotation.AzureClientSecret; + } + >(); + + const connectionId = watch("connection.id"); + + const { data: clients, isPending: isClientsPending } = useAzureConnectionListClients( + connectionId, + { enabled: Boolean(connectionId) } + ); + + return ( + ( + + Ensure that your connection has the{" "} + + Application.ReadWrite.All, Directory.ReadWrite.All, + Application.ReadWrite.OwnedBy, user_impersonation and User.Read + {" "} + permissions and the application exists in Azure. + + } + > +
+ Don't see the application you're looking for?{" "} + +
+ + } + > + client.id === value) ?? null} + onChange={(option) => { + onChange((option as SingleValue)?.id ?? null); + setValue("parameters.appName", (option as SingleValue)?.name ?? ""); + setValue("parameters.clientId", (option as SingleValue)?.appId ?? ""); + }} + options={clients} + placeholder="Select an application..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> +
+ )} + /> + ); +}; diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/SecretRotationV2ParametersFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/SecretRotationV2ParametersFields.tsx index cdbf63111..a871eb54f 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/SecretRotationV2ParametersFields.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/SecretRotationV2ParametersFields.tsx @@ -5,6 +5,7 @@ import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; import { TSecretRotationV2Form } from "../schemas"; import { Auth0ClientSecretRotationParametersFields } from "./Auth0ClientSecretRotationParametersFields"; import { AwsIamUserSecretRotationParametersFields } from "./AwsIamUserSecretRotationParametersFields"; +import { AzureClientSecretRotationParametersFields } from "./AzureClientSecretRotationParametersFields"; import { LdapPasswordRotationParametersFields } from "./LdapPasswordRotationParametersFields"; import { SqlCredentialsRotationParametersFields } from "./shared"; @@ -12,6 +13,7 @@ const COMPONENT_MAP: Record = { [SecretRotation.PostgresCredentials]: SqlCredentialsRotationParametersFields, [SecretRotation.MsSqlCredentials]: SqlCredentialsRotationParametersFields, [SecretRotation.Auth0ClientSecret]: Auth0ClientSecretRotationParametersFields, + [SecretRotation.AzureClientSecret]: AzureClientSecretRotationParametersFields, [SecretRotation.LdapPassword]: LdapPasswordRotationParametersFields, [SecretRotation.AwsIamUserSecret]: AwsIamUserSecretRotationParametersFields }; diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/AzureClientSecretRotationReviewFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/AzureClientSecretRotationReviewFields.tsx new file mode 100644 index 000000000..9770d14a4 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/AzureClientSecretRotationReviewFields.tsx @@ -0,0 +1,30 @@ +import { useFormContext } from "react-hook-form"; + +import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas"; +import { GenericFieldLabel } from "@app/components/v2"; +import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; + +import { SecretRotationReviewSection } from "./shared"; + +export const AzureClientSecretRotationReviewFields = () => { + const { watch } = useFormContext< + TSecretRotationV2Form & { + type: SecretRotation.AzureClientSecret; + } + >(); + + const [parameters, { clientId, clientSecret }] = watch(["parameters", "secretsMapping"]); + + return ( + <> + + {parameters.appName} + {parameters.objectId} + + + {clientId} + {clientSecret} + + + ); +}; diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx index 23ee25d7f..2bfdc16fd 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx @@ -8,6 +8,7 @@ import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; import { Auth0ClientSecretRotationReviewFields } from "./Auth0ClientSecretRotationReviewFields"; import { AwsIamUserSecretRotationReviewFields } from "./AwsIamUserSecretRotationReviewFields"; +import { AzureClientSecretRotationReviewFields } from "./AzureClientSecretRotationReviewFields"; import { LdapPasswordRotationReviewFields } from "./LdapPasswordRotationReviewFields"; import { SqlCredentialsRotationReviewFields } from "./shared"; @@ -15,6 +16,7 @@ const COMPONENT_MAP: Record = { [SecretRotation.PostgresCredentials]: SqlCredentialsRotationReviewFields, [SecretRotation.MsSqlCredentials]: SqlCredentialsRotationReviewFields, [SecretRotation.Auth0ClientSecret]: Auth0ClientSecretRotationReviewFields, + [SecretRotation.AzureClientSecret]: AzureClientSecretRotationReviewFields, [SecretRotation.LdapPassword]: LdapPasswordRotationReviewFields, [SecretRotation.AwsIamUserSecret]: AwsIamUserSecretRotationReviewFields }; diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/AzureClientSecretRotationSecretsMappingFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/AzureClientSecretRotationSecretsMappingFields.tsx new file mode 100644 index 000000000..77a34d083 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/AzureClientSecretRotationSecretsMappingFields.tsx @@ -0,0 +1,58 @@ +import { Controller, useFormContext } from "react-hook-form"; + +import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas"; +import { FormControl, Input } from "@app/components/v2"; +import { SecretRotation, useSecretRotationV2Option } from "@app/hooks/api/secretRotationsV2"; + +import { SecretsMappingTable } from "./shared"; + +export const AzureClientSecretRotationSecretsMappingFields = () => { + const { control } = useFormContext< + TSecretRotationV2Form & { + type: SecretRotation.AzureClientSecret; + } + >(); + + const { rotationOption } = useSecretRotationV2Option(SecretRotation.AzureClientSecret); + + const items = [ + { + name: "Client ID", + input: ( + ( + + + + )} + control={control} + name="secretsMapping.clientId" + /> + ) + }, + { + name: "Client Secret", + input: ( + ( + + + + )} + control={control} + name="secretsMapping.clientSecret" + /> + ) + } + ]; + + return ; +}; diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx index 16bffe6cf..9da51272b 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx @@ -5,6 +5,7 @@ import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; import { TSecretRotationV2Form } from "../schemas"; import { Auth0ClientSecretRotationSecretsMappingFields } from "./Auth0ClientSecretRotationSecretsMappingFields"; import { AwsIamUserSecretRotationSecretsMappingFields } from "./AwsIamUserSecretRotationSecretsMappingFields"; +import { AzureClientSecretRotationSecretsMappingFields } from "./AzureClientSecretRotationSecretsMappingFields"; import { LdapPasswordRotationSecretsMappingFields } from "./LdapPasswordRotationSecretsMappingFields"; import { SqlCredentialsRotationSecretsMappingFields } from "./shared"; @@ -12,6 +13,7 @@ const COMPONENT_MAP: Record = { [SecretRotation.PostgresCredentials]: SqlCredentialsRotationSecretsMappingFields, [SecretRotation.MsSqlCredentials]: SqlCredentialsRotationSecretsMappingFields, [SecretRotation.Auth0ClientSecret]: Auth0ClientSecretRotationSecretsMappingFields, + [SecretRotation.AzureClientSecret]: AzureClientSecretRotationSecretsMappingFields, [SecretRotation.LdapPassword]: LdapPasswordRotationSecretsMappingFields, [SecretRotation.AwsIamUserSecret]: AwsIamUserSecretRotationSecretsMappingFields }; diff --git a/frontend/src/components/secret-rotations-v2/forms/schemas/azure-client-secret-rotation-schema.ts b/frontend/src/components/secret-rotations-v2/forms/schemas/azure-client-secret-rotation-schema.ts new file mode 100644 index 000000000..3db113003 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/forms/schemas/azure-client-secret-rotation-schema.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +import { BaseSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/base-secret-rotation-v2-schema"; +import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; + +export const AzureClientSecretRotationSchema = z + .object({ + type: z.literal(SecretRotation.AzureClientSecret), + parameters: z.object({ + objectId: z.string().trim().min(1, "Object ID required"), + appName: z.string().trim().min(1, "App Name required"), + clientId: z.string().trim().min(1, "Client ID required") + }), + secretsMapping: z.object({ + clientId: z.string().trim().min(1, "Client ID required"), + clientSecret: z.string().trim().min(1, "Client Secret required") + }) + }) + .merge(BaseSecretRotationSchema); diff --git a/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts b/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts index 6dd65b0bb..b0484ae67 100644 --- a/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts +++ b/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts @@ -2,14 +2,16 @@ import { z } from "zod"; import { Auth0ClientSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/auth0-client-secret-rotation-schema"; import { AwsIamUserSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/aws-iam-user-secret-rotation-schema"; +import { AzureClientSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/azure-client-secret-rotation-schema"; import { LdapPasswordRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/ldap-password-rotation-schema"; import { MsSqlCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/mssql-credentials-rotation-schema"; import { PostgresCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/postgres-credentials-rotation-schema"; const SecretRotationUnionSchema = z.discriminatedUnion("type", [ + Auth0ClientSecretRotationSchema, + AzureClientSecretRotationSchema, PostgresCredentialsRotationSchema, MsSqlCredentialsRotationSchema, - Auth0ClientSecretRotationSchema, LdapPasswordRotationSchema, AwsIamUserSecretRotationSchema ]); diff --git a/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx b/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx index a2b69eaba..2fdb56c8c 100644 --- a/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx +++ b/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx @@ -19,6 +19,7 @@ type Props = { formContent?: ReactNode; children?: ReactNode; deletionMessage?: ReactNode; + buttonColorSchema?: "danger" | "primary" | "secondary" | "gray" | null; }; export const DeleteActionModal = ({ @@ -32,6 +33,7 @@ export const DeleteActionModal = ({ buttonText = "Delete", formContent, deletionMessage, + buttonColorSchema = "danger", children }: Props): JSX.Element => { const [inputData, setInputData] = useState(""); @@ -67,7 +69,7 @@ export const DeleteActionModal = ({
+ + + +
+ + + ); +}; diff --git a/frontend/src/pages/organization/AppConnections/OauthCallbackPage/OauthCallbackPage.tsx b/frontend/src/pages/organization/AppConnections/OauthCallbackPage/OauthCallbackPage.tsx index 8832d611e..ce7a1b489 100644 --- a/frontend/src/pages/organization/AppConnections/OauthCallbackPage/OauthCallbackPage.tsx +++ b/frontend/src/pages/organization/AppConnections/OauthCallbackPage/OauthCallbackPage.tsx @@ -7,9 +7,11 @@ import { ROUTE_PATHS } from "@app/const/routes"; import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; import { AzureAppConfigurationConnectionMethod, + AzureClientSecretsConnectionMethod, AzureKeyVaultConnectionMethod, GitHubConnectionMethod, TAzureAppConfigurationConnection, + TAzureClientSecretsConnection, TAzureKeyVaultConnection, TGitHubConnection, useCreateAppConnection, @@ -32,18 +34,26 @@ type AzureAppConfigurationFormData = BaseFormData & Pick & Pick; +type AzureClientSecretsFormData = BaseFormData & + Pick & + Pick; + type FormDataMap = { [AppConnection.GitHub]: GithubFormData & { app: AppConnection.GitHub }; [AppConnection.AzureKeyVault]: AzureKeyVaultFormData & { app: AppConnection.AzureKeyVault }; [AppConnection.AzureAppConfiguration]: AzureAppConfigurationFormData & { app: AppConnection.AzureAppConfiguration; }; + [AppConnection.AzureClientSecrets]: AzureClientSecretsFormData & { + app: AppConnection.AzureClientSecrets; + }; }; const formDataStorageFieldMap: Partial> = { [AppConnection.GitHub]: "githubConnectionFormData", [AppConnection.AzureKeyVault]: "azureKeyVaultConnectionFormData", - [AppConnection.AzureAppConfiguration]: "azureAppConfigurationConnectionFormData" + [AppConnection.AzureAppConfiguration]: "azureAppConfigurationConnectionFormData", + [AppConnection.AzureClientSecrets]: "azureClientSecretsConnectionFormData" }; export const OAuthCallbackPage = () => { @@ -194,6 +204,54 @@ export const OAuthCallbackPage = () => { }; }, []); + const handleAzureClientSecrets = useCallback(async () => { + const formData = getFormData(AppConnection.AzureClientSecrets); + if (formData === null) return null; + + clearState(AppConnection.AzureClientSecrets); + + const { connectionId, name, description, returnUrl } = formData; + + try { + if (connectionId) { + await updateAppConnection.mutateAsync({ + app: AppConnection.AzureClientSecrets, + connectionId, + credentials: { + code: code as string, + tenantId: formData.tenantId + } + }); + } else { + await createAppConnection.mutateAsync({ + app: AppConnection.AzureClientSecrets, + name, + description, + method: AzureClientSecretsConnectionMethod.OAuth, + credentials: { + code: code as string, + tenantId: formData.tenantId + } + }); + } + } catch (err: any) { + createNotification({ + title: `Failed to ${connectionId ? "update" : "add"} Azure Client Secrets Connection`, + text: err?.message, + type: "error" + }); + navigate({ + to: returnUrl ?? "/organization/app-connections" + }); + } + + return { + connectionId, + returnUrl, + appConnectionName: formData.app + }; + }, []); + const handleGithub = useCallback(async () => { const formData = getFormData(AppConnection.GitHub); if (formData === null) return null; @@ -280,6 +338,8 @@ export const OAuthCallbackPage = () => { data = await handleAzureKeyVault(); } else if (appConnection === AppConnection.AzureAppConfiguration) { data = await handleAzureAppConfiguration(); + } else if (appConnection === AppConnection.AzureClientSecrets) { + data = await handleAzureClientSecrets(); } if (data) { diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OIDCModal.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OIDCModal.tsx index 4fff131a7..241348b0d 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OIDCModal.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OIDCModal.tsx @@ -387,6 +387,7 @@ export const OIDCModal = ({ popUp, handlePopUpClose, handlePopUpToggle, hideDele RS256 RS512 HS256 + EdDSA )} diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgAuthTab.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgAuthTab.tsx index bf40c7484..05d105192 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgAuthTab.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgAuthTab.tsx @@ -23,6 +23,7 @@ import { OrgLDAPSection } from "./OrgLDAPSection"; import { OrgOIDCSection } from "./OrgOIDCSection"; import { OrgScimSection } from "./OrgSCIMSection"; import { OrgSSOSection } from "./OrgSSOSection"; +import { OrgUserAccessTokenLimitSection } from "./OrgUserAccessTokenLimitSection"; import { SSOModal } from "./SSOModal"; export const OrgAuthTab = withPermission( @@ -167,6 +168,7 @@ export const OrgAuthTab = withPermission( return ( <> + {shouldShowCreateIdentityProviderView ? ( createIdentityProviderView ) : ( diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgUserAccessTokenLimitSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgUserAccessTokenLimitSection.tsx new file mode 100644 index 000000000..43e72bd41 --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgUserAccessTokenLimitSection.tsx @@ -0,0 +1,171 @@ +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +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 formSchema = z.object({ + expirationValue: z.number().min(1, "Value must be at least 1"), + expirationUnit: z.enum(["m", "h", "d", "w"], { + invalid_type_error: "Please select a valid time unit" + }) +}); + +type TForm = z.infer; + +// Function to parse duration string like "30d" into value and unit +const parseDuration = (duration: string): { value: number; unit: string } => { + const match = duration.match(/^(\d+)([mhdw])$/); + if (match) { + return { + value: parseInt(match[1], 10), + unit: match[2] + }; + } + // Default to 30 days if invalid format + return { value: 30, unit: "d" }; +}; + +// Function to format value and unit back to duration string +const formatDuration = (value: number, unit: string): string => { + return `${value}${unit}`; +}; + +export const OrgUserAccessTokenLimitSection = () => { + const { mutateAsync: updateUserTokenExpiration } = useUpdateOrg(); + const { currentOrg } = useOrganization(); + + // Parse the current duration or use default + const currentDuration = parseDuration(currentOrg?.userTokenExpiration || "30d"); + + const { + control, + formState: { isSubmitting, isDirty }, + handleSubmit + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + expirationValue: currentDuration.value, + expirationUnit: currentDuration.unit as "m" | "h" | "d" | "w" + } + }); + + if (!currentOrg) return null; + + const handleUserTokenExpirationSubmit = async (formData: TForm) => { + try { + const userTokenExpiration = formatDuration(formData.expirationValue, formData.expirationUnit); + + await updateUserTokenExpiration({ + userTokenExpiration, + orgId: currentOrg.id + }); + + createNotification({ + text: "Successfully updated user token expiration", + type: "success" + }); + } catch { + createNotification({ + text: "Failed updating user token expiration", + type: "error" + }); + } + }; + + // Units for the dropdown with readable labels + const timeUnits = [ + { value: "m", label: "Minutes" }, + { value: "h", label: "Hours" }, + { value: "d", label: "Days" }, + { value: "w", label: "Weeks" } + ]; + + return ( +
+
+

User Token Expiration

+
+

+ This defines the maximum time a user token will be valid. After this time, the user will + need to re-authenticate. +

+ + {(isAllowed) => ( +
+
+
+ ( + + field.onChange(parseInt(e.target.value, 10))} + disabled={!isAllowed} + /> + + )} + /> +
+
+ ( + + + + )} + /> +
+
+ +
+ )} +
+
+ ); +}; diff --git a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx index f974a7f1a..5f0d90f6c 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx @@ -74,7 +74,7 @@ import { useUpdateSecretV3 } from "@app/hooks/api"; import { useGetProjectSecretsOverview } from "@app/hooks/api/dashboard/queries"; -import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types"; +import { DashboardSecretsOrderBy, ProjectSecretsImportedBy } from "@app/hooks/api/dashboard/types"; import { OrderByDirection } from "@app/hooks/api/generic/types"; import { useUpdateFolderBatch } from "@app/hooks/api/secretFolders/queries"; import { TUpdateFolderBatchDTO } from "@app/hooks/api/secretFolders/types"; @@ -274,7 +274,8 @@ export const OverviewPage = () => { totalUniqueSecretImportsInPage, totalUniqueDynamicSecretsInPage, totalUniqueSecretRotationsInPage, - importedByEnvs + importedByEnvs, + usedBySecretSyncs } = overview ?? {}; const secretImportsShaped = secretImports @@ -726,6 +727,98 @@ export const OverviewPage = () => { } }, [routerSearch.search]); + const selectedKeysCount = Object.keys(selectedEntries.secret).length; + + const secretsToDeleteKeys = useMemo(() => { + return Object.values(selectedEntries.secret).flatMap((entries) => + Object.values(entries).map((secret) => secret.key) + ); + }, [selectedEntries]); + + const filterAndMergeEnvironments = ( + envNames: string[], + envs: { environment: string; importedBy: ProjectSecretsImportedBy[] }[] + ): ProjectSecretsImportedBy[] => { + const filteredEnvs = envs.filter((env) => envNames.includes(env.environment)); + + if (filteredEnvs.length === 0) return []; + + const allImportedBy = filteredEnvs.flatMap((env) => env.importedBy); + const groupedBySlug: Record = {}; + + allImportedBy.forEach((item) => { + const { slug } = item.environment; + if (!groupedBySlug[slug]) groupedBySlug[slug] = []; + groupedBySlug[slug].push(item); + }); + + const mergedImportedBy = Object.values(groupedBySlug).map((group) => { + const { environment } = group[0]; + const allFolders = group.flatMap((item) => item.folders); + + const foldersByName: Record = {}; + allFolders.forEach((folder) => { + if (!foldersByName[folder.name]) foldersByName[folder.name] = []; + foldersByName[folder.name].push(folder); + }); + + const mergedFolders = Object.entries(foldersByName).map(([name, foldersData]) => { + const isImported = foldersData.some((folder) => folder.isImported); + const allSecrets = foldersData.flatMap((folder) => folder.secrets || []); + + const uniqueSecrets: { + secretId: string; + referencedSecretKey: string; + referencedSecretEnv: string; + }[] = []; + const secretIds = new Set(); + + allSecrets + .filter( + (secret) => + !secretsToDeleteKeys || + secretsToDeleteKeys.length === 0 || + secretsToDeleteKeys.includes(secret.referencedSecretKey) + ) + .forEach((secret) => { + if (!secretIds.has(secret.secretId)) { + secretIds.add(secret.secretId); + uniqueSecrets.push(secret); + } + }); + + return { + name, + isImported, + ...(uniqueSecrets.length > 0 ? { secrets: uniqueSecrets } : {}) + }; + }); + + return { + environment, + folders: mergedFolders.filter( + (folder) => folder.isImported || (folder.secrets && folder.secrets.length > 0) + ) + }; + }); + + return mergedImportedBy; + }; + + const importedBy = useMemo(() => { + if (!importedByEnvs) return []; + if (selectedKeysCount === 0) { + return filterAndMergeEnvironments( + visibleEnvs.map(({ slug }) => slug), + importedByEnvs + ); + } + return filterAndMergeEnvironments( + Object.values(selectedEntries.secret).flatMap((entries) => Object.keys(entries)), + importedByEnvs + ); + }, [importedByEnvs, selectedEntries, selectedKeysCount]); + if (isProjectV3 && visibleEnvs.length > 0 && isOverviewLoading) { return (
@@ -1044,7 +1137,9 @@ export const OverviewPage = () => { secretPath={secretPath} selectedEntries={selectedEntries} resetSelectedEntries={resetSelectedEntries} - importedByEnvs={importedByEnvs} + importedBy={importedBy} + secretsToDeleteKeys={secretsToDeleteKeys} + usedBySecretSyncs={usedBySecretSyncs} />
{ secretKey={key} getSecretByKey={getSecretByKey} scrollOffset={debouncedScrollOffset} + importedBy={importedBy} /> ))} Promise; onSecretDelete: (env: string, key: string, secretId?: string) => Promise; isRotatedSecret?: boolean; + importedBy?: { + environment: { name: string; slug: string }; + folders: { + name: string; + secrets?: { secretId: string; referencedSecretKey: string; referencedSecretEnv: string }[]; + isImported: boolean; + }[]; + }[]; }; export const SecretEditRow = ({ @@ -70,8 +79,13 @@ export const SecretEditRow = ({ secretPath, isVisible, secretId, - isRotatedSecret + isRotatedSecret, + importedBy }: Props) => { + const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([ + "editSecret" + ] as const); + const { handleSubmit, control, @@ -115,6 +129,20 @@ export const SecretEditRow = ({ if (isCreatable) { await onSecretCreate(environment, secretName, value); } else { + if ( + importedBy && + importedBy.some(({ folders }) => + folders?.some(({ secrets }) => + secrets?.some( + ({ referencedSecretKey, referencedSecretEnv }) => + referencedSecretKey === secretName && referencedSecretEnv === environment + ) + ) + ) + ) { + handlePopUpOpen("editSecret", { secretValue: value }); + return; + } await onSecretUpdate( environment, secretName, @@ -124,7 +152,25 @@ export const SecretEditRow = ({ ); } } - reset({ value }); + if (secretValueHidden && !isOverride) { + setTimeout(() => { + reset({ value: defaultValue || null }); + }, 50); + } else { + reset({ value }); + } + }; + + const handleEditSecret = async ({ secretValue }: { secretValue: string }) => { + await onSecretUpdate( + environment, + secretName, + secretValue, + isOverride ? SecretType.Personal : SecretType.Shared, + secretId + ); + reset({ value: secretValue }); + handlePopUpClose("editSecret"); }; const canReadSecretValue = hasSecretReadValueOrDescribePermission( @@ -132,6 +178,16 @@ export const SecretEditRow = ({ ProjectPermissionSecretActions.ReadValue ); + const canEditSecretValue = permission.can( + ProjectPermissionSecretActions.Edit, + subject(ProjectPermissionSub.Secrets, { + environment, + secretPath, + secretName, + secretTags: ["*"] + }) + ); + const handleDeleteSecret = useCallback(async () => { setIsDeleting.on(); setIsModalOpen(false); @@ -153,29 +209,32 @@ export const SecretEditRow = ({ deleteKey={secretName} onDeleteApproved={handleDeleteSecret} /> - + {secretValueHidden && !isOverride && ( + + + + )}
- {secretValueHidden ? ( - - ) : ( - ( - - )} - /> - )} + ( + + )} + />
)}
+ handlePopUpToggle("editSecret", isOpen)} + onDeleteApproved={() => handleEditSecret(popUp?.editSecret?.data)} + formContent={ + importedBy && + importedBy.length > 0 && ( + + ) + } + />
); }; diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx index 15e6753f3..206ea1ade 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx @@ -1,3 +1,4 @@ +import { subject } from "@casl/ability"; import { faCircle } from "@fortawesome/free-regular-svg-icons"; import { faAngleDown, @@ -14,6 +15,11 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; import { Button, Checkbox, TableContainer, Td, Tooltip, Tr } from "@app/components/v2"; +import { useProjectPermission } from "@app/context"; +import { + ProjectPermissionSecretActions, + ProjectPermissionSub +} from "@app/context/ProjectPermissionContext/types"; import { useToggle } from "@app/hooks"; import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/secrets/types"; import { WorkspaceEnv } from "@app/hooks/api/types"; @@ -44,6 +50,14 @@ type Props = { secretName: string ) => { secret?: SecretV3RawSanitized; environmentInfo?: WorkspaceEnv } | undefined; scrollOffset: number; + importedBy?: { + environment: { name: string; slug: string }; + folders: { + name: string; + secrets?: { secretId: string; referencedSecretKey: string; referencedSecretEnv: string }[]; + isImported: boolean; + }[]; + }[]; }; export const SecretOverviewTableRow = ({ @@ -58,12 +72,35 @@ export const SecretOverviewTableRow = ({ getImportedSecretByKey, scrollOffset, onToggleSecretSelect, - isSelected + isSelected, + importedBy }: Props) => { const [isFormExpanded, setIsFormExpanded] = useToggle(); const totalCols = environments.length + 1; // secret key row const [isSecretVisible, setIsSecretVisible] = useToggle(); + const { permission } = useProjectPermission(); + + const getDefaultValue = ( + secret: SecretV3RawSanitized | undefined, + importedSecret: { secret?: SecretV3RawSanitized } | undefined + ) => { + const canEditSecretValue = permission.can( + ProjectPermissionSecretActions.Edit, + subject(ProjectPermissionSub.Secrets, { + environment: secret?.env || "", + secretPath: secret?.path || "", + secretName: secret?.key || "", + secretTags: ["*"] + }) + ); + + if (secret?.secretValueHidden && !secret?.valueOverride) { + return canEditSecretValue ? "******" : ""; + } + return secret?.valueOverride || secret?.value || importedSecret?.secret?.value || ""; + }; + return ( <> setIsFormExpanded.toggle()} className="group"> @@ -228,13 +265,7 @@ export const SecretOverviewTableRow = ({ isVisible={isSecretVisible} secretName={secretKey} secretValueHidden={secret?.secretValueHidden || false} - defaultValue={ - secret?.secretValueHidden - ? "" - : secret?.valueOverride || - secret?.value || - importedSecret?.secret?.value - } + defaultValue={getDefaultValue(secret, importedSecret)} secretId={secret?.id} isOverride={Boolean(secret?.valueOverride)} isImportedSecret={isImportedSecret} @@ -244,6 +275,7 @@ export const SecretOverviewTableRow = ({ onSecretUpdate={onSecretUpdate} environment={slug} isRotatedSecret={secret?.isRotatedSecret} + importedBy={importedBy} /> diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/SelectionPanel/SelectionPanel.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/SelectionPanel/SelectionPanel.tsx index 2c6014940..91c412a6a 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/components/SelectionPanel/SelectionPanel.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/components/SelectionPanel/SelectionPanel.tsx @@ -15,7 +15,7 @@ import { import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types"; import { usePopUp } from "@app/hooks"; import { useDeleteFolder, useDeleteSecretBatch } from "@app/hooks/api"; -import { ProjectSecretsImportedBy } from "@app/hooks/api/dashboard/types"; +import { ProjectSecretsImportedBy, UsedBySecretSyncs } from "@app/hooks/api/dashboard/types"; import { SecretType, SecretV3RawSanitized, @@ -37,14 +37,18 @@ type Props = { [EntryType.FOLDER]: Record>; [EntryType.SECRET]: Record>; }; - importedByEnvs?: { environment: string; importedBy: ProjectSecretsImportedBy[] }[]; + importedBy?: ProjectSecretsImportedBy[] | null; + usedBySecretSyncs?: UsedBySecretSyncs[]; + secretsToDeleteKeys: string[]; }; export const SelectionPanel = ({ secretPath, resetSelectedEntries, selectedEntries, - importedByEnvs + importedBy, + secretsToDeleteKeys, + usedBySecretSyncs = [] }: Props) => { const { permission } = useProjectPermission(); @@ -81,80 +85,11 @@ export const SelectionPanel = ({ ) ); - const secretsToDeleteKeys = useMemo(() => { - return Object.values(selectedEntries.secret).flatMap((entries) => - Object.values(entries).map((secret) => secret.key) - ); - }, [selectedEntries]); - - const filterAndMergeEnvironments = ( - envNames: string[], - envs: { environment: string; importedBy: ProjectSecretsImportedBy[] }[] - ): ProjectSecretsImportedBy[] => { - const filteredEnvs = envs.filter((env) => envNames.includes(env.environment)); - - if (filteredEnvs.length === 0) return []; - - const allImportedBy = filteredEnvs.flatMap((env) => env.importedBy); - const groupedBySlug: Record = {}; - - allImportedBy.forEach((item) => { - const { slug } = item.environment; - if (!groupedBySlug[slug]) groupedBySlug[slug] = []; - groupedBySlug[slug].push(item); - }); - - const mergedImportedBy = Object.values(groupedBySlug).map((group) => { - const { environment } = group[0]; - const allFolders = group.flatMap((item) => item.folders); - - const foldersByName: Record = {}; - allFolders.forEach((folder) => { - if (!foldersByName[folder.name]) foldersByName[folder.name] = []; - foldersByName[folder.name].push(folder); - }); - - const mergedFolders = Object.entries(foldersByName).map(([name, folders]) => { - const isImported = folders.some((folder) => folder.isImported); - const allSecrets = folders.flatMap((folder) => folder.secrets || []); - - const uniqueSecrets: { secretId: string; referencedSecretKey: string }[] = []; - const secretIds = new Set(); - - allSecrets - .filter((secret) => secretsToDeleteKeys.includes(secret.referencedSecretKey)) - .forEach((secret) => { - if (!secretIds.has(secret.secretId)) { - secretIds.add(secret.secretId); - uniqueSecrets.push(secret); - } - }); - - return { - name, - isImported, - ...(uniqueSecrets.length > 0 ? { secrets: uniqueSecrets } : {}) - }; - }); - - return { - environment, - folders: mergedFolders.filter( - (folder) => folder.isImported || (folder.secrets && folder.secrets.length > 0) - ) - }; - }); - - return mergedImportedBy; - }; - - const importedBy = useMemo(() => { - if (selectedKeysCount === 0 || !importedByEnvs) return null; - return filterAndMergeEnvironments( - Object.values(selectedEntries.secret).flatMap((entries) => Object.keys(entries)), - importedByEnvs - ); - }, [importedByEnvs, selectedEntries, selectedKeysCount]); + const usedBySecretSyncsFiltered = useMemo(() => { + if (selectedKeysCount === 0 || usedBySecretSyncs.length === 0) return null; + const envs = Object.values(selectedEntries.secret).flatMap((entries) => Object.keys(entries)); + return usedBySecretSyncs.filter((syncItem) => envs.includes(syncItem.environment)); + }, [selectedEntries, usedBySecretSyncs, selectedKeysCount]); const getDeleteModalTitle = () => { if (selectedFolderCount > 0 && selectedKeysCount > 0) { @@ -326,11 +261,12 @@ export const SelectionPanel = ({ onChange={(isOpen) => handlePopUpToggle("bulkDeleteEntries", isOpen)} onDeleteApproved={handleBulkDelete} formContent={ - importedBy && - importedBy.some((element) => element.folders.length > 0) && ( + ((usedBySecretSyncsFiltered && usedBySecretSyncsFiltered.length > 0) || + (importedBy && importedBy.some((element) => element.folders.length > 0))) && ( ) } diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx index 63398d152..d28f28392 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx @@ -220,6 +220,7 @@ const Page = () => { totalSecretCount = 0, totalCount = 0, importedBy, + usedBySecretSyncs, totalSecretRotationCount = 0 } = data ?? {}; @@ -441,6 +442,7 @@ const Page = () => { onClickRollbackMode={() => handlePopUpToggle("snapshots", true)} protectedBranchPolicyName={boardPolicy?.name} importedBy={importedBy} + usedBySecretSyncs={usedBySecretSyncs} />
@@ -530,6 +532,7 @@ const Page = () => { secretPath={secretPath} isProtectedBranch={isProtectedBranch} importedBy={importedBy} + usedBySecretSyncs={usedBySecretSyncs} /> )} {noAccessSecretCount > 0 && } diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx index 4ac4ac804..13b6d45b7 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx @@ -69,6 +69,7 @@ import { dashboardKeys, fetchDashboardProjectSecretsByKeys } from "@app/hooks/api/dashboard/queries"; +import { UsedBySecretSyncs } from "@app/hooks/api/dashboard/types"; import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries"; import { fetchProjectSecrets, secretKeys } from "@app/hooks/api/secrets/queries"; import { ApiErrorTypes, SecretType, TApiErrors, WsTag } from "@app/hooks/api/types"; @@ -113,11 +114,12 @@ type Props = { onVisibilityToggle: () => void; onToggleRowType: (rowType: RowType) => void; onClickRollbackMode: () => void; + usedBySecretSyncs?: UsedBySecretSyncs[]; importedBy?: { environment: { name: string; slug: string }; folders: { name: string; - secrets?: { secretId: string; referencedSecretKey: string }[]; + secrets?: { secretId: string; referencedSecretKey: string; referencedSecretEnv: string }[]; isImported: boolean; }[]; }[]; @@ -139,7 +141,8 @@ export const ActionBar = ({ onClickRollbackMode, onToggleRowType, protectedBranchPolicyName, - importedBy + importedBy, + usedBySecretSyncs }: Props) => { const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([ "addFolder", @@ -1071,11 +1074,12 @@ export const ActionBar = ({ onChange={(isOpen) => handlePopUpToggle("bulkDeleteSecrets", isOpen)} onDeleteApproved={handleSecretBulkDelete} formContent={ - importedBy && - importedBy.length > 0 && ( + ((importedBy && importedBy.length > 0) || + (usedBySecretSyncs && usedBySecretSyncs?.length > 0)) && ( s.key)} + usedBySecretSyncs={usedBySecretSyncs} /> ) } diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/CollapsibleSecretImports.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/CollapsibleSecretImports.tsx index 9a66b23f5..70010fe81 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/CollapsibleSecretImports.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/CollapsibleSecretImports.tsx @@ -1,13 +1,16 @@ +/* eslint-disable no-nested-ternary */ import React, { useMemo } from "react"; -import { faFileImport, faKey, faWarning } from "@fortawesome/free-solid-svg-icons"; +import { faFileImport, faKey, faSync, faWarning } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Table, TBody, Td, Th, THead, Tr } from "@app/components/v2"; +import { Table, TBody, Td, Th, THead, Tooltip, Tr } from "@app/components/v2"; import { useWorkspace } from "@app/context"; +import { UsedBySecretSyncs } from "@app/hooks/api/dashboard/types"; enum ItemType { Folder = "Folder", - Secret = "Secret" + Secret = "Secret", + SecretSync = "SecretSync" } interface FlatItem { @@ -17,6 +20,8 @@ interface FlatItem { reference: string; id: string; environment: { name: string; slug: string }; + tooltipText?: string; + destination?: string; } interface CollapsibleSecretImportsProps { @@ -24,16 +29,20 @@ interface CollapsibleSecretImportsProps { environment: { name: string; slug: string }; folders: { name: string; - secrets?: { secretId: string; referencedSecretKey: string }[]; + secrets?: { secretId: string; referencedSecretKey: string; referencedSecretEnv: string }[]; isImported: boolean; }[]; }[]; + usedBySecretSyncs?: UsedBySecretSyncs[] | null; secretsToDelete: string[]; + onlyReferences?: boolean; } export const CollapsibleSecretImports: React.FC = ({ importedBy = [], - secretsToDelete + usedBySecretSyncs = [], + secretsToDelete, + onlyReferences }) => { const { currentWorkspace } = useWorkspace(); @@ -51,6 +60,15 @@ export const CollapsibleSecretImports: React.FC = }; const handlePathClick = (item: FlatItem) => { + if (item.type === ItemType.SecretSync) { + window.open( + `/secret-manager/${currentWorkspace.id}/integrations/secret-syncs/${item.destination}/${item.id}`, + "_blank", + "noopener,noreferrer" + ); + return; + } + let pathToNavigate; if (item.type === ItemType.Folder) { pathToNavigate = item.path; @@ -70,7 +88,7 @@ export const CollapsibleSecretImports: React.FC = importedBy.forEach((env) => { env.folders.forEach((folder) => { - if (folder.isImported) { + if (folder.isImported && !onlyReferences) { items.push({ type: ItemType.Folder, path: folder.name, @@ -103,7 +121,26 @@ export const CollapsibleSecretImports: React.FC = }); }); + // Add secret sync items + usedBySecretSyncs?.forEach((syncItem) => { + items.push({ + type: ItemType.SecretSync, + destination: syncItem.destination, + path: syncItem.path, + id: syncItem.id, + reference: "Secret Sync", + environment: { name: syncItem.environment, slug: "" }, + tooltipText: `Currently used by Secret Sync: ${syncItem.name}` + }); + }); + return items.sort((a, b) => { + if (a.type === ItemType.SecretSync && b.type !== ItemType.SecretSync) return 1; + if (a.type !== ItemType.SecretSync && b.type === ItemType.SecretSync) return -1; + + if (a.type === ItemType.SecretSync && b.type === ItemType.SecretSync) { + return a.path.localeCompare(b.path); + } const envCompare = a.environment.name.localeCompare(b.environment.name); if (envCompare !== 0) return envCompare; @@ -119,7 +156,7 @@ export const CollapsibleSecretImports: React.FC = return aPath.localeCompare(bPath); }); - }, [importedBy]); + }, [importedBy, usedBySecretSyncs, secretsToDelete, onlyReferences]); const hasImportedItems = importedBy.some((element) => { if (element.folders && element.folders.length > 0) { @@ -135,19 +172,33 @@ export const CollapsibleSecretImports: React.FC = return false; }); - if (!hasImportedItems) { + const hasSecretSyncItems = usedBySecretSyncs && usedBySecretSyncs.length > 0; + + if (!hasImportedItems && !hasSecretSyncItems) { return null; } + const alertColors = onlyReferences + ? { + border: "border-yellow-700/30", + bg: "bg-yellow-900/20", + text: "text-yellow-500" + } + : { + border: "border-red-700/30", + bg: "bg-red-900/20", + text: "text-red-500" + }; + return (
-
+
-
+
-

+

The following resources will be affected by this change

@@ -168,14 +219,36 @@ export const CollapsibleSecretImports: React.FC = key={item.id} onClick={() => handlePathClick(item)} className="cursor-pointer hover:bg-mineshaft-700" - title={`Navigate to ${item.path}`} + title={ + item.type === ItemType.SecretSync + ? "Navigate to Secret Sync" + : `Navigate to ${item.path}` + } > -
+ handlePopUpToggle("editSecret", isOpen)} + onDeleteApproved={() => handleEditSecret(popUp?.editSecret?.data)} + formContent={ + importedBy && + importedBy.length > 0 && ( + + ) + } + /> ); } diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretListView.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretListView.tsx index 5c70b91e4..70d783d2b 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretListView.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretListView.tsx @@ -8,6 +8,7 @@ import { DeleteActionModal } from "@app/components/v2"; import { usePopUp } from "@app/hooks"; import { useCreateSecretV3, useDeleteSecretV3, useUpdateSecretV3 } from "@app/hooks/api"; import { dashboardKeys } from "@app/hooks/api/dashboard/queries"; +import { UsedBySecretSyncs } from "@app/hooks/api/dashboard/types"; import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries"; import { secretKeys } from "@app/hooks/api/secrets/queries"; import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/secrets/types"; @@ -29,11 +30,12 @@ type Props = { tags?: WsTag[]; isVisible?: boolean; isProtectedBranch?: boolean; + usedBySecretSyncs?: UsedBySecretSyncs[]; importedBy?: { environment: { name: string; slug: string }; folders: { name: string; - secrets?: { secretId: string; referencedSecretKey: string }[]; + secrets?: { secretId: string; referencedSecretKey: string; referencedSecretEnv: string }[]; isImported: boolean; }[]; }[]; @@ -47,10 +49,9 @@ export const SecretListView = ({ tags: wsTags = [], isVisible, isProtectedBranch = false, + usedBySecretSyncs, importedBy }: Props) => { - console.log("secretssssss", secrets); - const queryClient = useQueryClient(); const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([ "deleteSecret", @@ -368,6 +369,7 @@ export const SecretListView = ({ onSaveSecret={handleSaveSecret} onDeleteSecret={onDeleteSecret} onDetailViewSecret={onDetailViewSecret} + importedBy={importedBy} onCreateTag={onCreateTag} handleSecretShare={() => handlePopUpOpen("createSharedSecret", { @@ -384,10 +386,11 @@ export const SecretListView = ({ onDeleteApproved={handleSecretDelete} buttonText="Delete Secret" formContent={ - importedBy && - importedBy.length > 0 && ( + ((importedBy && importedBy.length > 0) || + (usedBySecretSyncs && usedBySecretSyncs?.length > 0)) && ( )