diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/aws-iam-user-secret-rotation-router.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/aws-iam-user-secret-rotation-router.ts new file mode 100644 index 000000000..489f3ea55 --- /dev/null +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/aws-iam-user-secret-rotation-router.ts @@ -0,0 +1,19 @@ +import { + AwsIamUserSecretRotationGeneratedCredentialsSchema, + AwsIamUserSecretRotationSchema, + CreateAwsIamUserSecretRotationSchema, + UpdateAwsIamUserSecretRotationSchema +} from "@app/ee/services/secret-rotation-v2/aws-iam-user-secret"; +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; + +import { registerSecretRotationEndpoints } from "./secret-rotation-v2-endpoints"; + +export const registerAwsIamUserSecretRotationRouter = async (server: FastifyZodProvider) => + registerSecretRotationEndpoints({ + type: SecretRotation.AwsIamUserSecret, + server, + responseSchema: AwsIamUserSecretRotationSchema, + createSchema: CreateAwsIamUserSecretRotationSchema, + updateSchema: UpdateAwsIamUserSecretRotationSchema, + generatedCredentialsSchema: AwsIamUserSecretRotationGeneratedCredentialsSchema + }); 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 1dacf1bd2..7fee08164 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 @@ -1,6 +1,7 @@ import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; import { registerAuth0ClientSecretRotationRouter } from "./auth0-client-secret-rotation-router"; +import { registerAwsIamUserSecretRotationRouter } from "./aws-iam-user-secret-rotation-router"; import { registerMsSqlCredentialsRotationRouter } from "./mssql-credentials-rotation-router"; import { registerPostgresCredentialsRotationRouter } from "./postgres-credentials-rotation-router"; @@ -12,5 +13,6 @@ export const SECRET_ROTATION_REGISTER_ROUTER_MAP: Record< > = { [SecretRotation.PostgresCredentials]: registerPostgresCredentialsRotationRouter, [SecretRotation.MsSqlCredentials]: registerMsSqlCredentialsRotationRouter, - [SecretRotation.Auth0ClientSecret]: registerAuth0ClientSecretRotationRouter + [SecretRotation.Auth0ClientSecret]: registerAuth0ClientSecretRotationRouter, + [SecretRotation.AwsIamUserSecret]: registerAwsIamUserSecretRotationRouter }; 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 c1a2cb69d..2383d98e2 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 @@ -2,6 +2,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 { MsSqlCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/mssql-credentials"; import { PostgresCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/postgres-credentials"; import { SecretRotationV2Schema } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema"; @@ -13,7 +14,8 @@ import { AuthMode } from "@app/services/auth/auth-type"; const SecretRotationV2OptionsSchema = z.discriminatedUnion("type", [ PostgresCredentialsRotationListItemSchema, MsSqlCredentialsRotationListItemSchema, - Auth0ClientSecretRotationListItemSchema + Auth0ClientSecretRotationListItemSchema, + AwsIamUserSecretRotationListItemSchema ]); export const registerSecretRotationV2Router = async (server: FastifyZodProvider) => { diff --git a/backend/src/ee/services/secret-rotation-v2/aws-iam-user-secret/aws-iam-user-secret-rotation-constants.ts b/backend/src/ee/services/secret-rotation-v2/aws-iam-user-secret/aws-iam-user-secret-rotation-constants.ts new file mode 100644 index 000000000..1b36b5f30 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/aws-iam-user-secret/aws-iam-user-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 AWS_IAM_USER_SECRET_ROTATION_LIST_OPTION: TSecretRotationV2ListItem = { + name: "AWS IAM User Secret", + type: SecretRotation.AwsIamUserSecret, + connection: AppConnection.AWS, + template: { + secretsMapping: { + accessKeyId: "AWS_ACCESS_KEY_ID", + secretAccessKey: "AWS_SECRET_ACCESS_KEY" + } + } +}; diff --git a/backend/src/ee/services/secret-rotation-v2/aws-iam-user-secret/aws-iam-user-secret-rotation-fns.ts b/backend/src/ee/services/secret-rotation-v2/aws-iam-user-secret/aws-iam-user-secret-rotation-fns.ts new file mode 100644 index 000000000..c08eb162b --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/aws-iam-user-secret/aws-iam-user-secret-rotation-fns.ts @@ -0,0 +1,123 @@ +import AWS from "aws-sdk"; + +import { + TAwsIamUserSecretRotationGeneratedCredentials, + TAwsIamUserSecretRotationWithConnection +} from "@app/ee/services/secret-rotation-v2/aws-iam-user-secret/aws-iam-user-secret-rotation-types"; +import { + TRotationFactory, + TRotationFactoryGetSecretsPayload, + TRotationFactoryIssueCredentials, + TRotationFactoryRevokeCredentials, + TRotationFactoryRotateCredentials +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; +import { getAwsConnectionConfig } from "@app/services/app-connection/aws"; + +const getCreateDate = (key: AWS.IAM.AccessKeyMetadata): number => { + return key.CreateDate ? new Date(key.CreateDate).getTime() : 0; +}; + +export const awsIamUserSecretRotationFactory: TRotationFactory< + TAwsIamUserSecretRotationWithConnection, + TAwsIamUserSecretRotationGeneratedCredentials +> = (secretRotation) => { + const { + parameters: { region, userName }, + connection, + secretsMapping + } = secretRotation; + + const $rotateClientSecret = async () => { + const { credentials } = await getAwsConnectionConfig(connection, region); + const iam = new AWS.IAM({ credentials }); + + const { AccessKeyMetadata } = await iam.listAccessKeys({ UserName: userName }).promise(); + + if (AccessKeyMetadata && AccessKeyMetadata.length > 0) { + // Sort keys by creation date (oldest first) + const sortedKeys = [...AccessKeyMetadata].sort((a, b) => getCreateDate(a) - getCreateDate(b)); + + // If we already have 2 keys, delete the oldest one + if (sortedKeys.length >= 2) { + const accessId = sortedKeys[0].AccessKeyId || sortedKeys[1].AccessKeyId; + if (accessId) { + await iam + .deleteAccessKey({ + UserName: userName, + AccessKeyId: accessId + }) + .promise(); + } + } + } + + const { AccessKey } = await iam.createAccessKey({ UserName: userName }).promise(); + + return { + accessKeyId: AccessKey.AccessKeyId, + secretAccessKey: AccessKey.SecretAccessKey + }; + }; + + const issueCredentials: TRotationFactoryIssueCredentials = async ( + callback + ) => { + const credentials = await $rotateClientSecret(); + + return callback(credentials); + }; + + const revokeCredentials: TRotationFactoryRevokeCredentials = async ( + generatedCredentials, + callback + ) => { + const { credentials } = await getAwsConnectionConfig(connection, region); + const iam = new AWS.IAM({ credentials }); + + await Promise.all( + generatedCredentials.map((generatedCredential) => + iam + .deleteAccessKey({ + UserName: userName, + AccessKeyId: generatedCredential.accessKeyId + }) + .promise() + ) + ); + + return callback(); + }; + + const rotateCredentials: TRotationFactoryRotateCredentials = async ( + _, + callback + ) => { + const credentials = await $rotateClientSecret(); + + return callback(credentials); + }; + + const getSecretsPayload: TRotationFactoryGetSecretsPayload = ( + generatedCredentials + ) => { + const secrets = [ + { + key: secretsMapping.accessKeyId, + value: generatedCredentials.accessKeyId + }, + { + key: secretsMapping.secretAccessKey, + value: generatedCredentials.secretAccessKey + } + ]; + + return secrets; + }; + + return { + issueCredentials, + revokeCredentials, + rotateCredentials, + getSecretsPayload + }; +}; diff --git a/backend/src/ee/services/secret-rotation-v2/aws-iam-user-secret/aws-iam-user-secret-rotation-schemas.ts b/backend/src/ee/services/secret-rotation-v2/aws-iam-user-secret/aws-iam-user-secret-rotation-schemas.ts new file mode 100644 index 000000000..dba4c6102 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/aws-iam-user-secret/aws-iam-user-secret-rotation-schemas.ts @@ -0,0 +1,68 @@ +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, AWSRegion } from "@app/services/app-connection/app-connection-enums"; + +export const AwsIamUserSecretRotationGeneratedCredentialsSchema = z + .object({ + accessKeyId: z.string(), + secretAccessKey: z.string() + }) + .array() + .min(1) + .max(2); + +const AwsIamUserSecretRotationParametersSchema = z.object({ + userName: z + .string() + .trim() + .min(1, "Client Name Required") + .describe(SecretRotations.PARAMETERS.AWS_IAM_USER_SECRET.userName), + region: z.nativeEnum(AWSRegion).describe(SecretRotations.PARAMETERS.AWS_IAM_USER_SECRET.region).optional() +}); + +const AwsIamUserSecretRotationSecretsMappingSchema = z.object({ + accessKeyId: SecretNameSchema.describe(SecretRotations.SECRETS_MAPPING.AWS_IAM_USER_SECRET.accessKeyId), + secretAccessKey: SecretNameSchema.describe(SecretRotations.SECRETS_MAPPING.AWS_IAM_USER_SECRET.secretAccessKey) +}); + +export const AwsIamUserSecretRotationTemplateSchema = z.object({ + secretsMapping: z.object({ + accessKeyId: z.string(), + secretAccessKey: z.string() + }) +}); + +export const AwsIamUserSecretRotationSchema = BaseSecretRotationSchema(SecretRotation.AwsIamUserSecret).extend({ + type: z.literal(SecretRotation.AwsIamUserSecret), + parameters: AwsIamUserSecretRotationParametersSchema, + secretsMapping: AwsIamUserSecretRotationSecretsMappingSchema +}); + +export const CreateAwsIamUserSecretRotationSchema = BaseCreateSecretRotationSchema( + SecretRotation.AwsIamUserSecret +).extend({ + parameters: AwsIamUserSecretRotationParametersSchema, + secretsMapping: AwsIamUserSecretRotationSecretsMappingSchema +}); + +export const UpdateAwsIamUserSecretRotationSchema = BaseUpdateSecretRotationSchema( + SecretRotation.AwsIamUserSecret +).extend({ + parameters: AwsIamUserSecretRotationParametersSchema.optional(), + secretsMapping: AwsIamUserSecretRotationSecretsMappingSchema.optional() +}); + +export const AwsIamUserSecretRotationListItemSchema = z.object({ + name: z.literal("AWS IAM User Secret"), + connection: z.literal(AppConnection.AWS), + type: z.literal(SecretRotation.AwsIamUserSecret), + template: AwsIamUserSecretRotationTemplateSchema +}); diff --git a/backend/src/ee/services/secret-rotation-v2/aws-iam-user-secret/aws-iam-user-secret-rotation-types.ts b/backend/src/ee/services/secret-rotation-v2/aws-iam-user-secret/aws-iam-user-secret-rotation-types.ts new file mode 100644 index 000000000..5db7ef2b0 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/aws-iam-user-secret/aws-iam-user-secret-rotation-types.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +import { TAwsConnection } from "@app/services/app-connection/aws"; + +import { + AwsIamUserSecretRotationGeneratedCredentialsSchema, + AwsIamUserSecretRotationListItemSchema, + AwsIamUserSecretRotationSchema, + CreateAwsIamUserSecretRotationSchema +} from "./aws-iam-user-secret-rotation-schemas"; + +export type TAwsIamUserSecretRotation = z.infer; + +export type TAwsIamUserSecretRotationInput = z.infer; + +export type TAwsIamUserSecretRotationListItem = z.infer; + +export type TAwsIamUserSecretRotationWithConnection = TAwsIamUserSecretRotation & { + connection: TAwsConnection; +}; + +export type TAwsIamUserSecretRotationGeneratedCredentials = z.infer< + typeof AwsIamUserSecretRotationGeneratedCredentialsSchema +>; diff --git a/backend/src/ee/services/secret-rotation-v2/aws-iam-user-secret/index.ts b/backend/src/ee/services/secret-rotation-v2/aws-iam-user-secret/index.ts new file mode 100644 index 000000000..69635c68c --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/aws-iam-user-secret/index.ts @@ -0,0 +1,3 @@ +export * from "./aws-iam-user-secret-rotation-constants"; +export * from "./aws-iam-user-secret-rotation-schemas"; +export * from "./aws-iam-user-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 d43cacb3a..1e387a3b9 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 @@ -1,7 +1,8 @@ export enum SecretRotation { PostgresCredentials = "postgres-credentials", MsSqlCredentials = "mssql-credentials", - Auth0ClientSecret = "auth0-client-secret" + Auth0ClientSecret = "auth0-client-secret", + AwsIamUserSecret = "aws-iam-user-secret" } 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 603b77cc1..87115e7f4 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 @@ -4,6 +4,7 @@ import { getConfig } from "@app/lib/config/env"; 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 { MSSQL_CREDENTIALS_ROTATION_LIST_OPTION } from "./mssql-credentials"; import { POSTGRES_CREDENTIALS_ROTATION_LIST_OPTION } from "./postgres-credentials"; import { SecretRotation, SecretRotationStatus } from "./secret-rotation-v2-enums"; @@ -18,7 +19,8 @@ import { const SECRET_ROTATION_LIST_OPTIONS: Record = { [SecretRotation.PostgresCredentials]: POSTGRES_CREDENTIALS_ROTATION_LIST_OPTION, [SecretRotation.MsSqlCredentials]: MSSQL_CREDENTIALS_ROTATION_LIST_OPTION, - [SecretRotation.Auth0ClientSecret]: AUTH0_CLIENT_SECRET_ROTATION_LIST_OPTION + [SecretRotation.Auth0ClientSecret]: AUTH0_CLIENT_SECRET_ROTATION_LIST_OPTION, + [SecretRotation.AwsIamUserSecret]: AWS_IAM_USER_SECRET_ROTATION_LIST_OPTION }; export const listSecretRotationOptions = () => { 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 1050c3419..9af78e19a 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 @@ -3,12 +3,14 @@ import { AppConnection } from "@app/services/app-connection/app-connection-enums export const SECRET_ROTATION_NAME_MAP: Record = { [SecretRotation.PostgresCredentials]: "PostgreSQL Credentials", - [SecretRotation.MsSqlCredentials]: "Microsoft SQL Sever Credentials", - [SecretRotation.Auth0ClientSecret]: "Auth0 Client Secret" + [SecretRotation.MsSqlCredentials]: "Microsoft SQL Server Credentials", + [SecretRotation.Auth0ClientSecret]: "Auth0 Client Secret", + [SecretRotation.AwsIamUserSecret]: "AWS IAM User Secret" }; export const SECRET_ROTATION_CONNECTION_MAP: Record = { [SecretRotation.PostgresCredentials]: AppConnection.Postgres, [SecretRotation.MsSqlCredentials]: AppConnection.MsSql, - [SecretRotation.Auth0ClientSecret]: AppConnection.Auth0 + [SecretRotation.Auth0ClientSecret]: AppConnection.Auth0, + [SecretRotation.AwsIamUserSecret]: AppConnection.AWS }; 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 a828acb32..60094be97 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 @@ -77,6 +77,7 @@ import { import { TSecretVersionV2DALFactory } from "@app/services/secret-v2-bridge/secret-version-dal"; import { TSecretVersionV2TagDALFactory } from "@app/services/secret-v2-bridge/secret-version-tag-dal"; +import { awsIamUserSecretRotationFactory } from "./aws-iam-user-secret/aws-iam-user-secret-rotation-fns"; import { TSecretRotationV2DALFactory } from "./secret-rotation-v2-dal"; export type TSecretRotationV2ServiceFactoryDep = { @@ -114,7 +115,8 @@ type TRotationFactoryImplementation = TRotationFactory< const SECRET_ROTATION_FACTORY_MAP: Record = { [SecretRotation.PostgresCredentials]: sqlCredentialsRotationFactory as TRotationFactoryImplementation, [SecretRotation.MsSqlCredentials]: sqlCredentialsRotationFactory as TRotationFactoryImplementation, - [SecretRotation.Auth0ClientSecret]: auth0ClientSecretRotationFactory as TRotationFactoryImplementation + [SecretRotation.Auth0ClientSecret]: auth0ClientSecretRotationFactory as TRotationFactoryImplementation, + [SecretRotation.AwsIamUserSecret]: awsIamUserSecretRotationFactory as TRotationFactoryImplementation }; export const secretRotationV2ServiceFactory = ({ diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-types.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-types.ts index c52fa5465..85fa68d4f 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-types.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-types.ts @@ -12,6 +12,13 @@ import { TAuth0ClientSecretRotationListItem, TAuth0ClientSecretRotationWithConnection } from "./auth0-client-secret"; +import { + TAwsIamUserSecretRotation, + TAwsIamUserSecretRotationGeneratedCredentials, + TAwsIamUserSecretRotationInput, + TAwsIamUserSecretRotationListItem, + TAwsIamUserSecretRotationWithConnection +} from "./aws-iam-user-secret"; import { TMsSqlCredentialsRotation, TMsSqlCredentialsRotationInput, @@ -27,26 +34,34 @@ import { import { TSecretRotationV2DALFactory } from "./secret-rotation-v2-dal"; import { SecretRotation } from "./secret-rotation-v2-enums"; -export type TSecretRotationV2 = TPostgresCredentialsRotation | TMsSqlCredentialsRotation | TAuth0ClientSecretRotation; +export type TSecretRotationV2 = + | TPostgresCredentialsRotation + | TMsSqlCredentialsRotation + | TAuth0ClientSecretRotation + | TAwsIamUserSecretRotation; export type TSecretRotationV2WithConnection = | TPostgresCredentialsRotationWithConnection | TMsSqlCredentialsRotationWithConnection - | TAuth0ClientSecretRotationWithConnection; + | TAuth0ClientSecretRotationWithConnection + | TAwsIamUserSecretRotationWithConnection; export type TSecretRotationV2GeneratedCredentials = | TSqlCredentialsRotationGeneratedCredentials - | TAuth0ClientSecretRotationGeneratedCredentials; + | TAuth0ClientSecretRotationGeneratedCredentials + | TAwsIamUserSecretRotationGeneratedCredentials; export type TSecretRotationV2Input = | TPostgresCredentialsRotationInput | TMsSqlCredentialsRotationInput - | TAuth0ClientSecretRotationInput; + | TAuth0ClientSecretRotationInput + | TAwsIamUserSecretRotationInput; export type TSecretRotationV2ListItem = | TPostgresCredentialsRotationListItem | TMsSqlCredentialsRotationListItem - | TAuth0ClientSecretRotationListItem; + | TAuth0ClientSecretRotationListItem + | TAwsIamUserSecretRotationListItem; export type TSecretRotationV2Raw = NonNullable>>; 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 2db9c0251..af46cb36c 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 @@ -4,8 +4,11 @@ import { Auth0ClientSecretRotationSchema } from "@app/ee/services/secret-rotatio import { MsSqlCredentialsRotationSchema } from "@app/ee/services/secret-rotation-v2/mssql-credentials"; import { PostgresCredentialsRotationSchema } from "@app/ee/services/secret-rotation-v2/postgres-credentials"; +import { AwsIamUserSecretRotationSchema } from "./aws-iam-user-secret"; + export const SecretRotationV2Schema = z.discriminatedUnion("type", [ PostgresCredentialsRotationSchema, MsSqlCredentialsRotationSchema, - Auth0ClientSecretRotationSchema + Auth0ClientSecretRotationSchema, + AwsIamUserSecretRotationSchema ]); diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 0f88e269c..b5181ae81 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1857,6 +1857,10 @@ export const AppConnections = { WINDMILL: { instanceUrl: "The Windmill instance URL to connect with (defaults to https://app.windmill.dev).", accessToken: "The access token to use to connect with Windmill." + }, + TEAMCITY: { + instanceUrl: "The TeamCity instance URL to connect with.", + accessToken: "The access token to use to connect with TeamCity." } } }; @@ -1996,6 +2000,10 @@ export const SecretSyncs = { WINDMILL: { workspace: "The Windmill workspace to sync secrets to.", path: "The Windmill workspace path to sync secrets to." + }, + TEAMCITY: { + project: "The TeamCity project to sync secrets to.", + buildConfig: "The TeamCity build configuration to sync secrets to." } } }; @@ -2060,6 +2068,10 @@ export const SecretRotations = { }, AUTH0_CLIENT_SECRET: { clientId: "The client ID of the Auth0 Application to rotate the client secret for." + }, + AWS_IAM_USER_SECRET: { + userName: "The name of the client to rotate credentials for.", + region: "The AWS region the client is present in." } }, SECRETS_MAPPING: { @@ -2070,6 +2082,10 @@ export const SecretRotations = { AUTH0_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." + }, + AWS_IAM_USER_SECRET: { + accessKeyId: "The name of the secret that the access key ID will be mapped to.", + secretAccessKey: "The name of the secret that the rotated secret access key will be mapped to." } } }; 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 25bef7270..d472a9cb1 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 @@ -33,6 +33,10 @@ import { PostgresConnectionListItemSchema, SanitizedPostgresConnectionSchema } from "@app/services/app-connection/postgres"; +import { + SanitizedTeamCityConnectionSchema, + TeamCityConnectionListItemSchema +} from "@app/services/app-connection/teamcity"; import { SanitizedTerraformCloudConnectionSchema, TerraformCloudConnectionListItemSchema @@ -59,7 +63,8 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedMsSqlConnectionSchema.options, ...SanitizedCamundaConnectionSchema.options, ...SanitizedWindmillConnectionSchema.options, - ...SanitizedAuth0ConnectionSchema.options + ...SanitizedAuth0ConnectionSchema.options, + ...SanitizedTeamCityConnectionSchema.options ]); const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ @@ -76,7 +81,8 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ MsSqlConnectionListItemSchema, CamundaConnectionListItemSchema, WindmillConnectionListItemSchema, - Auth0ConnectionListItemSchema + Auth0ConnectionListItemSchema, + TeamCityConnectionListItemSchema ]); export const registerAppConnectionRouter = async (server: FastifyZodProvider) => { diff --git a/backend/src/server/routes/v1/app-connection-routers/aws-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/aws-connection-router.ts index 674e6e417..3226a6aa8 100644 --- a/backend/src/server/routes/v1/app-connection-routers/aws-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/aws-connection-router.ts @@ -59,4 +59,40 @@ export const registerAwsConnectionRouter = async (server: FastifyZodProvider) => return { kmsKeys }; } }); + + server.route({ + method: "GET", + url: `/:connectionId/users`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z.object({ + iamUsers: z + .object({ + UserName: z.string(), + Arn: z.string() + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + + const iamUsers = await server.services.appConnection.aws.listIamUsers( + { + connectionId + }, + req.permission + ); + + return { iamUsers }; + } + }); }; 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 a833b6882..de9db9d85 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -11,6 +11,7 @@ import { registerGitHubConnectionRouter } from "./github-connection-router"; import { registerHumanitecConnectionRouter } from "./humanitec-connection-router"; import { registerMsSqlConnectionRouter } from "./mssql-connection-router"; import { registerPostgresConnectionRouter } from "./postgres-connection-router"; +import { registerTeamCityConnectionRouter } from "./teamcity-connection-router"; import { registerTerraformCloudConnectionRouter } from "./terraform-cloud-router"; import { registerVercelConnectionRouter } from "./vercel-connection-router"; import { registerWindmillConnectionRouter } from "./windmill-connection-router"; @@ -32,5 +33,6 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { + registerAppConnectionEndpoints({ + app: AppConnection.TeamCity, + server, + sanitizedResponseSchema: SanitizedTeamCityConnectionSchema, + createSchema: CreateTeamCityConnectionSchema, + updateSchema: UpdateTeamCityConnectionSchema + }); + + // The following endpoints are for internal Infisical App use only and not part of the public API + server.route({ + method: "GET", + url: `/:connectionId/projects`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z + .object({ + id: z.string(), + name: z.string(), + buildTypes: z.object({ + buildType: z + .object({ + id: z.string(), + name: z.string() + }) + .array() + }) + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + const projects = await server.services.appConnection.teamcity.listProjects(connectionId, req.permission); + + return projects; + } + }); +}; diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index 09ed85315..7df68f39a 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -1,3 +1,4 @@ +import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { @@ -79,7 +80,17 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { includeGroupMembers: z .enum(["true", "false"]) .default("false") - .transform((value) => value === "true") + .transform((value) => value === "true"), + roles: z + .string() + .trim() + .transform(decodeURIComponent) + .refine((value) => { + if (!value) return true; + const slugs = value.split(","); + return slugs.every((slug) => slugify(slug.trim(), { lowercase: true }) === slug.trim()); + }) + .optional() }), params: z.object({ workspaceId: z.string().trim() @@ -118,13 +129,15 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { + const roles = (req.query.roles?.split(",") || []).filter(Boolean); const users = await server.services.projectMembership.getProjectMemberships({ actorId: req.permission.id, actor: req.permission.type, actorAuthMethod: req.permission.authMethod, includeGroupMembers: req.query.includeGroupMembers, projectId: req.params.workspaceId, - actorOrgId: req.permission.orgId + actorOrgId: req.permission.orgId, + roles }); return { users }; diff --git a/backend/src/server/routes/v1/secret-sync-routers/index.ts b/backend/src/server/routes/v1/secret-sync-routers/index.ts index ee407cee3..a54777727 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -9,6 +9,7 @@ import { registerDatabricksSyncRouter } from "./databricks-sync-router"; import { registerGcpSyncRouter } from "./gcp-sync-router"; import { registerGitHubSyncRouter } from "./github-sync-router"; import { registerHumanitecSyncRouter } from "./humanitec-sync-router"; +import { registerTeamCitySyncRouter } from "./teamcity-sync-router"; import { registerTerraformCloudSyncRouter } from "./terraform-cloud-sync-router"; import { registerVercelSyncRouter } from "./vercel-sync-router"; import { registerWindmillSyncRouter } from "./windmill-sync-router"; @@ -27,5 +28,6 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record { diff --git a/backend/src/server/routes/v1/secret-sync-routers/teamcity-sync-router.ts b/backend/src/server/routes/v1/secret-sync-routers/teamcity-sync-router.ts new file mode 100644 index 000000000..a3091aae5 --- /dev/null +++ b/backend/src/server/routes/v1/secret-sync-routers/teamcity-sync-router.ts @@ -0,0 +1,17 @@ +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + CreateTeamCitySyncSchema, + TeamCitySyncSchema, + UpdateTeamCitySyncSchema +} from "@app/services/secret-sync/teamcity"; + +import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints"; + +export const registerTeamCitySyncRouter = async (server: FastifyZodProvider) => + registerSyncSecretsEndpoints({ + destination: SecretSync.TeamCity, + server, + responseSchema: TeamCitySyncSchema, + createSchema: CreateTeamCitySyncSchema, + updateSchema: UpdateTeamCitySyncSchema + }); diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 6b6048f2a..8852ed6bc 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -12,7 +12,8 @@ export enum AppConnection { MsSql = "mssql", Camunda = "camunda", Windmill = "windmill", - Auth0 = "auth0" + Auth0 = "auth0", + TeamCity = "teamcity" } export enum AWSRegion { diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index 7e08a92b4..076f52f99 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -43,6 +43,11 @@ import { } from "./humanitec"; import { getMsSqlConnectionListItem, MsSqlConnectionMethod } from "./mssql"; import { getPostgresConnectionListItem, PostgresConnectionMethod } from "./postgres"; +import { + getTeamCityConnectionListItem, + TeamCityConnectionMethod, + validateTeamCityConnectionCredentials +} from "./teamcity"; import { getTerraformCloudConnectionListItem, TerraformCloudConnectionMethod, @@ -71,7 +76,8 @@ export const listAppConnectionOptions = () => { getMsSqlConnectionListItem(), getCamundaConnectionListItem(), getWindmillConnectionListItem(), - getAuth0ConnectionListItem() + getAuth0ConnectionListItem(), + getTeamCityConnectionListItem() ].sort((a, b) => a.name.localeCompare(b.name)); }; @@ -135,7 +141,8 @@ export const validateAppConnectionCredentials = async ( [AppConnection.Vercel]: validateVercelConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.TerraformCloud]: validateTerraformCloudConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Auth0]: validateAuth0ConnectionCredentials as TAppConnectionCredentialsValidator, - [AppConnection.Windmill]: validateWindmillConnectionCredentials as TAppConnectionCredentialsValidator + [AppConnection.Windmill]: validateWindmillConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.TeamCity]: validateTeamCityConnectionCredentials as TAppConnectionCredentialsValidator }; return VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection); @@ -167,6 +174,7 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case MsSqlConnectionMethod.UsernameAndPassword: return "Username & Password"; case WindmillConnectionMethod.AccessToken: + case TeamCityConnectionMethod.AccessToken: return "Access Token"; case Auth0ConnectionMethod.ClientCredentials: return "Client Credentials"; @@ -214,5 +222,6 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.Camunda]: platformManagedCredentialsNotSupported, [AppConnection.Vercel]: platformManagedCredentialsNotSupported, [AppConnection.Windmill]: platformManagedCredentialsNotSupported, - [AppConnection.Auth0]: platformManagedCredentialsNotSupported + [AppConnection.Auth0]: platformManagedCredentialsNotSupported, + [AppConnection.TeamCity]: platformManagedCredentialsNotSupported }; diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index 762a9bcf2..41cef4af1 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -14,5 +14,6 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.MsSql]: "Microsoft SQL Server", [AppConnection.Camunda]: "Camunda", [AppConnection.Windmill]: "Windmill", - [AppConnection.Auth0]: "Auth0" + [AppConnection.Auth0]: "Auth0", + [AppConnection.TeamCity]: "TeamCity" }; diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts index 5293761a8..5ca0c1424 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -45,6 +45,8 @@ import { ValidateHumanitecConnectionCredentialsSchema } from "./humanitec"; import { humanitecConnectionService } from "./humanitec/humanitec-connection-service"; import { ValidateMsSqlConnectionCredentialsSchema } from "./mssql"; import { ValidatePostgresConnectionCredentialsSchema } from "./postgres"; +import { ValidateTeamCityConnectionCredentialsSchema } from "./teamcity"; +import { teamcityConnectionService } from "./teamcity/teamcity-connection-service"; import { ValidateTerraformCloudConnectionCredentialsSchema } from "./terraform-cloud"; import { terraformCloudConnectionService } from "./terraform-cloud/terraform-cloud-connection-service"; import { ValidateVercelConnectionCredentialsSchema } from "./vercel"; @@ -74,7 +76,8 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record>>; @@ -118,6 +125,7 @@ export type TAppConnectionInput = { id: string } & ( | TCamundaConnectionInput | TWindmillConnectionInput | TAuth0ConnectionInput + | TTeamCityConnectionInput ); export type TSqlConnectionInput = TPostgresConnectionInput | TMsSqlConnectionInput; @@ -144,7 +152,8 @@ export type TAppConnectionConfig = | TSqlConnectionConfig | TCamundaConnectionConfig | TWindmillConnectionConfig - | TAuth0ConnectionConfig; + | TAuth0ConnectionConfig + | TTeamCityConnectionConfig; export type TValidateAppConnectionCredentialsSchema = | TValidateAwsConnectionCredentialsSchema @@ -160,7 +169,8 @@ export type TValidateAppConnectionCredentialsSchema = | TValidateTerraformCloudConnectionCredentialsSchema | TValidateVercelConnectionCredentialsSchema | TValidateWindmillConnectionCredentialsSchema - | TValidateAuth0ConnectionCredentialsSchema; + | TValidateAuth0ConnectionCredentialsSchema + | TValidateTeamCityConnectionCredentialsSchema; export type TListAwsConnectionKmsKeys = { connectionId: string; @@ -168,6 +178,10 @@ export type TListAwsConnectionKmsKeys = { destination: SecretSync.AWSParameterStore | SecretSync.AWSSecretsManager; }; +export type TListAwsConnectionIamUsers = { + connectionId: string; +}; + export type TAppConnectionCredentialsValidator = ( appConnection: TAppConnectionConfig ) => Promise; diff --git a/backend/src/services/app-connection/aws/aws-connection-fns.ts b/backend/src/services/app-connection/aws/aws-connection-fns.ts index 767cb82fb..28660173b 100644 --- a/backend/src/services/app-connection/aws/aws-connection-fns.ts +++ b/backend/src/services/app-connection/aws/aws-connection-fns.ts @@ -1,9 +1,11 @@ import { AssumeRoleCommand, STSClient } from "@aws-sdk/client-sts"; import AWS from "aws-sdk"; +import { AxiosError } from "axios"; import { randomUUID } from "crypto"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, InternalServerError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; import { AppConnection, AWSRegion } from "@app/services/app-connection/app-connection-enums"; import { AwsConnectionMethod } from "./aws-connection-enums"; @@ -90,9 +92,20 @@ export const validateAwsConnectionCredentials = async (appConnection: TAwsConnec const sts = new AWS.STS(awsConfig); resp = await sts.getCallerIdentity().promise(); - } catch (e: unknown) { + } catch (error: unknown) { + logger.error(error, "Error validating AWS connection credentials"); + + let message: string; + + if (error instanceof AxiosError) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + message = (error.response?.data?.message as string) || error.message || "verify credentials"; + } else { + message = (error as Error)?.message || "verify credentials"; + } + throw new BadRequestError({ - message: `Unable to validate connection: verify credentials` + message: `Unable to validate connection: ${message}` }); } diff --git a/backend/src/services/app-connection/aws/aws-connection-service.ts b/backend/src/services/app-connection/aws/aws-connection-service.ts index 689608b81..369116a9c 100644 --- a/backend/src/services/app-connection/aws/aws-connection-service.ts +++ b/backend/src/services/app-connection/aws/aws-connection-service.ts @@ -2,7 +2,10 @@ import AWS from "aws-sdk"; import { OrgServiceActor } from "@app/lib/types"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; -import { TListAwsConnectionKmsKeys } from "@app/services/app-connection/app-connection-types"; +import { + TListAwsConnectionIamUsers, + TListAwsConnectionKmsKeys +} from "@app/services/app-connection/app-connection-types"; import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns"; import { TAwsConnection } from "@app/services/app-connection/aws/aws-connection-types"; import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; @@ -70,6 +73,23 @@ const listAwsKmsKeys = async ( return kmsKeys; }; +const listAwsIamUsers = async (appConnection: TAwsConnection) => { + const { credentials } = await getAwsConnectionConfig(appConnection); + + const iam = new AWS.IAM({ credentials }); + + const userEntries: AWS.IAM.User[] = []; + let userMarker: string | undefined; + do { + // eslint-disable-next-line no-await-in-loop + const response = await iam.listUsers({ MaxItems: 100, Marker: userMarker }).promise(); + userEntries.push(...(response.Users || [])); + userMarker = response.Marker; + } while (userMarker); + + return userEntries; +}; + export const awsConnectionService = (getAppConnection: TGetAppConnectionFunc) => { const listKmsKeys = async ( { connectionId, region, destination }: TListAwsConnectionKmsKeys, @@ -82,7 +102,16 @@ export const awsConnectionService = (getAppConnection: TGetAppConnectionFunc) => return kmsKeys; }; + const listIamUsers = async ({ connectionId }: TListAwsConnectionIamUsers, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.AWS, connectionId, actor); + + const iamUsers = await listAwsIamUsers(appConnection); + + return iamUsers; + }; + return { - listKmsKeys + listKmsKeys, + listIamUsers }; }; diff --git a/backend/src/services/app-connection/teamcity/index.ts b/backend/src/services/app-connection/teamcity/index.ts new file mode 100644 index 000000000..89433f440 --- /dev/null +++ b/backend/src/services/app-connection/teamcity/index.ts @@ -0,0 +1,4 @@ +export * from "./teamcity-connection-enums"; +export * from "./teamcity-connection-fns"; +export * from "./teamcity-connection-schemas"; +export * from "./teamcity-connection-types"; diff --git a/backend/src/services/app-connection/teamcity/teamcity-connection-enums.ts b/backend/src/services/app-connection/teamcity/teamcity-connection-enums.ts new file mode 100644 index 000000000..7e2f93cb1 --- /dev/null +++ b/backend/src/services/app-connection/teamcity/teamcity-connection-enums.ts @@ -0,0 +1,3 @@ +export enum TeamCityConnectionMethod { + AccessToken = "access-token" +} diff --git a/backend/src/services/app-connection/teamcity/teamcity-connection-fns.ts b/backend/src/services/app-connection/teamcity/teamcity-connection-fns.ts new file mode 100644 index 000000000..c87eb06d2 --- /dev/null +++ b/backend/src/services/app-connection/teamcity/teamcity-connection-fns.ts @@ -0,0 +1,74 @@ +import { AxiosError } from "axios"; + +import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; +import { removeTrailingSlash } from "@app/lib/fn"; +import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +import { TeamCityConnectionMethod } from "./teamcity-connection-enums"; +import { + TTeamCityConnection, + TTeamCityConnectionConfig, + TTeamCityListProjectsResponse +} from "./teamcity-connection-types"; + +export const getTeamCityInstanceUrl = async (config: TTeamCityConnectionConfig) => { + const instanceUrl = removeTrailingSlash(config.credentials.instanceUrl); + + await blockLocalAndPrivateIpAddresses(instanceUrl); + + return instanceUrl; +}; + +export const getTeamCityConnectionListItem = () => { + return { + name: "TeamCity" as const, + app: AppConnection.TeamCity as const, + methods: Object.values(TeamCityConnectionMethod) as [TeamCityConnectionMethod.AccessToken] + }; +}; + +export const validateTeamCityConnectionCredentials = async (config: TTeamCityConnectionConfig) => { + const instanceUrl = await getTeamCityInstanceUrl(config); + + const { accessToken } = config.credentials; + + try { + await request.get(`${instanceUrl}/app/rest/server`, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + }); + } catch (error: unknown) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to validate credentials: ${error.message || "Unknown error"}` + }); + } + throw new BadRequestError({ + message: "Unable to validate connection: verify credentials" + }); + } + + return config.credentials; +}; + +export const listTeamCityProjects = async (appConnection: TTeamCityConnection) => { + const instanceUrl = await getTeamCityInstanceUrl(appConnection); + const { accessToken } = appConnection.credentials; + + const resp = await request.get( + `${instanceUrl}/app/rest/projects?fields=project(id,name,buildTypes(buildType(id,name)))`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + } + ); + + // Filter out the root project. Should not be seen by users. + return resp.data.project.filter((proj) => proj.id !== "_Root"); +}; diff --git a/backend/src/services/app-connection/teamcity/teamcity-connection-schemas.ts b/backend/src/services/app-connection/teamcity/teamcity-connection-schemas.ts new file mode 100644 index 000000000..30494e2ba --- /dev/null +++ b/backend/src/services/app-connection/teamcity/teamcity-connection-schemas.ts @@ -0,0 +1,70 @@ +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 { TeamCityConnectionMethod } from "./teamcity-connection-enums"; + +export const TeamCityConnectionAccessTokenCredentialsSchema = z.object({ + accessToken: z + .string() + .trim() + .min(1, "Access Token required") + .describe(AppConnections.CREDENTIALS.TEAMCITY.accessToken), + instanceUrl: z + .string() + .trim() + .url("Invalid Instance URL") + .min(1, "Instance URL required") + .describe(AppConnections.CREDENTIALS.TEAMCITY.instanceUrl) +}); + +const BaseTeamCityConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.TeamCity) }); + +export const TeamCityConnectionSchema = BaseTeamCityConnectionSchema.extend({ + method: z.literal(TeamCityConnectionMethod.AccessToken), + credentials: TeamCityConnectionAccessTokenCredentialsSchema +}); + +export const SanitizedTeamCityConnectionSchema = z.discriminatedUnion("method", [ + BaseTeamCityConnectionSchema.extend({ + method: z.literal(TeamCityConnectionMethod.AccessToken), + credentials: TeamCityConnectionAccessTokenCredentialsSchema.pick({ + instanceUrl: true + }) + }) +]); + +export const ValidateTeamCityConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(TeamCityConnectionMethod.AccessToken) + .describe(AppConnections.CREATE(AppConnection.TeamCity).method), + credentials: TeamCityConnectionAccessTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.TeamCity).credentials + ) + }) +]); + +export const CreateTeamCityConnectionSchema = ValidateTeamCityConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.TeamCity) +); + +export const UpdateTeamCityConnectionSchema = z + .object({ + credentials: TeamCityConnectionAccessTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.TeamCity).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.TeamCity)); + +export const TeamCityConnectionListItemSchema = z.object({ + name: z.literal("TeamCity"), + app: z.literal(AppConnection.TeamCity), + methods: z.nativeEnum(TeamCityConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/teamcity/teamcity-connection-service.ts b/backend/src/services/app-connection/teamcity/teamcity-connection-service.ts new file mode 100644 index 000000000..afad7f572 --- /dev/null +++ b/backend/src/services/app-connection/teamcity/teamcity-connection-service.ts @@ -0,0 +1,28 @@ +import { OrgServiceActor } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { listTeamCityProjects } from "./teamcity-connection-fns"; +import { TTeamCityConnection } from "./teamcity-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +export const teamcityConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listProjects = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.TeamCity, connectionId, actor); + + try { + const projects = await listTeamCityProjects(appConnection); + return projects; + } catch (error) { + return []; + } + }; + + return { + listProjects + }; +}; diff --git a/backend/src/services/app-connection/teamcity/teamcity-connection-types.ts b/backend/src/services/app-connection/teamcity/teamcity-connection-types.ts new file mode 100644 index 000000000..737e7c70d --- /dev/null +++ b/backend/src/services/app-connection/teamcity/teamcity-connection-types.ts @@ -0,0 +1,43 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateTeamCityConnectionSchema, + TeamCityConnectionSchema, + ValidateTeamCityConnectionCredentialsSchema +} from "./teamcity-connection-schemas"; + +export type TTeamCityConnection = z.infer; + +export type TTeamCityConnectionInput = z.infer & { + app: AppConnection.TeamCity; +}; + +export type TValidateTeamCityConnectionCredentialsSchema = typeof ValidateTeamCityConnectionCredentialsSchema; + +export type TTeamCityConnectionConfig = DiscriminativePick< + TTeamCityConnectionInput, + "method" | "app" | "credentials" +> & { + orgId: string; +}; + +export type TTeamCityProject = { + id: string; + name: string; +}; + +export type TTeamCityProjectWithBuildTypes = TTeamCityProject & { + buildTypes: { + buildType: { + id: string; + name: string; + }[]; + }; +}; + +export type TTeamCityListProjectsResponse = { + project: TTeamCityProjectWithBuildTypes[]; +}; diff --git a/backend/src/services/project-membership/project-membership-dal.ts b/backend/src/services/project-membership/project-membership-dal.ts index 61b703e70..1e71f4605 100644 --- a/backend/src/services/project-membership/project-membership-dal.ts +++ b/backend/src/services/project-membership/project-membership-dal.ts @@ -13,7 +13,7 @@ export const projectMembershipDALFactory = (db: TDbClient) => { // special query const findAllProjectMembers = async ( projectId: string, - filter: { usernames?: string[]; username?: string; id?: string } = {} + filter: { usernames?: string[]; username?: string; id?: string; roles?: string[] } = {} ) => { try { const docs = await db @@ -31,6 +31,29 @@ export const projectMembershipDALFactory = (db: TDbClient) => { if (filter.id) { void qb.where(`${TableName.ProjectMembership}.id`, filter.id); } + if (filter.roles && filter.roles.length > 0) { + void qb.whereExists((subQuery) => { + void subQuery + .select("role") + .from(TableName.ProjectUserMembershipRole) + .leftJoin( + TableName.ProjectRoles, + `${TableName.ProjectRoles}.id`, + `${TableName.ProjectUserMembershipRole}.customRoleId` + ) + .whereRaw("??.?? = ??.??", [ + TableName.ProjectUserMembershipRole, + "projectMembershipId", + TableName.ProjectMembership, + "id" + ]) + .where((subQb) => { + void subQb + .whereIn(`${TableName.ProjectUserMembershipRole}.role`, filter.roles as string[]) + .orWhereIn(`${TableName.ProjectRoles}.slug`, filter.roles as string[]); + }); + }); + } }) .join( TableName.UserEncryptionKey, diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index 1d6ba969a..1fe4961d2 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -79,7 +79,8 @@ export const projectMembershipServiceFactory = ({ actorOrgId, actorAuthMethod, includeGroupMembers, - projectId + projectId, + roles }: TGetProjectMembershipDTO) => { const { permission } = await permissionService.getProjectPermission({ actor, @@ -91,7 +92,7 @@ export const projectMembershipServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Read, ProjectPermissionSub.Member); - const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId); + const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId, { roles }); // projectMembers[0].project if (includeGroupMembers) { diff --git a/backend/src/services/project-membership/project-membership-types.ts b/backend/src/services/project-membership/project-membership-types.ts index 68819f5ae..9b8cf2ac3 100644 --- a/backend/src/services/project-membership/project-membership-types.ts +++ b/backend/src/services/project-membership/project-membership-types.ts @@ -1,6 +1,6 @@ import { TProjectPermission } from "@app/lib/types"; -export type TGetProjectMembershipDTO = { includeGroupMembers?: boolean } & TProjectPermission; +export type TGetProjectMembershipDTO = { includeGroupMembers?: boolean; roles?: string[] } & TProjectPermission; export type TLeaveProjectDTO = Omit; export enum ProjectUserMembershipTemporaryMode { Relative = "relative" diff --git a/backend/src/services/secret-sync/secret-sync-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts index 86273a4ff..687d76f33 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -10,7 +10,8 @@ export enum SecretSync { TerraformCloud = "terraform-cloud", Camunda = "camunda", Vercel = "vercel", - Windmill = "windmill" + Windmill = "windmill", + TeamCity = "teamcity" } export enum SecretSyncInitialSyncBehavior { diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index 0b821b593..143fa4622 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -27,6 +27,7 @@ import { GCP_SYNC_LIST_OPTION } from "./gcp"; import { GcpSyncFns } from "./gcp/gcp-sync-fns"; import { HUMANITEC_SYNC_LIST_OPTION } from "./humanitec"; import { HumanitecSyncFns } from "./humanitec/humanitec-sync-fns"; +import { TEAMCITY_SYNC_LIST_OPTION, TeamCitySyncFns } from "./teamcity"; import { TERRAFORM_CLOUD_SYNC_LIST_OPTION, TerraformCloudSyncFns } from "./terraform-cloud"; import { VERCEL_SYNC_LIST_OPTION, VercelSyncFns } from "./vercel"; import { WINDMILL_SYNC_LIST_OPTION, WindmillSyncFns } from "./windmill"; @@ -43,7 +44,8 @@ const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.TerraformCloud]: TERRAFORM_CLOUD_SYNC_LIST_OPTION, [SecretSync.Camunda]: CAMUNDA_SYNC_LIST_OPTION, [SecretSync.Vercel]: VERCEL_SYNC_LIST_OPTION, - [SecretSync.Windmill]: WINDMILL_SYNC_LIST_OPTION + [SecretSync.Windmill]: WINDMILL_SYNC_LIST_OPTION, + [SecretSync.TeamCity]: TEAMCITY_SYNC_LIST_OPTION }; export const listSecretSyncOptions = () => { @@ -140,6 +142,8 @@ export const SecretSyncFns = { return VercelSyncFns.syncSecrets(secretSync, secretMap); case SecretSync.Windmill: return WindmillSyncFns.syncSecrets(secretSync, secretMap); + case SecretSync.TeamCity: + return TeamCitySyncFns.syncSecrets(secretSync, secretMap); default: throw new Error( `Unhandled sync destination for sync secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -199,6 +203,9 @@ export const SecretSyncFns = { case SecretSync.Windmill: secretMap = await WindmillSyncFns.getSecrets(secretSync); break; + case SecretSync.TeamCity: + secretMap = await TeamCitySyncFns.getSecrets(secretSync); + break; default: throw new Error( `Unhandled sync destination for get secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -252,6 +259,8 @@ export const SecretSyncFns = { return VercelSyncFns.removeSecrets(secretSync, secretMap); case SecretSync.Windmill: return WindmillSyncFns.removeSecrets(secretSync, secretMap); + case SecretSync.TeamCity: + return TeamCitySyncFns.removeSecrets(secretSync, secretMap); default: throw new Error( `Unhandled sync destination for remove secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts index a9099543d..fdf4dfabb 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -13,7 +13,8 @@ export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.TerraformCloud]: "Terraform Cloud", [SecretSync.Camunda]: "Camunda", [SecretSync.Vercel]: "Vercel", - [SecretSync.Windmill]: "Windmill" + [SecretSync.Windmill]: "Windmill", + [SecretSync.TeamCity]: "TeamCity" }; export const SECRET_SYNC_CONNECTION_MAP: Record = { @@ -28,5 +29,6 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.TerraformCloud]: AppConnection.TerraformCloud, [SecretSync.Camunda]: AppConnection.Camunda, [SecretSync.Vercel]: AppConnection.Vercel, - [SecretSync.Windmill]: AppConnection.Windmill + [SecretSync.Windmill]: AppConnection.Windmill, + [SecretSync.TeamCity]: AppConnection.TeamCity }; diff --git a/backend/src/services/secret-sync/secret-sync-queue.ts b/backend/src/services/secret-sync/secret-sync-queue.ts index 3177b68b1..34ac84947 100644 --- a/backend/src/services/secret-sync/secret-sync-queue.ts +++ b/backend/src/services/secret-sync/secret-sync-queue.ts @@ -356,8 +356,11 @@ export const secretSyncQueueFactory = ({ }; if (Object.hasOwn(secretMap, key)) { - secretsToUpdate.push(secret); - if (importBehavior === SecretSyncImportBehavior.PrioritizeDestination) importedSecretMap[key] = secretData; + // Only update secrets if the source value is not empty + if (value) { + secretsToUpdate.push(secret); + if (importBehavior === SecretSyncImportBehavior.PrioritizeDestination) importedSecretMap[key] = secretData; + } } else { secretsToCreate.push(secret); importedSecretMap[key] = secretData; diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index 03e92a57a..716c9b44f 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -61,6 +61,12 @@ import { THumanitecSyncListItem, THumanitecSyncWithCredentials } from "./humanitec"; +import { + TTeamCitySync, + TTeamCitySyncInput, + TTeamCitySyncListItem, + TTeamCitySyncWithCredentials +} from "./teamcity/teamcity-sync-types"; import { TTerraformCloudSync, TTerraformCloudSyncInput, @@ -81,7 +87,8 @@ export type TSecretSync = | TTerraformCloudSync | TCamundaSync | TVercelSync - | TWindmillSync; + | TWindmillSync + | TTeamCitySync; export type TSecretSyncWithCredentials = | TAwsParameterStoreSyncWithCredentials @@ -95,7 +102,8 @@ export type TSecretSyncWithCredentials = | TTerraformCloudSyncWithCredentials | TCamundaSyncWithCredentials | TVercelSyncWithCredentials - | TWindmillSyncWithCredentials; + | TWindmillSyncWithCredentials + | TTeamCitySyncWithCredentials; export type TSecretSyncInput = | TAwsParameterStoreSyncInput @@ -109,7 +117,8 @@ export type TSecretSyncInput = | TTerraformCloudSyncInput | TCamundaSyncInput | TVercelSyncInput - | TWindmillSyncInput; + | TWindmillSyncInput + | TTeamCitySyncInput; export type TSecretSyncListItem = | TAwsParameterStoreSyncListItem @@ -123,7 +132,8 @@ export type TSecretSyncListItem = | TTerraformCloudSyncListItem | TCamundaSyncListItem | TVercelSyncListItem - | TWindmillSyncListItem; + | TWindmillSyncListItem + | TTeamCitySyncListItem; export type TSyncOptionsConfig = { canImportSecrets: boolean; diff --git a/backend/src/services/secret-sync/teamcity/index.ts b/backend/src/services/secret-sync/teamcity/index.ts new file mode 100644 index 000000000..add83cb20 --- /dev/null +++ b/backend/src/services/secret-sync/teamcity/index.ts @@ -0,0 +1,4 @@ +export * from "./teamcity-sync-constants"; +export * from "./teamcity-sync-fns"; +export * from "./teamcity-sync-schemas"; +export * from "./teamcity-sync-types"; diff --git a/backend/src/services/secret-sync/teamcity/teamcity-sync-constants.ts b/backend/src/services/secret-sync/teamcity/teamcity-sync-constants.ts new file mode 100644 index 000000000..30f541bbc --- /dev/null +++ b/backend/src/services/secret-sync/teamcity/teamcity-sync-constants.ts @@ -0,0 +1,10 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types"; + +export const TEAMCITY_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "TeamCity", + destination: SecretSync.TeamCity, + connection: AppConnection.TeamCity, + canImportSecrets: true +}; diff --git a/backend/src/services/secret-sync/teamcity/teamcity-sync-fns.ts b/backend/src/services/secret-sync/teamcity/teamcity-sync-fns.ts new file mode 100644 index 000000000..323f59851 --- /dev/null +++ b/backend/src/services/secret-sync/teamcity/teamcity-sync-fns.ts @@ -0,0 +1,183 @@ +import { request } from "@app/lib/config/request"; +import { getTeamCityInstanceUrl } from "@app/services/app-connection/teamcity"; +import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; +import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; +import { + TDeleteTeamCityVariable, + TPostTeamCityVariable, + TTeamCityListVariables, + TTeamCityListVariablesResponse, + TTeamCitySyncWithCredentials +} from "@app/services/secret-sync/teamcity/teamcity-sync-types"; + +// Note: Most variables won't be returned with a value due to them being a "password" type (starting with "env."). +// TeamCity API returns empty string for password-type variables for security reasons. +const listTeamCityVariables = async ({ instanceUrl, accessToken, project, buildConfig }: TTeamCityListVariables) => { + const { data } = await request.get( + buildConfig + ? `${instanceUrl}/app/rest/buildTypes/${encodeURIComponent(buildConfig)}/parameters` + : `${instanceUrl}/app/rest/projects/id:${encodeURIComponent(project)}/parameters`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + } + ); + + // Strips out "env." from map key, but the "name" field still has the original unaltered key. + return Object.fromEntries( + data.property.map((variable) => [ + variable.name.startsWith("env.") ? variable.name.substring(4) : variable.name, + { ...variable, value: variable.value || "" } // Password values will be empty strings from the API for security + ]) + ); +}; + +// Create and update both use the same method +const updateTeamCityVariable = async ({ + instanceUrl, + accessToken, + project, + buildConfig, + key, + value +}: TPostTeamCityVariable) => { + return request.post( + buildConfig + ? `${instanceUrl}/app/rest/buildTypes/${encodeURIComponent(buildConfig)}/parameters` + : `${instanceUrl}/app/rest/projects/id:${encodeURIComponent(project)}/parameters`, + { + name: key, + value, + type: { + rawValue: "password display='hidden'" + } + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json" + } + } + ); +}; + +const deleteTeamCityVariable = async ({ + instanceUrl, + accessToken, + project, + buildConfig, + key +}: TDeleteTeamCityVariable) => { + return request.delete( + buildConfig + ? `${instanceUrl}/app/rest/buildTypes/${encodeURIComponent(buildConfig)}/parameters/${encodeURIComponent(key)}` + : `${instanceUrl}/app/rest/projects/id:${encodeURIComponent(project)}/parameters/${encodeURIComponent(key)}`, + { + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); +}; + +export const TeamCitySyncFns = { + syncSecrets: async (secretSync: TTeamCitySyncWithCredentials, secretMap: TSecretMap) => { + const { + connection, + destinationConfig: { project, buildConfig } + } = secretSync; + + const instanceUrl = await getTeamCityInstanceUrl(connection); + const { accessToken } = connection.credentials; + + for await (const entry of Object.entries(secretMap)) { + const [key, { value }] = entry; + + const payload = { + instanceUrl, + accessToken, + project, + buildConfig, + key: `env.${key}`, + value + }; + + try { + // Replace every secret since TeamCity does not return secret values that we can cross-check + // No need to differenciate create / update because TeamCity uses the same method for both + await updateTeamCityVariable(payload); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + + if (secretSync.syncOptions.disableSecretDeletion) return; + + const variables = await listTeamCityVariables({ instanceUrl, accessToken, project, buildConfig }); + + for await (const [key, variable] of Object.entries(variables)) { + if (!(key in secretMap)) { + try { + await deleteTeamCityVariable({ + key: variable.name, // We use variable.name instead of key because key is stripped of "env." prefix in listTeamCityVariables(). + instanceUrl, + accessToken, + project, + buildConfig + }); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + } + }, + removeSecrets: async (secretSync: TTeamCitySyncWithCredentials, secretMap: TSecretMap) => { + const { + connection, + destinationConfig: { project, buildConfig } + } = secretSync; + + const instanceUrl = await getTeamCityInstanceUrl(connection); + const { accessToken } = connection.credentials; + + const variables = await listTeamCityVariables({ instanceUrl, accessToken, project, buildConfig }); + + for await (const [key, variable] of Object.entries(variables)) { + if (key in secretMap) { + try { + await deleteTeamCityVariable({ + key: variable.name, // We use variable.name instead of key because key is stripped of "env." prefix in listTeamCityVariables(). + instanceUrl, + accessToken, + project, + buildConfig + }); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + } + }, + getSecrets: async (secretSync: TTeamCitySyncWithCredentials) => { + const { + connection, + destinationConfig: { project, buildConfig } + } = secretSync; + + const instanceUrl = await getTeamCityInstanceUrl(connection); + const { accessToken } = connection.credentials; + + return listTeamCityVariables({ instanceUrl, accessToken, project, buildConfig }); + } +}; diff --git a/backend/src/services/secret-sync/teamcity/teamcity-sync-schemas.ts b/backend/src/services/secret-sync/teamcity/teamcity-sync-schemas.ts new file mode 100644 index 000000000..21c09092f --- /dev/null +++ b/backend/src/services/secret-sync/teamcity/teamcity-sync-schemas.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; + +import { SecretSyncs } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + BaseSecretSyncSchema, + GenericCreateSecretSyncFieldsSchema, + GenericUpdateSecretSyncFieldsSchema +} from "@app/services/secret-sync/secret-sync-schemas"; +import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; + +const TeamCitySyncDestinationConfigSchema = z.object({ + project: z.string().trim().min(1, "Project required").describe(SecretSyncs.DESTINATION_CONFIG.TEAMCITY.project), + buildConfig: z.string().trim().optional().describe(SecretSyncs.DESTINATION_CONFIG.TEAMCITY.buildConfig) +}); + +const TeamCitySyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; + +export const TeamCitySyncSchema = BaseSecretSyncSchema(SecretSync.TeamCity, TeamCitySyncOptionsConfig).extend({ + destination: z.literal(SecretSync.TeamCity), + destinationConfig: TeamCitySyncDestinationConfigSchema +}); + +export const CreateTeamCitySyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.TeamCity, + TeamCitySyncOptionsConfig +).extend({ + destinationConfig: TeamCitySyncDestinationConfigSchema +}); + +export const UpdateTeamCitySyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.TeamCity, + TeamCitySyncOptionsConfig +).extend({ + destinationConfig: TeamCitySyncDestinationConfigSchema.optional() +}); + +export const TeamCitySyncListItemSchema = z.object({ + name: z.literal("TeamCity"), + connection: z.literal(AppConnection.TeamCity), + destination: z.literal(SecretSync.TeamCity), + canImportSecrets: z.literal(true) +}); diff --git a/backend/src/services/secret-sync/teamcity/teamcity-sync-types.ts b/backend/src/services/secret-sync/teamcity/teamcity-sync-types.ts new file mode 100644 index 000000000..8b3f15e0d --- /dev/null +++ b/backend/src/services/secret-sync/teamcity/teamcity-sync-types.ts @@ -0,0 +1,46 @@ +import { z } from "zod"; + +import { TTeamCityConnection } from "@app/services/app-connection/teamcity"; + +import { CreateTeamCitySyncSchema, TeamCitySyncListItemSchema, TeamCitySyncSchema } from "./teamcity-sync-schemas"; + +export type TTeamCitySync = z.infer; + +export type TTeamCitySyncInput = z.infer; + +export type TTeamCitySyncListItem = z.infer; + +export type TTeamCitySyncWithCredentials = TTeamCitySync & { + connection: TTeamCityConnection; +}; + +export type TTeamCityVariable = { + name: string; + value: string; + inherited?: boolean; + type: { + rawValue: string; + }; +}; + +export type TTeamCityListVariablesResponse = { + property: (TTeamCityVariable & { value?: string })[]; + count: number; + href: string; +}; + +export type TTeamCityListVariables = { + accessToken: string; + instanceUrl: string; + project: string; + buildConfig?: string; +}; + +export type TPostTeamCityVariable = TTeamCityListVariables & { + key: string; + value: string; +}; + +export type TDeleteTeamCityVariable = TTeamCityListVariables & { + key: string; +}; diff --git a/docs/api-reference/endpoints/app-connections/teamcity/available.mdx b/docs/api-reference/endpoints/app-connections/teamcity/available.mdx new file mode 100644 index 000000000..c5cbd3c9d --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/teamcity/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/teamcity/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/teamcity/create.mdx b/docs/api-reference/endpoints/app-connections/teamcity/create.mdx new file mode 100644 index 000000000..19d30af70 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/teamcity/create.mdx @@ -0,0 +1,9 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/teamcity" +--- + + + Check out the configuration docs for [TeamCity Connections](/integrations/app-connections/teamcity) to learn how to obtain + the required credentials. + diff --git a/docs/api-reference/endpoints/app-connections/teamcity/delete.mdx b/docs/api-reference/endpoints/app-connections/teamcity/delete.mdx new file mode 100644 index 000000000..d4a5d67ae --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/teamcity/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/teamcity/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/teamcity/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/teamcity/get-by-id.mdx new file mode 100644 index 000000000..090725826 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/teamcity/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/teamcity/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/teamcity/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/teamcity/get-by-name.mdx new file mode 100644 index 000000000..ccb46a27d --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/teamcity/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/teamcity/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/teamcity/list.mdx b/docs/api-reference/endpoints/app-connections/teamcity/list.mdx new file mode 100644 index 000000000..e4987a876 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/teamcity/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/teamcity" +--- diff --git a/docs/api-reference/endpoints/app-connections/teamcity/update.mdx b/docs/api-reference/endpoints/app-connections/teamcity/update.mdx new file mode 100644 index 000000000..499f4a379 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/teamcity/update.mdx @@ -0,0 +1,9 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/teamcity/{connectionId}" +--- + + + Check out the configuration docs for [TeamCity Connections](/integrations/app-connections/teamcity) to learn how to obtain + the required credentials. + diff --git a/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/create.mdx b/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/create.mdx new file mode 100644 index 000000000..69557bb80 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/create.mdx @@ -0,0 +1,9 @@ +--- +title: "Create" +openapi: "POST /api/v2/secret-rotations/aws-iam-user-secret" +--- + + + Check out the configuration docs for [AWS IAM User Secret Rotations](/documentation/platform/secret-rotation/aws-iam-user-secret) to learn how to obtain the + required parameters. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/delete.mdx b/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/delete.mdx new file mode 100644 index 000000000..457e35b42 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v2/secret-rotations/aws-iam-user-secret/{rotationId}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/get-by-id.mdx b/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/get-by-id.mdx new file mode 100644 index 000000000..3e71aa4d7 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v2/secret-rotations/aws-iam-user-secret/{rotationId}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/get-by-name.mdx b/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/get-by-name.mdx new file mode 100644 index 000000000..ccc4b9e28 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v2/secret-rotations/aws-iam-user-secret/rotation-name/{rotationName}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/get-generated-credentials-by-id.mdx b/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/get-generated-credentials-by-id.mdx new file mode 100644 index 000000000..0ade7a3d9 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/get-generated-credentials-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Credentials by ID" +openapi: "GET /api/v2/secret-rotations/aws-iam-user-secret/{rotationId}/generated-credentials" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/list.mdx b/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/list.mdx new file mode 100644 index 000000000..6776b2d0f --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/secret-rotations/aws-iam-user-secret" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/rotate-secrets.mdx b/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/rotate-secrets.mdx new file mode 100644 index 000000000..6eda840d4 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/rotate-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Rotate Secrets" +openapi: "POST /api/v2/secret-rotations/aws-iam-user-secret/{rotationId}/rotate-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/update.mdx b/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/update.mdx new file mode 100644 index 000000000..e276af8d9 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/update.mdx @@ -0,0 +1,9 @@ +--- +title: "Update" +openapi: "PATCH /api/v2/secret-rotations/aws-iam-user-secret/{rotationId}" +--- + + + Check out the configuration docs for [AWS IAM User Secret Rotations](/documentation/platform/secret-rotation/aws-iam-user-secret) to learn how to obtain the + required parameters. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/secret-syncs/teamcity/create.mdx b/docs/api-reference/endpoints/secret-syncs/teamcity/create.mdx new file mode 100644 index 000000000..438702b34 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/teamcity/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/teamcity" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/teamcity/delete.mdx b/docs/api-reference/endpoints/secret-syncs/teamcity/delete.mdx new file mode 100644 index 000000000..f2b1af6eb --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/teamcity/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/teamcity/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/teamcity/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/teamcity/get-by-id.mdx new file mode 100644 index 000000000..857d1d5a2 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/teamcity/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/teamcity/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/teamcity/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/teamcity/get-by-name.mdx new file mode 100644 index 000000000..e7101c7b7 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/teamcity/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/teamcity/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/teamcity/import-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/teamcity/import-secrets.mdx new file mode 100644 index 000000000..961259300 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/teamcity/import-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Import Secrets" +openapi: "POST /api/v1/secret-syncs/teamcity/{syncId}/import-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/teamcity/list.mdx b/docs/api-reference/endpoints/secret-syncs/teamcity/list.mdx new file mode 100644 index 000000000..d3a6a8373 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/teamcity/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/teamcity" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/teamcity/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/teamcity/remove-secrets.mdx new file mode 100644 index 000000000..0f63752ba --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/teamcity/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/teamcity/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/teamcity/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/teamcity/sync-secrets.mdx new file mode 100644 index 000000000..36eaa361c --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/teamcity/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/teamcity/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/teamcity/update.mdx b/docs/api-reference/endpoints/secret-syncs/teamcity/update.mdx new file mode 100644 index 000000000..820c81b21 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/teamcity/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/teamcity/{syncId}" +--- diff --git a/docs/documentation/platform/secret-rotation/aws-iam-user-secret.mdx b/docs/documentation/platform/secret-rotation/aws-iam-user-secret.mdx new file mode 100644 index 000000000..1e8eb3950 --- /dev/null +++ b/docs/documentation/platform/secret-rotation/aws-iam-user-secret.mdx @@ -0,0 +1,191 @@ +--- +title: "AWS IAM User" +description: "Learn how to automatically rotate Access Key Id and Secret Key of AWS IAM Users." +--- + +Infisical's AWS IAM User secret rotation capability lets you update the **Access key** and **Secret access key** credentials of a target IAM user from within Infisical +at a specified interval or on-demand. + +## Prerequisites + +- Create an [AWS Connection](/integrations/app-connections/aws) with the required **Secret Rotation** permissions +- Make sure to add the following permissions to your IAM Role/IAM User Permission policy set used by your AWS Connection: + + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "iam:ListAccessKeys", + "iam:CreateAccessKey", + "iam:UpdateAccessKey", + "iam:DeleteAccessKey", + "iam:ListUsers" + ], + "Resource": "*" + } + ] + } + ``` + +## Workflow + +The typical workflow for using the AWS IAM User rotation strategy consists of four steps: + +1. Creating the target IAM user whose credentials you wish to rotate. +2. Configuring the rotation strategy in Infisical with the credentials of the managing IAM user. +3. Pressing the **Rotate** button in the Infisical dashboard to trigger the rotation of the target IAM user's credentials. The strategy can also be configured to rotate the credentials automatically at a specified interval. + +In the following steps, we explore the end-to-end workflow for setting up this strategy in Infisical. + + + + To begin, create an IAM user whose credentials you wish to rotate. If you already have an IAM user, + then you can skip this step. + + + + + 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 **AWS IAM User Secret** option. + ![Select AWS IAM User Secret](/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-option.png) + + 3. Select the **AWS Connection** to use and configure the rotation behavior. Then click **Next**. + ![Rotation Configuration](/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-configuration.png) + + - **AWS 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 AWS IAM user and the region of the user whose credentials you want to rotate. Then click **Next**. + ![Rotation Parameters](/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-parameters.png) + + 5. Specify the secret names that the AWS IAM access key credentials should be mapped to. Then click **Next**. + ![Rotation Secrets Mapping](/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-secrets-mapping.png) + + - **Access Key ID** - the name of the secret that the AWS access key ID will be mapped to. + - **Secret Access Key** - the name of the secret that the rotated secret access key will be mapped to. + + 6. Give your rotation a name and description (optional). Then click **Next**. + ![Rotation Details](/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-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/aws-iam-user-secret/aws-iam-user-secret-confirm.png) + + 8. Your **AWS IAM User** credentials are now available for use via the mapped secrets. + ![Rotation Created](/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-created.png) + + + To create an AWS IAM User Rotation, make an API request to the [Create AWS IAM User Rotation](/api-reference/endpoints/secret-rotations/aws-iam-user-secret/create) API endpoint. + + You will first need the **User Name** of the AWS IAM user you want to rotate the secret for. This can be obtained from the IAM console, on Users tab. + ![Users](/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-user-names.png) + + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://us.infisical.com/api/v2/secret-rotations/aws-iam-user-secret \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-aws-rotation", + "projectId": "9602cfc5-20b9-4c35-a056-dd7372db0f25", + "description": "My rotation strategy description", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/", + "isAutoRotationEnabled": true, + "rotationInterval": 2, + "rotateAtUtc": { + "hours": 11.5, + "minutes": 29.5 + }, + "parameters": { + "userName": "testUser", + "region": "us-east-1" + }, + "secretsMapping": { + "accessKeyId": "AWS_ACCESS_KEY_ID", + "secretAccessKey": "AWS_SECRET_ACCESS_KEY" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretRotation": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-aws-rotation", + "description": "My rotation strategy description", + "secretsMapping": { + "accessKeyId": "AWS_ACCESS_KEY_ID", + "secretAccessKey": "AWS_SECRET_ACCESS_KEY" + }, + "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": 123, + "rotationStatus": "success", + "lastRotationAttemptedAt": "2023-11-07T05:31:56Z", + "lastRotatedAt": "2023-11-07T05:31:56Z", + "lastRotationJobId": null, + "nextRotationAt": "2023-11-07T05:31:56Z", + "isLastRotationManual": true, + "connection": { + "app": "aws", + "name": "my-aws-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "projectId": "9602cfc5-20b9-4c35-a056-dd7372db0f25", + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/" + }, + "rotateAtUtc": { + "hours": 11.5, + "minutes": 29.5 + }, + "lastRotationMessage": null, + "type": "aws-iam-user-secret", + "parameters": { + "userName": "testUser", + "region": "us-east-1" + } + } + } + ``` + + + + + +**FAQ** + + + + There are a few reasons for why this might happen: + - The strategy configuration is invalid (e.g. the managing IAM user's credentials are incorrect, the target AWS region is incorrect, etc.) + - The managing IAM user is insufficently permissioned to rotate the credentials of the target IAM user. For instance, you may have setup + [paths](https://aws.amazon.com/blogs/security/optimize-aws-administration-with-iam-paths/) for the managing IAM user and the policy does not have the necessary + permissions to rotate the credentials. + + diff --git a/docs/documentation/platform/secret-rotation/aws-iam.mdx b/docs/documentation/platform/secret-rotation/aws-iam.mdx deleted file mode 100644 index c524abfbc..000000000 --- a/docs/documentation/platform/secret-rotation/aws-iam.mdx +++ /dev/null @@ -1,143 +0,0 @@ ---- -title: "AWS IAM User" -description: "Learn how to automatically rotate Access Key Id and Secret Key of AWS IAM Users." ---- - -Infisical's AWS IAM User secret rotation capability lets you update the **Access key** and **Secret access key** credentials of a target IAM user from within Infisical -at a specified interval or on-demand. - -## Workflow - -The typical workflow for using the AWS IAM User rotation strategy consists of four steps: - -1. Creating the target IAM user whose credentials you wish to rotate. -2. Creating the managing IAM user used by Infisical to rotate the credentials of the target IAM user. -3. Configuring the rotation strategy in Infisical with the credentials of the managing IAM user. -4. Pressing the **Rotate** button in the Infisical dashboard to trigger the rotation of the target IAM user's credentials. The strategy can also be configured to rotate the credentials automatically at a specified interval. - -In the following steps, we explore the end-to-end workflow for setting up this strategy in Infisical. - - - - To begin, create an IAM user whose credentials you wish to rotate. If you already have an IAM user, - then you can skip this step. - - - Next, create another IAM user to be used by Infisical to rotate the credentials of the IAM user in the previous step. - - 2.1. In your AWS console, head to IAM > Access management > Users and press **Create user**. - - ![iam user secret rotation create user](../../../images/platform/secret-rotation/aws-iam/rotation-manager-create-user.png) - - 2.2. Next, give the user a username like **infisical-rotation-manager** and press **Next**. - - ![iam user secret rotation username](../../../images/platform/secret-rotation/aws-iam/rotation-manager-username.png) - - 2.3. Next, in the **Set permissions** step, select **Attach policies directly** and then press **Create policy**. - - ![iam user secret rotation create policy](../../../images/platform/secret-rotation/aws-iam/rotation-manager-create-policy.png) - - 2.4. Next, in the **Policy editor**, paste the following JSON and press **Next**: - - ```json - { - "Version": "2012-10-17", - "Statement": [ - { - "Sid": "VisualEditor0", - "Effect": "Allow", - "Action": [ - "iam:DeleteAccessKey", - "iam:GetAccessKeyLastUsed", - "iam:CreateAccessKey" - ], - "Resource": "*" - } - ] - } - ``` - - - The IAM policy above uses the wildcard option in Resource: "*". - - You may want to restrict the policy to a specific path, and make any adjustments as necessary, to control access for the managing user in production. - - Read more about this [here](https://aws.amazon.com/blogs/security/optimize-aws-administration-with-iam-paths/). - - - In the **Review and create** step, give the policy a name like **infisical-rotation-manager**, press **Create policy** to finish creating the policy. - - ![iam user secret rotation policy review](../../../images/platform/secret-rotation/aws-iam/rotation-manager-policy-review.png) - - 2.5. Back in the **Set permissions** step from step 2.3, refresh the policy list and search for the policy you just created from step 2.4. - - Select the policy and press **Next**. - - ![iam user secret rotation attach policy](../../../images/platform/secret-rotation/aws-iam/rotation-manager-attach-policy.png) - - In the **Review and create** step, press **Create user** to finish creating the IAM user. - - ![iam user secret rotation manager user review](../../../images/platform/secret-rotation/aws-iam/rotation-manager-user-review.png) - - 2.5. Having created the user, head to its Security credentials > Access keys and press **Create access key**. - - Follow the subsequent steps to create the **access key** and **secret access key** credential pair for the user. - - ![iam user secret rotation manager create access key](../../../images/platform/secret-rotation/aws-iam/rotation-manager-create-access-key.png) - - At the end of the flow, copy the **Access key** and **Secret access key** to use when configuring the AWS IAM User rotation strategy back in Infisical next. - - ![iam user secret rotation manager access keys](../../../images/platform/secret-rotation/aws-iam/rotation-manager-access-keys.png) - - - 3.1. Back in Infisical, head to the Project > Secrets > Environment and path where you want the rotated AWS IAM credentials to appear and create two placeholder secrets. - - In this example, we'll create two secrets called `AWS_ACCESS_KEY` and `AWS_SECRET_ACCESS_KEY`. - - ![iam user secret rotation secrets](../../../images/platform/secret-rotation/aws-iam/rotation-config-secrets.png) - - 3.2. Next, in the **Secret Rotation** tab, press on the **AWS IAM** tile to configure the AWS IAM User rotation strategy. - - ![iam user secret rotation select aws iam user method](../../../images/platform/secret-rotation/aws-iam/rotations-select-aws-iam-user.png) - - 3.3. Input the configuration details for the AWS IAM User rotation strategy obtained from steps 1 and 2: - - ![iam user secret rotation config 1](../../../images/platform/secret-rotation/aws-iam/rotation-config-1.png) - - Here's some guidance on each field: - - - Manager User Access Key: The managing IAM user's access key from step 2.5. - - Manager User Secret Key: The managing IAM user's secret access key from step 2.5. - - Manager User AWS Region: The [AWS region](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Concepts.RegionsAndAvailabilityZones.html) for Infisical to make requests to such as `us-east-1`. - - IAM Username: The IAM username of the user from step 1. - - Next, specify the output secret mappings configuration for the rotated AWS IAM credentials; this is the secrets whose values will be replaced with new credentials after each rotation. - Here, you can also specify a rotation interval for the credentials to be automatically rotated periodically. - - In this example, we want to map the output of the rotated AWS IAM credentials to the secrets that we created in step 3.1 (i.e. `AWS_ACCESS_KEY` and `AWS_SECRET_ACCESS_KEY`). - - ![iam user secret rotation config 2](../../../images/platform/secret-rotation/aws-iam/rotation-config-2.png) - - Finally, press **Submit** to create the secret rotation strategy. - - - You should now see the AWS IAM User rotation strategy listed in the **Secret Rotation** tab. - - To manually trigger a rotation, you can press the **Rotate** button on the strategy. - Once triggered, the secrets in step 3.1 should be updated with new rotated credential values. - - ![iam user secret rotations aws iam user](../../../images/platform/secret-rotation/aws-iam/rotations-aws-iam-user.png) - - - -**FAQ** - - - - There are a few reasons for why this might happen: - - - The strategy configuration is invalid (e.g. the managing IAM user's credentials are incorrect, the target IAM username is incorrect, etc.). - - The managing IAM user is insufficently permissioned to rotate the credentials of the target IAM user. For instance, you may have setup [paths](https://aws.amazon.com/blogs/security/optimize-aws-administration-with-iam-paths/) for the managing IAM user and the policy does not have the necessary permissions to rotate the credentials. - - The target IAM user already has 2 access keys configured in AWS; you should delete one of the access keys to allow for rotation. - - \ No newline at end of file diff --git a/docs/images/app-connections/aws/iam-role-secret-rotation-permissions.png b/docs/images/app-connections/aws/iam-role-secret-rotation-permissions.png new file mode 100644 index 000000000..6d99922f8 Binary files /dev/null and b/docs/images/app-connections/aws/iam-role-secret-rotation-permissions.png differ diff --git a/docs/images/app-connections/general/add-connection.png b/docs/images/app-connections/general/add-connection.png index 97718065a..ad9d54716 100644 Binary files a/docs/images/app-connections/general/add-connection.png and b/docs/images/app-connections/general/add-connection.png differ diff --git a/docs/images/app-connections/teamcity/teamcity-app-connection-created.png b/docs/images/app-connections/teamcity/teamcity-app-connection-created.png new file mode 100644 index 000000000..698894139 Binary files /dev/null and b/docs/images/app-connections/teamcity/teamcity-app-connection-created.png differ diff --git a/docs/images/app-connections/teamcity/teamcity-app-connection-modal.png b/docs/images/app-connections/teamcity/teamcity-app-connection-modal.png new file mode 100644 index 000000000..9e602dc1b Binary files /dev/null and b/docs/images/app-connections/teamcity/teamcity-app-connection-modal.png differ diff --git a/docs/images/app-connections/teamcity/teamcity-app-connection-option.png b/docs/images/app-connections/teamcity/teamcity-app-connection-option.png new file mode 100644 index 000000000..52d001126 Binary files /dev/null and b/docs/images/app-connections/teamcity/teamcity-app-connection-option.png differ diff --git a/docs/images/app-connections/teamcity/teamcity-main-page.png b/docs/images/app-connections/teamcity/teamcity-main-page.png new file mode 100644 index 000000000..4ee08d774 Binary files /dev/null and b/docs/images/app-connections/teamcity/teamcity-main-page.png differ diff --git a/docs/images/app-connections/teamcity/teamcity-token-copy.png b/docs/images/app-connections/teamcity/teamcity-token-copy.png new file mode 100644 index 000000000..1aa363104 Binary files /dev/null and b/docs/images/app-connections/teamcity/teamcity-token-copy.png differ diff --git a/docs/images/app-connections/teamcity/teamcity-token-created.png b/docs/images/app-connections/teamcity/teamcity-token-created.png new file mode 100644 index 000000000..c909f4107 Binary files /dev/null and b/docs/images/app-connections/teamcity/teamcity-token-created.png differ diff --git a/docs/images/app-connections/teamcity/teamcity-token-page.png b/docs/images/app-connections/teamcity/teamcity-token-page.png new file mode 100644 index 000000000..1730a99ef Binary files /dev/null and b/docs/images/app-connections/teamcity/teamcity-token-page.png differ diff --git a/docs/images/app-connections/teamcity/teamcity-token-popup.png b/docs/images/app-connections/teamcity/teamcity-token-popup.png new file mode 100644 index 000000000..0e18d37ea Binary files /dev/null and b/docs/images/app-connections/teamcity/teamcity-token-popup.png differ diff --git a/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-configuration.png b/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-configuration.png new file mode 100644 index 000000000..0e530600b Binary files /dev/null and b/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-configuration.png differ diff --git a/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-confirm.png b/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-confirm.png new file mode 100644 index 000000000..545e8625a Binary files /dev/null and b/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-confirm.png differ diff --git a/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-created.png b/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-created.png new file mode 100644 index 000000000..51f28107e Binary files /dev/null and b/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-created.png differ diff --git a/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-details.png b/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-details.png new file mode 100644 index 000000000..272c93958 Binary files /dev/null and b/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-details.png differ diff --git a/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-option.png b/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-option.png new file mode 100644 index 000000000..92fcc22cd Binary files /dev/null and b/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-option.png differ diff --git a/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-parameters.png b/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-parameters.png new file mode 100644 index 000000000..1ccfa4d6c Binary files /dev/null and b/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-parameters.png differ diff --git a/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-secrets-mapping.png b/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-secrets-mapping.png new file mode 100644 index 000000000..83d7157dd Binary files /dev/null and b/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-secrets-mapping.png differ diff --git a/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-user-names.png b/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-user-names.png new file mode 100644 index 000000000..b8fa47ae3 Binary files /dev/null and b/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-user-names.png differ diff --git a/docs/images/secret-syncs/general/secret-sync-tab.png b/docs/images/secret-syncs/general/secret-sync-tab.png index dad8c2426..5317fabf0 100644 Binary files a/docs/images/secret-syncs/general/secret-sync-tab.png and b/docs/images/secret-syncs/general/secret-sync-tab.png differ diff --git a/docs/images/secret-syncs/teamcity/select-teamcity-option.png b/docs/images/secret-syncs/teamcity/select-teamcity-option.png new file mode 100644 index 000000000..261f53163 Binary files /dev/null and b/docs/images/secret-syncs/teamcity/select-teamcity-option.png differ diff --git a/docs/images/secret-syncs/teamcity/teamcity-sync-created.png b/docs/images/secret-syncs/teamcity/teamcity-sync-created.png new file mode 100644 index 000000000..871b94db9 Binary files /dev/null and b/docs/images/secret-syncs/teamcity/teamcity-sync-created.png differ diff --git a/docs/images/secret-syncs/teamcity/teamcity-sync-destination.png b/docs/images/secret-syncs/teamcity/teamcity-sync-destination.png new file mode 100644 index 000000000..bc546fc3b Binary files /dev/null and b/docs/images/secret-syncs/teamcity/teamcity-sync-destination.png differ diff --git a/docs/images/secret-syncs/teamcity/teamcity-sync-details.png b/docs/images/secret-syncs/teamcity/teamcity-sync-details.png new file mode 100644 index 000000000..02b890926 Binary files /dev/null and b/docs/images/secret-syncs/teamcity/teamcity-sync-details.png differ diff --git a/docs/images/secret-syncs/teamcity/teamcity-sync-options.png b/docs/images/secret-syncs/teamcity/teamcity-sync-options.png new file mode 100644 index 000000000..d9ef23fb0 Binary files /dev/null and b/docs/images/secret-syncs/teamcity/teamcity-sync-options.png differ diff --git a/docs/images/secret-syncs/teamcity/teamcity-sync-review.png b/docs/images/secret-syncs/teamcity/teamcity-sync-review.png new file mode 100644 index 000000000..753b7ba17 Binary files /dev/null and b/docs/images/secret-syncs/teamcity/teamcity-sync-review.png differ diff --git a/docs/images/secret-syncs/teamcity/teamcity-sync-source.png b/docs/images/secret-syncs/teamcity/teamcity-sync-source.png new file mode 100644 index 000000000..dc69e1db8 Binary files /dev/null and b/docs/images/secret-syncs/teamcity/teamcity-sync-source.png differ diff --git a/docs/integrations/app-connections/aws.mdx b/docs/integrations/app-connections/aws.mdx index 4944b0c34..195f98247 100644 --- a/docs/integrations/app-connections/aws.mdx +++ b/docs/integrations/app-connections/aws.mdx @@ -146,6 +146,34 @@ Infisical supports two methods for connecting to AWS. + + + + Use the following custom policy to grant the minimum permissions required by Infisical to rotate secrets to AWS Access Keys: + + ![IAM Role Secret Rotation Permissions](/images/app-connections/aws/iam-role-secret-rotation-permissions.png) + + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "iam:ListAccessKeys", + "iam:CreateAccessKey", + "iam:UpdateAccessKey", + "iam:DeleteAccessKey", + "iam:ListUsers" + ], + "Resource": "*" + } + ] + } + ``` + + + @@ -293,6 +321,34 @@ Infisical supports two methods for connecting to AWS. + + + + Use the following custom policy to grant the minimum permissions required by Infisical to rotate secrets to AWS Access Keys: + + ![IAM Role Secret Rotation Permissions](/images/app-connections/aws/iam-role-secret-rotation-permissions.png) + + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "iam:ListAccessKeys", + "iam:CreateAccessKey", + "iam:UpdateAccessKey", + "iam:DeleteAccessKey", + "iam:ListUsers" + ], + "Resource": "*" + } + ] + } + ``` + + + diff --git a/docs/integrations/app-connections/teamcity.mdx b/docs/integrations/app-connections/teamcity.mdx new file mode 100644 index 000000000..1ffafe637 --- /dev/null +++ b/docs/integrations/app-connections/teamcity.mdx @@ -0,0 +1,119 @@ +--- +title: "TeamCity Connection" +description: "Learn how to configure a TeamCity Connection for Infisical." +--- + +Infisical supports connecting to TeamCity using an Access Token to securely sync your secrets to TeamCity. + +## Setup TeamCity Connection in Infisical + + + + Navigate to the TeamCity **Profile** page by clicking on your profile icon in the bottom-left corner. + ![TeamCity Main Page](/images/app-connections/teamcity/teamcity-main-page.png) + + + Select the **Access Tokens** tab from the left sidebar navigation menu. + ![TeamCity Token Page](/images/app-connections/teamcity/teamcity-token-page.png) + + + Click the **Create access token** button and provide a name for your token (e.g., "Infisical Integration"). You may set an expiration date or leave it blank for no expiry. + The permission scope can either be **Same as current user** or **Limit per project**. + + If you're choosing **Limit per project**, make sure you select the relevant project and enable the permissions relevant to your use case: + + + + - View build configuration settings + - Edit project + + + + ![TeamCity Token Popup](/images/app-connections/teamcity/teamcity-token-popup.png) + + + Setting your permission scope to **Same as current user** will allow your integration to access multiple projects as long as the current user has read and write access to them. + + + If you configure an expiry date for your access token, you must manually rotate to a new token before the expiration date to prevent service interruption. + + + + After creation, a modal with the Access Token will be displayed. Copy this token immediately and store it securely, as you won't be able to view it again after closing this dialog. + ![TeamCity Token Copy Popup](/images/app-connections/teamcity/teamcity-token-copy.png) + + + You should now see your newly created token in the list of access tokens. + ![TeamCity Token Created](/images/app-connections/teamcity/teamcity-token-created.png) + + + + + 1. Navigate to App Connections + + In your Infisical dashboard, go to **Organization Settings** and select the [**App Connections**](https://app.infisical.com/organization/app-connections) tab. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + 2. Add Connection + + Click the **+ Add Connection** button and select the **TeamCity Connection** option from the available integrations. + ![Select TeamCity Connection](/images/app-connections/teamcity/teamcity-app-connection-option.png) + 3. Fill the TeamCity Connection Modal + + Complete the TeamCity Connection form by entering: + - A descriptive name for the connection + - The Access Token you generated in steps 3-4 + - The URL of your TeamCity instance + - An optional description for future reference + + ![TeamCity Connection Modal](/images/app-connections/teamcity/teamcity-app-connection-modal.png) + 4. Connection Created + + After clicking Create, your **TeamCity Connection** is established and ready to use with your Infisical projects. + ![TeamCity Connection Created](/images/app-connections/teamcity/teamcity-app-connection-created.png) + + + To create a TeamCity Connection, make an API request to the [Create TeamCity + Connection](/api-reference/endpoints/app-connections/teamcity/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/teamcity \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-teamcity-connection", + "method": "access-token", + "credentials": { + "accessToken": "...", + "instanceUrl": "https://yourcompany.teamcity.com" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "e5d18aca-86f7-4026-a95e-efb8aeb0d8e6", + "name": "my-teamcity-connection", + "description": null, + "version": 1, + "orgId": "6f03caa1-a5de-43ce-b127-95a145d3464c", + "createdAt": "2025-04-23T19:46:34.831Z", + "updatedAt": "2025-04-23T19:46:34.831Z", + "isPlatformManagedCredentials": false, + "credentialsHash": "7c2d371dec195f82a6a0d5b41c970a229cfcaf88e894a5b6395e2dbd0280661f", + "app": "teamcity", + "method": "access-token", + "credentials": { + "instanceUrl": "https://yourcompany.teamcity.com" + } + } + } + ``` + + + + diff --git a/docs/integrations/cloud/teamcity.mdx b/docs/integrations/cloud/teamcity.mdx index 3e713cc6a..0b58f1b80 100644 --- a/docs/integrations/cloud/teamcity.mdx +++ b/docs/integrations/cloud/teamcity.mdx @@ -3,43 +3,6 @@ title: "TeamCity" description: "How to sync secrets from Infisical to TeamCity" --- -Prerequisites: - -- Set up and add envars to [Infisical Cloud](https://app.infisical.com) - - - - Obtain a TeamCity Access Token in Profile > Access Tokens - - ![integrations teamcity dashboard](../../images/integrations/teamcity/integrations-teamcity-dashboard.png) - ![integrations teamcity token](../../images/integrations/teamcity/integrations-teamcity-token.png) - - - For this integration to work, the TeamCity Access Token must either have the - **Same as current user** account-wide permission enabled or, if **Limit per project** - is selected, then it must at minimum have the **View build configuration settings** and **Edit project** permissions enabled. - - - Navigate to your project's integrations tab in Infisical. - - ![integrations](../../images/integrations.png) - - Press on the TeamCity tile and input your TeamCity Access Token and Server URL to grant Infisical access to your TeamCity account. - - ![integrations teamcity authorization](../../images/integrations/teamcity/integrations-teamcity-auth.png) - - - - Select which Infisical environment secrets you want to sync to which TeamCity project (and optionally build configuration) and press create integration to start syncing secrets to TeamCity. - - ![integrations teamcity](../../images/integrations/teamcity/integrations-teamcity-create.png) - - - Infisical integrates with both TeamCity's project-level and build configuration-level environment variables. - - To sync secrets to a specific build configuration in a TeamCity project, you can select a build configuration from the **TeamCity Build Config** dropdown; otherwise, leaving it empty will sync secrets to TeamCity at the project-level. - - - ![integrations teamcity](../../images/integrations/teamcity/integrations-teamcity.png) - - + + The TeamCity Native Integration will be deprecated in 2026. Please migrate to our new [TeamCity Sync](../secret-syncs/teamcity). + diff --git a/docs/integrations/cloud/windmill.mdx b/docs/integrations/cloud/windmill.mdx index d0b2b9643..7d4c2cc82 100644 --- a/docs/integrations/cloud/windmill.mdx +++ b/docs/integrations/cloud/windmill.mdx @@ -3,39 +3,6 @@ title: "Windmill" description: "How to sync secrets from Infisical to Windmill" --- -Prerequisites: - -- Set up and add envars to [Infisical Cloud](https://app.infisical.com) - - - - Obtain a [Windmill](https://www.windmill.dev/) access token in Access Tokens - - ![integrations windmill dashboard](../../images/integrations/windmill/integrations-windmill-dashboard.png) - ![integrations windmill token](../../images/integrations/windmill/integrations-windmill-token.png) - - Navigate to your project's integrations tab in Infisical. - - ![integrations](../../images/integrations.png) - - Press on the Windmill tile and input your Windmill access token to grant Infisical access to your Windmill account. - - ![integrations windmill authorization](../../images/integrations/windmill/integrations-windmill-auth.png) - - - - Select which Infisical environment secrets you want to sync to which Windmill workspace and press create integration to start syncing secrets to Windmill. - - ![integrations windmill](../../images/integrations/windmill/integrations-windmill-create.png) - ![integrations windmill](../../images/integrations/windmill/integrations-windmill.png) - - - Secrets synced to Windmill are subject to the [ownership path - prefix](https://www.windmill.dev/docs/core_concepts/roles_and_permissions) - convention of Windmill. Accordingly, all secrets must be prefixed with either - `u/` or `f/` for user-based and folder-based secret along with the name of the - secret. Put differently, you must use the full path of the secret as its name - in Infisical to be considered valid such as `u/user/FOO/BAR`. - - - \ No newline at end of file + + The Windmill Native Integration will be deprecated in 2026. Please migrate to our new [Windmill Sync](../secret-syncs/windmill). + diff --git a/docs/integrations/secret-syncs/teamcity.mdx b/docs/integrations/secret-syncs/teamcity.mdx new file mode 100644 index 000000000..e79fc0f0c --- /dev/null +++ b/docs/integrations/secret-syncs/teamcity.mdx @@ -0,0 +1,147 @@ +--- +title: "TeamCity Sync" +description: "Learn how to configure a TeamCity Sync for Infisical." +--- + +**Prerequisites:** + + - Set up and add secrets to [Infisical Cloud](https://app.infisical.com) + - Create a [TeamCity Connection](/integrations/app-connections/teamcity) with the required **Secret Sync** permissions + + + + 1. Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button. + ![Secret Syncs Tab](/images/secret-syncs/general/secret-sync-tab.png) + + 2. Select the **TeamCity** option. + ![Select TeamCity](/images/secret-syncs/teamcity/select-teamcity-option.png) + + 3. Configure the **Source** from where secrets should be retrieved, then click **Next**. + ![Configure Source](/images/secret-syncs/teamcity/teamcity-sync-source.png) + + - **Environment**: The project environment to retrieve secrets from. + - **Secret Path**: The folder path to retrieve secrets from. + + + If you need to sync secrets from multiple folder locations, check out [secret imports](/documentation/platform/secret-reference#secret-imports). + + + 4. Configure the **Destination** to where secrets should be deployed, then click **Next**. + ![Configure Destination](/images/secret-syncs/teamcity/teamcity-sync-destination.png) + + - **TeamCity Connection**: The TeamCity Connection to authenticate with. + - **Project**: The TeamCity project to sync secrets to. + - **Build Configuration**: The build configuration to sync secrets to. + + + Not including a Build Configuration will sync secrets to the entire project. + + + 5. Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. + ![Configure Options](/images/secret-syncs/teamcity/teamcity-sync-options.png) + + - **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync. + - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. + - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over TeamCity when keys conflict. + - **Import Secrets (Prioritize TeamCity)**: Imports secrets from the destination endpoint before syncing, prioritizing values from TeamCity over Infisical when keys conflict. + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. + - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. + + 6. Configure the **Details** of your TeamCity Sync, then click **Next**. + ![Configure Details](/images/secret-syncs/teamcity/teamcity-sync-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + 7. Review your TeamCity Sync configuration, then click **Create Sync**. + ![Confirm Configuration](/images/secret-syncs/teamcity/teamcity-sync-review.png) + + 8. If enabled, your TeamCity Sync will begin syncing your secrets to the destination endpoint. + ![Sync Secrets](/images/secret-syncs/teamcity/teamcity-sync-created.png) + + + + To create a **TeamCity Sync**, make an API request to the [Create TeamCity Sync](/api-reference/endpoints/secret-syncs/teamcity/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/teamcity \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-teamcity-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "an example sync", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/my-secrets", + "isEnabled": true, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination" + }, + "destinationConfig": { + "project": "TestProject", + "buildConfig": "TestBuildConfig" + } + }' + ``` + + + The **Project** and **Build Config** parameters must use project and build configuration IDs, not their names. + + + ### Sample response + + ```bash Response + { + "secretSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-teamcity-sync", + "description": "an example sync", + "isEnabled": true, + "version": 1, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "syncStatus": "succeeded", + "lastSyncJobId": "123", + "lastSyncMessage": null, + "lastSyncedAt": "2023-11-07T05:31:56Z", + "importStatus": null, + "lastImportJobId": null, + "lastImportMessage": null, + "lastImportedAt": null, + "removeStatus": null, + "lastRemoveJobId": null, + "lastRemoveMessage": null, + "lastRemovedAt": null, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination" + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "teamcity", + "name": "my-teamcity-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/my-secrets" + }, + "destination": "teamcity", + "destinationConfig": { + "project": "TestProject", + "buildConfig": "TestBuildConfig" + } + } + } + ``` + + diff --git a/docs/mint.json b/docs/mint.json index 3bdad3fe0..cebdf9e7f 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -179,6 +179,7 @@ "pages": [ "documentation/platform/secret-rotation/overview", "documentation/platform/secret-rotation/auth0-client-secret", + "documentation/platform/secret-rotation/aws-iam-user-secret", "documentation/platform/secret-rotation/postgres-credentials", "documentation/platform/secret-rotation/mssql-credentials" ] @@ -434,6 +435,7 @@ "integrations/app-connections/humanitec", "integrations/app-connections/mssql", "integrations/app-connections/postgres", + "integrations/app-connections/teamcity", "integrations/app-connections/terraform-cloud", "integrations/app-connections/vercel", "integrations/app-connections/windmill" @@ -457,6 +459,7 @@ "integrations/secret-syncs/gcp-secret-manager", "integrations/secret-syncs/github", "integrations/secret-syncs/humanitec", + "integrations/secret-syncs/teamcity", "integrations/secret-syncs/terraform-cloud", "integrations/secret-syncs/vercel", "integrations/secret-syncs/windmill" @@ -566,9 +569,7 @@ }, { "group": "Others", - "pages": [ - "integrations/external/backstage" - ] + "pages": ["integrations/external/backstage"] }, { "group": "", @@ -869,6 +870,19 @@ "api-reference/endpoints/secret-rotations/auth0-client-secret/update" ] }, + { + "group": "AWS IAM User Secret", + "pages": [ + "api-reference/endpoints/secret-rotations/aws-iam-user-secret/create", + "api-reference/endpoints/secret-rotations/aws-iam-user-secret/delete", + "api-reference/endpoints/secret-rotations/aws-iam-user-secret/get-by-id", + "api-reference/endpoints/secret-rotations/aws-iam-user-secret/get-by-name", + "api-reference/endpoints/secret-rotations/aws-iam-user-secret/get-generated-credentials-by-id", + "api-reference/endpoints/secret-rotations/aws-iam-user-secret/list", + "api-reference/endpoints/secret-rotations/aws-iam-user-secret/rotate-secrets", + "api-reference/endpoints/secret-rotations/aws-iam-user-secret/update" + ] + }, { "group": "Microsoft SQL Server Credentials", "pages": [ @@ -1045,6 +1059,18 @@ "api-reference/endpoints/app-connections/postgres/delete" ] }, + { + "group": "TeamCity", + "pages": [ + "api-reference/endpoints/app-connections/teamcity/list", + "api-reference/endpoints/app-connections/teamcity/available", + "api-reference/endpoints/app-connections/teamcity/get-by-id", + "api-reference/endpoints/app-connections/teamcity/get-by-name", + "api-reference/endpoints/app-connections/teamcity/create", + "api-reference/endpoints/app-connections/teamcity/update", + "api-reference/endpoints/app-connections/teamcity/delete" + ] + }, { "group": "Terraform Cloud", "pages": [ @@ -1210,6 +1236,20 @@ "api-reference/endpoints/secret-syncs/humanitec/remove-secrets" ] }, + { + "group": "TeamCity", + "pages": [ + "api-reference/endpoints/secret-syncs/teamcity/list", + "api-reference/endpoints/secret-syncs/teamcity/get-by-id", + "api-reference/endpoints/secret-syncs/teamcity/get-by-name", + "api-reference/endpoints/secret-syncs/teamcity/create", + "api-reference/endpoints/secret-syncs/teamcity/update", + "api-reference/endpoints/secret-syncs/teamcity/delete", + "api-reference/endpoints/secret-syncs/teamcity/sync-secrets", + "api-reference/endpoints/secret-syncs/teamcity/import-secrets", + "api-reference/endpoints/secret-syncs/teamcity/remove-secrets" + ] + }, { "group": "Terraform Cloud", "pages": [ diff --git a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewAwsIamUserSecretRotationGeneratedCredentials.tsx b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewAwsIamUserSecretRotationGeneratedCredentials.tsx new file mode 100644 index 000000000..b07a615ff --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewAwsIamUserSecretRotationGeneratedCredentials.tsx @@ -0,0 +1,42 @@ +import { CredentialDisplay } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/CredentialDisplay"; +import { TAwsIamUserSecretRotationGeneratedCredentialsResponse } from "@app/hooks/api/secretRotationsV2/types/aws-iam-user-secret-rotation"; + +import { ViewRotationGeneratedCredentialsDisplay } from "./shared"; + +type Props = { + generatedCredentialsResponse: TAwsIamUserSecretRotationGeneratedCredentialsResponse; +}; + +export const ViewAwsIamUserSecretRotationGeneratedCredentials = ({ + generatedCredentialsResponse: { generatedCredentials, activeIndex } +}: Props) => { + const inactiveIndex = activeIndex === 0 ? 1 : 0; + + const activeCredentials = generatedCredentials[activeIndex]; + const inactiveCredentials = generatedCredentials[inactiveIndex]; + + return ( + + + {activeCredentials?.accessKeyId} + + + {activeCredentials?.secretAccessKey} + + + } + inactiveCredentials={ + <> + + {inactiveCredentials?.accessKeyId} + + + {inactiveCredentials?.secretAccessKey} + + + } + /> + ); +}; diff --git a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx index 7d594b0f5..12ad1f9a5 100644 --- a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx +++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx @@ -13,6 +13,7 @@ import { } from "@app/hooks/api/secretRotationsV2"; import { ViewSqlCredentialsRotationGeneratedCredentials } from "./shared"; +import { ViewAwsIamUserSecretRotationGeneratedCredentials } from "./ViewAwsIamUserSecretRotationGeneratedCredentials"; type Props = { secretRotation?: TSecretRotationV2; @@ -67,6 +68,13 @@ const Content = ({ secretRotation }: ContentProps) => { /> ); break; + case SecretRotation.AwsIamUserSecret: + Component = ( + + ); + break; default: throw new Error("Unhandled View Generated Credential Rotation Type"); } diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/AwsIamUserSecretRotationParametersFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/AwsIamUserSecretRotationParametersFields.tsx new file mode 100644 index 000000000..525bde198 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/AwsIamUserSecretRotationParametersFields.tsx @@ -0,0 +1,84 @@ +import { Controller, useFormContext } from "react-hook-form"; +import { SingleValue } from "react-select"; +import { faCircleInfo } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas"; +import { AwsRegionSelect } from "@app/components/secret-syncs/forms/SecretSyncDestinationFields/shared"; +import { FilterableSelect, FormControl, Tooltip } from "@app/components/v2"; +import { TAwsIamUserSecret, useListAwsConnectionIamUsers } from "@app/hooks/api/appConnections/aws"; +import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; + +export const AwsIamUserSecretRotationParametersFields = () => { + const { control, watch } = useFormContext< + TSecretRotationV2Form & { + type: SecretRotation.AwsIamUserSecret; + } + >(); + + const connectionId = watch("connection.id"); + + const { data: clients, isPending: isClientsPending } = useListAwsConnectionIamUsers({ + connectionId + }); + + return ( + <> + ( + Ensure that your connection has the correct permissions.} + > +
+ Don't see the IAM user you're looking for?{" "} + +
+ + } + > + client.UserName === value) ?? ""} + onChange={(option) => { + onChange((option as SingleValue)?.UserName ?? ""); + }} + options={clients} + placeholder="Select an IAM user..." + getOptionLabel={(option) => + (option as SingleValue)?.UserName ?? "" + } + getOptionValue={(option) => + (option as SingleValue)?.UserName ?? "" + } + /> +
+ )} + /> + ( + + + + )} + /> + + ); +}; 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 444510e1e..e0841d096 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/SecretRotationV2ParametersFields.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/SecretRotationV2ParametersFields.tsx @@ -4,12 +4,14 @@ import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; import { TSecretRotationV2Form } from "../schemas"; import { Auth0ClientSecretRotationParametersFields } from "./Auth0ClientSecretRotationParametersFields"; +import { AwsIamUserSecretRotationParametersFields } from "./AwsIamUserSecretRotationParametersFields"; import { SqlCredentialsRotationParametersFields } from "./shared"; const COMPONENT_MAP: Record = { [SecretRotation.PostgresCredentials]: SqlCredentialsRotationParametersFields, [SecretRotation.MsSqlCredentials]: SqlCredentialsRotationParametersFields, - [SecretRotation.Auth0ClientSecret]: Auth0ClientSecretRotationParametersFields + [SecretRotation.Auth0ClientSecret]: Auth0ClientSecretRotationParametersFields, + [SecretRotation.AwsIamUserSecret]: AwsIamUserSecretRotationParametersFields }; export const SecretRotationV2ParametersFields = () => { diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/AwsIamUserSecretRotationReviewFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/AwsIamUserSecretRotationReviewFields.tsx new file mode 100644 index 000000000..d84c0753f --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/AwsIamUserSecretRotationReviewFields.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 AwsIamUserSecretRotationReviewFields = () => { + const { watch } = useFormContext< + TSecretRotationV2Form & { + type: SecretRotation.AwsIamUserSecret; + } + >(); + + const [parameters, { accessKeyId, secretAccessKey }] = watch(["parameters", "secretsMapping"]); + + return ( + <> + + {parameters.region} + {parameters.userName} + + + {accessKeyId} + {secretAccessKey} + + + ); +}; 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 4fb3b6d24..d8624e308 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx @@ -7,12 +7,14 @@ import { getRotateAtLocal } from "@app/helpers/secretRotationsV2"; import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; import { Auth0ClientSecretRotationReviewFields } from "./Auth0ClientSecretRotationReviewFields"; +import { AwsIamUserSecretRotationReviewFields } from "./AwsIamUserSecretRotationReviewFields"; import { SqlCredentialsRotationReviewFields } from "./shared"; const COMPONENT_MAP: Record = { [SecretRotation.PostgresCredentials]: SqlCredentialsRotationReviewFields, [SecretRotation.MsSqlCredentials]: SqlCredentialsRotationReviewFields, - [SecretRotation.Auth0ClientSecret]: Auth0ClientSecretRotationReviewFields + [SecretRotation.Auth0ClientSecret]: Auth0ClientSecretRotationReviewFields, + [SecretRotation.AwsIamUserSecret]: AwsIamUserSecretRotationReviewFields }; export const SecretRotationV2ReviewFields = () => { diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/AwsIamUserSecretRotationSecretsMappingFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/AwsIamUserSecretRotationSecretsMappingFields.tsx new file mode 100644 index 000000000..2c6432122 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/AwsIamUserSecretRotationSecretsMappingFields.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 AwsIamUserSecretRotationSecretsMappingFields = () => { + const { control } = useFormContext< + TSecretRotationV2Form & { + type: SecretRotation.AwsIamUserSecret; + } + >(); + + const { rotationOption } = useSecretRotationV2Option(SecretRotation.AwsIamUserSecret); + + const items = [ + { + name: "Access Key ID", + input: ( + ( + + + + )} + control={control} + name="secretsMapping.accessKeyId" + /> + ) + }, + { + name: "Secret Access Key", + input: ( + ( + + + + )} + control={control} + name="secretsMapping.secretAccessKey" + /> + ) + } + ]; + + 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 58277d593..6ede945db 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx @@ -4,12 +4,14 @@ import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; import { TSecretRotationV2Form } from "../schemas"; import { Auth0ClientSecretRotationSecretsMappingFields } from "./Auth0ClientSecretRotationSecretsMappingFields"; +import { AwsIamUserSecretRotationSecretsMappingFields } from "./AwsIamUserSecretRotationSecretsMappingFields"; import { SqlCredentialsRotationSecretsMappingFields } from "./shared"; const COMPONENT_MAP: Record = { [SecretRotation.PostgresCredentials]: SqlCredentialsRotationSecretsMappingFields, [SecretRotation.MsSqlCredentials]: SqlCredentialsRotationSecretsMappingFields, - [SecretRotation.Auth0ClientSecret]: Auth0ClientSecretRotationSecretsMappingFields + [SecretRotation.Auth0ClientSecret]: Auth0ClientSecretRotationSecretsMappingFields, + [SecretRotation.AwsIamUserSecret]: AwsIamUserSecretRotationSecretsMappingFields }; export const SecretRotationV2SecretsMappingFields = () => { diff --git a/frontend/src/components/secret-rotations-v2/forms/schemas/aws-iam-user-secret-rotation-schema.ts b/frontend/src/components/secret-rotations-v2/forms/schemas/aws-iam-user-secret-rotation-schema.ts new file mode 100644 index 000000000..a8ead3bed --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/forms/schemas/aws-iam-user-secret-rotation-schema.ts @@ -0,0 +1,18 @@ +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 AwsIamUserSecretRotationSchema = z + .object({ + type: z.literal(SecretRotation.AwsIamUserSecret), + parameters: z.object({ + userName: z.string().trim().min(1, "User Name required"), + region: z.string().trim().optional() + }), + secretsMapping: z.object({ + accessKeyId: z.string().trim().min(1, "Access Key ID required"), + secretAccessKey: z.string().trim().min(1, "Secret Access Key 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 295e199fe..acd56e571 100644 --- a/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts +++ b/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts @@ -1,13 +1,15 @@ 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 { 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", [ PostgresCredentialsRotationSchema, MsSqlCredentialsRotationSchema, - Auth0ClientSecretRotationSchema + Auth0ClientSecretRotationSchema, + AwsIamUserSecretRotationSchema ]); export const SecretRotationV2FormSchema = SecretRotationUnionSchema; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx index b2008b4f6..47afc6f04 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx @@ -12,6 +12,7 @@ import { DatabricksSyncFields } from "./DatabricksSyncFields"; import { GcpSyncFields } from "./GcpSyncFields"; import { GitHubSyncFields } from "./GitHubSyncFields"; import { HumanitecSyncFields } from "./HumanitecSyncFields"; +import { TeamCitySyncFields } from "./TeamCitySyncFields"; import { TerraformCloudSyncFields } from "./TerraformCloudSyncFields"; import { VercelSyncFields } from "./VercelSyncFields"; import { WindmillSyncFields } from "./WindmillSyncFields"; @@ -46,6 +47,8 @@ export const SecretSyncDestinationFields = () => { return ; case SecretSync.Windmill: return ; + case SecretSync.TeamCity: + return ; default: throw new Error(`Unhandled Destination Config Field: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/TeamCitySyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/TeamCitySyncFields.tsx new file mode 100644 index 000000000..f9948fcaa --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/TeamCitySyncFields.tsx @@ -0,0 +1,128 @@ +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { SingleValue } from "react-select"; +import { faCircleInfo } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; +import { FilterableSelect, FormControl, Tooltip } from "@app/components/v2"; +import { + TTeamCityProjectWithBuildTypes, + useTeamCityConnectionListProjects +} from "@app/hooks/api/appConnections/teamcity"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; + +export const TeamCitySyncFields = () => { + const { control, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.TeamCity } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + + const { data: projects, isLoading: isProjectsLoading } = useTeamCityConnectionListProjects( + connectionId, + { + enabled: Boolean(connectionId) + } + ); + + // For Build Config dropdown + const selectedProjectId = useWatch({ name: "destinationConfig.project", control }); + const selectedProject = projects?.find((proj) => proj.id === selectedProjectId); + + const buildTypes = selectedProject?.buildTypes?.buildType || []; + + return ( + <> + { + setValue("destinationConfig.project", ""); + setValue("destinationConfig.buildConfig", ""); + }} + /> + + ( + +
+ Don't see the project you're looking for?{" "} + +
+ + } + > + proj.id === value) ?? null} + onChange={(option) => { + onChange((option as SingleValue)?.id ?? null); + setValue("destinationConfig.buildConfig", ""); + }} + options={projects} + placeholder="Select a project..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> +
+ )} + /> + + ( + +
+ Don't see the configuration you're looking for?{" "} + +
+ + } + > + buildType.id === value) ?? null} + onChange={(option) => { + const selectedOption = option as SingleValue<{ id: string; name: string }>; + onChange(selectedOption?.id ?? ""); + }} + options={buildTypes} + isClearable={true} + placeholder="Select a build configuration..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> +
+ )} + /> + + + Not selecting a Build Configuration will sync your secrets to the entire project. + + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index 79c437d27..e2c285b6f 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -43,6 +43,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { case SecretSync.Camunda: case SecretSync.Vercel: case SecretSync.Windmill: + case SecretSync.TeamCity: AdditionalSyncOptionsFieldsComponent = null; break; default: diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index 99182f207..da9651535 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -22,6 +22,7 @@ import { DatabricksSyncReviewFields } from "./DatabricksSyncReviewFields"; import { GcpSyncReviewFields } from "./GcpSyncReviewFields"; import { GitHubSyncReviewFields } from "./GitHubSyncReviewFields"; import { HumanitecSyncReviewFields } from "./HumanitecSyncReviewFields"; +import { TeamCitySyncReviewFields } from "./TeamCitySyncReviewFields"; import { TerraformCloudSyncReviewFields } from "./TerraformCloudSyncReviewFields"; import { VercelSyncReviewFields } from "./VercelSyncReviewFields"; import { WindmillSyncReviewFields } from "./WindmillSyncReviewFields"; @@ -88,6 +89,9 @@ export const SecretSyncReviewFields = () => { case SecretSync.Windmill: DestinationFieldsComponent = ; break; + case SecretSync.TeamCity: + DestinationFieldsComponent = ; + break; default: throw new Error(`Unhandled Destination Review Fields: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/TeamCitySyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/TeamCitySyncReviewFields.tsx new file mode 100644 index 000000000..277ebe1b4 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/TeamCitySyncReviewFields.tsx @@ -0,0 +1,18 @@ +import { useFormContext } from "react-hook-form"; + +import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; +import { GenericFieldLabel } from "@app/components/v2"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const TeamCitySyncReviewFields = () => { + const { watch } = useFormContext(); + const project = watch("destinationConfig.project"); + const buildConfig = watch("destinationConfig.buildConfig"); + + return ( + <> + {project} + {buildConfig} + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts index d58e60b19..50221dc39 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts @@ -9,6 +9,7 @@ import { DatabricksSyncDestinationSchema } from "./databricks-sync-destination-s import { GcpSyncDestinationSchema } from "./gcp-sync-destination-schema"; import { GitHubSyncDestinationSchema } from "./github-sync-destination-schema"; import { HumanitecSyncDestinationSchema } from "./humanitec-sync-destination-schema"; +import { TeamCitySyncDestinationSchema } from "./teamcity-sync-destination-schema"; import { TerraformCloudSyncDestinationSchema } from "./terraform-cloud-destination-schema"; import { VercelSyncDestinationSchema } from "./vercel-sync-destination-schema"; import { WindmillSyncDestinationSchema } from "./windmill-sync-destination-schema"; @@ -25,7 +26,8 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ TerraformCloudSyncDestinationSchema, CamundaSyncDestinationSchema, VercelSyncDestinationSchema, - WindmillSyncDestinationSchema + WindmillSyncDestinationSchema, + TeamCitySyncDestinationSchema ]); export const SecretSyncFormSchema = SecretSyncUnionSchema; diff --git a/frontend/src/components/secret-syncs/forms/schemas/teamcity-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/teamcity-sync-destination-schema.ts new file mode 100644 index 000000000..e2cd60050 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/teamcity-sync-destination-schema.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const TeamCitySyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.TeamCity), + destinationConfig: z.object({ + project: z.string().trim().min(1, "Project required"), + buildConfig: z.string().trim().optional() + }) + }) +); diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index c94bd6648..fd8b6666d 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -15,6 +15,7 @@ import { MsSqlConnectionMethod, PostgresConnectionMethod, TAppConnection, + TeamCityConnectionMethod, TerraformCloudConnectionMethod, VercelConnectionMethod, WindmillConnectionMethod @@ -43,7 +44,8 @@ export const APP_CONNECTION_MAP: Record< [AppConnection.MsSql]: { name: "Microsoft SQL Server", image: "MsSql.png" }, [AppConnection.Camunda]: { name: "Camunda", image: "Camunda.png" }, [AppConnection.Windmill]: { name: "Windmill", image: "Windmill.png" }, - [AppConnection.Auth0]: { name: "Auth0", image: "Auth0.png", size: 40 } + [AppConnection.Auth0]: { name: "Auth0", image: "Auth0.png", size: 40 }, + [AppConnection.TeamCity]: { name: "TeamCity", image: "TeamCity.png" } }; export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) => { @@ -71,6 +73,7 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case PostgresConnectionMethod.UsernameAndPassword: case MsSqlConnectionMethod.UsernameAndPassword: return { name: "Username & Password", icon: faLock }; + case TeamCityConnectionMethod.AccessToken: case WindmillConnectionMethod.AccessToken: return { name: "Access Token", icon: faKey }; case Auth0ConnectionMethod.ClientCredentials: diff --git a/frontend/src/helpers/secretRotationsV2.ts b/frontend/src/helpers/secretRotationsV2.ts index 1a57d37cd..e5ab1419d 100644 --- a/frontend/src/helpers/secretRotationsV2.ts +++ b/frontend/src/helpers/secretRotationsV2.ts @@ -19,20 +19,27 @@ export const SECRET_ROTATION_MAP: Record< name: "Auth0 Client Secret", image: "Auth0.png", size: 35 + }, + [SecretRotation.AwsIamUserSecret]: { + name: "AWS IAM User Secret", + image: "Amazon Web Services.png", + size: 50 } }; export const SECRET_ROTATION_CONNECTION_MAP: Record = { [SecretRotation.PostgresCredentials]: AppConnection.Postgres, [SecretRotation.MsSqlCredentials]: AppConnection.MsSql, - [SecretRotation.Auth0ClientSecret]: AppConnection.Auth0 + [SecretRotation.Auth0ClientSecret]: AppConnection.Auth0, + [SecretRotation.AwsIamUserSecret]: AppConnection.AWS }; // if a rotation can potentially have downtime due to rotating a single credential set this to false export const IS_ROTATION_DUAL_CREDENTIALS: Record = { [SecretRotation.PostgresCredentials]: true, [SecretRotation.MsSqlCredentials]: true, - [SecretRotation.Auth0ClientSecret]: false + [SecretRotation.Auth0ClientSecret]: false, + [SecretRotation.AwsIamUserSecret]: true }; export const getRotateAtLocal = ({ hours, minutes }: TSecretRotationV2["rotateAtUtc"]) => { diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index 291b6f80a..009c2804b 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -39,6 +39,10 @@ export const SECRET_SYNC_MAP: Record = { [SecretSync.TerraformCloud]: AppConnection.TerraformCloud, [SecretSync.Camunda]: AppConnection.Camunda, [SecretSync.Vercel]: AppConnection.Vercel, - [SecretSync.Windmill]: AppConnection.Windmill + [SecretSync.Windmill]: AppConnection.Windmill, + [SecretSync.TeamCity]: AppConnection.TeamCity }; export const SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP: Record< diff --git a/frontend/src/hooks/api/appConnections/aws/queries.tsx b/frontend/src/hooks/api/appConnections/aws/queries.tsx index 87965507b..895ed35ee 100644 --- a/frontend/src/hooks/api/appConnections/aws/queries.tsx +++ b/frontend/src/hooks/api/appConnections/aws/queries.tsx @@ -4,15 +4,20 @@ import { apiRequest } from "@app/config/request"; import { appConnectionKeys } from "@app/hooks/api/appConnections"; import { + TAwsConnectionIamUser, TAwsConnectionKmsKey, + TAwsConnectionListIamUsersResponse, TAwsConnectionListKmsKeysResponse, + TListAwsConnectionIamUsers, TListAwsConnectionKmsKeys } from "./types"; const awsConnectionKeys = { all: [...appConnectionKeys.all, "aws"] as const, listKmsKeys: (params: TListAwsConnectionKmsKeys) => - [...awsConnectionKeys.all, "kms-keys", params] as const + [...awsConnectionKeys.all, "kms-keys", params] as const, + listIamUsers: (params: TListAwsConnectionIamUsers) => + [...awsConnectionKeys.all, "iam-users", params] as const }; export const useListAwsConnectionKmsKeys = ( @@ -40,3 +45,28 @@ export const useListAwsConnectionKmsKeys = ( ...options }); }; + +export const useListAwsConnectionIamUsers = ( + { connectionId }: TListAwsConnectionIamUsers, + options?: Omit< + UseQueryOptions< + TAwsConnectionIamUser[], + unknown, + TAwsConnectionIamUser[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: awsConnectionKeys.listIamUsers({ connectionId }), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/aws/${connectionId}/users` + ); + + return data.iamUsers; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/aws/types.ts b/frontend/src/hooks/api/appConnections/aws/types.ts index 7661b131d..d2c7c39cb 100644 --- a/frontend/src/hooks/api/appConnections/aws/types.ts +++ b/frontend/src/hooks/api/appConnections/aws/types.ts @@ -14,3 +14,18 @@ export type TAwsConnectionKmsKey = { export type TAwsConnectionListKmsKeysResponse = { kmsKeys: TAwsConnectionKmsKey[]; }; + +export type TListAwsConnectionIamUsers = { + connectionId: string; +}; + +export type TAwsConnectionIamUser = { + arn: string; + UserName: string; +}; + +export type TAwsConnectionListIamUsersResponse = { + iamUsers: TAwsConnectionIamUser[]; +}; + +export type TAwsIamUserSecret = TAwsConnectionIamUser; diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index 704b200bb..31e2df71f 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -12,5 +12,6 @@ export enum AppConnection { MsSql = "mssql", Camunda = "camunda", Windmill = "windmill", - Auth0 = "auth0" + Auth0 = "auth0", + TeamCity = "teamcity" } diff --git a/frontend/src/hooks/api/appConnections/teamcity/index.ts b/frontend/src/hooks/api/appConnections/teamcity/index.ts new file mode 100644 index 000000000..2c1906d36 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/teamcity/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/teamcity/queries.tsx b/frontend/src/hooks/api/appConnections/teamcity/queries.tsx new file mode 100644 index 000000000..9d117c265 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/teamcity/queries.tsx @@ -0,0 +1,37 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { appConnectionKeys } from "../queries"; +import { TTeamCityProjectWithBuildTypes } from "./types"; + +const teamcityConnectionKeys = { + all: [...appConnectionKeys.all, "teamcity"] as const, + listProjects: (connectionId: string) => + [...teamcityConnectionKeys.all, "projects", connectionId] as const +}; + +export const useTeamCityConnectionListProjects = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TTeamCityProjectWithBuildTypes[], + unknown, + TTeamCityProjectWithBuildTypes[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: teamcityConnectionKeys.listProjects(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/teamcity/${connectionId}/projects` + ); + + return data; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/teamcity/types.ts b/frontend/src/hooks/api/appConnections/teamcity/types.ts new file mode 100644 index 000000000..a7adab034 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/teamcity/types.ts @@ -0,0 +1,13 @@ +export type TTeamCityProject = { + id: string; + name: string; +}; + +export type TTeamCityProjectWithBuildTypes = TTeamCityProject & { + buildTypes: { + buildType: { + id: string; + name: string; + }[]; + }; +}; diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index ae57fbda2..3122ca32c 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -67,6 +67,10 @@ export type TAuth0ConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Auth0; }; +export type TTeamCityConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.TeamCity; +}; + export type TAppConnectionOption = | TAwsConnectionOption | TGitHubConnectionOption @@ -81,7 +85,8 @@ export type TAppConnectionOption = | TMsSqlConnectionOption | TCamundaConnectionOption | TWindmillConnectionOption - | TAuth0ConnectionOption; + | TAuth0ConnectionOption + | TTeamCityConnectionOption; export type TAppConnectionOptionMap = { [AppConnection.AWS]: TAwsConnectionOption; @@ -98,4 +103,5 @@ export type TAppConnectionOptionMap = { [AppConnection.Camunda]: TCamundaConnectionOption; [AppConnection.Windmill]: TWindmillConnectionOption; [AppConnection.Auth0]: TAuth0ConnectionOption; + [AppConnection.TeamCity]: TTeamCityConnectionOption; }; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index 29e82bff6..929ca796d 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -11,6 +11,7 @@ import { TGitHubConnection } from "./github-connection"; import { THumanitecConnection } from "./humanitec-connection"; import { TMsSqlConnection } from "./mssql-connection"; import { TPostgresConnection } from "./postgres-connection"; +import { TTeamCityConnection } from "./teamcity-connection"; import { TTerraformCloudConnection } from "./terraform-cloud-connection"; import { TVercelConnection } from "./vercel-connection"; import { TWindmillConnection } from "./windmill-connection"; @@ -26,6 +27,7 @@ export * from "./github-connection"; export * from "./humanitec-connection"; export * from "./mssql-connection"; export * from "./postgres-connection"; +export * from "./teamcity-connection"; export * from "./terraform-cloud-connection"; export * from "./vercel-connection"; export * from "./windmill-connection"; @@ -44,7 +46,8 @@ export type TAppConnection = | TMsSqlConnection | TCamundaConnection | TWindmillConnection - | TAuth0Connection; + | TAuth0Connection + | TTeamCityConnection; export type TAvailableAppConnection = Pick; @@ -86,4 +89,5 @@ export type TAppConnectionMap = { [AppConnection.Camunda]: TCamundaConnection; [AppConnection.Windmill]: TWindmillConnection; [AppConnection.Auth0]: TAuth0Connection; + [AppConnection.TeamCity]: TTeamCityConnection; }; diff --git a/frontend/src/hooks/api/appConnections/types/teamcity-connection.ts b/frontend/src/hooks/api/appConnections/types/teamcity-connection.ts new file mode 100644 index 000000000..972df6c96 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/teamcity-connection.ts @@ -0,0 +1,14 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum TeamCityConnectionMethod { + AccessToken = "access-token" +} + +export type TTeamCityConnection = TRootAppConnection & { app: AppConnection.TeamCity } & { + method: TeamCityConnectionMethod.AccessToken; + credentials: { + accessToken: string; + instanceUrl: string; + }; +}; diff --git a/frontend/src/hooks/api/secretRotationsV2/enums.ts b/frontend/src/hooks/api/secretRotationsV2/enums.ts index d43cacb3a..1e387a3b9 100644 --- a/frontend/src/hooks/api/secretRotationsV2/enums.ts +++ b/frontend/src/hooks/api/secretRotationsV2/enums.ts @@ -1,7 +1,8 @@ export enum SecretRotation { PostgresCredentials = "postgres-credentials", MsSqlCredentials = "mssql-credentials", - Auth0ClientSecret = "auth0-client-secret" + Auth0ClientSecret = "auth0-client-secret", + AwsIamUserSecret = "aws-iam-user-secret" } export enum SecretRotationStatus { diff --git a/frontend/src/hooks/api/secretRotationsV2/types/aws-iam-user-secret-rotation.ts b/frontend/src/hooks/api/secretRotationsV2/types/aws-iam-user-secret-rotation.ts new file mode 100644 index 000000000..23397506c --- /dev/null +++ b/frontend/src/hooks/api/secretRotationsV2/types/aws-iam-user-secret-rotation.ts @@ -0,0 +1,38 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; +import { + TSecretRotationV2Base, + TSecretRotationV2GeneratedCredentialsResponseBase +} from "@app/hooks/api/secretRotationsV2/types/shared"; + +export type TAwsIamUserSecretRotation = TSecretRotationV2Base & { + type: SecretRotation.AwsIamUserSecret; + parameters: { + region?: string; + userName: string; + }; + secretsMapping: { + accessKeyId: string; + secretAccessKey: string; + }; +}; + +export type TAwsIamUserSecretRotationGeneratedCredentials = { + accessKeyId: string; + secretAccessKey: string; +}; + +export type TAwsIamUserSecretRotationGeneratedCredentialsResponse = + TSecretRotationV2GeneratedCredentialsResponseBase< + SecretRotation.AwsIamUserSecret, + TAwsIamUserSecretRotationGeneratedCredentials + >; + +export type TAwsIamUserSecretRotationOption = { + name: string; + type: SecretRotation.AwsIamUserSecret; + connection: AppConnection.AWS; + template: { + secretsMapping: TAwsIamUserSecretRotation["secretsMapping"]; + }; +}; diff --git a/frontend/src/hooks/api/secretRotationsV2/types/index.ts b/frontend/src/hooks/api/secretRotationsV2/types/index.ts index 96d568d74..212175ae0 100644 --- a/frontend/src/hooks/api/secretRotationsV2/types/index.ts +++ b/frontend/src/hooks/api/secretRotationsV2/types/index.ts @@ -4,6 +4,11 @@ import { TAuth0ClientSecretRotationGeneratedCredentialsResponse, TAuth0ClientSecretRotationOption } from "@app/hooks/api/secretRotationsV2/types/auth0-client-secret-rotation"; +import { + TAwsIamUserSecretRotation, + TAwsIamUserSecretRotationGeneratedCredentialsResponse, + TAwsIamUserSecretRotationOption +} from "@app/hooks/api/secretRotationsV2/types/aws-iam-user-secret-rotation"; import { TMsSqlCredentialsRotation, TMsSqlCredentialsRotationGeneratedCredentialsResponse @@ -20,13 +25,15 @@ export type TSecretRotationV2 = ( | TPostgresCredentialsRotation | TMsSqlCredentialsRotation | TAuth0ClientSecretRotation + | TAwsIamUserSecretRotation ) & { secrets: (SecretV3RawSanitized | null)[]; }; export type TSecretRotationV2Option = | TSqlCredentialsRotationOption - | TAuth0ClientSecretRotationOption; + | TAuth0ClientSecretRotationOption + | TAwsIamUserSecretRotationOption; export type TListSecretRotationV2Options = { secretRotationOptions: TSecretRotationV2Option[] }; @@ -35,7 +42,8 @@ export type TSecretRotationV2Response = { secretRotation: TSecretRotationV2 }; export type TViewSecretRotationGeneratedCredentialsResponse = | TPostgresCredentialsRotationGeneratedCredentialsResponse | TMsSqlCredentialsRotationGeneratedCredentialsResponse - | TAuth0ClientSecretRotationGeneratedCredentialsResponse; + | TAuth0ClientSecretRotationGeneratedCredentialsResponse + | TAwsIamUserSecretRotationGeneratedCredentialsResponse; export type TCreateSecretRotationV2DTO = DiscriminativePick< TSecretRotationV2, @@ -82,10 +90,12 @@ export type TSecretRotationOptionMap = { [SecretRotation.PostgresCredentials]: TSqlCredentialsRotationOption; [SecretRotation.MsSqlCredentials]: TSqlCredentialsRotationOption; [SecretRotation.Auth0ClientSecret]: TAuth0ClientSecretRotationOption; + [SecretRotation.AwsIamUserSecret]: TAwsIamUserSecretRotationOption; }; export type TSecretRotationGeneratedCredentialsResponseMap = { [SecretRotation.PostgresCredentials]: TPostgresCredentialsRotationGeneratedCredentialsResponse; [SecretRotation.MsSqlCredentials]: TMsSqlCredentialsRotationGeneratedCredentialsResponse; [SecretRotation.Auth0ClientSecret]: TAuth0ClientSecretRotationGeneratedCredentialsResponse; + [SecretRotation.AwsIamUserSecret]: TAwsIamUserSecretRotationGeneratedCredentialsResponse; }; diff --git a/frontend/src/hooks/api/secretSyncs/enums.ts b/frontend/src/hooks/api/secretSyncs/enums.ts index b0d3fd7bb..450df773a 100644 --- a/frontend/src/hooks/api/secretSyncs/enums.ts +++ b/frontend/src/hooks/api/secretSyncs/enums.ts @@ -10,7 +10,8 @@ export enum SecretSync { TerraformCloud = "terraform-cloud", Camunda = "camunda", Vercel = "vercel", - Windmill = "windmill" + Windmill = "windmill", + TeamCity = "teamcity" } export enum SecretSyncStatus { diff --git a/frontend/src/hooks/api/secretSyncs/types/index.ts b/frontend/src/hooks/api/secretSyncs/types/index.ts index 21fda56c7..e9ac538fe 100644 --- a/frontend/src/hooks/api/secretSyncs/types/index.ts +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -10,6 +10,7 @@ import { TDatabricksSync } from "./databricks-sync"; import { TGcpSync } from "./gcp-sync"; import { TGitHubSync } from "./github-sync"; import { THumanitecSync } from "./humanitec-sync"; +import { TTeamCitySync } from "./teamcity-sync"; import { TTerraformCloudSync } from "./terraform-cloud-sync"; import { TVercelSync } from "./vercel-sync"; import { TWindmillSync } from "./windmill-sync"; @@ -32,7 +33,8 @@ export type TSecretSync = | TTerraformCloudSync | TCamundaSync | TVercelSync - | TWindmillSync; + | TWindmillSync + | TTeamCitySync; export type TListSecretSyncs = { secretSyncs: TSecretSync[] }; diff --git a/frontend/src/hooks/api/secretSyncs/types/teamcity-sync.ts b/frontend/src/hooks/api/secretSyncs/types/teamcity-sync.ts new file mode 100644 index 000000000..17f218d06 --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/teamcity-sync.ts @@ -0,0 +1,16 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; + +export type TTeamCitySync = TRootSecretSync & { + destination: SecretSync.TeamCity; + destinationConfig: { + project: string; + buildConfig?: string; + }; + connection: { + app: AppConnection.TeamCity; + name: string; + id: string; + }; +}; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 8145e1904..0a2bf491b 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -431,9 +431,13 @@ export const useDeleteWsEnvironment = () => { }); }; -export const useGetWorkspaceUsers = (workspaceId: string, includeGroupMembers?: boolean) => { +export const useGetWorkspaceUsers = ( + workspaceId: string, + includeGroupMembers?: boolean, + roles?: string[] +) => { return useQuery({ - queryKey: workspaceKeys.getWorkspaceUsers(workspaceId), + queryKey: workspaceKeys.getWorkspaceUsers(workspaceId, includeGroupMembers, roles), queryFn: async () => { const { data: { users } @@ -441,7 +445,11 @@ export const useGetWorkspaceUsers = (workspaceId: string, includeGroupMembers?: `/api/v1/workspace/${workspaceId}/users`, { params: { - includeGroupMembers + includeGroupMembers, + roles: + roles && roles.length > 0 + ? roles.map((role) => encodeURIComponent(role)).join(",") + : undefined } } ); diff --git a/frontend/src/hooks/api/workspace/query-keys.tsx b/frontend/src/hooks/api/workspace/query-keys.tsx index b23ca6868..05e9c7588 100644 --- a/frontend/src/hooks/api/workspace/query-keys.tsx +++ b/frontend/src/hooks/api/workspace/query-keys.tsx @@ -15,7 +15,8 @@ export const workspaceKeys = { type ? ["workspaces", { type }] : (["workspaces"] as const), getWorkspaceAuditLogs: (workspaceId: string) => [{ workspaceId }, "workspace-audit-logs"] as const, - getWorkspaceUsers: (workspaceId: string) => [{ workspaceId }, "workspace-users"] as const, + getWorkspaceUsers: (workspaceId: string, includeGroupMembers?: boolean, roles?: string[]) => + [{ workspaceId, includeGroupMembers, roles }, "workspace-users"] as const, getWorkspaceUserDetails: (workspaceId: string, membershipId: string) => [{ workspaceId, membershipId }, "workspace-user-details"] as const, getWorkspaceIdentityMemberships: (workspaceId: string) => diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx index 05a7eebbc..1259c50d6 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -20,6 +20,7 @@ import { GitHubConnectionForm } from "./GitHubConnectionForm"; import { HumanitecConnectionForm } from "./HumanitecConnectionForm"; import { MsSqlConnectionForm } from "./MsSqlConnectionForm"; import { PostgresConnectionForm } from "./PostgresConnectionForm"; +import { TeamCityConnectionForm } from "./TeamCityConnectionForm"; import { TerraformCloudConnectionForm } from "./TerraformCloudConnectionForm"; import { VercelConnectionForm } from "./VercelConnectionForm"; import { WindmillConnectionForm } from "./WindmillConnectionForm"; @@ -89,6 +90,8 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => { return ; case AppConnection.Auth0: return ; + case AppConnection.TeamCity: + return ; default: throw new Error(`Unhandled App ${app}`); } @@ -153,6 +156,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { return ; case AppConnection.Auth0: return ; + case AppConnection.TeamCity: + return ; default: throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`); } diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/TeamCityConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/TeamCityConnectionForm.tsx new file mode 100644 index 000000000..c7479e43f --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/TeamCityConnectionForm.tsx @@ -0,0 +1,150 @@ +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { + Button, + FormControl, + Input, + ModalClose, + SecretInput, + Select, + SelectItem +} from "@app/components/v2"; +import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; +import { TeamCityConnectionMethod, TTeamCityConnection } from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: TTeamCityConnection; + onSubmit: (formData: FormData) => void; +}; + +const rootSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.TeamCity) +}); + +const formSchema = z.discriminatedUnion("method", [ + rootSchema.extend({ + method: z.literal(TeamCityConnectionMethod.AccessToken), + credentials: z.object({ + accessToken: z.string().trim().min(1, "Access Token required"), + instanceUrl: z.string().trim().url("Invalid Instance URL") + }) + }) +]); + +type FormData = z.infer; + +export const TeamCityConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.TeamCity, + method: TeamCityConnectionMethod.AccessToken + } + }); + + const { + handleSubmit, + control, + formState: { isSubmitting, isDirty } + } = form; + + return ( + +
+ {!isUpdate && } + ( + + + + )} + /> + ( + + + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MembersTable.tsx b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MembersTable.tsx index 7c93f9acf..fd303f653 100644 --- a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MembersTable.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MembersTable.tsx @@ -1,9 +1,12 @@ -import { useMemo } from "react"; +import { useCallback, useMemo, useState } from "react"; import { faArrowDown, faArrowUp, + faCheckCircle, + faChevronRight, faClock, faEllipsisV, + faFilter, faMagnifyingGlass, faSearch, faTrash, @@ -15,6 +18,14 @@ import { twMerge } from "tailwind-merge"; import { ProjectPermissionCan } from "@app/components/permissions"; import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + DropdownSubMenu, + DropdownSubMenuContent, + DropdownSubMenuTrigger, EmptyState, HoverCard, HoverCardContent, @@ -40,7 +51,7 @@ import { useWorkspace } from "@app/context"; import { usePagination, useResetPageHelper } from "@app/hooks"; -import { useGetWorkspaceUsers } from "@app/hooks/api"; +import { useGetProjectRoles, useGetWorkspaceUsers } from "@app/hooks/api"; import { OrderByDirection } from "@app/hooks/api/generic/types"; import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -65,13 +76,22 @@ enum MembersOrderBy { Email = "email" } +type Filter = { + roles: string[]; +}; + export const MembersTable = ({ handlePopUpOpen }: Props) => { const { currentWorkspace } = useWorkspace(); const { user } = useUser(); const navigate = useNavigate(); + const [filter, setFilter] = useState({ + roles: [] + }); + const filterRoles = useMemo(() => filter.roles, [filter.roles]); const userId = user?.id || ""; const workspaceId = currentWorkspace?.id || ""; + const { data: projectRoles } = useGetProjectRoles(workspaceId); const { search, @@ -86,9 +106,20 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => { setOrderBy, setOrderDirection, toggleOrderDirection - } = usePagination(MembersOrderBy.Name, { initPerPage: 20 }); + } = usePagination(MembersOrderBy.Name, { + initPerPage: parseInt(localStorage.getItem("PROJECT_MEMBERS_TABLE_PER_PAGE") || "20", 10) + }); - const { data: members = [], isPending: isMembersLoading } = useGetWorkspaceUsers(workspaceId); + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + localStorage.setItem("PROJECT_MEMBERS_TABLE_PER_PAGE", newPerPage.toString()); + }; + + const { data: members = [], isPending: isMembersLoading } = useGetWorkspaceUsers( + workspaceId, + undefined, + filterRoles + ); const filteredUsers = useMemo( () => @@ -142,14 +173,81 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => { setOrderDirection(OrderByDirection.ASC); }; + const isTableFiltered = Boolean(filter.roles.length); + + const handleRoleToggle = useCallback( + (roleSlug: string) => + setFilter((state) => { + const roles = state.roles || []; + + if (roles.includes(roleSlug)) { + return { ...state, roles: roles.filter((role) => role !== roleSlug) }; + } + return { ...state, roles: [...roles, roleSlug] }; + }), + [] + ); + return (
- setSearch(e.target.value)} - leftIcon={} - placeholder="Search members..." - /> +
+ + + + + + + + Filter By + + } + > + Roles + + + + Apply Roles to Filter Users + + {projectRoles?.map(({ id, slug, name }) => ( + { + evt.preventDefault(); + handleRoleToggle(slug); + }} + key={id} + icon={filter.roles.includes(slug) && } + iconPos="right" + > +
+
+ {name} +
+ + ))} + + + + + setSearch(e.target.value)} + leftIcon={} + placeholder="Search members..." + /> +
@@ -358,7 +456,7 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => { page={page} perPage={perPage} onChangePage={setPage} - onChangePerPage={setPerPage} + onChangePerPage={handlePerPageChange} /> )} {!isMembersLoading && !filteredUsers?.length && ( diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx index 03371c8ce..776f4665e 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx @@ -9,6 +9,7 @@ import { DatabricksSyncDestinationCol } from "./DatabricksSyncDestinationCol"; import { GcpSyncDestinationCol } from "./GcpSyncDestinationCol"; import { GitHubSyncDestinationCol } from "./GitHubSyncDestinationCol"; import { HumanitecSyncDestinationCol } from "./HumanitecSyncDestinationCol"; +import { TeamCitySyncDestinationCol } from "./TeamCitySyncDestinationCol"; import { TerraformCloudSyncDestinationCol } from "./TerraformCloudSyncDestinationCol"; import { VercelSyncDestinationCol } from "./VercelSyncDestinationCol"; import { WindmillSyncDestinationCol } from "./WindmillSyncDestinationCol"; @@ -43,6 +44,8 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => { return ; case SecretSync.Windmill: return ; + case SecretSync.TeamCity: + return ; default: throw new Error( `Unhandled Secret Sync Destination Col: ${(secretSync as TSecretSync).destination}` diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/TeamCitySyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/TeamCitySyncDestinationCol.tsx new file mode 100644 index 000000000..b26304aa5 --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/TeamCitySyncDestinationCol.tsx @@ -0,0 +1,14 @@ +import { TTeamCitySync } from "@app/hooks/api/secretSyncs/types/teamcity-sync"; + +import { getSecretSyncDestinationColValues } from "../helpers"; +import { SecretSyncTableCell } from "../SecretSyncTableCell"; + +type Props = { + secretSync: TTeamCitySync; +}; + +export const TeamCitySyncDestinationCol = ({ secretSync }: Props) => { + const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync); + + return ; +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts index 7ef5644ed..27dc9d5a1 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts @@ -94,6 +94,10 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => { primaryText = destinationConfig.workspace; secondaryText = destinationConfig.path; break; + case SecretSync.TeamCity: + primaryText = destinationConfig.project; + secondaryText = destinationConfig.buildConfig; + break; default: throw new Error(`Unhandled Destination Col Values ${destination}`); } diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx index a3c948b94..505f3ac3b 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx @@ -19,6 +19,7 @@ import { DatabricksSyncDestinationSection } from "./DatabricksSyncDestinationSec import { GcpSyncDestinationSection } from "./GcpSyncDestinationSection"; import { GitHubSyncDestinationSection } from "./GitHubSyncDestinationSection"; import { HumanitecSyncDestinationSection } from "./HumanitecSyncDestinationSection"; +import { TeamCitySyncDestinationSection } from "./TeamCitySyncDestinationSection"; import { TerraformCloudSyncDestinationSection } from "./TerraformCloudSyncDestinationSection"; import { VercelSyncDestinationSection } from "./VercelSyncDestinationSection"; import { WindmillSyncDestinationSection } from "./WindmillSyncDestinationSection"; @@ -73,6 +74,9 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }: case SecretSync.Windmill: DestinationComponents = ; break; + case SecretSync.TeamCity: + DestinationComponents = ; + break; default: throw new Error(`Unhandled Destination Section components: ${destination}`); } diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/TeamCitySyncDestinationSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/TeamCitySyncDestinationSection.tsx new file mode 100644 index 000000000..f712eaa20 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/TeamCitySyncDestinationSection.tsx @@ -0,0 +1,19 @@ +import { GenericFieldLabel } from "@app/components/secret-syncs"; +import { TTeamCitySync } from "@app/hooks/api/secretSyncs/types/teamcity-sync"; + +type Props = { + secretSync: TTeamCitySync; +}; + +export const TeamCitySyncDestinationSection = ({ secretSync }: Props) => { + const { + destinationConfig: { project, buildConfig } + } = secretSync; + + return ( + <> + {project} + {buildConfig} + + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx index 4fe701c8a..bc6713d3d 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx @@ -52,6 +52,7 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) = case SecretSync.Camunda: case SecretSync.Vercel: case SecretSync.Windmill: + case SecretSync.TeamCity: AdditionalSyncOptionsComponent = null; break; default: