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 902c564a7..bc6f0b7af 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -28,7 +28,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/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 94f6c7752..dfb23a6c3 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1882,6 +1882,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." } } }; @@ -2094,6 +2098,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." }, @@ -2124,6 +2133,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/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 55d4bf399..b1e011709 100644 --- a/backend/src/lib/knex/index.ts +++ b/backend/src/lib/knex/index.ts @@ -47,21 +47,21 @@ export const buildFindFilter = if ($in) { Object.entries($in).forEach(([key, val]) => { if (val) { - void bd.whereIn([`${tableName ? `${tableName}.` : ""}${key}`] as never, val as never); + void bd.whereIn(`${tableName ? `${tableName}.` : ""}${key}`, val as never); } }); } if ($notNull?.length) { $notNull.forEach((key) => { - void bd.whereNotNull([`${tableName ? `${tableName}.` : ""}${key as string}`] as never); + void bd.whereNotNull(`${tableName ? `${tableName}.` : ""}${key as string}`); }); } if ($search) { Object.entries($search).forEach(([key, val]) => { if (val) { - void bd.whereILike([`${tableName ? `${tableName}.` : ""}${key}`] as never, val as never); + void bd.whereILike(`${tableName ? `${tableName}.` : ""}${key}`, val as never); } }); } diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 8ceeba648..a71a69c20 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1541,6 +1541,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 a62776494..f6c260ea5 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 @@ -67,9 +71,10 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedPostgresConnectionSchema.options, ...SanitizedMsSqlConnectionSchema.options, ...SanitizedCamundaConnectionSchema.options, - ...SanitizedWindmillConnectionSchema.options, ...SanitizedAuth0ConnectionSchema.options, ...SanitizedHCVaultConnectionSchema.options, + ...SanitizedAzureClientSecretsConnectionSchema.options, + ...SanitizedWindmillConnectionSchema.options, ...SanitizedLdapConnectionSchema.options, ...SanitizedTeamCityConnectionSchema.options ]); @@ -87,9 +92,10 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ PostgresConnectionListItemSchema, MsSqlConnectionListItemSchema, CamundaConnectionListItemSchema, - WindmillConnectionListItemSchema, Auth0ConnectionListItemSchema, HCVaultConnectionListItemSchema, + 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 41302df77..eeae5e5e3 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"; @@ -27,6 +28,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/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 24dcd764c..c2912c2b6 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 cc92f0c84..95afdcbd2 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, @@ -81,6 +86,7 @@ export const listAppConnectionOptions = () => { getPostgresConnectionListItem(), getMsSqlConnectionListItem(), getCamundaConnectionListItem(), + getAzureClientSecretsConnectionListItem(), getWindmillConnectionListItem(), getAuth0ConnectionListItem(), getHCVaultConnectionListItem(), @@ -142,6 +148,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, @@ -164,6 +172,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: @@ -236,6 +245,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.HCVault]: platformManagedCredentialsNotSupported, diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index 08de27250..05e00446c 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 b584a0474..7a8b1a09c 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"; @@ -78,6 +80,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 58f575661..e88174cc6 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -154,6 +154,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/cli/packages/cmd/agent.go b/cli/packages/cmd/agent.go index b8fc6ed7b..b14fd04e2 100644 --- a/cli/packages/cmd/agent.go +++ b/cli/packages/cmd/agent.go @@ -338,7 +338,7 @@ func secretTemplateFunction(accessToken string, existingEtag string, currentEtag parsedArguments.SetDefaults() - res, err := util.GetPlainTextSecretsV3(accessToken, projectID, envSlug, secretPath, false, parsedArguments.IsRecursive, "", *parsedArguments.ShouldExpandSecretReferences) + res, err := util.GetPlainTextSecretsV3(accessToken, projectID, envSlug, secretPath, true, parsedArguments.IsRecursive, "", *parsedArguments.ShouldExpandSecretReferences) if err != nil { return nil, err } 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/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/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/internals/bug-bounty.mdx b/docs/internals/bug-bounty.mdx new file mode 100644 index 000000000..b823e6246 --- /dev/null +++ b/docs/internals/bug-bounty.mdx @@ -0,0 +1,60 @@ +--- +title: "Bug bounty program" +description: " Learn about our bug bounty program and how to report vulnerabilities." +--- + +The Infisical Bug Bounty Program is our way of recognizing and rewarding the work of security researchers who help keep our platform secure. By reporting vulnerabilities or potential risks, you help us protect secrets, infrastructure, and the organizations who rely on us. + +We value reports that help identify vulnerabilities that affect the integrity of secrets, prevent unauthorized access to environments, or expose flaws in our authentication or authorization flows. + +### How to Report + +- Send reports to **security@infisical.com** with clear steps to reproduce, impact, and (if possible) a proof-of-concept. +- We will acknowledge receipt within 3 business days. +- We'll provide an initial assessment or next steps within 5 business days. + +### What's in Scope? + +- Vulnerabilities in our cloud-hosted platform (e.g., `app.infisical.com`, `eu.infisical.com`) +- Security issues in the open source Infisical codebase, as maintained in our official GitHub repository +- Authentication bypass, privilege escalation, or access to secrets/data without authorization + +### Reward Guidelines + +Bounties are based on severity, impact, and exploitability, as well as whether the report introduces a new vulnerability class or helps improve an existing fix. + +| Severity | Examples | Typical Reward (USD currency) | +| --- | --- | --- | +| **Critical** | Full unauthorized access to secrets, authentication bypass, cross-tenant access, RCE, full compromise, etc | $2,000 - $5,000 | +| **High** | Privilege escalation, project-level access without authorization, persistent DoS | $750 - $2,000 | +| **Medium** | Info disclosure, scoped DoS (e.g. ReDoS with auth), or minor access control issues | $250 - $1,000 | +| **Low / Informational** | Missing headers, CSP warnings, theoretical flaws, self-hosting misconfigurations | Recognition only | + + +We may award lower amounts for: +- Duplicate class vulnerabilities already under review +- Patch bypasses of previously rewarded issues +- Vulnerabilities requiring unrealistic attacker conditions + +All final reward amounts are determined at Infisical's discretion based on impact, report quality, and how actionable the issue is. + + +### Out of Scope + +- Social engineering or phishing +- Rate limiting issues on non-sensitive endpoints +- Denial-of-service attacks that require authentication and don't impact core service availability +- Findings based on outdated or forked code not maintained by the Infisical team +- Vulnerabilities in third-party dependencies unless they result in a direct risk to Infisical users + + +### Responsible Disclosure + +We ask that researchers: + +- Avoid accessing data that isn't yours +- Do not publicly disclose without coordination +- Use testing accounts where possible +- Give us a reasonable window to investigate and patch before going public + +Researchers can also spin up our [self-hosted version of Infisical](/self-hosting/overview) to test for vulnerabilities locally. \ No newline at end of file diff --git a/docs/internals/security.mdx b/docs/internals/security.mdx index 17daf88cb..219c32287 100644 --- a/docs/internals/security.mdx +++ b/docs/internals/security.mdx @@ -118,8 +118,6 @@ It should be noted that, even on Infisical Cloud, it is physically impossible fo Please email security@infisical.com if you have any specific inquiries about employee data and security policies. -## Get in touch - -If you have any concerns about Infisical or believe you have uncovered a vulnerability, please get in touch via the e-mail address security@infisical.com. In the message, try to provide a description of the issue and ideally a way of reproducing it. The security team will get back to you as soon as possible. - -Note that this security address should be used for undisclosed vulnerabilities. Please report any security problems to us before disclosing it publicly. +## Bug Bounty Program +We run a [Bug Bounty Program](/internals/bug-bounty) to recognize and reward security researchers who help make Infisical more secure. +If you've found a vulnerability, please review the program details for scope, disclosure guidelines, and reward tiers. \ No newline at end of file diff --git a/docs/mint.json b/docs/mint.json index fcde10fa7..f8ac6ea6e 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -181,6 +181,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" @@ -430,6 +431,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", @@ -889,6 +891,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": [ @@ -982,6 +997,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": [ @@ -1521,6 +1548,7 @@ }, "internals/components", "internals/security", + "internals/bug-bounty", "internals/service-tokens" ] }, 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/auth/Mfa.tsx b/frontend/src/components/auth/Mfa.tsx index 3eff8a89c..64c8a5ee6 100644 --- a/frontend/src/components/auth/Mfa.tsx +++ b/frontend/src/components/auth/Mfa.tsx @@ -31,6 +31,25 @@ const codeInputProps = { } } as const; +const codeInputPropsPhone = { + inputStyle: { + fontFamily: "monospace", + margin: "4px", + MozAppearance: "textfield", + width: "40px", + borderRadius: "5px", + fontSize: "24px", + height: "40px", + paddingLeft: "7", + backgroundColor: "#0d1117", + color: "white", + border: "1px solid #2d2f33", + textAlign: "center", + outlineColor: "#8ca542", + borderColor: "#2d2f33" + } +} as const; + type Props = { successCallback: () => void | Promise; closeMfa?: () => void; @@ -172,6 +191,24 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop )} +
+ {method === MfaMethod.EMAIL && ( + + )} + {method === MfaMethod.TOTP && ( +
+ setMfaCode(e.target.value)} /> +
+ )} +
{typeof triesLeft === "number" && ( )} 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/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, @@ -133,6 +161,18 @@ export const SecretEditRow = ({ } }; + 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( permission, ProjectPermissionSecretActions.ReadValue @@ -325,6 +365,26 @@ export const SecretEditRow = ({ )}
+ 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 c6dbb555b..206ea1ade 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx @@ -50,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 = ({ @@ -64,7 +72,8 @@ export const SecretOverviewTableRow = ({ getImportedSecretByKey, scrollOffset, onToggleSecretSelect, - isSelected + isSelected, + importedBy }: Props) => { const [isFormExpanded, setIsFormExpanded] = useToggle(); const totalCols = environments.length + 1; // secret key row @@ -266,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 1ef2c7076..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,6 +49,7 @@ export const SecretListView = ({ tags: wsTags = [], isVisible, isProtectedBranch = false, + usedBySecretSyncs, importedBy }: Props) => { const queryClient = useQueryClient(); @@ -366,6 +369,7 @@ export const SecretListView = ({ onSaveSecret={handleSaveSecret} onDeleteSecret={onDeleteSecret} onDetailViewSecret={onDetailViewSecret} + importedBy={importedBy} onCreateTag={onCreateTag} handleSecretShare={() => handlePopUpOpen("createSharedSecret", { @@ -382,10 +386,11 @@ export const SecretListView = ({ onDeleteApproved={handleSecretDelete} buttonText="Delete Secret" formContent={ - importedBy && - importedBy.length > 0 && ( + ((importedBy && importedBy.length > 0) || + (usedBySecretSyncs && usedBySecretSyncs?.length > 0)) && ( )