diff --git a/backend/src/db/migrations/20250425163216_ssh-nullable-ca-defaults.ts b/backend/src/db/migrations/20250425163216_ssh-nullable-ca-defaults.ts new file mode 100644 index 000000000..2a0b85e1c --- /dev/null +++ b/backend/src/db/migrations/20250425163216_ssh-nullable-ca-defaults.ts @@ -0,0 +1,47 @@ +import { Knex } from "knex"; + +import { ProjectType, TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasDefaultUserCaCol = await knex.schema.hasColumn(TableName.ProjectSshConfig, "defaultUserSshCaId"); + const hasDefaultHostCaCol = await knex.schema.hasColumn(TableName.ProjectSshConfig, "defaultHostSshCaId"); + + if (hasDefaultUserCaCol && hasDefaultHostCaCol) { + await knex.schema.alterTable(TableName.ProjectSshConfig, (t) => { + t.dropForeign(["defaultUserSshCaId"]); + t.dropForeign(["defaultHostSshCaId"]); + }); + await knex.schema.alterTable(TableName.ProjectSshConfig, (t) => { + // allow nullable (does not wipe existing values) + t.uuid("defaultUserSshCaId").nullable().alter(); + t.uuid("defaultHostSshCaId").nullable().alter(); + // re-add with SET NULL behavior (previously CASCADE) + t.foreign("defaultUserSshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("SET NULL"); + t.foreign("defaultHostSshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("SET NULL"); + }); + } + + // (dangtony98): backfill by adding null defaults CAs for all existing Infisical SSH projects + // that do not have an associated ProjectSshConfig record introduced in Infisical SSH V2. + + const allProjects = await knex(TableName.Project).where("type", ProjectType.SSH).select("id"); + + const projectsWithConfig = await knex(TableName.ProjectSshConfig).select("projectId"); + const projectIdsWithConfig = new Set(projectsWithConfig.map((config) => config.projectId)); + + const projectsNeedingConfig = allProjects.filter((project) => !projectIdsWithConfig.has(project.id)); + + if (projectsNeedingConfig.length > 0) { + const configsToInsert = projectsNeedingConfig.map((project) => ({ + projectId: project.id, + defaultUserSshCaId: null, + defaultHostSshCaId: null, + createdAt: new Date(), + updatedAt: new Date() + })); + + await knex.batchInsert(TableName.ProjectSshConfig, configsToInsert); + } +} + +export async function down(): Promise {} 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/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index a31200a1b..f85fbd6a3 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -249,6 +249,8 @@ export enum EventType { DELETE_SLACK_INTEGRATION = "delete-slack-integration", GET_PROJECT_SLACK_CONFIG = "get-project-slack-config", UPDATE_PROJECT_SLACK_CONFIG = "update-project-slack-config", + GET_PROJECT_SSH_CONFIG = "get-project-ssh-config", + UPDATE_PROJECT_SSH_CONFIG = "update-project-ssh-config", INTEGRATION_SYNCED = "integration-synced", CREATE_CMEK = "create-cmek", UPDATE_CMEK = "update-cmek", @@ -1992,6 +1994,25 @@ interface GetProjectSlackConfig { id: string; }; } + +interface GetProjectSshConfig { + type: EventType.GET_PROJECT_SSH_CONFIG; + metadata: { + id: string; + projectId: string; + }; +} + +interface UpdateProjectSshConfig { + type: EventType.UPDATE_PROJECT_SSH_CONFIG; + metadata: { + id: string; + projectId: string; + defaultUserSshCaId?: string | null; + defaultHostSshCaId?: string | null; + }; +} + interface IntegrationSyncedEvent { type: EventType.INTEGRATION_SYNCED; metadata: { @@ -2677,6 +2698,8 @@ export type Event = | GetSlackIntegration | UpdateProjectSlackConfig | GetProjectSlackConfig + | GetProjectSshConfig + | UpdateProjectSshConfig | IntegrationSyncedEvent | CreateCmekEvent | UpdateCmekEvent diff --git a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts index 88f2d90f1..4adf8b7e2 100644 --- a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts +++ b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts @@ -130,7 +130,17 @@ export const dynamicSecretLeaseServiceFactory = ({ if (expireAt > maxExpiryDate) throw new BadRequestError({ message: "TTL cannot be larger than max TTL" }); } - const { entityId, data } = await selectedProvider.create(decryptedStoredInput, expireAt.getTime()); + let result; + try { + result = await selectedProvider.create(decryptedStoredInput, expireAt.getTime()); + } catch (error: unknown) { + if (error && typeof error === "object" && error !== null && "sqlMessage" in error) { + throw new BadRequestError({ message: error.sqlMessage as string }); + } + throw error; + } + const { entityId, data } = result; + const dynamicSecretLease = await dynamicSecretLeaseDAL.create({ expireAt, version: 1, diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index b5cfadbeb..d171fb3d8 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -965,7 +965,6 @@ const buildMemberPermissionRules = () => { can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiAlerts); can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiCollections); - can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificateAuthorities); can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificates); can([ProjectPermissionActions.Create], ProjectPermissionSub.SshCertificates); can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificateTemplates); @@ -1031,7 +1030,6 @@ const buildViewerPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateAuthorities); can(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates); can(ProjectPermissionCmekActions.Read, ProjectPermissionSub.Cmek); - can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateAuthorities); can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificates); can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateTemplates); can(ProjectPermissionSecretSyncActions.Read, ProjectPermissionSub.SecretSyncs); 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 240ef3524..eca66d68a 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1863,6 +1863,10 @@ export const AppConnections = { accessToken: "The access token used to connect with Hashicorp Vault.", roleId: "The Role ID used to connect with Hashicorp Vault.", secretId: "The Secret ID used to connect with Hashicorp Vault." + }, + TEAMCITY: { + instanceUrl: "The TeamCity instance URL to connect with.", + accessToken: "The access token to use to connect with TeamCity." } } }; @@ -2006,6 +2010,10 @@ export const SecretSyncs = { HC_VAULT: { mount: "The Hashicorp Vault Secrets Engine Mount to sync secrets to.", path: "The Hashicorp Vault path to sync secrets to." + }, + TEAMCITY: { + project: "The TeamCity project to sync secrets to.", + buildConfig: "The TeamCity build configuration to sync secrets to." } } }; @@ -2070,6 +2078,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: { @@ -2080,6 +2092,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 49f60224b..7de22410d 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 @@ -37,6 +37,10 @@ import { PostgresConnectionListItemSchema, SanitizedPostgresConnectionSchema } from "@app/services/app-connection/postgres"; +import { + SanitizedTeamCityConnectionSchema, + TeamCityConnectionListItemSchema +} from "@app/services/app-connection/teamcity"; import { SanitizedTerraformCloudConnectionSchema, TerraformCloudConnectionListItemSchema @@ -64,7 +68,8 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedCamundaConnectionSchema.options, ...SanitizedWindmillConnectionSchema.options, ...SanitizedAuth0ConnectionSchema.options, - ...SanitizedHCVaultConnectionSchema.options + ...SanitizedHCVaultConnectionSchema.options, + ...SanitizedTeamCityConnectionSchema.options ]); const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ @@ -82,7 +87,8 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ CamundaConnectionListItemSchema, WindmillConnectionListItemSchema, Auth0ConnectionListItemSchema, - HCVaultConnectionListItemSchema + HCVaultConnectionListItemSchema, + 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 81f26355e..fae87c3c3 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -12,6 +12,7 @@ import { registerHCVaultConnectionRouter } from "./hc-vault-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"; @@ -34,5 +35,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 19423901b..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 { @@ -6,6 +7,7 @@ import { ProjectMembershipsSchema, ProjectRolesSchema, ProjectSlackConfigsSchema, + ProjectSshConfigsSchema, ProjectType, SecretFoldersSchema, SortDirection, @@ -78,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() @@ -117,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 }; @@ -623,6 +637,107 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/:workspaceId/ssh-config", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + workspaceId: z.string().trim() + }), + response: { + 200: ProjectSshConfigsSchema.pick({ + id: true, + createdAt: true, + updatedAt: true, + projectId: true, + defaultUserSshCaId: true, + defaultHostSshCaId: true + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const sshConfig = await server.services.project.getProjectSshConfig({ + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + projectId: req.params.workspaceId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: sshConfig.projectId, + event: { + type: EventType.GET_PROJECT_SSH_CONFIG, + metadata: { + id: sshConfig.id, + projectId: sshConfig.projectId + } + } + }); + + return sshConfig; + } + }); + + server.route({ + method: "PATCH", + url: "/:workspaceId/ssh-config", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + workspaceId: z.string().trim() + }), + body: z.object({ + defaultUserSshCaId: z.string().optional(), + defaultHostSshCaId: z.string().optional() + }), + response: { + 200: ProjectSshConfigsSchema.pick({ + id: true, + createdAt: true, + updatedAt: true, + projectId: true, + defaultUserSshCaId: true, + defaultHostSshCaId: true + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const sshConfig = await server.services.project.updateProjectSshConfig({ + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + projectId: req.params.workspaceId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: sshConfig.projectId, + event: { + type: EventType.UPDATE_PROJECT_SSH_CONFIG, + metadata: { + id: sshConfig.id, + projectId: sshConfig.projectId, + defaultUserSshCaId: sshConfig.defaultUserSshCaId, + defaultHostSshCaId: sshConfig.defaultHostSshCaId + } + } + }); + + return sshConfig; + } + }); + server.route({ method: "GET", url: "/:workspaceId/slack-config", 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 5127a70e5..75b3ac68e 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -10,6 +10,7 @@ import { registerGcpSyncRouter } from "./gcp-sync-router"; import { registerGitHubSyncRouter } from "./github-sync-router"; import { registerHCVaultSyncRouter } from "./hc-vault-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"; @@ -29,5 +30,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/server/routes/v2/user-router.ts b/backend/src/server/routes/v2/user-router.ts index 851d9c4ff..027f527fc 100644 --- a/backend/src/server/routes/v2/user-router.ts +++ b/backend/src/server/routes/v2/user-router.ts @@ -252,6 +252,31 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "DELETE", + url: "/me/sessions/:sessionId", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + sessionId: z.string().trim() + }), + response: { + 200: z.object({ + message: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + await server.services.authToken.revokeMySessionById(req.permission.id, req.params.sessionId); + return { + message: "Successfully revoked session" + }; + } + }); + server.route({ method: "GET", url: "/me", diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 062f65647..98a086100 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -13,7 +13,8 @@ export enum AppConnection { Camunda = "camunda", Windmill = "windmill", Auth0 = "auth0", - HCVault = "hashicorp-vault" + HCVault = "hashicorp-vault", + 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 90ae8003a..4d791acd8 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -48,6 +48,11 @@ import { } from "./humanitec"; import { getMsSqlConnectionListItem, MsSqlConnectionMethod } from "./mssql"; import { getPostgresConnectionListItem, PostgresConnectionMethod } from "./postgres"; +import { + getTeamCityConnectionListItem, + TeamCityConnectionMethod, + validateTeamCityConnectionCredentials +} from "./teamcity"; import { getTerraformCloudConnectionListItem, TerraformCloudConnectionMethod, @@ -77,7 +82,8 @@ export const listAppConnectionOptions = () => { getCamundaConnectionListItem(), getWindmillConnectionListItem(), getAuth0ConnectionListItem(), - getHCVaultConnectionListItem() + getHCVaultConnectionListItem(), + getTeamCityConnectionListItem() ].sort((a, b) => a.name.localeCompare(b.name)); }; @@ -142,7 +148,8 @@ export const validateAppConnectionCredentials = async ( [AppConnection.TerraformCloud]: validateTerraformCloudConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Auth0]: validateAuth0ConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Windmill]: validateWindmillConnectionCredentials as TAppConnectionCredentialsValidator, - [AppConnection.HCVault]: validateHCVaultConnectionCredentials as TAppConnectionCredentialsValidator + [AppConnection.HCVault]: validateHCVaultConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.TeamCity]: validateTeamCityConnectionCredentials as TAppConnectionCredentialsValidator }; return VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection); @@ -175,6 +182,7 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => return "Username & Password"; case WindmillConnectionMethod.AccessToken: case HCVaultConnectionMethod.AccessToken: + case TeamCityConnectionMethod.AccessToken: return "Access Token"; case Auth0ConnectionMethod.ClientCredentials: return "Client Credentials"; @@ -225,5 +233,6 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.Vercel]: platformManagedCredentialsNotSupported, [AppConnection.Windmill]: platformManagedCredentialsNotSupported, [AppConnection.Auth0]: platformManagedCredentialsNotSupported, - [AppConnection.HCVault]: platformManagedCredentialsNotSupported + [AppConnection.HCVault]: 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 012851ed5..68a7fd3c1 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -15,5 +15,6 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.Camunda]: "Camunda", [AppConnection.Windmill]: "Windmill", [AppConnection.Auth0]: "Auth0", - [AppConnection.HCVault]: "Hashicorp Vault" + [AppConnection.HCVault]: "Hashicorp Vault", + [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 acc2c84d6..72a81b456 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -47,6 +47,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"; @@ -77,7 +79,8 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record>>; @@ -126,6 +133,7 @@ export type TAppConnectionInput = { id: string } & ( | TWindmillConnectionInput | TAuth0ConnectionInput | THCVaultConnectionInput + | TTeamCityConnectionInput ); export type TSqlConnectionInput = TPostgresConnectionInput | TMsSqlConnectionInput; @@ -153,7 +161,8 @@ export type TAppConnectionConfig = | TCamundaConnectionConfig | TWindmillConnectionConfig | TAuth0ConnectionConfig - | THCVaultConnectionConfig; + | THCVaultConnectionConfig + | TTeamCityConnectionConfig; export type TValidateAppConnectionCredentialsSchema = | TValidateAwsConnectionCredentialsSchema @@ -170,7 +179,8 @@ export type TValidateAppConnectionCredentialsSchema = | TValidateVercelConnectionCredentialsSchema | TValidateWindmillConnectionCredentialsSchema | TValidateAuth0ConnectionCredentialsSchema - | TValidateHCVaultConnectionCredentialsSchema; + | TValidateHCVaultConnectionCredentialsSchema + | TValidateTeamCityConnectionCredentialsSchema; export type TListAwsConnectionKmsKeys = { connectionId: string; @@ -178,6 +188,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/auth-token/auth-token-dal.ts b/backend/src/services/auth-token/auth-token-dal.ts index 221b691cf..ca7841d8e 100644 --- a/backend/src/services/auth-token/auth-token-dal.ts +++ b/backend/src/services/auth-token/auth-token-dal.ts @@ -47,7 +47,10 @@ export const tokenDALFactory = (db: TDbClient) => { const findTokenSessions = async (filter: Partial, tx?: Knex) => { try { - const sessions = await (tx || db.replicaNode())(TableName.AuthTokenSession).where(filter); + const sessions = await (tx || db.replicaNode())(TableName.AuthTokenSession) + .where(filter) + .orderBy("lastUsed", "desc"); + return sessions; } catch (error) { throw new DatabaseError({ name: "Find all token session", error }); diff --git a/backend/src/services/auth-token/auth-token-service.ts b/backend/src/services/auth-token/auth-token-service.ts index 2468e3c8a..f26464340 100644 --- a/backend/src/services/auth-token/auth-token-service.ts +++ b/backend/src/services/auth-token/auth-token-service.ts @@ -151,6 +151,9 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAu const revokeAllMySessions = async (userId: string) => tokenDAL.deleteTokenSession({ userId }); + const revokeMySessionById = async (userId: string, sessionId: string) => + tokenDAL.deleteTokenSession({ userId, id: sessionId }); + const validateRefreshToken = async (refreshToken?: string) => { const appCfg = getConfig(); if (!refreshToken) @@ -223,6 +226,7 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAu clearTokenSessionById, getTokenSessionByUser, revokeAllMySessions, + revokeMySessionById, validateRefreshToken, fnValidateJwtIdentity, getUserTokenSessionById diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index e576d6768..bc9c4afa3 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -12,6 +12,7 @@ import { generateSrpServerKey, srpCheckClientProof } from "@app/lib/crypto"; import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { getUserPrivateKey } from "@app/lib/crypto/srp"; import { BadRequestError, DatabaseError, ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors"; +import { removeTrailingSlash } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; import { getUserAgentType } from "@app/server/plugins/audit-log"; import { getServerCfg } from "@app/services/super-admin/super-admin-service"; @@ -39,7 +40,6 @@ import { AuthTokenType, MfaMethod } from "./auth-type"; -import { removeTrailingSlash } from "@app/lib/fn"; type TAuthLoginServiceFactoryDep = { userDAL: TUserDALFactory; 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/project/project-service.ts b/backend/src/services/project/project-service.ts index 9b7c29c1b..9f2de8a85 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -73,6 +73,7 @@ import { TGetProjectDTO, TGetProjectKmsKey, TGetProjectSlackConfig, + TGetProjectSshConfig, TListProjectAlertsDTO, TListProjectCasDTO, TListProjectCertificateTemplatesDTO, @@ -92,6 +93,7 @@ import { TUpdateProjectKmsDTO, TUpdateProjectNameDTO, TUpdateProjectSlackConfig, + TUpdateProjectSshConfig, TUpdateProjectVersionLimitDTO, TUpgradeProjectDTO } from "./project-types"; @@ -104,7 +106,7 @@ export const DEFAULT_PROJECT_ENVS = [ type TProjectServiceFactoryDep = { projectDAL: TProjectDALFactory; - projectSshConfigDAL: Pick; + projectSshConfigDAL: Pick; projectQueue: TProjectQueueFactory; userDAL: TUserDALFactory; projectBotService: Pick; @@ -129,7 +131,7 @@ type TProjectServiceFactoryDep = { certificateTemplateDAL: Pick; pkiAlertDAL: Pick; pkiCollectionDAL: Pick; - sshCertificateAuthorityDAL: Pick; + sshCertificateAuthorityDAL: Pick; sshCertificateAuthoritySecretDAL: Pick; sshCertificateDAL: Pick; sshCertificateTemplateDAL: Pick; @@ -1327,6 +1329,129 @@ export const projectServiceFactory = ({ return { secretManagerKmsKey: kmsKey }; }; + const getProjectSshConfig = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + projectId + }: TGetProjectSshConfig) => { + const project = await projectDAL.findById(projectId); + if (!project) { + throw new NotFoundError({ + message: `Project with ID '${projectId}' not found` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Settings); + + const projectSshConfig = await projectSshConfigDAL.findOne({ + projectId: project.id + }); + + if (!projectSshConfig) { + throw new NotFoundError({ + message: `Project SSH config with ID '${project.id}' not found` + }); + } + + return projectSshConfig; + }; + + const updateProjectSshConfig = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + projectId, + defaultUserSshCaId, + defaultHostSshCaId + }: TUpdateProjectSshConfig) => { + const project = await projectDAL.findById(projectId); + if (!project) { + throw new NotFoundError({ + message: `Project with ID '${projectId}' not found` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings); + + let projectSshConfig = await projectSshConfigDAL.findOne({ + projectId: project.id + }); + + if (!projectSshConfig) { + throw new NotFoundError({ + message: `Project SSH config with ID '${project.id}' not found` + }); + } + + projectSshConfig = await projectSshConfigDAL.transaction(async (tx) => { + if (defaultUserSshCaId) { + const userSshCa = await sshCertificateAuthorityDAL.findOne( + { + id: defaultUserSshCaId, + projectId: project.id + }, + tx + ); + + if (!userSshCa) { + throw new NotFoundError({ + message: "User SSH CA must exist and belong to this project" + }); + } + } + + if (defaultHostSshCaId) { + const hostSshCa = await sshCertificateAuthorityDAL.findOne( + { + id: defaultHostSshCaId, + projectId: project.id + }, + tx + ); + + if (!hostSshCa) { + throw new NotFoundError({ + message: "Host SSH CA must exist and belong to this project" + }); + } + } + + const updatedProjectSshConfig = await projectSshConfigDAL.updateById( + projectSshConfig.id, + { + defaultUserSshCaId, + defaultHostSshCaId + }, + tx + ); + + return updatedProjectSshConfig; + }); + + return projectSshConfig; + }; + const getProjectSlackConfig = async ({ actorId, actor, @@ -1548,6 +1673,8 @@ export const projectServiceFactory = ({ getProjectKmsBackup, loadProjectKmsBackup, getProjectKmsKeys, + getProjectSshConfig, + updateProjectSshConfig, getProjectSlackConfig, updateProjectSlackConfig, requestProjectAccess, diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index 444f6309c..274189668 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -159,6 +159,13 @@ export type TListProjectSshCertificatesDTO = { limit: number; } & TProjectPermission; +export type TUpdateProjectSshConfig = { + defaultUserSshCaId?: string; + defaultHostSshCaId?: string; +} & TProjectPermission; + +export type TGetProjectSshConfig = TProjectPermission; + export type TGetProjectSlackConfig = TProjectPermission; export type TUpdateProjectSlackConfig = { diff --git a/backend/src/services/secret-sync/secret-sync-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts index af49060df..9d59ebb76 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -11,7 +11,8 @@ export enum SecretSync { Camunda = "camunda", Vercel = "vercel", Windmill = "windmill", - HCVault = "hashicorp-vault" + HCVault = "hashicorp-vault", + 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 cc5f8ed07..f5737edb3 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -28,6 +28,7 @@ import { GcpSyncFns } from "./gcp/gcp-sync-fns"; import { HC_VAULT_SYNC_LIST_OPTION, HCVaultSyncFns } from "./hc-vault"; 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"; @@ -45,7 +46,8 @@ const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.Camunda]: CAMUNDA_SYNC_LIST_OPTION, [SecretSync.Vercel]: VERCEL_SYNC_LIST_OPTION, [SecretSync.Windmill]: WINDMILL_SYNC_LIST_OPTION, - [SecretSync.HCVault]: HC_VAULT_SYNC_LIST_OPTION + [SecretSync.HCVault]: HC_VAULT_SYNC_LIST_OPTION, + [SecretSync.TeamCity]: TEAMCITY_SYNC_LIST_OPTION }; export const listSecretSyncOptions = () => { @@ -144,6 +146,8 @@ export const SecretSyncFns = { return WindmillSyncFns.syncSecrets(secretSync, secretMap); case SecretSync.HCVault: return HCVaultSyncFns.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}` @@ -206,6 +210,9 @@ export const SecretSyncFns = { case SecretSync.HCVault: secretMap = await HCVaultSyncFns.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}` @@ -261,6 +268,8 @@ export const SecretSyncFns = { return WindmillSyncFns.removeSecrets(secretSync, secretMap); case SecretSync.HCVault: return HCVaultSyncFns.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 e4dbc4324..c6d7adc8c 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -14,7 +14,8 @@ export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.Camunda]: "Camunda", [SecretSync.Vercel]: "Vercel", [SecretSync.Windmill]: "Windmill", - [SecretSync.HCVault]: "Hashicorp Vault" + [SecretSync.HCVault]: "Hashicorp Vault", + [SecretSync.TeamCity]: "TeamCity" }; export const SECRET_SYNC_CONNECTION_MAP: Record = { @@ -30,5 +31,6 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.Camunda]: AppConnection.Camunda, [SecretSync.Vercel]: AppConnection.Vercel, [SecretSync.Windmill]: AppConnection.Windmill, - [SecretSync.HCVault]: AppConnection.HCVault + [SecretSync.HCVault]: AppConnection.HCVault, + [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 602846616..58f575661 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -67,6 +67,12 @@ import { THumanitecSyncListItem, THumanitecSyncWithCredentials } from "./humanitec"; +import { + TTeamCitySync, + TTeamCitySyncInput, + TTeamCitySyncListItem, + TTeamCitySyncWithCredentials +} from "./teamcity/teamcity-sync-types"; import { TTerraformCloudSync, TTerraformCloudSyncInput, @@ -88,7 +94,8 @@ export type TSecretSync = | TCamundaSync | TVercelSync | TWindmillSync - | THCVaultSync; + | THCVaultSync + | TTeamCitySync; export type TSecretSyncWithCredentials = | TAwsParameterStoreSyncWithCredentials @@ -103,7 +110,8 @@ export type TSecretSyncWithCredentials = | TCamundaSyncWithCredentials | TVercelSyncWithCredentials | TWindmillSyncWithCredentials - | THCVaultSyncWithCredentials; + | THCVaultSyncWithCredentials + | TTeamCitySyncWithCredentials; export type TSecretSyncInput = | TAwsParameterStoreSyncInput @@ -118,7 +126,8 @@ export type TSecretSyncInput = | TCamundaSyncInput | TVercelSyncInput | TWindmillSyncInput - | THCVaultSyncInput; + | THCVaultSyncInput + | TTeamCitySyncInput; export type TSecretSyncListItem = | TAwsParameterStoreSyncListItem @@ -133,7 +142,8 @@ export type TSecretSyncListItem = | TCamundaSyncListItem | TVercelSyncListItem | TWindmillSyncListItem - | THCVaultSyncListItem; + | THCVaultSyncListItem + | 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/cli/packages/cmd/ssh.go b/cli/packages/cmd/ssh.go index 5b2bb37bb..a11e4da4c 100644 --- a/cli/packages/cmd/ssh.go +++ b/cli/packages/cmd/ssh.go @@ -177,7 +177,6 @@ func issueCredentials(cmd *cobra.Command, args []string) { infisicalToken = token.Token } else { util.RequireLogin() - util.RequireLocalWorkspaceFile() loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) if err != nil { @@ -411,7 +410,6 @@ func signKey(cmd *cobra.Command, args []string) { infisicalToken = token.Token } else { util.RequireLogin() - util.RequireLocalWorkspaceFile() loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) if err != nil { @@ -610,25 +608,82 @@ func signKey(cmd *cobra.Command, args []string) { } func sshConnect(cmd *cobra.Command, args []string) { - util.RequireLogin() - util.RequireLocalWorkspaceFile() - - loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) + token, err := util.GetInfisicalToken(cmd) if err != nil { - util.HandleError(err, "Unable to authenticate") + util.HandleError(err, "Unable to parse flag") } + + var infisicalToken string - if loggedInUserDetails.LoginExpired { - util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + if token != nil && (token.Type == util.SERVICE_TOKEN_IDENTIFIER || token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER) { + infisicalToken = token.Token + } else { + util.RequireLogin() + + loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) + if err != nil { + util.HandleError(err, "Unable to authenticate") + } + + if loggedInUserDetails.LoginExpired { + util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + } + infisicalToken = loggedInUserDetails.UserCredentials.JTWToken } - infisicalToken := loggedInUserDetails.UserCredentials.JTWToken - writeHostCaToFile, err := cmd.Flags().GetBool("writeHostCaToFile") if err != nil { util.HandleError(err, "Unable to parse --writeHostCaToFile flag") } + outFilePath, err := cmd.Flags().GetString("outFilePath") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + hostname, _ := cmd.Flags().GetString("hostname") + loginUser, _ := cmd.Flags().GetString("loginUser") + + var outputDir, privateKeyPath, publicKeyPath, signedKeyPath string + if outFilePath != "" { + if strings.HasPrefix(outFilePath, "~") { + homeDir, err := os.UserHomeDir() + if err != nil { + util.HandleError(err, "Failed to resolve home directory") + } + outFilePath = strings.Replace(outFilePath, "~", homeDir, 1) + } + + if strings.HasSuffix(outFilePath, "-cert.pub") { + signedKeyPath = outFilePath + baseName := strings.TrimSuffix(filepath.Base(outFilePath), "-cert.pub") + outputDir = filepath.Dir(outFilePath) + privateKeyPath = filepath.Join(outputDir, baseName) + publicKeyPath = filepath.Join(outputDir, baseName+".pub") + } else { + outputDir = outFilePath + info, err := os.Stat(outputDir) + if os.IsNotExist(err) { + err = os.MkdirAll(outputDir, 0755) + if err != nil { + util.HandleError(err, "Failed to create output directory") + } + } else if err != nil { + util.HandleError(err, "Failed to access output directory") + } else if !info.IsDir() { + util.PrintErrorMessageAndExit("The provided --outFilePath is not a directory") + } + fileName := "id_ed25519" + privateKeyPath = filepath.Join(outputDir, fileName) + publicKeyPath = filepath.Join(outputDir, fileName+".pub") + signedKeyPath = filepath.Join(outputDir, fileName+"-cert.pub") + } + + if privateKeyPath == "" || publicKeyPath == "" || signedKeyPath == "" { + util.PrintErrorMessageAndExit("Failed to resolve file paths for writing credentials") + } + } + customHeaders, err := util.GetInfisicalCustomHeadersMap() if err != nil { util.HandleError(err, "Unable to get custom headers") @@ -651,43 +706,68 @@ func sshConnect(cmd *cobra.Command, args []string) { util.PrintErrorMessageAndExit("You do not have access to any SSH hosts") } - // Prompt to select host - hostNames := make([]string, len(hosts)) - for i, h := range hosts { - hostNames[i] = h.Hostname + var selectedHost = hosts[0] + if hostname != "" { + foundHost := false + for _, h := range hosts { + if h.Hostname == hostname { + selectedHost = h + foundHost = true + break + } + } + if !foundHost { + util.PrintErrorMessageAndExit("Specified --hostname not found or not accessible") + } + } else { + hostNames := make([]string, len(hosts)) + for i, h := range hosts { + hostNames[i] = h.Hostname + } + hostPrompt := promptui.Select{ + Label: "Select an SSH Host", + Items: hostNames, + Size: 10, + } + hostIdx, _, err := hostPrompt.Run() + if err != nil { + util.HandleError(err, "Prompt failed") + } + selectedHost = hosts[hostIdx] } - hostPrompt := promptui.Select{ - Label: "Select an SSH Host", - Items: hostNames, - Size: 10, + var selectedLoginUser string + if loginUser != "" { + foundLoginUser := false + for _, m := range selectedHost.LoginMappings { + if m.LoginUser == loginUser { + selectedLoginUser = loginUser + foundLoginUser = true + break + } + } + if !foundLoginUser { + util.PrintErrorMessageAndExit("Specified --loginUser not valid for selected host") + } + } else { + if len(selectedHost.LoginMappings) == 0 { + util.PrintErrorMessageAndExit("No login users available for selected host") + } + loginUsers := make([]string, len(selectedHost.LoginMappings)) + for i, m := range selectedHost.LoginMappings { + loginUsers[i] = m.LoginUser + } + loginPrompt := promptui.Select{ + Label: "Select Login User", + Items: loginUsers, + Size: 5, + } + loginIdx, _, err := loginPrompt.Run() + if err != nil { + util.HandleError(err, "Prompt failed") + } + selectedLoginUser = selectedHost.LoginMappings[loginIdx].LoginUser } - hostIdx, _, err := hostPrompt.Run() - if err != nil { - util.HandleError(err, "Prompt failed") - } - selectedHost := hosts[hostIdx] - - // Prompt to select login user - if len(selectedHost.LoginMappings) == 0 { - util.PrintErrorMessageAndExit("No login users available for selected host") - } - - loginUsers := make([]string, len(selectedHost.LoginMappings)) - for i, m := range selectedHost.LoginMappings { - loginUsers[i] = m.LoginUser - } - - loginPrompt := promptui.Select{ - Label: "Select Login User", - Items: loginUsers, - Size: 5, - } - loginIdx, _, err := loginPrompt.Run() - if err != nil { - util.HandleError(err, "Prompt failed") - } - selectedLoginUser := selectedHost.LoginMappings[loginIdx].LoginUser // Issue SSH creds for host creds, err := infisicalClient.Ssh().IssueSshHostUserCert(selectedHost.ID, infisicalSdk.IssueSshHostUserCertOptions{ @@ -731,10 +811,27 @@ func sshConnect(cmd *cobra.Command, args []string) { util.HandleError(err, "Failed to write Host CA to known_hosts") } - fmt.Printf("📁 Wrote Host CA entry to %s\n", knownHostsPath) + fmt.Printf("Successfully wrote Host CA entry to %s\n", knownHostsPath) } } + if outFilePath != "" { + err = writeToFile(privateKeyPath, creds.PrivateKey, 0600) + if err != nil { + util.HandleError(err, "Failed to write private key") + } + err = writeToFile(publicKeyPath, creds.PublicKey, 0644) + if err != nil { + util.HandleError(err, "Failed to write public key") + } + err = writeToFile(signedKeyPath, creds.SignedKey, 0644) + if err != nil { + util.HandleError(err, "Failed to write signed cert") + } + fmt.Printf("Successfully wrote credentials to %s, %s, and %s\n", privateKeyPath, publicKeyPath, signedKeyPath) + return + } + // Load credentials into SSH agent err = addCredentialsToAgent(creds.PrivateKey, creds.SignedKey) if err != nil { @@ -769,7 +866,6 @@ func sshAddHost(cmd *cobra.Command, args []string) { infisicalToken = token.Token } else { util.RequireLogin() - util.RequireLocalWorkspaceFile() loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) if err != nil { @@ -1006,16 +1102,20 @@ func init() { sshIssueCredentialsCmd.Flags().Bool("addToAgent", false, "Whether to add issued SSH credentials to the SSH agent") sshCmd.AddCommand(sshIssueCredentialsCmd) - sshConnectCmd.Flags().Bool("writeHostCaToFile", true, "Write Host CA public key to ~/.ssh/known_hosts as a separate entry if doesn't already exist") + sshConnectCmd.Flags().String("token", "", "Use a machine identity access token") + sshConnectCmd.Flags().Bool("write-host-ca-to-file", true, "Write Host CA public key to ~/.ssh/known_hosts as a separate entry if doesn't already exist") + sshConnectCmd.Flags().String("hostname", "", "Hostname of the SSH host to connect to") + sshConnectCmd.Flags().String("login-user", "", "Login user for the SSH connection") + sshConnectCmd.Flags().String("out-file-path", "", "The path to write the SSH credentials to such as ~/.ssh, ./some_folder, ./some_folder/id_rsa-cert.pub. If not provided, the credentials will be added to the SSH agent and used to establish an interactive SSH connection") sshCmd.AddCommand(sshConnectCmd) sshAddHostCmd.Flags().String("token", "", "Use a machine identity access token") sshAddHostCmd.Flags().String("projectId", "", "Project ID the host belongs to (required)") sshAddHostCmd.Flags().String("hostname", "", "Hostname of the SSH host (required)") - sshAddHostCmd.Flags().Bool("writeUserCaToFile", false, "Write User CA public key to /etc/ssh/infisical_user_ca.pub") - sshAddHostCmd.Flags().String("userCaOutFilePath", "/etc/ssh/infisical_user_ca.pub", "Custom file path to write the User CA public key") - sshAddHostCmd.Flags().Bool("writeHostCertToFile", false, "Write SSH host certificate to /etc/ssh/ssh_host__key-cert.pub") - sshAddHostCmd.Flags().Bool("configureSshd", false, "Update TrustedUserCAKeys, HostKey, and HostCertificate in the sshd_config file") + sshAddHostCmd.Flags().Bool("write-user-ca-to-file", false, "Write User CA public key to /etc/ssh/infisical_user_ca.pub") + sshAddHostCmd.Flags().String("user-ca-out-file-path", "/etc/ssh/infisical_user_ca.pub", "Custom file path to write the User CA public key") + sshAddHostCmd.Flags().Bool("write-host-cert-to-file", false, "Write SSH host certificate to /etc/ssh/ssh_host__key-cert.pub") + sshAddHostCmd.Flags().Bool("configure-sshd", false, "Update TrustedUserCAKeys, HostKey, and HostCertificate in the sshd_config file") sshAddHostCmd.Flags().Bool("force", false, "Force overwrite of existing certificate files as part of writeUserCaToFile and writeHostCertToFile") sshCmd.AddCommand(sshAddHostCmd) diff --git a/cli/packages/cmd/user.go b/cli/packages/cmd/user.go index d3e6096a9..2879e4ccb 100644 --- a/cli/packages/cmd/user.go +++ b/cli/packages/cmd/user.go @@ -1,9 +1,12 @@ package cmd import ( + "encoding/base64" + "encoding/json" "errors" "fmt" "net/url" + "strings" "github.com/Infisical/infisical-merge/packages/config" "github.com/Infisical/infisical-merge/packages/models" @@ -85,6 +88,57 @@ var switchCmd = &cobra.Command{ }, } +var userGetCmd = &cobra.Command{ + Use: "get", + Short: "Used to get properties of an Infisical profile", + DisableFlagsInUseLine: true, + Example: "infisical user get", + Args: cobra.ExactArgs(0), + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +var userGetTokenCmd = &cobra.Command{ + Use: "token", + Short: "Used to get the access token of an Infisical user", + DisableFlagsInUseLine: true, + Example: "infisical user get token", + Args: cobra.ExactArgs(0), + PreRun: func(cmd *cobra.Command, args []string) { + util.RequireLogin() + }, + Run: func(cmd *cobra.Command, args []string) { + loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) + if loggedInUserDetails.LoginExpired { + util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + } + if err != nil { + util.HandleError(err, "[infisical user get token]: Unable to get logged in user token") + } + + tokenParts := strings.Split(loggedInUserDetails.UserCredentials.JTWToken, ".") + if len(tokenParts) != 3 { + util.HandleError(errors.New("invalid token format"), "[infisical user get token]: Invalid token format") + } + + payload, err := base64.RawURLEncoding.DecodeString(tokenParts[1]) + if err != nil { + util.HandleError(err, "[infisical user get token]: Unable to decode token payload") + } + + var tokenPayload struct { + TokenVersionId string `json:"tokenVersionId"` + } + if err := json.Unmarshal(payload, &tokenPayload); err != nil { + util.HandleError(err, "[infisical user get token]: Unable to parse token payload") + } + + fmt.Println("Session ID:", tokenPayload.TokenVersionId) + fmt.Println("Token:", loggedInUserDetails.UserCredentials.JTWToken) + }, +} + var updateCmd = &cobra.Command{ Use: "update", Short: "Used to update properties of an Infisical profile", @@ -185,6 +239,8 @@ var domainCmd = &cobra.Command{ func init() { updateCmd.AddCommand(domainCmd) userCmd.AddCommand(updateCmd) + userGetCmd.AddCommand(userGetTokenCmd) + userCmd.AddCommand(userGetCmd) userCmd.AddCommand(switchCmd) rootCmd.AddCommand(userCmd) } 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/cli/commands/ssh.mdx b/docs/cli/commands/ssh.mdx index 78712ba6f..d99a69dda 100644 --- a/docs/cli/commands/ssh.mdx +++ b/docs/cli/commands/ssh.mdx @@ -7,10 +7,38 @@ description: "Generate SSH credentials with the CLI" [Infisical SSH](/documentation/platform/ssh) lets you issue SSH credentials to clients to provide short-lived, secure SSH access to infrastructure. -This command enables you to obtain SSH credentials used to access a remote host; we recommend using the `issue-credentials` sub-command to generate dynamic SSH credentials for each SSH session. +This command enables you to obtain SSH credentials used to access a remote host. We recommend using the `connect` sub-command which handles the full workflow of issuing credentials and establishing an SSH connection in one step. ### Sub-commands + + This command is used to connect to an SSH host using issued credentials. It will automatically issue credentials and either add them to your SSH agent or write them to disk before establishing an SSH connection. + + ```bash + $ infisical ssh connect + ``` + + ### Flags + + The hostname of the SSH host to connect to. If not provided, you will be prompted to select from available hosts. + + + The login user for the SSH connection. If not provided, you will be prompted to select from available login users. + + + Whether to write the Host CA public key to `~/.ssh/known_hosts` if it doesn't already exist. + + Default value: `true` + + + The path to write the SSH credentials to such as `~/.ssh`, `./some_folder`, `./some_folder/id_rsa-cert.pub`. If not provided, the credentials will be added to the SSH agent and used to establish an interactive SSH connection. + + + An authenticated token to use to authenticate with Infisical. + + + + This command is used to issue SSH credentials (SSH certificate, public key, and private key) against a certificate template. @@ -29,43 +57,44 @@ This command enables you to obtain SSH credentials used to access a remote host; Whether to add issued SSH credentials to the SSH agent. - + Default value: `false` - + Note that either the `--outFilePath` or `--addToAgent` flag must be set for the sub-command to execute successfully. The path to write the SSH credentials to such as `~/.ssh`, `./some_folder`, `./some_folder/id_rsa-cert.pub`. If not provided, the credentials will be saved to the current working directory where the command is run. - + Note that either the `--outFilePath` or `--addToAgent` flag must be set for the sub-command to execute successfully. The key algorithm to issue SSH credentials for. - + Default value: `RSA_2048` - + Available options: `RSA_2048`, `RSA_4096`, `EC_prime256v1`, `EC_secp384r1`. The certificate type to issue SSH credentials for. - + Default value: `user` - + Available options: `user` or `host` The time-to-live (TTL) for the issued SSH certificate (e.g. `2 days`, `1d`, `2h`, `1y`). - + Defaults to the Default TTL value set in the certificate template. A custom Key ID to issue SSH credentials for. - + Defaults to the autogenerated Key ID by Infisical. An authenticated token to use to issue SSH credentials. + @@ -95,22 +124,23 @@ This command enables you to obtain SSH credentials used to access a remote host; The certificate type to issue SSH credentials for. - + Default value: `user` - + Available options: `user` or `host` The time-to-live (TTL) for the issued SSH certificate (e.g. `2 days`, `1d`, `2h`, `1y`). - + Defaults to the Default TTL value set in the certificate template. A custom Key ID to issue SSH credentials for. - + Defaults to the autogenerated Key ID by Infisical. An authenticated token to use to issue SSH credentials. - \ No newline at end of file + + diff --git a/docs/cli/commands/user.mdx b/docs/cli/commands/user.mdx index 38a92bbab..43a6111e1 100644 --- a/docs/cli/commands/user.mdx +++ b/docs/cli/commands/user.mdx @@ -8,22 +8,46 @@ infisical user ``` ## Description + This command allows you to manage the current logged in users on the CLI -### Sub-commands - - Use this command to switch between profiles that are currently logged into the CLI +### Sub-commands + + + Use this command to switch between profiles that are currently logged into the CLI + +```bash +infisical user switch +``` - ```bash - infisical user switch - ``` With this command, you can modify the backend API that is utilized for all requests associated with a specific profile. For instance, you have the option to point the profile to use either the Infisical Cloud or your own self-hosted Infisical instance. - ```bash - infisical user update domain +```bash +infisical user update domain +``` + + + + + Use this command to get your current Infisical access token and session information. This command requires you to be logged in. + + The command will display: + + - Your session ID + - Your full JWT access token + + ```bash + infisical user get token + ``` + + Example output: + + ```bash + Session ID: abc123-xyz-456 + Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... ``` diff --git a/docs/documentation/platform/dynamic-secrets/overview.mdx b/docs/documentation/platform/dynamic-secrets/overview.mdx index 1f17869ef..43e8867ab 100644 --- a/docs/documentation/platform/dynamic-secrets/overview.mdx +++ b/docs/documentation/platform/dynamic-secrets/overview.mdx @@ -41,3 +41,16 @@ Dynamic secrets are particularly useful in environments with stringent security 4. [Oracle](./oracle) 6. [Redis](./redis) 5. [AWS IAM](./aws-iam) + +**FAQ** + + + + This usually happens when the SQL statements defined for creating or revoking the secret are not compatible with your database provider. + + Different SQL engines have different expectations for quoting identifiers and values. For example, some use backticks (`` `username` ``), others use single quotes (`'username'`), and some expect double quotes (`"username"`). A statement that works on one provider might fail on another. + + **Recommendation:** + Make sure to adjust your SQL statements to follow the syntax required by your specific database provider. Always test them directly on your target database to ensure they execute without errors. + + 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/documentation/platform/ssh.mdx b/docs/documentation/platform/ssh.mdx index ae1e43df5..2bc433f75 100644 --- a/docs/documentation/platform/ssh.mdx +++ b/docs/documentation/platform/ssh.mdx @@ -10,10 +10,10 @@ Infisical SSH can be configured to provide users on your team short-lived, secur and improves upon traditional SSH key-based authentication by mitigating private key compromise, static key management, unauthorized access, and SSH key sprawl. -The following entities and concepts are important to understand when using Infisical SSH: +The following entities are important to understand when configuring and using Infisical SSH: - Administrator: An individual on your team who is responsible for configuring Infisical SSH. -- Users: Other individuals on your team that need access to the remote host. +- Users: Other individuals that gain access to remote hosts through Infisical SSH. - Host: A remote machine (e.g. EC2 instance, GCP VM, Azure VM, on-prem Linux server, Raspberry Pi, VMware VM, etc.) that users need SSH access to that is registered with Infisical SSH. ## Workflow @@ -72,7 +72,7 @@ we will register a remote host with Infisical through a [machine identity](/docu Next, use the `infisical ssh add-host` command to register the remote host with Infisical. As part of this command, input the ID of the Infisical SSH project you created in step 1 for the `--projectId` flag and the hostname of the remote host for the `--hostname` flag. ```bash - sudo infisical ssh add-host --projectId= --hostname= --token="$INFISICAL_TOKEN" --writeUserCaToFile --writeHostCertToFile --configureSshd + sudo infisical ssh add-host --projectId= --hostname= --token="$INFISICAL_TOKEN" --write-user-ca-to-file --write-host-cert-to-file --configure-sshd ``` @@ -136,44 +136,66 @@ Once Infisical SSH is configured by an administrator, users can SSH to the remot Follow the instructions [here](/cli/overview) to install the Infisical CLI onto your local machine. - - Run the `infisical login` command to authenticate with Infisical. - - ```bash - infisical login - ``` - - Run the `infisical ssh connect` command to connect to a remote host. + The `infisical ssh connect` command can be used in either interactive or non-interactive mode to connect to a remote host. - ```bash - infisical ssh connect - ``` + + + In interactive mode, you'll first need to authenticate with Infisical by running: - You'll be prompted to select an SSH Host from a list of accessible hosts; this is based on project membership and login mappings configured on hosts by - the administrator. + ```bash + infisical login + ``` - ```bash - Use the arrow keys to navigate: ↓ ↑ → ← - ? Select an SSH Host: - ▸ ec2-12-345-678-910.ap-northeast-1.compute.amazonaws.com - ``` + Then simply run: - After selecting a host, you'll be prompted to select a login user from a list of allowed login users: + ```bash + infisical ssh connect + ``` - ```bash - ? Select Login User: - ▸ ec2-user - ``` + You'll be prompted to select an SSH Host from a list of accessible hosts; this is based on project membership and login mappings configured on hosts by + the administrator. - If successful, you should be able to SSH to the remote host. + ```bash + Use the arrow keys to navigate: ↓ ↑ → ← + ? Select an SSH Host: + ▸ ec2-12-345-678-910.ap-northeast-1.compute.amazonaws.com + ``` - ```bash - ✔ ec2-54-199-104-116.ap-northeast-1.compute.amazonaws.com - ✔ ec2-user - ✔ SSH credentials successfully added to agent - Connecting to ec2-user@ec2-12-345-678-910.ap-northeast-1.compute.amazonaws.com... - ``` + After selecting a host, you'll be prompted to select a login user from a list of allowed login users: + + ```bash + ? Select Login User: + ▸ ec2-user + ``` + + If successful, you should be able to SSH to the remote host. + + ```bash + ✔ ec2-54-199-104-116.ap-northeast-1.compute.amazonaws.com + ✔ ec2-user + ✔ SSH credentials successfully added to agent + Connecting to ec2-user@ec2-12-345-678-910.ap-northeast-1.compute.amazonaws.com... + ``` + + + For CI/CD pipelines or automation scenarios, you can use the non-interactive mode with an Infisical token: + + ```bash + infisical ssh connect \ + --hostname ec2-12-345-678-910.ap-northeast-1.compute.amazonaws.com \ + --login-user ec2-user \ + --out-file-path ~/.ssh/id_rsa-cert.pub \ + --token + ``` + + This will: + - Connect to the specified hostname + - Use the specified login user + - Write the SSH credentials to the specified path instead of adding them to the SSH agent + - Authenticate using the provided Infisical token + + 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 17a187977..ddf8c8899 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" ] @@ -435,6 +436,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" @@ -459,6 +461,7 @@ "integrations/secret-syncs/github", "integrations/secret-syncs/hashicorp-vault", "integrations/secret-syncs/humanitec", + "integrations/secret-syncs/teamcity", "integrations/secret-syncs/terraform-cloud", "integrations/secret-syncs/vercel", "integrations/secret-syncs/windmill" @@ -869,6 +872,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": [ @@ -1057,6 +1073,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": [ @@ -1236,6 +1264,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 04a2fd8ed..1d7a1dd55 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx @@ -13,6 +13,7 @@ import { GcpSyncFields } from "./GcpSyncFields"; import { GitHubSyncFields } from "./GitHubSyncFields"; import { HCVaultSyncFields } from "./HCVaultSyncFields"; import { HumanitecSyncFields } from "./HumanitecSyncFields"; +import { TeamCitySyncFields } from "./TeamCitySyncFields"; import { TerraformCloudSyncFields } from "./TerraformCloudSyncFields"; import { VercelSyncFields } from "./VercelSyncFields"; import { WindmillSyncFields } from "./WindmillSyncFields"; @@ -49,6 +50,8 @@ export const SecretSyncDestinationFields = () => { return ; case SecretSync.HCVault: 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..4f2718089 --- /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 + 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 2373dd14e..e4aa4ad65 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -44,6 +44,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { case SecretSync.Vercel: case SecretSync.Windmill: case SecretSync.HCVault: + 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 9400874b7..62402e540 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -23,6 +23,7 @@ import { GcpSyncReviewFields } from "./GcpSyncReviewFields"; import { GitHubSyncReviewFields } from "./GitHubSyncReviewFields"; import { HCVaultSyncReviewFields } from "./HCVaultSyncReviewFields"; import { HumanitecSyncReviewFields } from "./HumanitecSyncReviewFields"; +import { TeamCitySyncReviewFields } from "./TeamCitySyncReviewFields"; import { TerraformCloudSyncReviewFields } from "./TerraformCloudSyncReviewFields"; import { VercelSyncReviewFields } from "./VercelSyncReviewFields"; import { WindmillSyncReviewFields } from "./WindmillSyncReviewFields"; @@ -92,6 +93,9 @@ export const SecretSyncReviewFields = () => { case SecretSync.HCVault: 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 c0f9454b2..bc6184bc7 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 @@ -10,6 +10,7 @@ import { GcpSyncDestinationSchema } from "./gcp-sync-destination-schema"; import { GitHubSyncDestinationSchema } from "./github-sync-destination-schema"; import { HCVaultSyncDestinationSchema } from "./hc-vault-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"; @@ -27,7 +28,8 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ CamundaSyncDestinationSchema, VercelSyncDestinationSchema, WindmillSyncDestinationSchema, - HCVaultSyncDestinationSchema + HCVaultSyncDestinationSchema, + 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 93458b9c1..289392718 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -16,6 +16,7 @@ import { MsSqlConnectionMethod, PostgresConnectionMethod, TAppConnection, + TeamCityConnectionMethod, TerraformCloudConnectionMethod, VercelConnectionMethod, WindmillConnectionMethod @@ -45,7 +46,8 @@ export const APP_CONNECTION_MAP: Record< [AppConnection.Camunda]: { name: "Camunda", image: "Camunda.png" }, [AppConnection.Windmill]: { name: "Windmill", image: "Windmill.png" }, [AppConnection.Auth0]: { name: "Auth0", image: "Auth0.png", size: 40 }, - [AppConnection.HCVault]: { name: "Hashicorp Vault", image: "Vault.png" } + [AppConnection.HCVault]: { name: "Hashicorp Vault", image: "Vault.png" }, + [AppConnection.TeamCity]: { name: "TeamCity", image: "TeamCity.png" } }; export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) => { @@ -74,6 +76,7 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case MsSqlConnectionMethod.UsernameAndPassword: return { name: "Username & Password", icon: faLock }; case HCVaultConnectionMethod.AccessToken: + 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 1a06ad88b..58d9f3e48 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -43,6 +43,10 @@ export const SECRET_SYNC_MAP: Record = { [SecretSync.Camunda]: AppConnection.Camunda, [SecretSync.Vercel]: AppConnection.Vercel, [SecretSync.Windmill]: AppConnection.Windmill, - [SecretSync.HCVault]: AppConnection.HCVault + [SecretSync.HCVault]: AppConnection.HCVault, + [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 b04cdd938..9b1e06058 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -13,5 +13,6 @@ export enum AppConnection { Camunda = "camunda", Windmill = "windmill", Auth0 = "auth0", - HCVault = "hashicorp-vault" + HCVault = "hashicorp-vault", + 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 043abbdf9..16b0f1b63 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -71,6 +71,10 @@ export type THCVaultConnectionOption = TAppConnectionOptionBase & { app: AppConnection.HCVault; }; +export type TTeamCityConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.TeamCity; +}; + export type TAppConnectionOption = | TAwsConnectionOption | TGitHubConnectionOption @@ -86,7 +90,8 @@ export type TAppConnectionOption = | TCamundaConnectionOption | TWindmillConnectionOption | TAuth0ConnectionOption - | THCVaultConnectionOption; + | THCVaultConnectionOption + | TTeamCityConnectionOption; export type TAppConnectionOptionMap = { [AppConnection.AWS]: TAwsConnectionOption; @@ -104,4 +109,5 @@ export type TAppConnectionOptionMap = { [AppConnection.Windmill]: TWindmillConnectionOption; [AppConnection.Auth0]: TAuth0ConnectionOption; [AppConnection.HCVault]: THCVaultConnectionOption; + [AppConnection.TeamCity]: TTeamCityConnectionOption; }; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index 3e979aacc..55ead2752 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -12,6 +12,7 @@ import { THCVaultConnection } from "./hc-vault-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"; @@ -28,6 +29,7 @@ export * from "./hc-vault-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"; @@ -47,7 +49,8 @@ export type TAppConnection = | TCamundaConnection | TWindmillConnection | TAuth0Connection - | THCVaultConnection; + | THCVaultConnection + | TTeamCityConnection; export type TAvailableAppConnection = Pick; @@ -90,4 +93,5 @@ export type TAppConnectionMap = { [AppConnection.Windmill]: TWindmillConnection; [AppConnection.Auth0]: TAuth0Connection; [AppConnection.HCVault]: THCVaultConnection; + [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 7191ce68f..d078765bc 100644 --- a/frontend/src/hooks/api/secretSyncs/enums.ts +++ b/frontend/src/hooks/api/secretSyncs/enums.ts @@ -11,7 +11,8 @@ export enum SecretSync { Camunda = "camunda", Vercel = "vercel", Windmill = "windmill", - HCVault = "hashicorp-vault" + HCVault = "hashicorp-vault", + 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 189923d7e..2dba65649 100644 --- a/frontend/src/hooks/api/secretSyncs/types/index.ts +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -11,6 +11,7 @@ import { TGcpSync } from "./gcp-sync"; import { TGitHubSync } from "./github-sync"; import { THCVaultSync } from "./hc-vault-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"; @@ -34,7 +35,8 @@ export type TSecretSync = | TCamundaSync | TVercelSync | TWindmillSync - | THCVaultSync; + | THCVaultSync + | 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/users/index.tsx b/frontend/src/hooks/api/users/index.tsx index b9d9f159b..0774275f9 100644 --- a/frontend/src/hooks/api/users/index.tsx +++ b/frontend/src/hooks/api/users/index.tsx @@ -1,6 +1,7 @@ export { useAddUserToWsE2EE, useAddUserToWsNonE2EE, + useRevokeMySessionById, useSendEmailVerificationCode, useVerifyEmailVerificationCode } from "./mutation"; diff --git a/frontend/src/hooks/api/users/mutation.tsx b/frontend/src/hooks/api/users/mutation.tsx index c5cf6c27b..1b873b31c 100644 --- a/frontend/src/hooks/api/users/mutation.tsx +++ b/frontend/src/hooks/api/users/mutation.tsx @@ -171,3 +171,16 @@ export const useResendOrgMemberInvitation = () => { } }); }; + +export const useRevokeMySessionById = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (sessionId: string) => { + const { data } = await apiRequest.delete(`/api/v2/users/me/sessions/${sessionId}`); + return data; + }, + onSuccess() { + queryClient.invalidateQueries({ queryKey: userKeys.mySessions }); + } + }); +}; diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index c0f5f027d..c4defb68d 100644 --- a/frontend/src/hooks/api/workspace/index.tsx +++ b/frontend/src/hooks/api/workspace/index.tsx @@ -4,7 +4,8 @@ export { useLeaveProject, useMigrateProjectToV3, useRequestProjectAccess, - useUpdateGroupWorkspaceRole + useUpdateGroupWorkspaceRole, + useUpdateProjectSshConfig } from "./mutations"; export { useAddIdentityToWorkspace, @@ -14,6 +15,7 @@ export { useDeleteUserFromWorkspace, useDeleteWorkspace, useDeleteWsEnvironment, + useGetProjectSshConfig, useGetUpgradeProjectStatus, useGetUserWorkspaceMemberships, useGetUserWorkspaces, diff --git a/frontend/src/hooks/api/workspace/mutations.tsx b/frontend/src/hooks/api/workspace/mutations.tsx index 56f83f601..ea7376d3d 100644 --- a/frontend/src/hooks/api/workspace/mutations.tsx +++ b/frontend/src/hooks/api/workspace/mutations.tsx @@ -4,7 +4,11 @@ import { apiRequest } from "@app/config/request"; import { userKeys } from "../users/query-keys"; import { workspaceKeys } from "./query-keys"; -import { TUpdateWorkspaceGroupRoleDTO } from "./types"; +import { + TProjectSshConfig, + TUpdateProjectSshConfigDTO, + TUpdateWorkspaceGroupRoleDTO +} from "./types"; export const useAddGroupToWorkspace = () => { const queryClient = useQueryClient(); @@ -117,3 +121,20 @@ export const useRequestProjectAccess = () => { } }); }; + +export const useUpdateProjectSshConfig = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ projectId, defaultUserSshCaId, defaultHostSshCaId }) => { + return apiRequest.patch(`/api/v1/workspace/${projectId}/ssh-config`, { + defaultUserSshCaId, + defaultHostSshCaId + }); + }, + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries({ + queryKey: workspaceKeys.getProjectSshConfig(projectId) + }); + } + }); +}; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index ca8feb6b6..0a2bf491b 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -34,6 +34,7 @@ import { TListProjectIdentitiesDTO, ToggleAutoCapitalizationDTO, ToggleDeleteProjectProtectionDTO, + TProjectSshConfig, TSearchProjectsDTO, TUpdateWorkspaceIdentityRoleDTO, TUpdateWorkspaceUserRoleDTO, @@ -430,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 } @@ -440,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 } } ); @@ -887,3 +896,17 @@ export const useGetWorkspaceSlackConfig = ({ workspaceId }: { workspaceId: strin enabled: Boolean(workspaceId) }); }; + +export const useGetProjectSshConfig = (projectId: string) => { + return useQuery({ + queryKey: workspaceKeys.getProjectSshConfig(projectId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/workspace/${projectId}/ssh-config` + ); + + return data; + }, + enabled: Boolean(projectId) + }); +}; diff --git a/frontend/src/hooks/api/workspace/query-keys.tsx b/frontend/src/hooks/api/workspace/query-keys.tsx index 539ed2ac7..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) => @@ -69,5 +70,6 @@ export const workspaceKeys = { projectId: string; }) => [...workspaceKeys.allWorkspaceSshCertificates(projectId), { offset, limit }] as const, getWorkspaceSshCertificateTemplates: (projectId: string) => - [{ projectId }, "workspace-ssh-certificate-templates"] as const + [{ projectId }, "workspace-ssh-certificate-templates"] as const, + getProjectSshConfig: (projectId: string) => [{ projectId }, "project-ssh-config"] as const }; diff --git a/frontend/src/hooks/api/workspace/types.ts b/frontend/src/hooks/api/workspace/types.ts index 814a920c2..ddcf383fb 100644 --- a/frontend/src/hooks/api/workspace/types.ts +++ b/frontend/src/hooks/api/workspace/types.ts @@ -184,3 +184,18 @@ export type TSearchProjectsDTO = { orderBy?: ProjectIdentityOrderBy; orderDirection?: OrderByDirection; }; + +export type TProjectSshConfig = { + id: string; + createdAt: string; + updatedAt: string; + projectId: string; + defaultUserSshCaId: string | null; + defaultHostSshCaId: string | null; +}; + +export type TUpdateProjectSshConfigDTO = { + projectId: string; + defaultUserSshCaId?: string; + defaultHostSshCaId?: string; +}; diff --git a/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx b/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx index 7aed0a6e8..dc64ccb12 100644 --- a/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx +++ b/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx @@ -4,6 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Link, Outlet, useRouterState } from "@tanstack/react-router"; import { motion } from "framer-motion"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { BreadcrumbContainer, Menu, @@ -11,7 +12,12 @@ import { MenuItem, TBreadcrumbFormat } from "@app/components/v2"; -import { useSubscription, useWorkspace } from "@app/context"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useSubscription, + useWorkspace +} from "@app/context"; import { useGetAccessRequestsCount, useGetSecretApprovalRequestCount, @@ -187,22 +193,31 @@ export const ProjectLayout = () => { )} */} - {/* - {({ isActive }) => ( - - Certificate Authorities - - )} - */} + {(isAllowed) => + isAllowed && ( + + {({ isActive }) => ( + + Certificate Authorities + + )} + + ) + } + )} {isSecretManager && ( diff --git a/frontend/src/lib/fn/string.ts b/frontend/src/lib/fn/string.ts index f4c4a43db..6b2842701 100644 --- a/frontend/src/lib/fn/string.ts +++ b/frontend/src/lib/fn/string.ts @@ -11,3 +11,65 @@ export const formatReservedPaths = (secretPath: string) => { export const camelCaseToSpaces = (input: string) => { return input.replace(/([a-z])([A-Z])/g, "$1 $2"); }; + +export const formatSessionUserAgent = (userAgent: string) => { + const result = { + os: "Unknown", + browser: "Unknown", + device: "Desktop" + }; + + // Operating System detection + if (userAgent.includes("Windows")) { + result.os = "Windows"; + } else if ( + userAgent.includes("Mac OS") || + userAgent.includes("Macintosh") || + userAgent.includes("macOS") + ) { + result.os = "macOS"; + } else if (userAgent.includes("Linux") && !userAgent.includes("Android")) { + result.os = "Linux"; + } else if (userAgent.includes("Android")) { + result.os = "Android"; + result.device = "Mobile"; + } else if ( + userAgent.includes("iOS") || + userAgent.includes("iPhone") || + userAgent.includes("iPad") + ) { + result.os = "iOS"; + result.device = userAgent.includes("iPad") ? "Tablet" : "Mobile"; + } + + // Browser detection + if (userAgent.includes("Firefox/")) { + result.browser = "Firefox"; + } else if (userAgent.includes("Edge/") || userAgent.includes("Edg/")) { + result.browser = "Edge"; + } else if (userAgent.includes("Brave/") || userAgent.includes("Brave ")) { + result.browser = "Brave"; + } else if ( + userAgent.includes("Chrome/") && + !userAgent.includes("Chromium/") && + !userAgent.includes("Edg/") + ) { + result.browser = "Chrome"; + } else if ( + userAgent.includes("Safari/") && + !userAgent.includes("Chrome/") && + !userAgent.includes("Chromium/") + ) { + result.browser = "Safari"; + } else if (userAgent.includes("Opera/") || userAgent.includes("OPR/")) { + result.browser = "Opera"; + } else if (userAgent.includes("Trident/") || userAgent.includes("MSIE")) { + result.browser = "Internet Explorer"; + } + + if (userAgent.toLowerCase() === "cli") { + result.browser = "CLI"; + } + + return result; +}; diff --git a/frontend/src/pages/middlewares/authenticate.tsx b/frontend/src/pages/middlewares/authenticate.tsx index 615d03b60..03005ce05 100644 --- a/frontend/src/pages/middlewares/authenticate.tsx +++ b/frontend/src/pages/middlewares/authenticate.tsx @@ -1,7 +1,9 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; import { AxiosError } from "axios"; +import { addSeconds, formatISO } from "date-fns"; import { createNotification } from "@app/components/notifications"; +import { SessionStorageKeys } from "@app/const"; import { ROUTE_PATHS } from "@app/const/routes"; import { userKeys } from "@app/hooks/api"; import { authKeys, fetchAuthToken } from "@app/hooks/api/auth/queries"; @@ -24,6 +26,16 @@ export const Route = createFileRoute("/_authenticate")({ title: "Access Restricted", text: " You need to log in to access this page. Please log in to continue." }); + + // persist current URL in session storage so that we can come back to this after successful login + sessionStorage.setItem( + SessionStorageKeys.ORG_LOGIN_SUCCESS_REDIRECT_URL, + JSON.stringify({ + expiry: formatISO(addSeconds(new Date(), 60)), + data: window.location.href + }) + ); + throw redirect({ to: "/login" }); 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 14b75b5f8..24dae4370 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -21,6 +21,7 @@ import { HCVaultConnectionForm } from "./HCVaultConnectionForm"; 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"; @@ -92,6 +93,8 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => { return ; case AppConnection.HCVault: return ; + case AppConnection.TeamCity: + return ; default: throw new Error(`Unhandled App ${app}`); } @@ -158,6 +161,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { return ; case AppConnection.HCVault: 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 06105001d..abfbf100c 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 @@ -10,6 +10,7 @@ import { GcpSyncDestinationCol } from "./GcpSyncDestinationCol"; import { GitHubSyncDestinationCol } from "./GitHubSyncDestinationCol"; import { HCVaultSyncDestinationCol } from "./HCVaultSyncDestinationCol"; import { HumanitecSyncDestinationCol } from "./HumanitecSyncDestinationCol"; +import { TeamCitySyncDestinationCol } from "./TeamCitySyncDestinationCol"; import { TerraformCloudSyncDestinationCol } from "./TerraformCloudSyncDestinationCol"; import { VercelSyncDestinationCol } from "./VercelSyncDestinationCol"; import { WindmillSyncDestinationCol } from "./WindmillSyncDestinationCol"; @@ -46,6 +47,8 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => { return ; case SecretSync.HCVault: 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 1dc008014..b1fe387e5 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 @@ -98,6 +98,10 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => { primaryText = destinationConfig.mount; 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 be358f79b..4ea5798d9 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx @@ -20,6 +20,7 @@ import { GcpSyncDestinationSection } from "./GcpSyncDestinationSection"; import { GitHubSyncDestinationSection } from "./GitHubSyncDestinationSection"; import { HCVaultSyncDestinationSection } from "./HCVaultSyncDestinationSection"; import { HumanitecSyncDestinationSection } from "./HumanitecSyncDestinationSection"; +import { TeamCitySyncDestinationSection } from "./TeamCitySyncDestinationSection"; import { TerraformCloudSyncDestinationSection } from "./TerraformCloudSyncDestinationSection"; import { VercelSyncDestinationSection } from "./VercelSyncDestinationSection"; import { WindmillSyncDestinationSection } from "./WindmillSyncDestinationSection"; @@ -77,6 +78,9 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }: case SecretSync.HCVault: 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 f2526e3b6..5995e7cd1 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx @@ -53,6 +53,7 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) = case SecretSync.Vercel: case SecretSync.Windmill: case SecretSync.HCVault: + case SecretSync.TeamCity: AdditionalSyncOptionsComponent = null; break; default: diff --git a/frontend/src/pages/ssh/SettingsPage/SettingsPage.tsx b/frontend/src/pages/ssh/SettingsPage/SettingsPage.tsx index dce31f01b..03d0e1c81 100644 --- a/frontend/src/pages/ssh/SettingsPage/SettingsPage.tsx +++ b/frontend/src/pages/ssh/SettingsPage/SettingsPage.tsx @@ -1,11 +1,12 @@ import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { ProjectGeneralTab } from "./components/ProjectGeneralTab"; - -const tabs = [{ name: "General", key: "tab-project-general", Component: ProjectGeneralTab }]; +import { ProjectSshTab } from "./components/ProjectSshTab"; export const SettingsPage = () => { const { t } = useTranslation(); @@ -17,19 +18,28 @@ export const SettingsPage = () => {
- + - {tabs.map((tab) => ( - - {tab.name} - - ))} + General + + {(isAllowed) => isAllowed && SSH Settings} + - {tabs.map(({ key, Component }) => ( - - - - ))} + + + + + {(isAllowed) => + isAllowed && ( + + + + ) + } +
diff --git a/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/ProjectSshTab.tsx b/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/ProjectSshTab.tsx new file mode 100644 index 000000000..8a79591bc --- /dev/null +++ b/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/ProjectSshTab.tsx @@ -0,0 +1,9 @@ +import { ProjectSshConfigCasSection } from "./components"; + +export const ProjectSshTab = () => { + return ( +
+ +
+ ); +}; diff --git a/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/components/ProjectSshConfigCasSection.tsx b/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/components/ProjectSshConfigCasSection.tsx new file mode 100644 index 000000000..503717326 --- /dev/null +++ b/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/components/ProjectSshConfigCasSection.tsx @@ -0,0 +1,139 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Button, FormControl, Select, SelectItem } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { + useGetProjectSshConfig, + useListWorkspaceSshCas, + useUpdateProjectSshConfig +} from "@app/hooks/api"; + +const schema = z + .object({ + defaultUserSshCaId: z.string(), + defaultHostSshCaId: z.string() + }) + .required(); + +export type FormData = z.infer; + +export const ProjectSshConfigCasSection = () => { + const { currentWorkspace } = useWorkspace(); + const { data: sshConfig } = useGetProjectSshConfig(currentWorkspace.id); + const { data: sshCas } = useListWorkspaceSshCas(currentWorkspace.id); + const { mutate: updateProjectSshConfig } = useUpdateProjectSshConfig(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema) + }); + + useEffect(() => { + if (sshConfig) { + reset({ + defaultUserSshCaId: sshConfig.defaultUserSshCaId || "", + defaultHostSshCaId: sshConfig.defaultHostSshCaId || "" + }); + } + }, [sshConfig]); + + const onFormSubmit = async ({ defaultUserSshCaId, defaultHostSshCaId }: FormData) => { + try { + await updateProjectSshConfig({ + projectId: currentWorkspace.id, + defaultUserSshCaId: defaultUserSshCaId || undefined, + defaultHostSshCaId: defaultHostSshCaId || undefined + }); + + createNotification({ + text: "Successfully updated SSH project settings", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to update SSH project settings", + type: "error" + }); + } + }; + + return ( +
+

Certificate Authorities

+
+ ( + + + + )} + /> + ( + + + + )} + /> + + {(isAllowed) => ( + + )} + + +
+ ); +}; diff --git a/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/components/index.tsx b/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/components/index.tsx new file mode 100644 index 000000000..79010edb9 --- /dev/null +++ b/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/components/index.tsx @@ -0,0 +1 @@ +export { ProjectSshConfigCasSection } from "./ProjectSshConfigCasSection"; diff --git a/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/index.tsx b/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/index.tsx new file mode 100644 index 000000000..2fff359fe --- /dev/null +++ b/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/index.tsx @@ -0,0 +1 @@ +export { ProjectSshTab } from "./ProjectSshTab"; diff --git a/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx b/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx index a779716d4..249fc1b35 100644 --- a/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx +++ b/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx @@ -23,6 +23,7 @@ import { useCreateSshHost, useGetSshHostById, useGetWorkspaceUsers, + useListWorkspaceSshHosts, useUpdateSshHost } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -58,6 +59,7 @@ export type FormData = z.infer; export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { const { currentWorkspace } = useWorkspace(); const projectId = currentWorkspace?.id || ""; + const { data: sshHosts } = useListWorkspaceSshHosts(currentWorkspace.id); const { data: members = [] } = useGetWorkspaceUsers(projectId); const [expandedMappings, setExpandedMappings] = useState>({}); @@ -116,6 +118,18 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { try { if (!projectId) return; + // check if there is already a different host with the same hostname + const existingHostnames = + sshHosts?.filter((h) => h.id !== sshHost?.id).map((h) => h.hostname) || []; + + if (existingHostnames.includes(hostname)) { + createNotification({ + text: "A host with this hostname already exists.", + type: "error" + }); + return; + } + if (sshHost) { await updateMutateAsync({ sshHostId: sshHost.id, diff --git a/frontend/src/pages/user/PersonalSettingsPage/components/SessionsSection/SessionsTable.tsx b/frontend/src/pages/user/PersonalSettingsPage/components/SessionsSection/SessionsTable.tsx index 9a24b1e27..8d10be318 100644 --- a/frontend/src/pages/user/PersonalSettingsPage/components/SessionsSection/SessionsTable.tsx +++ b/frontend/src/pages/user/PersonalSettingsPage/components/SessionsSection/SessionsTable.tsx @@ -1,6 +1,8 @@ import { faServer } from "@fortawesome/free-solid-svg-icons"; +import { createNotification } from "@app/components/notifications"; import { + DeleteActionModal, EmptyState, Table, TableContainer, @@ -9,59 +11,131 @@ import { Td, Th, THead, + Tooltip, Tr } from "@app/components/v2"; -import { useGetMySessions } from "@app/hooks/api"; +import { Button } from "@app/components/v2/Button"; +import { useGetMySessions, useRevokeMySessionById } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; +import { timeAgo } from "@app/lib/fn/date"; +import { formatSessionUserAgent } from "@app/lib/fn/string"; + +const formatLocalDateTime = (date: Date): string => { + return date.toLocaleString(undefined, { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + }); +}; export const SessionsTable = () => { const { data, isPending } = useGetMySessions(); + const { mutateAsync: revokeMySessionById } = useRevokeMySessionById(); + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "deleteSession" + ] as const); - const formatDate = (dateToFormat: string) => { - const date = new Date(dateToFormat); - const year = date.getFullYear(); - const month = date.getMonth() + 1; - const day = date.getDate(); + const handleSignOut = async (sessionId: string) => { + try { + await revokeMySessionById(sessionId); + createNotification({ + text: "Session revoked successfully", + type: "success" + }); + } catch (error) { + console.error(error); + createNotification({ + text: "Failed to revoke session", + type: "error" + }); + } - const formattedDate = `${day}/${month}/${year}`; - - return formattedDate; + handlePopUpClose("deleteSession"); }; return ( - -
- - - - - - - - - - {isPending && } - {!isPending && - data && - data.length > 0 && - data.map(({ id, createdAt, lastUsed, ip, userAgent }) => { - return ( - - - - - - - ); - })} - {!isPending && data && data?.length === 0 && ( + <> + handlePopUpToggle("deleteSession", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + handleSignOut((popUp?.deleteSession?.data as { sessionId: string })?.sessionId) + } + /> + +
CreatedLast activeIP addressDevice
{formatDate(createdAt)}{formatDate(lastUsed)}{ip}{userAgent}
+ - + + + + - )} - -
- - IP & Session IDOS & BrowserLast accessedManage
-
+ + + {isPending && } + {!isPending && + data && + data.length > 0 && + data.map(({ id, createdAt, lastUsed, ip, userAgent }) => { + const { os, browser } = formatSessionUserAgent(userAgent); + const lastUsedDate = new Date(lastUsed); + const createdAtDate = new Date(createdAt); + + return ( + + +
+ {ip} + ID: {id} +
+ + +
+ {os} + {browser} +
+ + +
+ + {timeAgo(lastUsedDate, new Date())} + + + + Created {timeAgo(createdAtDate, new Date())} + + +
+ + + + + + ); + })} + {!isPending && data && data?.length === 0 && ( + + + + + + )} + + + + ); };