diff --git a/.env.example b/.env.example index b54f09921..3f3664f2e 100644 --- a/.env.example +++ b/.env.example @@ -88,3 +88,20 @@ PLAIN_WISH_LABEL_IDS= SSL_CLIENT_CERTIFICATE_HEADER_KEY= ENABLE_MSSQL_SECRET_ROTATION_ENCRYPT=true + +# App Connections + +# aws assume-role +INF_APP_CONNECTION_AWS_ACCESS_KEY_ID= +INF_APP_CONNECTION_AWS_SECRET_ACCESS_KEY= + +# github oauth +INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID= +INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_SECRET= + +#github app +INF_APP_CONNECTION_GITHUB_APP_CLIENT_ID= +INF_APP_CONNECTION_GITHUB_APP_CLIENT_SECRET= +INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY= +INF_APP_CONNECTION_GITHUB_APP_SLUG= +INF_APP_CONNECTION_GITHUB_APP_ID= \ No newline at end of file diff --git a/Dockerfile.fips.standalone-infisical b/Dockerfile.fips.standalone-infisical index 34cd3eed8..75e95eae8 100644 --- a/Dockerfile.fips.standalone-infisical +++ b/Dockerfile.fips.standalone-infisical @@ -137,6 +137,7 @@ RUN apt-get update && apt-get install -y \ freetds-dev \ freetds-bin \ tdsodbc \ + openssh \ && rm -rf /var/lib/apt/lists/* # Configure ODBC in production diff --git a/Dockerfile.standalone-infisical b/Dockerfile.standalone-infisical index cd5477083..98370b2a8 100644 --- a/Dockerfile.standalone-infisical +++ b/Dockerfile.standalone-infisical @@ -139,7 +139,8 @@ RUN apk --update add \ freetds-dev \ bash \ curl \ - git + git \ + openssh # Configure ODBC in production RUN printf "[FreeTDS]\nDescription = FreeTDS Driver\nDriver = /usr/lib/libtdsodbc.so\nSetup = /usr/lib/libtdsodbc.so\nFileUsage = 1\n" > /etc/odbcinst.ini diff --git a/README.md b/README.md index e5b493107..9a825e09d 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ We're on a mission to make security tooling more accessible to everyone, not jus ### Key Management (KMS): -- **[Cryptograhic Keys](https://infisical.com/docs/documentation/platform/kms)**: Centrally manage keys across projects through a user-friendly interface or via the API. +- **[Cryptographic Keys](https://infisical.com/docs/documentation/platform/kms)**: Centrally manage keys across projects through a user-friendly interface or via the API. - **[Encrypt and Decrypt Data](https://infisical.com/docs/documentation/platform/kms#guide-to-encrypting-data)**: Use symmetric keys to encrypt and decrypt data. ### General Platform: diff --git a/backend/Dockerfile b/backend/Dockerfile index 0bb358ee0..5db874e75 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -7,7 +7,8 @@ WORKDIR /app RUN apk --update add \ python3 \ make \ - g++ + g++ \ + openssh # install dependencies for TDS driver (required for SAP ASE dynamic secrets) RUN apk add --no-cache \ diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev index 3eda2ad03..0d1c16574 100644 --- a/backend/Dockerfile.dev +++ b/backend/Dockerfile.dev @@ -17,7 +17,8 @@ RUN apk --update add \ openssl-dev \ python3 \ make \ - g++ + g++ \ + openssh # install dependencies for TDS driver (required for SAP ASE dynamic secrets) RUN apk add --no-cache \ diff --git a/backend/e2e-test/mocks/queue.ts b/backend/e2e-test/mocks/queue.ts index 0028381bd..99e3999e1 100644 --- a/backend/e2e-test/mocks/queue.ts +++ b/backend/e2e-test/mocks/queue.ts @@ -22,8 +22,10 @@ export const mockQueue = (): TQueueServiceFactory => { listen: (name, event) => { events[name] = event; }, + getRepeatableJobs: async () => [], clearQueue: async () => {}, stopJobById: async () => {}, - stopRepeatableJobByJobId: async () => true + stopRepeatableJobByJobId: async () => true, + stopRepeatableJobByKey: async () => true }; }; diff --git a/backend/e2e-test/vitest-environment-knex.ts b/backend/e2e-test/vitest-environment-knex.ts index 66fd4dc75..58f2bffeb 100644 --- a/backend/e2e-test/vitest-environment-knex.ts +++ b/backend/e2e-test/vitest-environment-knex.ts @@ -53,7 +53,7 @@ export default { extension: "ts" }); const smtp = mockSmtpServer(); - const queue = queueServiceFactory(cfg.REDIS_URL, cfg.DB_CONNECTION_URI); + const queue = queueServiceFactory(cfg.REDIS_URL, { dbConnectionUrl: cfg.DB_CONNECTION_URI }); const keyStore = keyStoreFactory(cfg.REDIS_URL); const hsmModule = initializeHsmModule(); diff --git a/backend/package-lock.json b/backend/package-lock.json index 2fba00120..9d0a3c8c9 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -49,7 +49,6 @@ "@sindresorhus/slugify": "1.1.0", "@slack/oauth": "^3.0.1", "@slack/web-api": "^7.3.4", - "@team-plain/typescript-sdk": "^4.6.1", "@ucast/mongo2js": "^1.3.4", "ajv": "^8.12.0", "argon2": "^0.31.2", @@ -5678,14 +5677,6 @@ "uuid": "dist/bin/uuid" } }, - "node_modules/@graphql-typed-document-node/core": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", - "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", - "peerDependencies": { - "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, "node_modules/@grpc/grpc-js": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.12.2.tgz", @@ -9970,18 +9961,6 @@ "optional": true, "peer": true }, - "node_modules/@team-plain/typescript-sdk": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@team-plain/typescript-sdk/-/typescript-sdk-4.6.1.tgz", - "integrity": "sha512-Uy9QJXu9U7bJb6WXL9sArGk7FXPpzdqBd6q8tAF1vexTm8fbTJRqcikTKxGtZmNADt+C2SapH3cApM4oHpO4lQ==", - "dependencies": { - "@graphql-typed-document-node/core": "^3.2.0", - "ajv": "^8.12.0", - "ajv-formats": "^2.1.1", - "graphql": "^16.6.0", - "zod": "3.22.4" - } - }, "node_modules/@techteamer/ocsp": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@techteamer/ocsp/-/ocsp-1.0.1.tgz", @@ -15180,14 +15159,6 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, - "node_modules/graphql": { - "version": "16.9.0", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.9.0.tgz", - "integrity": "sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw==", - "engines": { - "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" - } - }, "node_modules/gtoken": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", diff --git a/backend/package.json b/backend/package.json index 0dafc475c..a7321f67d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -157,7 +157,6 @@ "@sindresorhus/slugify": "1.1.0", "@slack/oauth": "^3.0.1", "@slack/web-api": "^7.3.4", - "@team-plain/typescript-sdk": "^4.6.1", "@ucast/mongo2js": "^1.3.4", "ajv": "^8.12.0", "argon2": "^0.31.2", diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 4221eadcb..02159fa9c 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -31,9 +31,12 @@ import { TSecretApprovalRequestServiceFactory } from "@app/ee/services/secret-ap import { TSecretRotationServiceFactory } from "@app/ee/services/secret-rotation/secret-rotation-service"; import { TSecretScanningServiceFactory } from "@app/ee/services/secret-scanning/secret-scanning-service"; import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service"; +import { TSshCertificateAuthorityServiceFactory } from "@app/ee/services/ssh/ssh-certificate-authority-service"; +import { TSshCertificateTemplateServiceFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-service"; import { TTrustedIpServiceFactory } from "@app/ee/services/trusted-ip/trusted-ip-service"; import { TAuthMode } from "@app/server/plugins/auth/inject-identity"; import { TApiKeyServiceFactory } from "@app/services/api-key/api-key-service"; +import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; import { TAuthLoginFactory } from "@app/services/auth/auth-login-service"; import { TAuthPasswordFactory } from "@app/services/auth/auth-password-service"; import { TAuthSignupFactory } from "@app/services/auth/auth-signup-service"; @@ -52,6 +55,7 @@ import { TIdentityAccessTokenServiceFactory } from "@app/services/identity-acces import { TIdentityAwsAuthServiceFactory } from "@app/services/identity-aws-auth/identity-aws-auth-service"; import { TIdentityAzureAuthServiceFactory } from "@app/services/identity-azure-auth/identity-azure-auth-service"; import { TIdentityGcpAuthServiceFactory } from "@app/services/identity-gcp-auth/identity-gcp-auth-service"; +import { TIdentityJwtAuthServiceFactory } from "@app/services/identity-jwt-auth/identity-jwt-auth-service"; import { TIdentityKubernetesAuthServiceFactory } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-service"; import { TIdentityOidcAuthServiceFactory } from "@app/services/identity-oidc-auth/identity-oidc-auth-service"; import { TIdentityProjectServiceFactory } from "@app/services/identity-project/identity-project-service"; @@ -162,6 +166,7 @@ declare module "fastify" { identityAwsAuth: TIdentityAwsAuthServiceFactory; identityAzureAuth: TIdentityAzureAuthServiceFactory; identityOidcAuth: TIdentityOidcAuthServiceFactory; + identityJwtAuth: TIdentityJwtAuthServiceFactory; accessApprovalPolicy: TAccessApprovalPolicyServiceFactory; accessApprovalRequest: TAccessApprovalRequestServiceFactory; secretApprovalPolicy: TSecretApprovalPolicyServiceFactory; @@ -175,6 +180,8 @@ declare module "fastify" { auditLogStream: TAuditLogStreamServiceFactory; certificate: TCertificateServiceFactory; certificateTemplate: TCertificateTemplateServiceFactory; + sshCertificateAuthority: TSshCertificateAuthorityServiceFactory; + sshCertificateTemplate: TSshCertificateTemplateServiceFactory; certificateAuthority: TCertificateAuthorityServiceFactory; certificateAuthorityCrl: TCertificateAuthorityCrlServiceFactory; certificateEst: TCertificateEstServiceFactory; @@ -202,6 +209,7 @@ declare module "fastify" { externalGroupOrgRoleMapping: TExternalGroupOrgRoleMappingServiceFactory; projectTemplate: TProjectTemplateServiceFactory; totp: TTotpServiceFactory; + appConnection: TAppConnectionServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index f5c44ff79..05dff5a73 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -98,6 +98,9 @@ import { TIdentityGcpAuths, TIdentityGcpAuthsInsert, TIdentityGcpAuthsUpdate, + TIdentityJwtAuths, + TIdentityJwtAuthsInsert, + TIdentityJwtAuthsUpdate, TIdentityKubernetesAuths, TIdentityKubernetesAuthsInsert, TIdentityKubernetesAuthsUpdate, @@ -199,6 +202,9 @@ import { TProjectSlackConfigs, TProjectSlackConfigsInsert, TProjectSlackConfigsUpdate, + TProjectSplitBackfillIds, + TProjectSplitBackfillIdsInsert, + TProjectSplitBackfillIdsUpdate, TProjectsUpdate, TProjectTemplates, TProjectTemplatesInsert, @@ -311,6 +317,21 @@ import { TSlackIntegrations, TSlackIntegrationsInsert, TSlackIntegrationsUpdate, + TSshCertificateAuthorities, + TSshCertificateAuthoritiesInsert, + TSshCertificateAuthoritiesUpdate, + TSshCertificateAuthoritySecrets, + TSshCertificateAuthoritySecretsInsert, + TSshCertificateAuthoritySecretsUpdate, + TSshCertificateBodies, + TSshCertificateBodiesInsert, + TSshCertificateBodiesUpdate, + TSshCertificates, + TSshCertificatesInsert, + TSshCertificatesUpdate, + TSshCertificateTemplates, + TSshCertificateTemplatesInsert, + TSshCertificateTemplatesUpdate, TSuperAdmin, TSuperAdminInsert, TSuperAdminUpdate, @@ -342,6 +363,7 @@ import { TWorkflowIntegrationsInsert, TWorkflowIntegrationsUpdate } from "@app/db/schemas"; +import { TAppConnections, TAppConnectionsInsert, TAppConnectionsUpdate } from "@app/db/schemas/app-connections"; import { TExternalGroupOrgRoleMappings, TExternalGroupOrgRoleMappingsInsert, @@ -372,6 +394,31 @@ declare module "knex/types/tables" { interface Tables { [TableName.Users]: KnexOriginal.CompositeTableType; [TableName.Groups]: KnexOriginal.CompositeTableType; + [TableName.SshCertificateAuthority]: KnexOriginal.CompositeTableType< + TSshCertificateAuthorities, + TSshCertificateAuthoritiesInsert, + TSshCertificateAuthoritiesUpdate + >; + [TableName.SshCertificateAuthoritySecret]: KnexOriginal.CompositeTableType< + TSshCertificateAuthoritySecrets, + TSshCertificateAuthoritySecretsInsert, + TSshCertificateAuthoritySecretsUpdate + >; + [TableName.SshCertificateTemplate]: KnexOriginal.CompositeTableType< + TSshCertificateTemplates, + TSshCertificateTemplatesInsert, + TSshCertificateTemplatesUpdate + >; + [TableName.SshCertificate]: KnexOriginal.CompositeTableType< + TSshCertificates, + TSshCertificatesInsert, + TSshCertificatesUpdate + >; + [TableName.SshCertificateBody]: KnexOriginal.CompositeTableType< + TSshCertificateBodies, + TSshCertificateBodiesInsert, + TSshCertificateBodiesUpdate + >; [TableName.CertificateAuthority]: KnexOriginal.CompositeTableType< TCertificateAuthorities, TCertificateAuthoritiesInsert, @@ -590,6 +637,11 @@ declare module "knex/types/tables" { TIdentityOidcAuthsInsert, TIdentityOidcAuthsUpdate >; + [TableName.IdentityJwtAuth]: KnexOriginal.CompositeTableType< + TIdentityJwtAuths, + TIdentityJwtAuthsInsert, + TIdentityJwtAuthsUpdate + >; [TableName.IdentityUaClientSecret]: KnexOriginal.CompositeTableType< TIdentityUaClientSecrets, TIdentityUaClientSecretsInsert, @@ -830,5 +882,15 @@ declare module "knex/types/tables" { TProjectTemplatesUpdate >; [TableName.TotpConfig]: KnexOriginal.CompositeTableType; + [TableName.ProjectSplitBackfillIds]: KnexOriginal.CompositeTableType< + TProjectSplitBackfillIds, + TProjectSplitBackfillIdsInsert, + TProjectSplitBackfillIdsUpdate + >; + [TableName.AppConnection]: KnexOriginal.CompositeTableType< + TAppConnections, + TAppConnectionsInsert, + TAppConnectionsUpdate + >; } } diff --git a/backend/src/db/migrations/20241203165840_allow-disabling-approval-workflows.ts b/backend/src/db/migrations/20241203165840_allow-disabling-approval-workflows.ts new file mode 100644 index 000000000..c7fb6fe39 --- /dev/null +++ b/backend/src/db/migrations/20241203165840_allow-disabling-approval-workflows.ts @@ -0,0 +1,59 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasAccessApprovalPolicyDeletedAtColumn = await knex.schema.hasColumn( + TableName.AccessApprovalPolicy, + "deletedAt" + ); + const hasSecretApprovalPolicyDeletedAtColumn = await knex.schema.hasColumn( + TableName.SecretApprovalPolicy, + "deletedAt" + ); + + if (!hasAccessApprovalPolicyDeletedAtColumn) { + await knex.schema.alterTable(TableName.AccessApprovalPolicy, (t) => { + t.timestamp("deletedAt"); + }); + } + if (!hasSecretApprovalPolicyDeletedAtColumn) { + await knex.schema.alterTable(TableName.SecretApprovalPolicy, (t) => { + t.timestamp("deletedAt"); + }); + } + + await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => { + t.dropForeign(["privilegeId"]); + + // Add the new foreign key constraint with ON DELETE SET NULL + t.foreign("privilegeId").references("id").inTable(TableName.ProjectUserAdditionalPrivilege).onDelete("SET NULL"); + }); +} + +export async function down(knex: Knex): Promise { + const hasAccessApprovalPolicyDeletedAtColumn = await knex.schema.hasColumn( + TableName.AccessApprovalPolicy, + "deletedAt" + ); + const hasSecretApprovalPolicyDeletedAtColumn = await knex.schema.hasColumn( + TableName.SecretApprovalPolicy, + "deletedAt" + ); + + if (hasAccessApprovalPolicyDeletedAtColumn) { + await knex.schema.alterTable(TableName.AccessApprovalPolicy, (t) => { + t.dropColumn("deletedAt"); + }); + } + if (hasSecretApprovalPolicyDeletedAtColumn) { + await knex.schema.alterTable(TableName.SecretApprovalPolicy, (t) => { + t.dropColumn("deletedAt"); + }); + } + + await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => { + t.dropForeign(["privilegeId"]); + t.foreign("privilegeId").references("id").inTable(TableName.ProjectUserAdditionalPrivilege).onDelete("CASCADE"); + }); +} diff --git a/backend/src/db/migrations/20241209144123_add-identity-jwt-auth.ts b/backend/src/db/migrations/20241209144123_add-identity-jwt-auth.ts new file mode 100644 index 000000000..03594b77c --- /dev/null +++ b/backend/src/db/migrations/20241209144123_add-identity-jwt-auth.ts @@ -0,0 +1,34 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.IdentityJwtAuth))) { + await knex.schema.createTable(TableName.IdentityJwtAuth, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.bigInteger("accessTokenTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenMaxTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenNumUsesLimit").defaultTo(0).notNullable(); + t.jsonb("accessTokenTrustedIps").notNullable(); + t.uuid("identityId").notNullable().unique(); + t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); + t.string("configurationType").notNullable(); + t.string("jwksUrl").notNullable(); + t.binary("encryptedJwksCaCert").notNullable(); + t.binary("encryptedPublicKeys").notNullable(); + t.string("boundIssuer").notNullable(); + t.string("boundAudiences").notNullable(); + t.jsonb("boundClaims").notNullable(); + t.string("boundSubject").notNullable(); + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.IdentityJwtAuth); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.IdentityJwtAuth); + await dropOnUpdateTrigger(knex, TableName.IdentityJwtAuth); +} diff --git a/backend/src/db/migrations/20241213122320_add-index-for-secret-version-v2-folder.ts b/backend/src/db/migrations/20241213122320_add-index-for-secret-version-v2-folder.ts new file mode 100644 index 000000000..96e8f08f6 --- /dev/null +++ b/backend/src/db/migrations/20241213122320_add-index-for-secret-version-v2-folder.ts @@ -0,0 +1,19 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.SecretVersionV2, "folderId")) { + await knex.schema.alterTable(TableName.SecretVersionV2, (t) => { + t.index("folderId"); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.SecretVersionV2, "folderId")) { + await knex.schema.alterTable(TableName.SecretVersionV2, (t) => { + t.dropIndex("folderId"); + }); + } +} diff --git a/backend/src/db/migrations/20241213122350_project-split-to-products.ts b/backend/src/db/migrations/20241213122350_project-split-to-products.ts new file mode 100644 index 000000000..d7a00a801 --- /dev/null +++ b/backend/src/db/migrations/20241213122350_project-split-to-products.ts @@ -0,0 +1,297 @@ +import slugify from "@sindresorhus/slugify"; +import { Knex } from "knex"; +import { v4 as uuidV4 } from "uuid"; + +import { alphaNumericNanoId } from "@app/lib/nanoid"; + +import { ProjectType, TableName } from "../schemas"; + +/* eslint-disable no-await-in-loop,@typescript-eslint/ban-ts-comment */ +const newProject = async (knex: Knex, projectId: string, projectType: ProjectType) => { + const newProjectId = uuidV4(); + const project = await knex(TableName.Project).where("id", projectId).first(); + await knex(TableName.Project).insert({ + ...project, + type: projectType, + // @ts-ignore id is required + id: newProjectId, + slug: slugify(`${project?.name}-${alphaNumericNanoId(4)}`) + }); + + const customRoleMapping: Record = {}; + const projectCustomRoles = await knex(TableName.ProjectRoles).where("projectId", projectId); + if (projectCustomRoles.length) { + await knex.batchInsert( + TableName.ProjectRoles, + projectCustomRoles.map((el) => { + const id = uuidV4(); + customRoleMapping[el.id] = id; + return { + ...el, + id, + projectId: newProjectId, + permissions: el.permissions ? JSON.stringify(el.permissions) : el.permissions + }; + }) + ); + } + const groupMembershipMapping: Record = {}; + const groupMemberships = await knex(TableName.GroupProjectMembership).where("projectId", projectId); + if (groupMemberships.length) { + await knex.batchInsert( + TableName.GroupProjectMembership, + groupMemberships.map((el) => { + const id = uuidV4(); + groupMembershipMapping[el.id] = id; + return { ...el, id, projectId: newProjectId }; + }) + ); + } + + const groupMembershipRoles = await knex(TableName.GroupProjectMembershipRole).whereIn( + "projectMembershipId", + groupMemberships.map((el) => el.id) + ); + if (groupMembershipRoles.length) { + await knex.batchInsert( + TableName.GroupProjectMembershipRole, + groupMembershipRoles.map((el) => { + const id = uuidV4(); + const projectMembershipId = groupMembershipMapping[el.projectMembershipId]; + const customRoleId = el.customRoleId ? customRoleMapping[el.customRoleId] : el.customRoleId; + return { ...el, id, projectMembershipId, customRoleId }; + }) + ); + } + + const identityProjectMembershipMapping: Record = {}; + const identities = await knex(TableName.IdentityProjectMembership).where("projectId", projectId); + if (identities.length) { + await knex.batchInsert( + TableName.IdentityProjectMembership, + identities.map((el) => { + const id = uuidV4(); + identityProjectMembershipMapping[el.id] = id; + return { ...el, id, projectId: newProjectId }; + }) + ); + } + + const identitiesRoles = await knex(TableName.IdentityProjectMembershipRole).whereIn( + "projectMembershipId", + identities.map((el) => el.id) + ); + if (identitiesRoles.length) { + await knex.batchInsert( + TableName.IdentityProjectMembershipRole, + identitiesRoles.map((el) => { + const id = uuidV4(); + const projectMembershipId = identityProjectMembershipMapping[el.projectMembershipId]; + const customRoleId = el.customRoleId ? customRoleMapping[el.customRoleId] : el.customRoleId; + return { ...el, id, projectMembershipId, customRoleId }; + }) + ); + } + + const projectMembershipMapping: Record = {}; + const projectUserMembers = await knex(TableName.ProjectMembership).where("projectId", projectId); + if (projectUserMembers.length) { + await knex.batchInsert( + TableName.ProjectMembership, + projectUserMembers.map((el) => { + const id = uuidV4(); + projectMembershipMapping[el.id] = id; + return { ...el, id, projectId: newProjectId }; + }) + ); + } + const membershipRoles = await knex(TableName.ProjectUserMembershipRole).whereIn( + "projectMembershipId", + projectUserMembers.map((el) => el.id) + ); + if (membershipRoles.length) { + await knex.batchInsert( + TableName.ProjectUserMembershipRole, + membershipRoles.map((el) => { + const id = uuidV4(); + const projectMembershipId = projectMembershipMapping[el.projectMembershipId]; + const customRoleId = el.customRoleId ? customRoleMapping[el.customRoleId] : el.customRoleId; + return { ...el, id, projectMembershipId, customRoleId }; + }) + ); + } + + const kmsKeys = await knex(TableName.KmsKey).where("projectId", projectId).andWhere("isReserved", true); + if (kmsKeys.length) { + await knex.batchInsert( + TableName.KmsKey, + kmsKeys.map((el) => { + const id = uuidV4(); + const slug = slugify(alphaNumericNanoId(8).toLowerCase()); + return { ...el, id, slug, projectId: newProjectId }; + }) + ); + } + + const projectBot = await knex(TableName.ProjectBot).where("projectId", projectId).first(); + if (projectBot) { + const newProjectBot = { ...projectBot, id: uuidV4(), projectId: newProjectId }; + await knex(TableName.ProjectBot).insert(newProjectBot); + } + + const projectKeys = await knex(TableName.ProjectKeys).where("projectId", projectId); + if (projectKeys.length) { + await knex.batchInsert( + TableName.ProjectKeys, + projectKeys.map((el) => { + const id = uuidV4(); + return { ...el, id, projectId: newProjectId }; + }) + ); + } + + return newProjectId; +}; + +const BATCH_SIZE = 500; +export async function up(knex: Knex): Promise { + const hasSplitMappingTable = await knex.schema.hasTable(TableName.ProjectSplitBackfillIds); + if (!hasSplitMappingTable) { + await knex.schema.createTable(TableName.ProjectSplitBackfillIds, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("sourceProjectId", 36).notNullable(); + t.foreign("sourceProjectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.string("destinationProjectType").notNullable(); + t.string("destinationProjectId", 36).notNullable(); + t.foreign("destinationProjectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + }); + } + + const hasTypeColumn = await knex.schema.hasColumn(TableName.Project, "type"); + if (!hasTypeColumn) { + await knex.schema.alterTable(TableName.Project, (t) => { + t.string("type"); + }); + + let projectsToBeTyped; + do { + // eslint-disable-next-line no-await-in-loop + projectsToBeTyped = await knex(TableName.Project).whereNull("type").limit(BATCH_SIZE).select("id"); + if (projectsToBeTyped.length) { + // eslint-disable-next-line no-await-in-loop + await knex(TableName.Project) + .whereIn( + "id", + projectsToBeTyped.map((el) => el.id) + ) + .update({ type: ProjectType.SecretManager }); + } + } while (projectsToBeTyped.length > 0); + + const projectsWithCertificates = await knex(TableName.CertificateAuthority) + .distinct("projectId") + .select("projectId"); + /* eslint-disable no-await-in-loop,no-param-reassign */ + for (const { projectId } of projectsWithCertificates) { + const newProjectId = await newProject(knex, projectId, ProjectType.CertificateManager); + await knex(TableName.CertificateAuthority).where("projectId", projectId).update({ projectId: newProjectId }); + await knex(TableName.PkiAlert).where("projectId", projectId).update({ projectId: newProjectId }); + await knex(TableName.PkiCollection).where("projectId", projectId).update({ projectId: newProjectId }); + await knex(TableName.ProjectSplitBackfillIds).insert({ + sourceProjectId: projectId, + destinationProjectType: ProjectType.CertificateManager, + destinationProjectId: newProjectId + }); + } + + const projectsWithCmek = await knex(TableName.KmsKey) + .where("isReserved", false) + .whereNotNull("projectId") + .distinct("projectId") + .select("projectId"); + for (const { projectId } of projectsWithCmek) { + if (projectId) { + const newProjectId = await newProject(knex, projectId, ProjectType.KMS); + await knex(TableName.KmsKey) + .where({ + isReserved: false, + projectId + }) + .update({ projectId: newProjectId }); + await knex(TableName.ProjectSplitBackfillIds).insert({ + sourceProjectId: projectId, + destinationProjectType: ProjectType.KMS, + destinationProjectId: newProjectId + }); + } + } + + /* eslint-enable */ + await knex.schema.alterTable(TableName.Project, (t) => { + t.string("type").notNullable().alter(); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasTypeColumn = await knex.schema.hasColumn(TableName.Project, "type"); + const hasSplitMappingTable = await knex.schema.hasTable(TableName.ProjectSplitBackfillIds); + + if (hasTypeColumn && hasSplitMappingTable) { + const splitProjectMappings = await knex(TableName.ProjectSplitBackfillIds).where({}); + const certMapping = splitProjectMappings.filter( + (el) => el.destinationProjectType === ProjectType.CertificateManager + ); + /* eslint-disable no-await-in-loop */ + for (const project of certMapping) { + await knex(TableName.CertificateAuthority) + .where("projectId", project.destinationProjectId) + .update({ projectId: project.sourceProjectId }); + await knex(TableName.PkiAlert) + .where("projectId", project.destinationProjectId) + .update({ projectId: project.sourceProjectId }); + await knex(TableName.PkiCollection) + .where("projectId", project.destinationProjectId) + .update({ projectId: project.sourceProjectId }); + } + + /* eslint-enable */ + const kmsMapping = splitProjectMappings.filter((el) => el.destinationProjectType === ProjectType.KMS); + /* eslint-disable no-await-in-loop */ + for (const project of kmsMapping) { + await knex(TableName.KmsKey) + .where({ + isReserved: false, + projectId: project.destinationProjectId + }) + .update({ projectId: project.sourceProjectId }); + } + /* eslint-enable */ + await knex(TableName.ProjectMembership) + .whereIn( + "projectId", + splitProjectMappings.map((el) => el.destinationProjectId) + ) + .delete(); + await knex(TableName.ProjectRoles) + .whereIn( + "projectId", + splitProjectMappings.map((el) => el.destinationProjectId) + ) + .delete(); + await knex(TableName.Project) + .whereIn( + "id", + splitProjectMappings.map((el) => el.destinationProjectId) + ) + .delete(); + + await knex.schema.alterTable(TableName.Project, (t) => { + t.dropColumn("type"); + }); + } + + if (hasSplitMappingTable) { + await knex.schema.dropTableIfExists(TableName.ProjectSplitBackfillIds); + } +} diff --git a/backend/src/db/migrations/20241216013357_ssh-mgmt.ts b/backend/src/db/migrations/20241216013357_ssh-mgmt.ts new file mode 100644 index 000000000..92831d382 --- /dev/null +++ b/backend/src/db/migrations/20241216013357_ssh-mgmt.ts @@ -0,0 +1,99 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.SshCertificateAuthority))) { + await knex.schema.createTable(TableName.SshCertificateAuthority, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.string("status").notNullable(); // active / disabled + t.string("friendlyName").notNullable(); + t.string("keyAlgorithm").notNullable(); + }); + await createOnUpdateTrigger(knex, TableName.SshCertificateAuthority); + } + + if (!(await knex.schema.hasTable(TableName.SshCertificateAuthoritySecret))) { + await knex.schema.createTable(TableName.SshCertificateAuthoritySecret, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("sshCaId").notNullable().unique(); + t.foreign("sshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("CASCADE"); + t.binary("encryptedPrivateKey").notNullable(); + }); + await createOnUpdateTrigger(knex, TableName.SshCertificateAuthoritySecret); + } + + if (!(await knex.schema.hasTable(TableName.SshCertificateTemplate))) { + await knex.schema.createTable(TableName.SshCertificateTemplate, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("sshCaId").notNullable(); + t.foreign("sshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("CASCADE"); + t.string("status").notNullable(); // active / disabled + t.string("name").notNullable(); + t.string("ttl").notNullable(); + t.string("maxTTL").notNullable(); + t.specificType("allowedUsers", "text[]").notNullable(); + t.specificType("allowedHosts", "text[]").notNullable(); + t.boolean("allowUserCertificates").notNullable(); + t.boolean("allowHostCertificates").notNullable(); + t.boolean("allowCustomKeyIds").notNullable(); + }); + await createOnUpdateTrigger(knex, TableName.SshCertificateTemplate); + } + + if (!(await knex.schema.hasTable(TableName.SshCertificate))) { + await knex.schema.createTable(TableName.SshCertificate, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("sshCaId").notNullable(); + t.foreign("sshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("SET NULL"); + t.uuid("sshCertificateTemplateId"); + t.foreign("sshCertificateTemplateId") + .references("id") + .inTable(TableName.SshCertificateTemplate) + .onDelete("SET NULL"); + t.string("serialNumber").notNullable().unique(); + t.string("certType").notNullable(); // user or host + t.specificType("principals", "text[]").notNullable(); + t.string("keyId").notNullable(); + t.datetime("notBefore").notNullable(); + t.datetime("notAfter").notNullable(); + }); + await createOnUpdateTrigger(knex, TableName.SshCertificate); + } + + if (!(await knex.schema.hasTable(TableName.SshCertificateBody))) { + await knex.schema.createTable(TableName.SshCertificateBody, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("sshCertId").notNullable().unique(); + t.foreign("sshCertId").references("id").inTable(TableName.SshCertificate).onDelete("CASCADE"); + t.binary("encryptedCertificate").notNullable(); + }); + + await createOnUpdateTrigger(knex, TableName.SshCertificateBody); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.SshCertificateBody); + await dropOnUpdateTrigger(knex, TableName.SshCertificateBody); + + await knex.schema.dropTableIfExists(TableName.SshCertificate); + await dropOnUpdateTrigger(knex, TableName.SshCertificate); + + await knex.schema.dropTableIfExists(TableName.SshCertificateTemplate); + await dropOnUpdateTrigger(knex, TableName.SshCertificateTemplate); + + await knex.schema.dropTableIfExists(TableName.SshCertificateAuthoritySecret); + await dropOnUpdateTrigger(knex, TableName.SshCertificateAuthoritySecret); + + await knex.schema.dropTableIfExists(TableName.SshCertificateAuthority); + await dropOnUpdateTrigger(knex, TableName.SshCertificateAuthority); +} diff --git a/backend/src/db/migrations/20241218181018_app-connection.ts b/backend/src/db/migrations/20241218181018_app-connection.ts new file mode 100644 index 000000000..d09907ae1 --- /dev/null +++ b/backend/src/db/migrations/20241218181018_app-connection.ts @@ -0,0 +1,28 @@ +import { Knex } from "knex"; + +import { TableName } from "@app/db/schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "@app/db/utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.AppConnection))) { + await knex.schema.createTable(TableName.AppConnection, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("name", 32).notNullable(); + t.string("description"); + t.string("app").notNullable(); + t.string("method").notNullable(); + t.binary("encryptedCredentials").notNullable(); + t.integer("version").defaultTo(1).notNullable(); + t.uuid("orgId").notNullable(); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.AppConnection); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.AppConnection); + await dropOnUpdateTrigger(knex, TableName.AppConnection); +} diff --git a/backend/src/db/schemas/access-approval-policies.ts b/backend/src/db/schemas/access-approval-policies.ts index f4c525a4f..3650face9 100644 --- a/backend/src/db/schemas/access-approval-policies.ts +++ b/backend/src/db/schemas/access-approval-policies.ts @@ -15,7 +15,8 @@ export const AccessApprovalPoliciesSchema = z.object({ envId: z.string().uuid(), createdAt: z.date(), updatedAt: z.date(), - enforcementLevel: z.string().default("hard") + enforcementLevel: z.string().default("hard"), + deletedAt: z.date().nullable().optional() }); export type TAccessApprovalPolicies = z.infer; diff --git a/backend/src/db/schemas/app-connections.ts b/backend/src/db/schemas/app-connections.ts new file mode 100644 index 000000000..8c9dff236 --- /dev/null +++ b/backend/src/db/schemas/app-connections.ts @@ -0,0 +1,27 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const AppConnectionsSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + description: z.string().nullable().optional(), + app: z.string(), + method: z.string(), + encryptedCredentials: zodBuffer, + version: z.number().default(1), + orgId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TAppConnections = z.infer; +export type TAppConnectionsInsert = Omit, TImmutableDBKeys>; +export type TAppConnectionsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/identity-jwt-auths.ts b/backend/src/db/schemas/identity-jwt-auths.ts new file mode 100644 index 000000000..1d3ea9c03 --- /dev/null +++ b/backend/src/db/schemas/identity-jwt-auths.ts @@ -0,0 +1,33 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const IdentityJwtAuthsSchema = z.object({ + id: z.string().uuid(), + accessTokenTTL: z.coerce.number().default(7200), + accessTokenMaxTTL: z.coerce.number().default(7200), + accessTokenNumUsesLimit: z.coerce.number().default(0), + accessTokenTrustedIps: z.unknown(), + identityId: z.string().uuid(), + configurationType: z.string(), + jwksUrl: z.string(), + encryptedJwksCaCert: zodBuffer, + encryptedPublicKeys: zodBuffer, + boundIssuer: z.string(), + boundAudiences: z.string(), + boundClaims: z.unknown(), + boundSubject: z.string(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TIdentityJwtAuths = z.infer; +export type TIdentityJwtAuthsInsert = Omit, TImmutableDBKeys>; +export type TIdentityJwtAuthsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 74741a8ff..19f45eb33 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -30,6 +30,7 @@ export * from "./identity-access-tokens"; export * from "./identity-aws-auths"; export * from "./identity-azure-auths"; export * from "./identity-gcp-auths"; +export * from "./identity-jwt-auths"; export * from "./identity-kubernetes-auths"; export * from "./identity-metadata"; export * from "./identity-oidc-auths"; @@ -64,6 +65,7 @@ export * from "./project-keys"; export * from "./project-memberships"; export * from "./project-roles"; export * from "./project-slack-configs"; +export * from "./project-split-backfill-ids"; export * from "./project-templates"; export * from "./project-user-additional-privilege"; export * from "./project-user-membership-roles"; @@ -105,6 +107,11 @@ export * from "./secrets"; export * from "./secrets-v2"; export * from "./service-tokens"; export * from "./slack-integrations"; +export * from "./ssh-certificate-authorities"; +export * from "./ssh-certificate-authority-secrets"; +export * from "./ssh-certificate-bodies"; +export * from "./ssh-certificate-templates"; +export * from "./ssh-certificates"; export * from "./super-admin"; export * from "./totp-configs"; export * from "./trusted-ips"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 171931f7e..620c526a5 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -2,6 +2,11 @@ import { z } from "zod"; export enum TableName { Users = "users", + SshCertificateAuthority = "ssh_certificate_authorities", + SshCertificateAuthoritySecret = "ssh_certificate_authority_secrets", + SshCertificateTemplate = "ssh_certificate_templates", + SshCertificate = "ssh_certificates", + SshCertificateBody = "ssh_certificate_bodies", CertificateAuthority = "certificate_authorities", CertificateTemplateEstConfig = "certificate_template_est_configs", CertificateAuthorityCert = "certificate_authority_certs", @@ -68,6 +73,7 @@ export enum TableName { IdentityUaClientSecret = "identity_ua_client_secrets", IdentityAwsAuth = "identity_aws_auths", IdentityOidcAuth = "identity_oidc_auths", + IdentityJwtAuth = "identity_jwt_auths", IdentityOrgMembership = "identity_org_memberships", IdentityProjectMembership = "identity_project_memberships", IdentityProjectMembershipRole = "identity_project_membership_role", @@ -105,6 +111,7 @@ export enum TableName { SecretApprovalRequestSecretV2 = "secret_approval_requests_secrets_v2", SecretApprovalRequestSecretTagV2 = "secret_approval_request_secret_tags_v2", SnapshotSecretV2 = "secret_snapshot_secrets_v2", + ProjectSplitBackfillIds = "project_split_backfill_ids", // junction tables with tags SecretV2JnTag = "secret_v2_tag_junction", JnSecretTag = "secret_tag_junction", @@ -122,7 +129,8 @@ export enum TableName { KmsKeyVersion = "kms_key_versions", WorkflowIntegrations = "workflow_integrations", SlackIntegrations = "slack_integrations", - ProjectSlackConfigs = "project_slack_configs" + ProjectSlackConfigs = "project_slack_configs", + AppConnection = "app_connections" } export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt"; @@ -196,5 +204,13 @@ export enum IdentityAuthMethod { GCP_AUTH = "gcp-auth", AWS_AUTH = "aws-auth", AZURE_AUTH = "azure-auth", - OIDC_AUTH = "oidc-auth" + OIDC_AUTH = "oidc-auth", + JWT_AUTH = "jwt-auth" +} + +export enum ProjectType { + SecretManager = "secret-manager", + CertificateManager = "cert-manager", + KMS = "kms", + SSH = "ssh" } diff --git a/backend/src/db/schemas/project-split-backfill-ids.ts b/backend/src/db/schemas/project-split-backfill-ids.ts new file mode 100644 index 000000000..182d85049 --- /dev/null +++ b/backend/src/db/schemas/project-split-backfill-ids.ts @@ -0,0 +1,21 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const ProjectSplitBackfillIdsSchema = z.object({ + id: z.string().uuid(), + sourceProjectId: z.string(), + destinationProjectType: z.string(), + destinationProjectId: z.string() +}); + +export type TProjectSplitBackfillIds = z.infer; +export type TProjectSplitBackfillIdsInsert = Omit, TImmutableDBKeys>; +export type TProjectSplitBackfillIdsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/projects.ts b/backend/src/db/schemas/projects.ts index 5c5f9774b..ec43be292 100644 --- a/backend/src/db/schemas/projects.ts +++ b/backend/src/db/schemas/projects.ts @@ -24,7 +24,8 @@ export const ProjectsSchema = z.object({ auditLogsRetentionDays: z.number().nullable().optional(), kmsSecretManagerKeyId: z.string().uuid().nullable().optional(), kmsSecretManagerEncryptedDataKey: zodBuffer.nullable().optional(), - description: z.string().nullable().optional() + description: z.string().nullable().optional(), + type: z.string() }); export type TProjects = z.infer; diff --git a/backend/src/db/schemas/secret-approval-policies.ts b/backend/src/db/schemas/secret-approval-policies.ts index 94aeba050..06ae3e5c4 100644 --- a/backend/src/db/schemas/secret-approval-policies.ts +++ b/backend/src/db/schemas/secret-approval-policies.ts @@ -15,7 +15,8 @@ export const SecretApprovalPoliciesSchema = z.object({ envId: z.string().uuid(), createdAt: z.date(), updatedAt: z.date(), - enforcementLevel: z.string().default("hard") + enforcementLevel: z.string().default("hard"), + deletedAt: z.date().nullable().optional() }); export type TSecretApprovalPolicies = z.infer; diff --git a/backend/src/db/schemas/ssh-certificate-authorities.ts b/backend/src/db/schemas/ssh-certificate-authorities.ts new file mode 100644 index 000000000..81e789288 --- /dev/null +++ b/backend/src/db/schemas/ssh-certificate-authorities.ts @@ -0,0 +1,24 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SshCertificateAuthoritiesSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + projectId: z.string(), + status: z.string(), + friendlyName: z.string(), + keyAlgorithm: z.string() +}); + +export type TSshCertificateAuthorities = z.infer; +export type TSshCertificateAuthoritiesInsert = Omit, TImmutableDBKeys>; +export type TSshCertificateAuthoritiesUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/ssh-certificate-authority-secrets.ts b/backend/src/db/schemas/ssh-certificate-authority-secrets.ts new file mode 100644 index 000000000..934c10ab2 --- /dev/null +++ b/backend/src/db/schemas/ssh-certificate-authority-secrets.ts @@ -0,0 +1,27 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SshCertificateAuthoritySecretsSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + sshCaId: z.string().uuid(), + encryptedPrivateKey: zodBuffer +}); + +export type TSshCertificateAuthoritySecrets = z.infer; +export type TSshCertificateAuthoritySecretsInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TSshCertificateAuthoritySecretsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/ssh-certificate-bodies.ts b/backend/src/db/schemas/ssh-certificate-bodies.ts new file mode 100644 index 000000000..baafb773b --- /dev/null +++ b/backend/src/db/schemas/ssh-certificate-bodies.ts @@ -0,0 +1,22 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SshCertificateBodiesSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + sshCertId: z.string().uuid(), + encryptedCertificate: zodBuffer +}); + +export type TSshCertificateBodies = z.infer; +export type TSshCertificateBodiesInsert = Omit, TImmutableDBKeys>; +export type TSshCertificateBodiesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/ssh-certificate-templates.ts b/backend/src/db/schemas/ssh-certificate-templates.ts new file mode 100644 index 000000000..6c16c3942 --- /dev/null +++ b/backend/src/db/schemas/ssh-certificate-templates.ts @@ -0,0 +1,30 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SshCertificateTemplatesSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + sshCaId: z.string().uuid(), + status: z.string(), + name: z.string(), + ttl: z.string(), + maxTTL: z.string(), + allowedUsers: z.string().array(), + allowedHosts: z.string().array(), + allowUserCertificates: z.boolean(), + allowHostCertificates: z.boolean(), + allowCustomKeyIds: z.boolean() +}); + +export type TSshCertificateTemplates = z.infer; +export type TSshCertificateTemplatesInsert = Omit, TImmutableDBKeys>; +export type TSshCertificateTemplatesUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/ssh-certificates.ts b/backend/src/db/schemas/ssh-certificates.ts new file mode 100644 index 000000000..6fe5bc261 --- /dev/null +++ b/backend/src/db/schemas/ssh-certificates.ts @@ -0,0 +1,26 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SshCertificatesSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + sshCaId: z.string().uuid(), + sshCertificateTemplateId: z.string().uuid().nullable().optional(), + serialNumber: z.string(), + certType: z.string(), + principals: z.string().array(), + keyId: z.string(), + notBefore: z.date(), + notAfter: z.date() +}); + +export type TSshCertificates = z.infer; +export type TSshCertificatesInsert = Omit, TImmutableDBKeys>; +export type TSshCertificatesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/seeds/3-project.ts b/backend/src/db/seeds/3-project.ts index 934130494..b6c80bb63 100644 --- a/backend/src/db/seeds/3-project.ts +++ b/backend/src/db/seeds/3-project.ts @@ -4,7 +4,7 @@ import { Knex } from "knex"; import { encryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto"; -import { ProjectMembershipRole, SecretEncryptionAlgo, SecretKeyEncoding, TableName } from "../schemas"; +import { ProjectMembershipRole, ProjectType, SecretEncryptionAlgo, SecretKeyEncoding, TableName } from "../schemas"; import { buildUserProjectKey, getUserPrivateKey, seedData1 } from "../seed-data"; export const DEFAULT_PROJECT_ENVS = [ @@ -24,6 +24,7 @@ export async function seed(knex: Knex): Promise { name: seedData1.project.name, orgId: seedData1.organization.id, slug: "first-project", + type: ProjectType.SecretManager, // eslint-disable-next-line // @ts-ignore id: seedData1.project.id diff --git a/backend/src/db/seeds/4-project-v3.ts b/backend/src/db/seeds/4-project-v3.ts index 60431919d..f89b965a6 100644 --- a/backend/src/db/seeds/4-project-v3.ts +++ b/backend/src/db/seeds/4-project-v3.ts @@ -1,6 +1,6 @@ import { Knex } from "knex"; -import { ProjectMembershipRole, ProjectVersion, TableName } from "../schemas"; +import { ProjectMembershipRole, ProjectType, ProjectVersion, TableName } from "../schemas"; import { seedData1 } from "../seed-data"; export const DEFAULT_PROJECT_ENVS = [ @@ -16,6 +16,7 @@ export async function seed(knex: Knex): Promise { orgId: seedData1.organization.id, slug: seedData1.projectV3.slug, version: ProjectVersion.V3, + type: ProjectType.SecretManager, // eslint-disable-next-line // @ts-ignore id: seedData1.projectV3.id diff --git a/backend/src/ee/routes/v1/access-approval-request-router.ts b/backend/src/ee/routes/v1/access-approval-request-router.ts index 7dbb62fc2..4aa26eb36 100644 --- a/backend/src/ee/routes/v1/access-approval-request-router.ts +++ b/backend/src/ee/routes/v1/access-approval-request-router.ts @@ -109,7 +109,8 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv approvers: z.string().array(), secretPath: z.string().nullish(), envId: z.string(), - enforcementLevel: z.string() + enforcementLevel: z.string(), + deletedAt: z.date().nullish() }), reviewers: z .object({ diff --git a/backend/src/ee/routes/v1/dynamic-secret-router.ts b/backend/src/ee/routes/v1/dynamic-secret-router.ts index 4b1566c55..1d24c0578 100644 --- a/backend/src/ee/routes/v1/dynamic-secret-router.ts +++ b/backend/src/ee/routes/v1/dynamic-secret-router.ts @@ -1,4 +1,3 @@ -import slugify from "@sindresorhus/slugify"; import ms from "ms"; import { z } from "zod"; @@ -8,6 +7,7 @@ import { DYNAMIC_SECRETS } from "@app/lib/api-docs"; import { daysToMillisecond } from "@app/lib/dates"; import { removeTrailingSlash } from "@app/lib/fn"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { SanitizedDynamicSecretSchema } from "@app/server/routes/sanitizedSchemas"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -48,15 +48,7 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => .nullable(), path: z.string().describe(DYNAMIC_SECRETS.CREATE.path).trim().default("/").transform(removeTrailingSlash), environmentSlug: z.string().describe(DYNAMIC_SECRETS.CREATE.environmentSlug).min(1), - name: z - .string() - .describe(DYNAMIC_SECRETS.CREATE.name) - .min(1) - .toLowerCase() - .max(64) - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid" - }) + name: slugSchema({ min: 1, max: 64, field: "Name" }).describe(DYNAMIC_SECRETS.CREATE.name) }), response: { 200: z.object({ diff --git a/backend/src/ee/routes/v1/group-router.ts b/backend/src/ee/routes/v1/group-router.ts index 780e5ec00..67f955ecb 100644 --- a/backend/src/ee/routes/v1/group-router.ts +++ b/backend/src/ee/routes/v1/group-router.ts @@ -1,8 +1,9 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { GroupsSchema, OrgMembershipRole, UsersSchema } from "@app/db/schemas"; +import { EFilterReturnedUsers } from "@app/ee/services/group/group-types"; import { GROUPS } from "@app/lib/api-docs"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -14,15 +15,7 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { schema: { body: z.object({ name: z.string().trim().min(1).max(50).describe(GROUPS.CREATE.name), - slug: z - .string() - .min(5) - .max(36) - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .optional() - .describe(GROUPS.CREATE.slug), + slug: slugSchema({ min: 5, max: 36 }).optional().describe(GROUPS.CREATE.slug), role: z.string().trim().min(1).default(OrgMembershipRole.NoAccess).describe(GROUPS.CREATE.role) }), response: { @@ -100,14 +93,7 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { body: z .object({ name: z.string().trim().min(1).describe(GROUPS.UPDATE.name), - slug: z - .string() - .min(5) - .max(36) - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .describe(GROUPS.UPDATE.slug), + slug: slugSchema({ min: 5, max: 36 }).describe(GROUPS.UPDATE.slug), role: z.string().trim().min(1).describe(GROUPS.UPDATE.role) }) .partial(), @@ -166,7 +152,8 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { offset: z.coerce.number().min(0).max(100).default(0).describe(GROUPS.LIST_USERS.offset), limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_USERS.limit), username: z.string().trim().optional().describe(GROUPS.LIST_USERS.username), - search: z.string().trim().optional().describe(GROUPS.LIST_USERS.search) + search: z.string().trim().optional().describe(GROUPS.LIST_USERS.search), + filter: z.nativeEnum(EFilterReturnedUsers).optional().describe(GROUPS.LIST_USERS.filterUsers) }), response: { 200: z.object({ @@ -179,7 +166,8 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { }) .merge( z.object({ - isPartOfGroup: z.boolean() + isPartOfGroup: z.boolean(), + joinedGroupAt: z.date().nullable() }) ) .array(), diff --git a/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts b/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts index d342f95ce..1eadb4051 100644 --- a/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts +++ b/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts @@ -8,6 +8,7 @@ import { IDENTITY_ADDITIONAL_PRIVILEGE } from "@app/lib/api-docs"; import { UnauthorizedError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { ProjectPermissionSchema, @@ -33,17 +34,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F body: z.object({ identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.identityId), projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.projectSlug), - slug: z - .string() - .min(1) - .max(60) - .trim() - .refine((val) => val.toLowerCase() === val, "Must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .optional() - .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug), + slug: slugSchema({ min: 1, max: 60 }).optional().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug), permissions: ProjectPermissionSchema.array() .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions) .optional(), @@ -77,7 +68,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F actorOrgId: req.permission.orgId, actorAuthMethod: req.permission.authMethod, ...req.body, - slug: req.body.slug ? slugify(req.body.slug) : slugify(alphaNumericNanoId(12)), + slug: req.body.slug ?? slugify(alphaNumericNanoId(12)), isTemporary: false, // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore-error this is valid ts @@ -103,17 +94,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F body: z.object({ identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.identityId), projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.projectSlug), - slug: z - .string() - .min(1) - .max(60) - .trim() - .refine((val) => val.toLowerCase() === val, "Must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .optional() - .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug), + slug: slugSchema({ min: 1, max: 60 }).optional().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug), permissions: ProjectPermissionSchema.array() .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions) .optional(), @@ -159,7 +140,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F actorOrgId: req.permission.orgId, actorAuthMethod: req.permission.authMethod, ...req.body, - slug: req.body.slug ? slugify(req.body.slug) : slugify(alphaNumericNanoId(12)), + slug: req.body.slug ?? slugify(alphaNumericNanoId(12)), isTemporary: true, // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore-error this is valid ts @@ -189,16 +170,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.projectSlug), privilegeDetails: z .object({ - slug: z - .string() - .min(1) - .max(60) - .trim() - .refine((val) => val.toLowerCase() === val, "Must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.newSlug), + slug: slugSchema({ min: 1, max: 60 }).describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.newSlug), permissions: ProjectPermissionSchema.array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.permissions), privilegePermission: ProjectSpecificPrivilegePermissionSchema.describe( IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.privilegePermission diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 5e3a0eafe..6a5ddf51e 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -25,6 +25,9 @@ import { registerSecretRotationRouter } from "./secret-rotation-router"; import { registerSecretScanningRouter } from "./secret-scanning-router"; import { registerSecretVersionRouter } from "./secret-version-router"; import { registerSnapshotRouter } from "./snapshot-router"; +import { registerSshCaRouter } from "./ssh-certificate-authority-router"; +import { registerSshCertRouter } from "./ssh-certificate-router"; +import { registerSshCertificateTemplateRouter } from "./ssh-certificate-template-router"; import { registerTrustedIpRouter } from "./trusted-ip-router"; import { registerUserAdditionalPrivilegeRouter } from "./user-additional-privilege-router"; @@ -68,6 +71,15 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { { prefix: "/pki" } ); + await server.register( + async (sshRouter) => { + await sshRouter.register(registerSshCaRouter, { prefix: "/ca" }); + await sshRouter.register(registerSshCertRouter, { prefix: "/certificates" }); + await sshRouter.register(registerSshCertificateTemplateRouter, { prefix: "/certificate-templates" }); + }, + { prefix: "/ssh" } + ); + await server.register( async (ssoRouter) => { await ssoRouter.register(registerSamlRouter); diff --git a/backend/src/ee/routes/v1/org-role-router.ts b/backend/src/ee/routes/v1/org-role-router.ts index 232f4b0b5..3a0ad47da 100644 --- a/backend/src/ee/routes/v1/org-role-router.ts +++ b/backend/src/ee/routes/v1/org-role-router.ts @@ -1,8 +1,8 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { OrgMembershipRole, OrgMembershipsSchema, OrgRolesSchema } from "@app/db/schemas"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -18,19 +18,12 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { organizationId: z.string().trim() }), body: z.object({ - slug: z - .string() - .min(1) - .trim() - .refine( - (val) => !Object.values(OrgMembershipRole).includes(val as OrgMembershipRole), - "Please choose a different slug, the slug you have entered is reserved" - ) - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid" - }), + slug: slugSchema({ min: 1, max: 64 }).refine( + (val) => !Object.values(OrgMembershipRole).includes(val as OrgMembershipRole), + "Please choose a different slug, the slug you have entered is reserved" + ), name: z.string().trim(), - description: z.string().trim().optional(), + description: z.string().trim().nullish(), permissions: z.any().array() }), response: { @@ -94,19 +87,15 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { roleId: z.string().trim() }), body: z.object({ - slug: z - .string() - .trim() - .optional() + // TODO: Switch to slugSchema after verifying correct methods with Akhil - Omar 11/24 + slug: slugSchema({ min: 1, max: 64 }) .refine( - (val) => typeof val !== "undefined" && !Object.keys(OrgMembershipRole).includes(val), + (val) => !Object.keys(OrgMembershipRole).includes(val), "Please choose a different slug, the slug you have entered is reserved." ) - .refine((val) => typeof val === "undefined" || slugify(val) === val, { - message: "Slug must be a valid" - }), + .optional(), name: z.string().trim().optional(), - description: z.string().trim().optional(), + description: z.string().trim().nullish(), permissions: z.any().array().optional() }), response: { diff --git a/backend/src/ee/routes/v1/project-role-router.ts b/backend/src/ee/routes/v1/project-role-router.ts index ba2c0aa9f..469460491 100644 --- a/backend/src/ee/routes/v1/project-role-router.ts +++ b/backend/src/ee/routes/v1/project-role-router.ts @@ -1,5 +1,4 @@ import { packRules } from "@casl/ability/extra"; -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { ProjectMembershipRole, ProjectMembershipsSchema, ProjectRolesSchema } from "@app/db/schemas"; @@ -9,6 +8,7 @@ import { } from "@app/ee/services/permission/project-permission"; import { PROJECT_ROLE } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { SanitizedRoleSchemaV1 } from "@app/server/routes/sanitizedSchemas"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -32,21 +32,14 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { projectSlug: z.string().trim().describe(PROJECT_ROLE.CREATE.projectSlug) }), body: z.object({ - slug: z - .string() - .toLowerCase() - .trim() - .min(1) + slug: slugSchema({ max: 64 }) .refine( (val) => !Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole), "Please choose a different slug, the slug you have entered is reserved" ) - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid" - }) .describe(PROJECT_ROLE.CREATE.slug), name: z.string().min(1).trim().describe(PROJECT_ROLE.CREATE.name), - description: z.string().trim().optional().describe(PROJECT_ROLE.CREATE.description), + description: z.string().trim().nullish().describe(PROJECT_ROLE.CREATE.description), permissions: ProjectPermissionV1Schema.array().describe(PROJECT_ROLE.CREATE.permissions) }), response: { @@ -94,23 +87,15 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { roleId: z.string().trim().describe(PROJECT_ROLE.UPDATE.roleId) }), body: z.object({ - slug: z - .string() - .toLowerCase() - .trim() - .optional() - .describe(PROJECT_ROLE.UPDATE.slug) + slug: slugSchema({ max: 64 }) .refine( - (val) => - typeof val === "undefined" || - !Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole), + (val) => !Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole), "Please choose a different slug, the slug you have entered is reserved" ) - .refine((val) => typeof val === "undefined" || slugify(val) === val, { - message: "Slug must be a valid" - }), + .describe(PROJECT_ROLE.UPDATE.slug) + .optional(), name: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.name), - description: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.description), + description: z.string().trim().nullish().describe(PROJECT_ROLE.UPDATE.description), permissions: ProjectPermissionV1Schema.array().describe(PROJECT_ROLE.UPDATE.permissions).optional() }), response: { diff --git a/backend/src/ee/routes/v1/project-template-router.ts b/backend/src/ee/routes/v1/project-template-router.ts index 5b115ab4e..60f93d65d 100644 --- a/backend/src/ee/routes/v1/project-template-router.ts +++ b/backend/src/ee/routes/v1/project-template-router.ts @@ -1,4 +1,3 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { ProjectMembershipRole, ProjectTemplatesSchema } from "@app/db/schemas"; @@ -8,22 +7,13 @@ import { ProjectTemplateDefaultEnvironments } from "@app/ee/services/project-tem import { isInfisicalProjectTemplate } from "@app/ee/services/project-template/project-template-fns"; import { ProjectTemplates } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { UnpackedPermissionSchema } from "@app/server/routes/santizedSchemas/permission"; import { AuthMode } from "@app/services/auth/auth-type"; const MAX_JSON_SIZE_LIMIT_IN_BYTES = 32_768; -const SlugSchema = z - .string() - .trim() - .min(1) - .max(32) - .refine((val) => val.toLowerCase() === val, "Must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Must be valid slug format" - }); - const isReservedRoleSlug = (slug: string) => Object.values(ProjectMembershipRole).includes(slug as ProjectMembershipRole); @@ -34,14 +24,14 @@ const SanitizedProjectTemplateSchema = ProjectTemplatesSchema.extend({ roles: z .object({ name: z.string().trim().min(1), - slug: SlugSchema, + slug: slugSchema(), permissions: UnpackedPermissionSchema.array() }) .array(), environments: z .object({ name: z.string().trim().min(1), - slug: SlugSchema, + slug: slugSchema(), position: z.number().min(1) }) .array() @@ -50,7 +40,7 @@ const SanitizedProjectTemplateSchema = ProjectTemplatesSchema.extend({ const ProjectTemplateRolesSchema = z .object({ name: z.string().trim().min(1), - slug: SlugSchema, + slug: slugSchema(), permissions: ProjectPermissionV2Schema.array() }) .array() @@ -78,7 +68,7 @@ const ProjectTemplateRolesSchema = z const ProjectTemplateEnvironmentsSchema = z .object({ name: z.string().trim().min(1), - slug: SlugSchema, + slug: slugSchema(), position: z.number().min(1) }) .array() @@ -188,9 +178,11 @@ export const registerProjectTemplateRouter = async (server: FastifyZodProvider) schema: { description: "Create a project template.", body: z.object({ - name: SlugSchema.refine((val) => !isInfisicalProjectTemplate(val), { - message: `The requested project template name is reserved.` - }).describe(ProjectTemplates.CREATE.name), + name: slugSchema({ field: "name" }) + .refine((val) => !isInfisicalProjectTemplate(val), { + message: `The requested project template name is reserved.` + }) + .describe(ProjectTemplates.CREATE.name), description: z.string().max(256).trim().optional().describe(ProjectTemplates.CREATE.description), roles: ProjectTemplateRolesSchema.default([]).describe(ProjectTemplates.CREATE.roles), environments: ProjectTemplateEnvironmentsSchema.default(ProjectTemplateDefaultEnvironments).describe( @@ -230,9 +222,10 @@ export const registerProjectTemplateRouter = async (server: FastifyZodProvider) description: "Update a project template.", params: z.object({ templateId: z.string().uuid().describe(ProjectTemplates.UPDATE.templateId) }), body: z.object({ - name: SlugSchema.refine((val) => !isInfisicalProjectTemplate(val), { - message: `The requested project template name is reserved.` - }) + name: slugSchema({ field: "name" }) + .refine((val) => !isInfisicalProjectTemplate(val), { + message: `The requested project template name is reserved.` + }) .optional() .describe(ProjectTemplates.UPDATE.name), description: z.string().max(256).trim().optional().describe(ProjectTemplates.UPDATE.description), diff --git a/backend/src/ee/routes/v1/secret-approval-request-router.ts b/backend/src/ee/routes/v1/secret-approval-request-router.ts index 5fbf784f6..e1c56583c 100644 --- a/backend/src/ee/routes/v1/secret-approval-request-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-request-router.ts @@ -52,7 +52,8 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv }) .array(), secretPath: z.string().optional().nullable(), - enforcementLevel: z.string() + enforcementLevel: z.string(), + deletedAt: z.date().nullish() }), committerUser: approvalRequestUser, commits: z.object({ op: z.string(), secretId: z.string().nullable().optional() }).array(), @@ -260,7 +261,8 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv approvals: z.number(), approvers: approvalRequestUser.array(), secretPath: z.string().optional().nullable(), - enforcementLevel: z.string() + enforcementLevel: z.string(), + deletedAt: z.date().nullish() }), environment: z.string(), statusChangedByUser: approvalRequestUser.optional(), diff --git a/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts b/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts new file mode 100644 index 000000000..ab80888d7 --- /dev/null +++ b/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts @@ -0,0 +1,279 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { sanitizedSshCa } from "@app/ee/services/ssh/ssh-certificate-authority-schema"; +import { SshCaStatus } from "@app/ee/services/ssh/ssh-certificate-authority-types"; +import { sanitizedSshCertificateTemplate } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-schema"; +import { SSH_CERTIFICATE_AUTHORITIES } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; + +export const registerSshCaRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Create SSH CA", + body: z.object({ + projectId: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.projectId), + friendlyName: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.friendlyName), + keyAlgorithm: z + .nativeEnum(CertKeyAlgorithm) + .default(CertKeyAlgorithm.RSA_2048) + .describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.keyAlgorithm) + }), + response: { + 200: z.object({ + ca: sanitizedSshCa.extend({ + publicKey: z.string() + }) + }) + } + }, + handler: async (req) => { + const ca = await server.services.sshCertificateAuthority.createSshCa({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.CREATE_SSH_CA, + metadata: { + sshCaId: ca.id, + friendlyName: ca.friendlyName + } + } + }); + + return { + ca + }; + } + }); + + server.route({ + method: "GET", + url: "/:sshCaId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get SSH CA", + params: z.object({ + sshCaId: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.GET.sshCaId) + }), + response: { + 200: z.object({ + ca: sanitizedSshCa.extend({ + publicKey: z.string() + }) + }) + } + }, + handler: async (req) => { + const ca = await server.services.sshCertificateAuthority.getSshCaById({ + caId: req.params.sshCaId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.GET_SSH_CA, + metadata: { + sshCaId: ca.id, + friendlyName: ca.friendlyName + } + } + }); + + return { + ca + }; + } + }); + + server.route({ + method: "GET", + url: "/:sshCaId/public-key", + config: { + rateLimit: readLimit + }, + schema: { + description: "Get public key of SSH CA", + params: z.object({ + sshCaId: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.GET_PUBLIC_KEY.sshCaId) + }), + response: { + 200: z.string() + } + }, + handler: async (req) => { + const publicKey = await server.services.sshCertificateAuthority.getSshCaPublicKey({ + caId: req.params.sshCaId + }); + + return publicKey; + } + }); + + server.route({ + method: "PATCH", + url: "/:sshCaId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update SSH CA", + params: z.object({ + sshCaId: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.UPDATE.sshCaId) + }), + body: z.object({ + friendlyName: z.string().optional().describe(SSH_CERTIFICATE_AUTHORITIES.UPDATE.friendlyName), + status: z.nativeEnum(SshCaStatus).optional().describe(SSH_CERTIFICATE_AUTHORITIES.UPDATE.status) + }), + response: { + 200: z.object({ + ca: sanitizedSshCa.extend({ + publicKey: z.string() + }) + }) + } + }, + handler: async (req) => { + const ca = await server.services.sshCertificateAuthority.updateSshCaById({ + caId: req.params.sshCaId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.UPDATE_SSH_CA, + metadata: { + sshCaId: ca.id, + friendlyName: ca.friendlyName, + status: ca.status as SshCaStatus + } + } + }); + + return { + ca + }; + } + }); + + server.route({ + method: "DELETE", + url: "/:sshCaId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Delete SSH CA", + params: z.object({ + sshCaId: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.DELETE.sshCaId) + }), + response: { + 200: z.object({ + ca: sanitizedSshCa + }) + } + }, + handler: async (req) => { + const ca = await server.services.sshCertificateAuthority.deleteSshCaById({ + caId: req.params.sshCaId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.DELETE_SSH_CA, + metadata: { + sshCaId: ca.id, + friendlyName: ca.friendlyName + } + } + }); + + return { + ca + }; + } + }); + + server.route({ + method: "GET", + url: "/:sshCaId/certificate-templates", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get list of certificate templates for the SSH CA", + params: z.object({ + sshCaId: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.GET_CERTIFICATE_TEMPLATES.sshCaId) + }), + response: { + 200: z.object({ + certificateTemplates: sanitizedSshCertificateTemplate.array() + }) + } + }, + handler: async (req) => { + const { certificateTemplates, ca } = await server.services.sshCertificateAuthority.getSshCaCertificateTemplates({ + caId: req.params.sshCaId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.GET_SSH_CA_CERTIFICATE_TEMPLATES, + metadata: { + sshCaId: ca.id, + friendlyName: ca.friendlyName + } + } + }); + + return { + certificateTemplates + }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/ssh-certificate-router.ts b/backend/src/ee/routes/v1/ssh-certificate-router.ts new file mode 100644 index 000000000..5c135c3f1 --- /dev/null +++ b/backend/src/ee/routes/v1/ssh-certificate-router.ts @@ -0,0 +1,164 @@ +import ms from "ms"; +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { SshCertType } from "@app/ee/services/ssh/ssh-certificate-authority-types"; +import { SSH_CERTIFICATE_AUTHORITIES } from "@app/lib/api-docs"; +import { writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; + +export const registerSshCertRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/sign", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Sign SSH public key", + body: z.object({ + certificateTemplateId: z + .string() + .trim() + .min(1) + .describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.certificateTemplateId), + publicKey: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.publicKey), + certType: z + .nativeEnum(SshCertType) + .default(SshCertType.USER) + .describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.certType), + principals: z + .array(z.string().transform((val) => val.trim())) + .nonempty("Principals array must not be empty") + .describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.principals), + ttl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .optional() + .describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.ttl), + keyId: z.string().trim().max(50).optional().describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.keyId) + }), + response: { + 200: z.object({ + serialNumber: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.serialNumber), + signedKey: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.signedKey) + }) + } + }, + handler: async (req) => { + const { serialNumber, signedPublicKey, certificateTemplate, ttl, keyId } = + await server.services.sshCertificateAuthority.signSshKey({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.SIGN_SSH_KEY, + metadata: { + certificateTemplateId: certificateTemplate.id, + certType: req.body.certType, + principals: req.body.principals, + ttl: String(ttl), + keyId + } + } + }); + + return { + serialNumber, + signedKey: signedPublicKey + }; + } + }); + + server.route({ + method: "POST", + url: "/issue", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Issue SSH credentials (certificate + key)", + body: z.object({ + certificateTemplateId: z + .string() + .trim() + .min(1) + .describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.certificateTemplateId), + keyAlgorithm: z + .nativeEnum(CertKeyAlgorithm) + .default(CertKeyAlgorithm.RSA_2048) + .describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.keyAlgorithm), + certType: z + .nativeEnum(SshCertType) + .default(SshCertType.USER) + .describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.certType), + principals: z + .array(z.string().transform((val) => val.trim())) + .nonempty("Principals array must not be empty") + .describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.principals), + ttl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .optional() + .describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.ttl), + keyId: z.string().trim().max(50).optional().describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.keyId) + }), + response: { + 200: z.object({ + serialNumber: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.serialNumber), + signedKey: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.signedKey), + privateKey: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.privateKey), + publicKey: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.publicKey), + keyAlgorithm: z + .nativeEnum(CertKeyAlgorithm) + .describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.keyAlgorithm) + }) + } + }, + handler: async (req) => { + const { serialNumber, signedPublicKey, privateKey, publicKey, certificateTemplate, ttl, keyId } = + await server.services.sshCertificateAuthority.issueSshCreds({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.ISSUE_SSH_CREDS, + metadata: { + certificateTemplateId: certificateTemplate.id, + keyAlgorithm: req.body.keyAlgorithm, + certType: req.body.certType, + principals: req.body.principals, + ttl: String(ttl), + keyId + } + } + }); + + return { + serialNumber, + signedKey: signedPublicKey, + privateKey, + publicKey, + keyAlgorithm: req.body.keyAlgorithm + }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/ssh-certificate-template-router.ts b/backend/src/ee/routes/v1/ssh-certificate-template-router.ts new file mode 100644 index 000000000..f94b9d50a --- /dev/null +++ b/backend/src/ee/routes/v1/ssh-certificate-template-router.ts @@ -0,0 +1,258 @@ +import slugify from "@sindresorhus/slugify"; +import ms from "ms"; +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { sanitizedSshCertificateTemplate } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-schema"; +import { SshCertTemplateStatus } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-types"; +import { + isValidHostPattern, + isValidUserPattern +} from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-validators"; +import { SSH_CERTIFICATE_TEMPLATES } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerSshCertificateTemplateRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/:certificateTemplateId", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + certificateTemplateId: z.string().describe(SSH_CERTIFICATE_TEMPLATES.GET.certificateTemplateId) + }), + response: { + 200: sanitizedSshCertificateTemplate + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.sshCertificateTemplate.getSshCertTemplate({ + id: req.params.certificateTemplateId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateTemplate.projectId, + event: { + type: EventType.GET_SSH_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id + } + } + }); + + return certificateTemplate; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + body: z + .object({ + sshCaId: z.string().describe(SSH_CERTIFICATE_TEMPLATES.CREATE.sshCaId), + name: z + .string() + .min(1) + .max(36) + .refine((v) => slugify(v) === v, { + message: "Name must be a valid slug" + }) + .describe(SSH_CERTIFICATE_TEMPLATES.CREATE.name), + ttl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .default("1h") + .describe(SSH_CERTIFICATE_TEMPLATES.CREATE.ttl), + maxTTL: z + .string() + .refine((val) => ms(val) > 0, "Max TTL must be a positive number") + .default("30d") + .describe(SSH_CERTIFICATE_TEMPLATES.CREATE.maxTTL), + allowedUsers: z + .array(z.string().refine(isValidUserPattern, "Invalid user pattern")) + .describe(SSH_CERTIFICATE_TEMPLATES.CREATE.allowedUsers), + allowedHosts: z + .array(z.string().refine(isValidHostPattern, "Invalid host pattern")) + .describe(SSH_CERTIFICATE_TEMPLATES.CREATE.allowedHosts), + allowUserCertificates: z.boolean().describe(SSH_CERTIFICATE_TEMPLATES.CREATE.allowUserCertificates), + allowHostCertificates: z.boolean().describe(SSH_CERTIFICATE_TEMPLATES.CREATE.allowHostCertificates), + allowCustomKeyIds: z.boolean().describe(SSH_CERTIFICATE_TEMPLATES.CREATE.allowCustomKeyIds) + }) + .refine((data) => ms(data.maxTTL) > ms(data.ttl), { + message: "Max TLL must be greater than TTL", + path: ["maxTTL"] + }), + response: { + 200: sanitizedSshCertificateTemplate + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { certificateTemplate, ca } = await server.services.sshCertificateTemplate.createSshCertTemplate({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.CREATE_SSH_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id, + sshCaId: ca.id, + name: certificateTemplate.name, + ttl: certificateTemplate.ttl, + maxTTL: certificateTemplate.maxTTL, + allowedUsers: certificateTemplate.allowedUsers, + allowedHosts: certificateTemplate.allowedHosts, + allowUserCertificates: certificateTemplate.allowUserCertificates, + allowHostCertificates: certificateTemplate.allowHostCertificates, + allowCustomKeyIds: certificateTemplate.allowCustomKeyIds + } + } + }); + + return certificateTemplate; + } + }); + + server.route({ + method: "PATCH", + url: "/:certificateTemplateId", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + status: z.nativeEnum(SshCertTemplateStatus).optional(), + name: z + .string() + .min(1) + .max(36) + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }) + .optional() + .describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.name), + ttl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .optional() + .describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.ttl), + maxTTL: z + .string() + .refine((val) => ms(val) > 0, "Max TTL must be a positive number") + .optional() + .describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.maxTTL), + allowedUsers: z + .array(z.string().refine(isValidUserPattern, "Invalid user pattern")) + .optional() + .describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.allowedUsers), + allowedHosts: z + .array(z.string().refine(isValidHostPattern, "Invalid host pattern")) + .optional() + .describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.allowedHosts), + allowUserCertificates: z.boolean().optional().describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.allowUserCertificates), + allowHostCertificates: z.boolean().optional().describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.allowHostCertificates), + allowCustomKeyIds: z.boolean().optional().describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.allowCustomKeyIds) + }), + params: z.object({ + certificateTemplateId: z.string().describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.certificateTemplateId) + }), + response: { + 200: sanitizedSshCertificateTemplate + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { certificateTemplate, projectId } = await server.services.sshCertificateTemplate.updateSshCertTemplate({ + ...req.body, + id: req.params.certificateTemplateId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.UPDATE_SSH_CERTIFICATE_TEMPLATE, + metadata: { + status: certificateTemplate.status as SshCertTemplateStatus, + certificateTemplateId: certificateTemplate.id, + sshCaId: certificateTemplate.sshCaId, + name: certificateTemplate.name, + ttl: certificateTemplate.ttl, + maxTTL: certificateTemplate.maxTTL, + allowedUsers: certificateTemplate.allowedUsers, + allowedHosts: certificateTemplate.allowedHosts, + allowUserCertificates: certificateTemplate.allowUserCertificates, + allowHostCertificates: certificateTemplate.allowHostCertificates, + allowCustomKeyIds: certificateTemplate.allowCustomKeyIds + } + } + }); + + return certificateTemplate; + } + }); + + server.route({ + method: "DELETE", + url: "/:certificateTemplateId", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + certificateTemplateId: z.string().describe(SSH_CERTIFICATE_TEMPLATES.DELETE.certificateTemplateId) + }), + response: { + 200: sanitizedSshCertificateTemplate + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.sshCertificateTemplate.deleteSshCertTemplate({ + id: req.params.certificateTemplateId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateTemplate.projectId, + event: { + type: EventType.DELETE_SSH_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id + } + } + }); + + return certificateTemplate; + } + }); +}; diff --git a/backend/src/ee/routes/v1/user-additional-privilege-router.ts b/backend/src/ee/routes/v1/user-additional-privilege-router.ts index e58a6335b..bb3e179dd 100644 --- a/backend/src/ee/routes/v1/user-additional-privilege-router.ts +++ b/backend/src/ee/routes/v1/user-additional-privilege-router.ts @@ -7,6 +7,7 @@ import { ProjectUserAdditionalPrivilegeTemporaryMode } from "@app/ee/services/pr import { PROJECT_USER_ADDITIONAL_PRIVILEGE } from "@app/lib/api-docs"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { SanitizedUserProjectAdditionalPrivilegeSchema } from "@app/server/routes/santizedSchemas/user-additional-privilege"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -21,17 +22,7 @@ export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodPr schema: { body: z.object({ projectMembershipId: z.string().min(1).describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.projectMembershipId), - slug: z - .string() - .min(1) - .max(60) - .trim() - .refine((v) => v.toLowerCase() === v, "Slug must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .optional() - .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.slug), + slug: slugSchema({ min: 1, max: 60 }).optional().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.slug), permissions: ProjectPermissionV2Schema.array().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.permissions), type: z.discriminatedUnion("isTemporary", [ z.object({ @@ -87,15 +78,7 @@ export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodPr }), body: z .object({ - slug: z - .string() - .max(60) - .trim() - .refine((v) => v.toLowerCase() === v, "Slug must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.slug), + slug: slugSchema({ min: 1, max: 60 }).describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.slug), permissions: ProjectPermissionV2Schema.array() .optional() .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.permissions), diff --git a/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts b/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts index 5df03f68d..7934c3f90 100644 --- a/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts +++ b/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts @@ -7,6 +7,7 @@ import { ProjectPermissionV2Schema } from "@app/ee/services/permission/project-p import { IDENTITY_ADDITIONAL_PRIVILEGE_V2 } from "@app/lib/api-docs"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { SanitizedIdentityPrivilegeSchema } from "@app/server/routes/santizedSchemas/identitiy-additional-privilege"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -28,17 +29,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F body: z.object({ identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.CREATE.identityId), projectId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.CREATE.projectId), - slug: z - .string() - .min(1) - .max(60) - .trim() - .refine((val) => val.toLowerCase() === val, "Must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .optional() - .describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.CREATE.slug), + slug: slugSchema({ min: 1, max: 60 }).optional().describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.CREATE.slug), permissions: ProjectPermissionV2Schema.array().describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.CREATE.permission), type: z.discriminatedUnion("isTemporary", [ z.object({ @@ -100,16 +91,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F id: z.string().trim().describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.UPDATE.id) }), body: z.object({ - slug: z - .string() - .min(1) - .max(60) - .trim() - .refine((val) => val.toLowerCase() === val, "Must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.UPDATE.slug), + slug: slugSchema({ min: 1, max: 60 }).describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.UPDATE.slug), permissions: ProjectPermissionV2Schema.array() .optional() .describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.UPDATE.privilegePermission), diff --git a/backend/src/ee/routes/v2/project-role-router.ts b/backend/src/ee/routes/v2/project-role-router.ts index 70511ce87..2d3b1984d 100644 --- a/backend/src/ee/routes/v2/project-role-router.ts +++ b/backend/src/ee/routes/v2/project-role-router.ts @@ -1,11 +1,11 @@ import { packRules } from "@casl/ability/extra"; -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { ProjectMembershipRole, ProjectRolesSchema } from "@app/db/schemas"; import { ProjectPermissionV2Schema } from "@app/ee/services/permission/project-permission"; import { PROJECT_ROLE } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { SanitizedRoleSchema } from "@app/server/routes/sanitizedSchemas"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -29,21 +29,14 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { projectId: z.string().trim().describe(PROJECT_ROLE.CREATE.projectId) }), body: z.object({ - slug: z - .string() - .toLowerCase() - .trim() - .min(1) + slug: slugSchema({ min: 1, max: 64 }) .refine( (val) => !Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole), "Please choose a different slug, the slug you have entered is reserved" ) - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid" - }) .describe(PROJECT_ROLE.CREATE.slug), name: z.string().min(1).trim().describe(PROJECT_ROLE.CREATE.name), - description: z.string().trim().optional().describe(PROJECT_ROLE.CREATE.description), + description: z.string().trim().nullish().describe(PROJECT_ROLE.CREATE.description), permissions: ProjectPermissionV2Schema.array().describe(PROJECT_ROLE.CREATE.permissions) }), response: { @@ -90,23 +83,15 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { roleId: z.string().trim().describe(PROJECT_ROLE.UPDATE.roleId) }), body: z.object({ - slug: z - .string() - .toLowerCase() - .trim() - .optional() - .describe(PROJECT_ROLE.UPDATE.slug) + slug: slugSchema({ min: 1, max: 64 }) .refine( - (val) => - typeof val === "undefined" || - !Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole), + (val) => !Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole), "Please choose a different slug, the slug you have entered is reserved" ) - .refine((val) => typeof val === "undefined" || slugify(val) === val, { - message: "Slug must be a valid" - }), + .optional() + .describe(PROJECT_ROLE.UPDATE.slug), name: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.name), - description: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.description), + description: z.string().trim().nullish().describe(PROJECT_ROLE.UPDATE.description), permissions: ProjectPermissionV2Schema.array().describe(PROJECT_ROLE.UPDATE.permissions).optional() }), response: { diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts index 220701410..e14451498 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts @@ -139,5 +139,10 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => { } }; - return { ...accessApprovalPolicyOrm, find, findById }; + const softDeleteById = async (policyId: string, tx?: Knex) => { + const softDeletedPolicy = await accessApprovalPolicyOrm.updateById(policyId, { deletedAt: new Date() }, tx); + return softDeletedPolicy; + }; + + return { ...accessApprovalPolicyOrm, find, findById, softDeleteById }; }; diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts index ee7cf2572..72de6810a 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts @@ -1,5 +1,6 @@ import { ForbiddenError } from "@casl/ability"; +import { ProjectType } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; @@ -8,7 +9,11 @@ import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; import { TUserDALFactory } from "@app/services/user/user-dal"; +import { TAccessApprovalRequestDALFactory } from "../access-approval-request/access-approval-request-dal"; +import { TAccessApprovalRequestReviewerDALFactory } from "../access-approval-request/access-approval-request-reviewer-dal"; +import { ApprovalStatus } from "../access-approval-request/access-approval-request-types"; import { TGroupDALFactory } from "../group/group-dal"; +import { TProjectUserAdditionalPrivilegeDALFactory } from "../project-user-additional-privilege/project-user-additional-privilege-dal"; import { TAccessApprovalPolicyApproverDALFactory } from "./access-approval-policy-approver-dal"; import { TAccessApprovalPolicyDALFactory } from "./access-approval-policy-dal"; import { @@ -21,7 +26,7 @@ import { TUpdateAccessApprovalPolicy } from "./access-approval-policy-types"; -type TSecretApprovalPolicyServiceFactoryDep = { +type TAccessApprovalPolicyServiceFactoryDep = { projectDAL: TProjectDALFactory; permissionService: Pick; accessApprovalPolicyDAL: TAccessApprovalPolicyDALFactory; @@ -30,6 +35,9 @@ type TSecretApprovalPolicyServiceFactoryDep = { projectMembershipDAL: Pick; groupDAL: TGroupDALFactory; userDAL: Pick; + accessApprovalRequestDAL: Pick; + additionalPrivilegeDAL: Pick; + accessApprovalRequestReviewerDAL: Pick; }; export type TAccessApprovalPolicyServiceFactory = ReturnType; @@ -41,8 +49,11 @@ export const accessApprovalPolicyServiceFactory = ({ permissionService, projectEnvDAL, projectDAL, - userDAL -}: TSecretApprovalPolicyServiceFactoryDep) => { + userDAL, + accessApprovalRequestDAL, + additionalPrivilegeDAL, + accessApprovalRequestReviewerDAL +}: TAccessApprovalPolicyServiceFactoryDep) => { const createAccessApprovalPolicy = async ({ name, actor, @@ -76,13 +87,15 @@ export const accessApprovalPolicyServiceFactory = ({ if (!groupApprovers && approvals > userApprovers.length + userApproverNames.length) throw new BadRequestError({ message: "Approvals cannot be greater than approvers" }); - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, project.id, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); + ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Create, ProjectPermissionSub.SecretApproval @@ -180,16 +193,9 @@ export const accessApprovalPolicyServiceFactory = ({ if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); // Anyone in the project should be able to get the policies. - /* const { permission } = */ await permissionService.getProjectPermission( - actor, - actorId, - project.id, - actorAuthMethod, - actorOrgId - ); - // ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); + await permissionService.getProjectPermission(actor, actorId, project.id, actorAuthMethod, actorOrgId); - const accessApprovalPolicies = await accessApprovalPolicyDAL.find({ projectId: project.id }); + const accessApprovalPolicies = await accessApprovalPolicyDAL.find({ projectId: project.id, deletedAt: null }); return accessApprovalPolicies; }; @@ -231,13 +237,14 @@ export const accessApprovalPolicyServiceFactory = ({ if (!accessApprovalPolicy) { throw new NotFoundError({ message: `Secret approval policy with ID '${policyId}' not found` }); } - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, accessApprovalPolicy.projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval); @@ -314,19 +321,42 @@ export const accessApprovalPolicyServiceFactory = ({ const policy = await accessApprovalPolicyDAL.findById(policyId); if (!policy) throw new NotFoundError({ message: `Secret approval policy with ID '${policyId}' not found` }); - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, policy.projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Delete, ProjectPermissionSub.SecretApproval ); - await accessApprovalPolicyDAL.deleteById(policyId); + await accessApprovalPolicyDAL.transaction(async (tx) => { + await accessApprovalPolicyDAL.softDeleteById(policyId, tx); + const allAccessApprovalRequests = await accessApprovalRequestDAL.find({ policyId }); + + if (allAccessApprovalRequests.length) { + const accessApprovalRequestsIds = allAccessApprovalRequests.map((request) => request.id); + + const privilegeIdsArray = allAccessApprovalRequests + .map((request) => request.privilegeId) + .filter((id): id is string => id != null); + + if (privilegeIdsArray.length) { + await additionalPrivilegeDAL.delete({ $in: { id: privilegeIdsArray } }, tx); + } + + await accessApprovalRequestReviewerDAL.update( + { $in: { id: accessApprovalRequestsIds }, status: ApprovalStatus.PENDING }, + { status: ApprovalStatus.REJECTED }, + tx + ); + } + }); + return policy; }; @@ -356,7 +386,11 @@ export const accessApprovalPolicyServiceFactory = ({ const environment = await projectEnvDAL.findOne({ projectId: project.id, slug: envSlug }); if (!environment) throw new NotFoundError({ message: `Environment with slug '${envSlug}' not found` }); - const policies = await accessApprovalPolicyDAL.find({ envId: environment.id, projectId: project.id }); + const policies = await accessApprovalPolicyDAL.find({ + envId: environment.id, + projectId: project.id, + deletedAt: null + }); if (!policies) throw new NotFoundError({ message: `No policies found in environment with slug '${envSlug}'` }); return { count: policies.length }; diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts index 8784d05e2..c1ccedff7 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts @@ -61,7 +61,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { db.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"), db.ref("secretPath").withSchema(TableName.AccessApprovalPolicy).as("policySecretPath"), db.ref("enforcementLevel").withSchema(TableName.AccessApprovalPolicy).as("policyEnforcementLevel"), - db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId") + db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId"), + db.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt") ) .select(db.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover)) @@ -118,7 +119,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { approvals: doc.policyApprovals, secretPath: doc.policySecretPath, enforcementLevel: doc.policyEnforcementLevel, - envId: doc.policyEnvId + envId: doc.policyEnvId, + deletedAt: doc.policyDeletedAt }, requestedByUser: { userId: doc.requestedByUserId, @@ -141,7 +143,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { } : null, - isApproved: !!doc.privilegeId + isApproved: !!doc.policyDeletedAt || !!doc.privilegeId }), childrenMapper: [ { @@ -252,7 +254,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { tx.ref("slug").withSchema(TableName.Environment).as("environment"), tx.ref("secretPath").withSchema(TableName.AccessApprovalPolicy).as("policySecretPath"), tx.ref("enforcementLevel").withSchema(TableName.AccessApprovalPolicy).as("policyEnforcementLevel"), - tx.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals") + tx.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"), + tx.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt") ); const findById = async (id: string, tx?: Knex) => { @@ -271,7 +274,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { name: el.policyName, approvals: el.policyApprovals, secretPath: el.policySecretPath, - enforcementLevel: el.policyEnforcementLevel + enforcementLevel: el.policyEnforcementLevel, + deletedAt: el.policyDeletedAt }, requestedByUser: { userId: el.requestedByUserId, @@ -363,6 +367,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { ) .where(`${TableName.Environment}.projectId`, projectId) + .where(`${TableName.AccessApprovalPolicy}.deletedAt`, null) .select(selectAllTableCols(TableName.AccessApprovalRequest)) .select(db.ref("status").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerStatus")) .select(db.ref("reviewerUserId").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerUserId")); diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts index 14accff41..b8475c446 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts @@ -130,6 +130,9 @@ export const accessApprovalRequestServiceFactory = ({ message: `No policy in environment with slug '${environment.slug}' and with secret path '${secretPath}' was found.` }); } + if (policy.deletedAt) { + throw new BadRequestError({ message: "The policy linked to this request has been deleted" }); + } const approverIds: string[] = []; const approverGroupIds: string[] = []; @@ -309,6 +312,12 @@ export const accessApprovalRequestServiceFactory = ({ } const { policy } = accessApprovalRequest; + if (policy.deletedAt) { + throw new BadRequestError({ + message: "The policy associated with this access request has been deleted." + }); + } + const { membership, hasRole } = await permissionService.getProjectPermission( actor, actorId, 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 51090e594..a8d98977f 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -2,9 +2,14 @@ import { TCreateProjectTemplateDTO, TUpdateProjectTemplateDTO } from "@app/ee/services/project-template/project-template-types"; +import { SshCaStatus, SshCertType } from "@app/ee/services/ssh/ssh-certificate-authority-types"; +import { SshCertTemplateStatus } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-types"; import { SymmetricEncryption } from "@app/lib/crypto/cipher"; import { TProjectPermission } from "@app/lib/types"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { TCreateAppConnectionDTO, TUpdateAppConnectionDTO } from "@app/services/app-connection/app-connection-types"; import { ActorType } from "@app/services/auth/auth-type"; +import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; import { CaStatus } from "@app/services/certificate-authority/certificate-authority-types"; import { TIdentityTrustedIp } from "@app/services/identity/identity-types"; import { PkiItemType } from "@app/services/pki-collection/pki-collection-types"; @@ -60,6 +65,7 @@ export enum EventType { DELETE_SECRETS = "delete-secrets", GET_WORKSPACE_KEY = "get-workspace-key", AUTHORIZE_INTEGRATION = "authorize-integration", + UPDATE_INTEGRATION_AUTH = "update-integration-auth", UNAUTHORIZE_INTEGRATION = "unauthorize-integration", CREATE_INTEGRATION = "create-integration", DELETE_INTEGRATION = "delete-integration", @@ -94,6 +100,11 @@ export enum EventType { UPDATE_IDENTITY_OIDC_AUTH = "update-identity-oidc-auth", GET_IDENTITY_OIDC_AUTH = "get-identity-oidc-auth", REVOKE_IDENTITY_OIDC_AUTH = "revoke-identity-oidc-auth", + LOGIN_IDENTITY_JWT_AUTH = "login-identity-jwt-auth", + ADD_IDENTITY_JWT_AUTH = "add-identity-jwt-auth", + UPDATE_IDENTITY_JWT_AUTH = "update-identity-jwt-auth", + GET_IDENTITY_JWT_AUTH = "get-identity-jwt-auth", + REVOKE_IDENTITY_JWT_AUTH = "revoke-identity-jwt-auth", CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "create-identity-universal-auth-client-secret", REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "revoke-identity-universal-auth-client-secret", GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS = "get-identity-universal-auth-client-secret", @@ -137,6 +148,17 @@ export enum EventType { SECRET_APPROVAL_REQUEST = "secret-approval-request", SECRET_APPROVAL_CLOSED = "secret-approval-closed", SECRET_APPROVAL_REOPENED = "secret-approval-reopened", + SIGN_SSH_KEY = "sign-ssh-key", + ISSUE_SSH_CREDS = "issue-ssh-creds", + CREATE_SSH_CA = "create-ssh-certificate-authority", + GET_SSH_CA = "get-ssh-certificate-authority", + UPDATE_SSH_CA = "update-ssh-certificate-authority", + DELETE_SSH_CA = "delete-ssh-certificate-authority", + GET_SSH_CA_CERTIFICATE_TEMPLATES = "get-ssh-certificate-authority-certificate-templates", + CREATE_SSH_CERTIFICATE_TEMPLATE = "create-ssh-certificate-template", + UPDATE_SSH_CERTIFICATE_TEMPLATE = "update-ssh-certificate-template", + DELETE_SSH_CERTIFICATE_TEMPLATE = "delete-ssh-certificate-template", + GET_SSH_CERTIFICATE_TEMPLATE = "get-ssh-certificate-template", CREATE_CA = "create-certificate-authority", GET_CA = "get-certificate-authority", UPDATE_CA = "update-certificate-authority", @@ -202,7 +224,12 @@ export enum EventType { CREATE_PROJECT_TEMPLATE = "create-project-template", UPDATE_PROJECT_TEMPLATE = "update-project-template", DELETE_PROJECT_TEMPLATE = "delete-project-template", - APPLY_PROJECT_TEMPLATE = "apply-project-template" + APPLY_PROJECT_TEMPLATE = "apply-project-template", + GET_APP_CONNECTIONS = "get-app-connections", + GET_APP_CONNECTION = "get-app-connection", + CREATE_APP_CONNECTION = "create-app-connection", + UPDATE_APP_CONNECTION = "update-app-connection", + DELETE_APP_CONNECTION = "delete-app-connection" } interface UserActorMetadata { @@ -357,6 +384,13 @@ interface AuthorizeIntegrationEvent { }; } +interface UpdateIntegrationAuthEvent { + type: EventType.UPDATE_INTEGRATION_AUTH; + metadata: { + integration: string; + }; +} + interface UnauthorizeIntegrationEvent { type: EventType.UNAUTHORIZE_INTEGRATION; metadata: { @@ -895,6 +929,67 @@ interface GetIdentityOidcAuthEvent { }; } +interface LoginIdentityJwtAuthEvent { + type: EventType.LOGIN_IDENTITY_JWT_AUTH; + metadata: { + identityId: string; + identityJwtAuthId: string; + identityAccessTokenId: string; + }; +} + +interface AddIdentityJwtAuthEvent { + type: EventType.ADD_IDENTITY_JWT_AUTH; + metadata: { + identityId: string; + configurationType: string; + jwksUrl?: string; + jwksCaCert: string; + publicKeys: string[]; + boundIssuer: string; + boundAudiences: string; + boundClaims: Record; + boundSubject: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: Array; + }; +} + +interface UpdateIdentityJwtAuthEvent { + type: EventType.UPDATE_IDENTITY_JWT_AUTH; + metadata: { + identityId: string; + configurationType?: string; + jwksUrl?: string; + jwksCaCert?: string; + publicKeys?: string[]; + boundIssuer?: string; + boundAudiences?: string; + boundClaims?: Record; + boundSubject?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: Array; + }; +} + +interface DeleteIdentityJwtAuthEvent { + type: EventType.REVOKE_IDENTITY_JWT_AUTH; + metadata: { + identityId: string; + }; +} + +interface GetIdentityJwtAuthEvent { + type: EventType.GET_IDENTITY_JWT_AUTH; + metadata: { + identityId: string; + }; +} + interface CreateEnvironmentEvent { type: EventType.CREATE_ENVIRONMENT; metadata: { @@ -1132,6 +1227,117 @@ interface SecretApprovalRequest { }; } +interface SignSshKey { + type: EventType.SIGN_SSH_KEY; + metadata: { + certificateTemplateId: string; + certType: SshCertType; + principals: string[]; + ttl: string; + keyId: string; + }; +} + +interface IssueSshCreds { + type: EventType.ISSUE_SSH_CREDS; + metadata: { + certificateTemplateId: string; + keyAlgorithm: CertKeyAlgorithm; + certType: SshCertType; + principals: string[]; + ttl: string; + keyId: string; + }; +} + +interface CreateSshCa { + type: EventType.CREATE_SSH_CA; + metadata: { + sshCaId: string; + friendlyName: string; + }; +} + +interface GetSshCa { + type: EventType.GET_SSH_CA; + metadata: { + sshCaId: string; + friendlyName: string; + }; +} + +interface UpdateSshCa { + type: EventType.UPDATE_SSH_CA; + metadata: { + sshCaId: string; + friendlyName: string; + status: SshCaStatus; + }; +} + +interface DeleteSshCa { + type: EventType.DELETE_SSH_CA; + metadata: { + sshCaId: string; + friendlyName: string; + }; +} + +interface GetSshCaCertificateTemplates { + type: EventType.GET_SSH_CA_CERTIFICATE_TEMPLATES; + metadata: { + sshCaId: string; + friendlyName: string; + }; +} + +interface CreateSshCertificateTemplate { + type: EventType.CREATE_SSH_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + sshCaId: string; + name: string; + ttl: string; + maxTTL: string; + allowedUsers: string[]; + allowedHosts: string[]; + allowUserCertificates: boolean; + allowHostCertificates: boolean; + allowCustomKeyIds: boolean; + }; +} + +interface GetSshCertificateTemplate { + type: EventType.GET_SSH_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + }; +} + +interface UpdateSshCertificateTemplate { + type: EventType.UPDATE_SSH_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + sshCaId: string; + name: string; + status: SshCertTemplateStatus; + ttl: string; + maxTTL: string; + allowedUsers: string[]; + allowedHosts: string[]; + allowUserCertificates: boolean; + allowHostCertificates: boolean; + allowCustomKeyIds: boolean; + }; +} + +interface DeleteSshCertificateTemplate { + type: EventType.DELETE_SSH_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + }; +} + interface CreateCa { type: EventType.CREATE_CA; metadata: { @@ -1668,6 +1874,39 @@ interface ApplyProjectTemplateEvent { }; } +interface GetAppConnectionsEvent { + type: EventType.GET_APP_CONNECTIONS; + metadata: { + app?: AppConnection; + count: number; + connectionIds: string[]; + }; +} + +interface GetAppConnectionEvent { + type: EventType.GET_APP_CONNECTION; + metadata: { + connectionId: string; + }; +} + +interface CreateAppConnectionEvent { + type: EventType.CREATE_APP_CONNECTION; + metadata: Omit & { connectionId: string }; +} + +interface UpdateAppConnectionEvent { + type: EventType.UPDATE_APP_CONNECTION; + metadata: Omit & { connectionId: string; credentialsUpdated: boolean }; +} + +interface DeleteAppConnectionEvent { + type: EventType.DELETE_APP_CONNECTION; + metadata: { + connectionId: string; + }; +} + export type Event = | GetSecretsEvent | GetSecretEvent @@ -1680,6 +1919,7 @@ export type Event = | DeleteSecretBatchEvent | GetWorkspaceKeyEvent | AuthorizeIntegrationEvent + | UpdateIntegrationAuthEvent | UnauthorizeIntegrationEvent | CreateIntegrationEvent | DeleteIntegrationEvent @@ -1733,6 +1973,11 @@ export type Event = | DeleteIdentityOidcAuthEvent | UpdateIdentityOidcAuthEvent | GetIdentityOidcAuthEvent + | LoginIdentityJwtAuthEvent + | AddIdentityJwtAuthEvent + | UpdateIdentityJwtAuthEvent + | GetIdentityJwtAuthEvent + | DeleteIdentityJwtAuthEvent | CreateEnvironmentEvent | GetEnvironmentEvent | UpdateEnvironmentEvent @@ -1757,6 +2002,17 @@ export type Event = | SecretApprovalClosed | SecretApprovalRequest | SecretApprovalReopened + | SignSshKey + | IssueSshCreds + | CreateSshCa + | GetSshCa + | UpdateSshCa + | DeleteSshCa + | GetSshCaCertificateTemplates + | CreateSshCertificateTemplate + | UpdateSshCertificateTemplate + | GetSshCertificateTemplate + | DeleteSshCertificateTemplate | CreateCa | GetCa | UpdateCa @@ -1822,4 +2078,9 @@ export type Event = | CreateProjectTemplateEvent | UpdateProjectTemplateEvent | DeleteProjectTemplateEvent - | ApplyProjectTemplateEvent; + | ApplyProjectTemplateEvent + | GetAppConnectionsEvent + | GetAppConnectionEvent + | CreateAppConnectionEvent + | UpdateAppConnectionEvent + | DeleteAppConnectionEvent; 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 82d1604eb..81e76ff71 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 @@ -1,7 +1,7 @@ import { ForbiddenError, subject } from "@casl/ability"; import ms from "ms"; -import { SecretKeyEncoding } from "@app/db/schemas"; +import { ProjectType, SecretKeyEncoding } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { @@ -67,13 +67,14 @@ export const dynamicSecretLeaseServiceFactory = ({ if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); const projectId = project.id; - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionDynamicSecretActions.Lease, subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path }) @@ -146,13 +147,14 @@ export const dynamicSecretLeaseServiceFactory = ({ if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); const projectId = project.id; - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionDynamicSecretActions.Lease, subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path }) @@ -225,13 +227,14 @@ export const dynamicSecretLeaseServiceFactory = ({ if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); const projectId = project.id; - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionDynamicSecretActions.Lease, subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path }) diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts index 5eff1cdcf..db60b3e57 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts @@ -1,6 +1,6 @@ import { ForbiddenError, subject } from "@casl/ability"; -import { SecretKeyEncoding } from "@app/db/schemas"; +import { ProjectType, SecretKeyEncoding } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { @@ -73,13 +73,14 @@ export const dynamicSecretServiceFactory = ({ if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); const projectId = project.id; - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionDynamicSecretActions.CreateRootCredential, subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path }) @@ -144,13 +145,14 @@ export const dynamicSecretServiceFactory = ({ const projectId = project.id; - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionDynamicSecretActions.EditRootCredential, subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path }) @@ -227,13 +229,14 @@ export const dynamicSecretServiceFactory = ({ const projectId = project.id; - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionDynamicSecretActions.DeleteRootCredential, subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path }) diff --git a/backend/src/ee/services/group/group-dal.ts b/backend/src/ee/services/group/group-dal.ts index 5e25f6113..fc38a2a9b 100644 --- a/backend/src/ee/services/group/group-dal.ts +++ b/backend/src/ee/services/group/group-dal.ts @@ -5,6 +5,8 @@ import { TableName, TGroups } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt } from "@app/lib/knex"; +import { EFilterReturnedUsers } from "./group-types"; + export type TGroupDALFactory = ReturnType; export const groupDALFactory = (db: TDbClient) => { @@ -66,7 +68,8 @@ export const groupDALFactory = (db: TDbClient) => { offset = 0, limit, username, // depreciated in favor of search - search + search, + filter }: { orgId: string; groupId: string; @@ -74,6 +77,7 @@ export const groupDALFactory = (db: TDbClient) => { limit?: number; username?: string; search?: string; + filter?: EFilterReturnedUsers; }) => { try { const query = db @@ -90,6 +94,7 @@ export const groupDALFactory = (db: TDbClient) => { .select( db.ref("id").withSchema(TableName.OrgMembership), db.ref("groupId").withSchema(TableName.UserGroupMembership), + db.ref("createdAt").withSchema(TableName.UserGroupMembership).as("joinedGroupAt"), db.ref("email").withSchema(TableName.Users), db.ref("username").withSchema(TableName.Users), db.ref("firstName").withSchema(TableName.Users), @@ -111,17 +116,37 @@ export const groupDALFactory = (db: TDbClient) => { void query.andWhere(`${TableName.Users}.username`, "ilike", `%${username}%`); } + switch (filter) { + case EFilterReturnedUsers.EXISTING_MEMBERS: + void query.andWhere(`${TableName.UserGroupMembership}.createdAt`, "is not", null); + break; + case EFilterReturnedUsers.NON_MEMBERS: + void query.andWhere(`${TableName.UserGroupMembership}.createdAt`, "is", null); + break; + default: + break; + } + const members = await query; return { members: members.map( - ({ email, username: memberUsername, firstName, lastName, userId, groupId: memberGroupId }) => ({ + ({ + email, + username: memberUsername, + firstName, + lastName, + userId, + groupId: memberGroupId, + joinedGroupAt + }) => ({ id: userId, email, username: memberUsername, firstName, lastName, - isPartOfGroup: !!memberGroupId + isPartOfGroup: !!memberGroupId, + joinedGroupAt }) ), // @ts-expect-error col select is raw and not strongly typed diff --git a/backend/src/ee/services/group/group-service.ts b/backend/src/ee/services/group/group-service.ts index 7e7139a6b..68c48524b 100644 --- a/backend/src/ee/services/group/group-service.ts +++ b/backend/src/ee/services/group/group-service.ts @@ -222,7 +222,8 @@ export const groupServiceFactory = ({ actorId, actorAuthMethod, actorOrgId, - search + search, + filter }: TListGroupUsersDTO) => { if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); @@ -251,7 +252,8 @@ export const groupServiceFactory = ({ offset, limit, username, - search + search, + filter }); return { users: members, totalCount }; @@ -283,8 +285,8 @@ export const groupServiceFactory = ({ const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId); // check if user has broader or equal to privileges than group - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, groupRolePermission); - if (!hasRequiredPriviledges) + const hasRequiredPrivileges = isAtLeastAsPrivileged(permission, groupRolePermission); + if (!hasRequiredPrivileges) throw new ForbiddenRequestError({ message: "Failed to add user to more privileged group" }); const user = await userDAL.findOne({ username }); @@ -338,8 +340,8 @@ export const groupServiceFactory = ({ const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId); // check if user has broader or equal to privileges than group - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, groupRolePermission); - if (!hasRequiredPriviledges) + const hasRequiredPrivileges = isAtLeastAsPrivileged(permission, groupRolePermission); + if (!hasRequiredPrivileges) throw new ForbiddenRequestError({ message: "Failed to delete user from more privileged group" }); const user = await userDAL.findOne({ username }); diff --git a/backend/src/ee/services/group/group-types.ts b/backend/src/ee/services/group/group-types.ts index a6eb4782b..9424075ca 100644 --- a/backend/src/ee/services/group/group-types.ts +++ b/backend/src/ee/services/group/group-types.ts @@ -39,6 +39,7 @@ export type TListGroupUsersDTO = { limit: number; username?: string; search?: string; + filter?: EFilterReturnedUsers; } & TGenericPermission; export type TAddUserToGroupDTO = { @@ -101,3 +102,8 @@ export type TConvertPendingGroupAdditionsToGroupMemberships = { projectBotDAL: Pick; tx?: Knex; }; + +export enum EFilterReturnedUsers { + EXISTING_MEMBERS = "existingMembers", + NON_MEMBERS = "nonMembers" +} diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index 70c299564..69daa8514 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -49,7 +49,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ }, pkiEst: false, enforceMfa: false, - projectTemplates: false + projectTemplates: false, + appConnections: false }); export const setupLicenseRequestWithStore = (baseURL: string, refreshUrl: string, licenseKey: string) => { diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 622b0e06b..381044f82 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -67,6 +67,7 @@ export type TFeatureSet = { pkiEst: boolean; enforceMfa: boolean; projectTemplates: false; + appConnections: false; // TODO: remove once live }; export type TOrgPlansTableDTO = { diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index aac45b2d5..487d2155c 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -27,7 +27,8 @@ export enum OrgPermissionSubjects { Kms = "kms", AdminConsole = "organization-admin-console", AuditLogs = "audit-logs", - ProjectTemplates = "project-templates" + ProjectTemplates = "project-templates", + AppConnections = "app-connections" } export type OrgPermissionSet = @@ -46,6 +47,7 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.Kms] | [OrgPermissionActions, OrgPermissionSubjects.AuditLogs] | [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates] + | [OrgPermissionActions, OrgPermissionSubjects.AppConnections] | [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole]; const buildAdminPermission = () => { @@ -123,6 +125,11 @@ const buildAdminPermission = () => { can(OrgPermissionActions.Edit, OrgPermissionSubjects.ProjectTemplates); can(OrgPermissionActions.Delete, OrgPermissionSubjects.ProjectTemplates); + can(OrgPermissionActions.Read, OrgPermissionSubjects.AppConnections); + can(OrgPermissionActions.Create, OrgPermissionSubjects.AppConnections); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.AppConnections); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.AppConnections); + can(OrgPermissionAdminConsoleAction.AccessAllProjects, OrgPermissionSubjects.AdminConsole); return rules; @@ -153,6 +160,8 @@ const buildMemberPermission = () => { can(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs); + can(OrgPermissionActions.Read, OrgPermissionSubjects.AppConnections); + return rules; }; diff --git a/backend/src/ee/services/permission/permission-dal.ts b/backend/src/ee/services/permission/permission-dal.ts index 730ad3bbc..3a3c82414 100644 --- a/backend/src/ee/services/permission/permission-dal.ts +++ b/backend/src/ee/services/permission/permission-dal.ts @@ -269,6 +269,7 @@ export const permissionDALFactory = (db: TDbClient) => { db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue"), db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), db.ref("orgId").withSchema(TableName.Project), + db.ref("type").withSchema(TableName.Project).as("projectType"), db.ref("id").withSchema(TableName.Project).as("projectId") ); @@ -284,13 +285,15 @@ export const permissionDALFactory = (db: TDbClient) => { membershipCreatedAt, groupMembershipCreatedAt, groupMembershipUpdatedAt, - membershipUpdatedAt + membershipUpdatedAt, + projectType }) => ({ orgId, orgAuthEnforced, userId, projectId, username, + projectType, id: membershipId || groupMembershipId, createdAt: membershipCreatedAt || groupMembershipCreatedAt, updatedAt: membershipUpdatedAt || groupMembershipUpdatedAt @@ -449,6 +452,7 @@ export const permissionDALFactory = (db: TDbClient) => { db.ref("id").withSchema(TableName.IdentityProjectMembership).as("membershipId"), db.ref("name").withSchema(TableName.Identity).as("identityName"), db.ref("orgId").withSchema(TableName.Project).as("orgId"), // Now you can select orgId from Project + db.ref("type").withSchema(TableName.Project).as("projectType"), db.ref("createdAt").withSchema(TableName.IdentityProjectMembership).as("membershipCreatedAt"), db.ref("updatedAt").withSchema(TableName.IdentityProjectMembership).as("membershipUpdatedAt"), db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), @@ -480,7 +484,14 @@ export const permissionDALFactory = (db: TDbClient) => { const permission = sqlNestRelationships({ data: docs, key: "membershipId", - parentMapper: ({ membershipId, membershipCreatedAt, membershipUpdatedAt, orgId, identityName }) => ({ + parentMapper: ({ + membershipId, + membershipCreatedAt, + membershipUpdatedAt, + orgId, + identityName, + projectType + }) => ({ id: membershipId, identityId, username: identityName, @@ -488,6 +499,7 @@ export const permissionDALFactory = (db: TDbClient) => { createdAt: membershipCreatedAt, updatedAt: membershipUpdatedAt, orgId, + projectType, // just a prefilled value orgAuthEnforced: false }), diff --git a/backend/src/ee/services/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index 13645b8f1..96e189115 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -6,6 +6,7 @@ import handlebars from "handlebars"; import { OrgMembershipRole, ProjectMembershipRole, + ProjectType, ServiceTokenScopes, TIdentityProjectMemberships, TProjectMemberships @@ -255,6 +256,13 @@ export const permissionServiceFactory = ({ return { permission, membership: userProjectPermission, + ForbidOnInvalidProjectType: (productType: ProjectType) => { + if (productType !== userProjectPermission.projectType) { + throw new BadRequestError({ + message: `The project is of type ${userProjectPermission.projectType}. Operations of type ${productType} are not allowed.` + }); + } + }, hasRole: (role: string) => userProjectPermission.roles.findIndex( ({ role: slug, customRoleSlug }) => role === slug || slug === customRoleSlug @@ -323,6 +331,13 @@ export const permissionServiceFactory = ({ return { permission, membership: identityProjectPermission, + ForbidOnInvalidProjectType: (productType: ProjectType) => { + if (productType !== identityProjectPermission.projectType) { + throw new BadRequestError({ + message: `The project is of type ${identityProjectPermission.projectType}. Operations of type ${productType} are not allowed.` + }); + } + }, hasRole: (role: string) => identityProjectPermission.roles.findIndex( ({ role: slug, customRoleSlug }) => role === slug || slug === customRoleSlug @@ -361,7 +376,14 @@ export const permissionServiceFactory = ({ const scopes = ServiceTokenScopes.parse(serviceToken.scopes || []); return { permission: buildServiceTokenProjectPermission(scopes, serviceToken.permissions), - membership: undefined + membership: undefined, + ForbidOnInvalidProjectType: (productType: ProjectType) => { + if (productType !== serviceTokenProject.type) { + throw new BadRequestError({ + message: `The project is of type ${serviceTokenProject.type}. Operations of type ${productType} are not allowed.` + }); + } + } }; }; @@ -370,6 +392,7 @@ export const permissionServiceFactory = ({ permission: MongoAbility; membership: undefined; hasRole: (arg: string) => boolean; + ForbidOnInvalidProjectType: (type: ProjectType) => void; } // service token doesn't have both membership and roles : { permission: MongoAbility; @@ -379,6 +402,7 @@ export const permissionServiceFactory = ({ roles: Array<{ role: string }>; }; hasRole: (role: string) => boolean; + ForbidOnInvalidProjectType: (type: ProjectType) => void; }; const getProjectPermission = async ( diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index f6d7f715f..47142054e 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -54,6 +54,9 @@ export enum ProjectPermissionSub { CertificateAuthorities = "certificate-authorities", Certificates = "certificates", CertificateTemplates = "certificate-templates", + SshCertificateAuthorities = "ssh-certificate-authorities", + SshCertificates = "ssh-certificates", + SshCertificateTemplates = "ssh-certificate-templates", PkiAlerts = "pki-alerts", PkiCollections = "pki-collections", Kms = "kms", @@ -132,6 +135,9 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities] | [ProjectPermissionActions, ProjectPermissionSub.Certificates] | [ProjectPermissionActions, ProjectPermissionSub.CertificateTemplates] + | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateAuthorities] + | [ProjectPermissionActions, ProjectPermissionSub.SshCertificates] + | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateTemplates] | [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts] | [ProjectPermissionActions, ProjectPermissionSub.PkiCollections] | [ProjectPermissionCmekActions, ProjectPermissionSub.Cmek] @@ -338,6 +344,28 @@ const GeneralPermissionSchema = [ "Describe what action an entity can take." ) }), + z.object({ + subject: z + .literal(ProjectPermissionSub.SshCertificateAuthorities) + .describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.SshCertificates).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z + .literal(ProjectPermissionSub.SshCertificateTemplates) + .describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), z.object({ subject: z.literal(ProjectPermissionSub.PkiAlerts).describe("The entity this permission pertains to."), action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( @@ -480,7 +508,10 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.Certificates, ProjectPermissionSub.CertificateTemplates, ProjectPermissionSub.PkiAlerts, - ProjectPermissionSub.PkiCollections + ProjectPermissionSub.PkiCollections, + ProjectPermissionSub.SshCertificateAuthorities, + ProjectPermissionSub.SshCertificates, + ProjectPermissionSub.SshCertificateTemplates ].forEach((el) => { can( [ @@ -665,6 +696,11 @@ 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); + can( [ ProjectPermissionCmekActions.Create, @@ -707,6 +743,9 @@ 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); return rules; }; diff --git a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-dal.ts b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-dal.ts index bb77660aa..6644b14b8 100644 --- a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-dal.ts +++ b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-dal.ts @@ -177,5 +177,10 @@ export const secretApprovalPolicyDALFactory = (db: TDbClient) => { } }; - return { ...secretApprovalPolicyOrm, findById, find }; + const softDeleteById = async (policyId: string, tx?: Knex) => { + const softDeletedPolicy = await secretApprovalPolicyOrm.updateById(policyId, { deletedAt: new Date() }, tx); + return softDeletedPolicy; + }; + + return { ...secretApprovalPolicyOrm, findById, find, softDeleteById }; }; diff --git a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts index cb3452685..b0de6ad75 100644 --- a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts +++ b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts @@ -1,6 +1,7 @@ import { ForbiddenError } from "@casl/ability"; import picomatch from "picomatch"; +import { ProjectType } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; @@ -11,6 +12,8 @@ import { TUserDALFactory } from "@app/services/user/user-dal"; import { ApproverType } from "../access-approval-policy/access-approval-policy-types"; import { TLicenseServiceFactory } from "../license/license-service"; +import { TSecretApprovalRequestDALFactory } from "../secret-approval-request/secret-approval-request-dal"; +import { RequestState } from "../secret-approval-request/secret-approval-request-types"; import { TSecretApprovalPolicyApproverDALFactory } from "./secret-approval-policy-approver-dal"; import { TSecretApprovalPolicyDALFactory } from "./secret-approval-policy-dal"; import { @@ -34,6 +37,7 @@ type TSecretApprovalPolicyServiceFactoryDep = { userDAL: Pick; secretApprovalPolicyApproverDAL: TSecretApprovalPolicyApproverDALFactory; licenseService: Pick; + secretApprovalRequestDAL: Pick; }; export type TSecretApprovalPolicyServiceFactory = ReturnType; @@ -44,7 +48,8 @@ export const secretApprovalPolicyServiceFactory = ({ secretApprovalPolicyApproverDAL, projectEnvDAL, userDAL, - licenseService + licenseService, + secretApprovalRequestDAL }: TSecretApprovalPolicyServiceFactoryDep) => { const createSecretApprovalPolicy = async ({ name, @@ -74,13 +79,14 @@ export const secretApprovalPolicyServiceFactory = ({ if (!groupApprovers.length && approvals > approvers.length) throw new BadRequestError({ message: "Approvals cannot be greater than approvers" }); - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Create, ProjectPermissionSub.SecretApproval @@ -187,13 +193,14 @@ export const secretApprovalPolicyServiceFactory = ({ }); } - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, secretApprovalPolicy.projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval); const plan = await licenseService.getPlan(actorOrgId); @@ -281,13 +288,14 @@ export const secretApprovalPolicyServiceFactory = ({ if (!sapPolicy) throw new NotFoundError({ message: `Secret approval policy with ID '${secretPolicyId}' not found` }); - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, sapPolicy.projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Delete, ProjectPermissionSub.SecretApproval @@ -301,8 +309,16 @@ export const secretApprovalPolicyServiceFactory = ({ }); } - await secretApprovalPolicyDAL.deleteById(secretPolicyId); - return sapPolicy; + const deletedPolicy = await secretApprovalPolicyDAL.transaction(async (tx) => { + await secretApprovalRequestDAL.update( + { policyId: secretPolicyId, status: RequestState.Open }, + { status: RequestState.Closed }, + tx + ); + const updatedPolicy = await secretApprovalPolicyDAL.softDeleteById(secretPolicyId, tx); + return updatedPolicy; + }); + return { ...deletedPolicy, projectId: sapPolicy.projectId, environment: sapPolicy.environment }; }; const getSecretApprovalPolicyByProjectId = async ({ @@ -321,7 +337,7 @@ export const secretApprovalPolicyServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); - const sapPolicies = await secretApprovalPolicyDAL.find({ projectId }); + const sapPolicies = await secretApprovalPolicyDAL.find({ projectId, deletedAt: null }); return sapPolicies; }; @@ -334,7 +350,7 @@ export const secretApprovalPolicyServiceFactory = ({ }); } - const policies = await secretApprovalPolicyDAL.find({ envId: env.id }); + const policies = await secretApprovalPolicyDAL.find({ envId: env.id, deletedAt: null }); if (!policies.length) return; // this will filter policies either without scoped to secret path or the one that matches with secret path const policiesFilteredByPath = policies.filter( diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts index 803b9464c..f842359bc 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts @@ -111,7 +111,8 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { tx.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath"), tx.ref("envId").withSchema(TableName.SecretApprovalPolicy).as("policyEnvId"), tx.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"), - tx.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals") + tx.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"), + tx.ref("deletedAt").withSchema(TableName.SecretApprovalPolicy).as("policyDeletedAt") ); const findById = async (id: string, tx?: Knex) => { @@ -147,7 +148,8 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { approvals: el.policyApprovals, secretPath: el.policySecretPath, enforcementLevel: el.policyEnforcementLevel, - envId: el.policyEnvId + envId: el.policyEnvId, + deletedAt: el.policyDeletedAt } }), childrenMapper: [ @@ -222,6 +224,11 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { `${TableName.SecretApprovalRequest}.policyId`, `${TableName.SecretApprovalPolicyApprover}.policyId` ) + .join( + TableName.SecretApprovalPolicy, + `${TableName.SecretApprovalRequest}.policyId`, + `${TableName.SecretApprovalPolicy}.id` + ) .where({ projectId }) .andWhere( (bd) => @@ -229,6 +236,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { .where(`${TableName.SecretApprovalPolicyApprover}.approverUserId`, userId) .orWhere(`${TableName.SecretApprovalRequest}.committerUserId`, userId) ) + .andWhere((bd) => void bd.where(`${TableName.SecretApprovalPolicy}.deletedAt`, null)) .select("status", `${TableName.SecretApprovalRequest}.id`) .groupBy(`${TableName.SecretApprovalRequest}.id`, "status") .count("status") diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index a39f44fd6..96ab03b4e 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -2,6 +2,7 @@ import { ForbiddenError, subject } from "@casl/ability"; import { ProjectMembershipRole, + ProjectType, SecretEncryptionAlgo, SecretKeyEncoding, SecretType, @@ -232,10 +233,10 @@ export const secretApprovalRequestServiceFactory = ({ type: KmsDataKey.SecretManager, projectId }); - const encrypedSecrets = await secretApprovalRequestSecretDAL.findByRequestIdBridgeSecretV2( + const encryptedSecrets = await secretApprovalRequestSecretDAL.findByRequestIdBridgeSecretV2( secretApprovalRequest.id ); - secrets = encrypedSecrets.map((el) => ({ + secrets = encryptedSecrets.map((el) => ({ ...el, secretKey: el.key, id: el.id, @@ -274,8 +275,8 @@ export const secretApprovalRequestServiceFactory = ({ })); } else { if (!botKey) throw new NotFoundError({ message: `Project bot key not found`, name: "BotKeyNotFound" }); // CLI depends on this error message. TODO(daniel): Make API check for name BotKeyNotFound instead of message - const encrypedSecrets = await secretApprovalRequestSecretDAL.findByRequestId(secretApprovalRequest.id); - secrets = encrypedSecrets.map((el) => ({ + const encryptedSecrets = await secretApprovalRequestSecretDAL.findByRequestId(secretApprovalRequest.id); + secrets = encryptedSecrets.map((el) => ({ ...el, ...decryptSecretWithBot(el, botKey), secret: el.secret @@ -323,6 +324,12 @@ export const secretApprovalRequestServiceFactory = ({ } const { policy } = secretApprovalRequest; + if (policy.deletedAt) { + throw new BadRequestError({ + message: "The policy associated with this secret approval request has been deleted." + }); + } + const { hasRole } = await permissionService.getProjectPermission( ActorType.USER, actorId, @@ -383,6 +390,12 @@ export const secretApprovalRequestServiceFactory = ({ } const { policy } = secretApprovalRequest; + if (policy.deletedAt) { + throw new BadRequestError({ + message: "The policy associated with this secret approval request has been deleted." + }); + } + const { hasRole } = await permissionService.getProjectPermission( ActorType.USER, actorId, @@ -433,6 +446,12 @@ export const secretApprovalRequestServiceFactory = ({ } const { policy, folderId, projectId } = secretApprovalRequest; + if (policy.deletedAt) { + throw new BadRequestError({ + message: "The policy associated with this secret approval request has been deleted." + }); + } + const { hasRole } = await permissionService.getProjectPermission( ActorType.USER, actorId, @@ -857,13 +876,14 @@ export const secretApprovalRequestServiceFactory = ({ }: TGenerateSecretApprovalRequestDTO) => { if (actor === ActorType.SERVICE) throw new BadRequestError({ message: "Cannot use service token" }); - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Read, subject(ProjectPermissionSub.Secrets, { environment, secretPath }) @@ -1137,14 +1157,14 @@ export const secretApprovalRequestServiceFactory = ({ if (actor === ActorType.SERVICE || actor === ActorType.Machine) throw new BadRequestError({ message: "Cannot use service token or machine token over protected branches" }); - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); - + ForbidOnInvalidProjectType(ProjectType.SecretManager); const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); if (!folder) throw new NotFoundError({ diff --git a/backend/src/ee/services/secret-rotation/secret-rotation-service.ts b/backend/src/ee/services/secret-rotation/secret-rotation-service.ts index 6dde2657f..7031d8d12 100644 --- a/backend/src/ee/services/secret-rotation/secret-rotation-service.ts +++ b/backend/src/ee/services/secret-rotation/secret-rotation-service.ts @@ -1,7 +1,7 @@ import { ForbiddenError, subject } from "@casl/ability"; import Ajv from "ajv"; -import { ProjectVersion, TableName } from "@app/db/schemas"; +import { ProjectType, ProjectVersion, TableName } from "@app/db/schemas"; import { decryptSymmetric128BitHexKeyUTF8, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { TProjectPermission } from "@app/lib/types"; @@ -53,13 +53,14 @@ export const secretRotationServiceFactory = ({ actorAuthMethod, projectId }: TProjectPermission) => { - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRotation); return { @@ -81,13 +82,14 @@ export const secretRotationServiceFactory = ({ secretPath, environment }: TCreateSecretRotationDTO) => { - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Create, ProjectPermissionSub.SecretRotation @@ -234,13 +236,14 @@ export const secretRotationServiceFactory = ({ message: "Failed to add secret rotation due to plan restriction. Upgrade plan to add secret rotation." }); - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, doc.projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretRotation); await secretRotationQueue.removeFromQueue(doc.id, doc.interval); await secretRotationQueue.addToQueue(doc.id, doc.interval); @@ -251,13 +254,14 @@ export const secretRotationServiceFactory = ({ const doc = await secretRotationDAL.findById(rotationId); if (!doc) throw new NotFoundError({ message: `Rotation with ID '${rotationId}' not found` }); - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, doc.projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Delete, ProjectPermissionSub.SecretRotation diff --git a/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts b/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts index 481123896..2526facb7 100644 --- a/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts +++ b/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts @@ -1,6 +1,6 @@ import { ForbiddenError, subject } from "@casl/ability"; -import { TableName, TSecretTagJunctionInsert, TSecretV2TagJunctionInsert } from "@app/db/schemas"; +import { ProjectType, TableName, TSecretTagJunctionInsert, TSecretV2TagJunctionInsert } from "@app/db/schemas"; import { decryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto"; import { InternalServerError, NotFoundError } from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; @@ -322,13 +322,14 @@ export const secretSnapshotServiceFactory = ({ if (!snapshot) throw new NotFoundError({ message: `Snapshot with ID '${snapshotId}' not found` }); const shouldUseBridge = snapshot.projectVersion === 3; - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, snapshot.projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback diff --git a/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-dal.ts b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-dal.ts new file mode 100644 index 000000000..b8afa0df2 --- /dev/null +++ b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-dal.ts @@ -0,0 +1,66 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TSshCertificateTemplateDALFactory = ReturnType; + +export const sshCertificateTemplateDALFactory = (db: TDbClient) => { + const sshCertificateTemplateOrm = ormify(db, TableName.SshCertificateTemplate); + + const getById = async (id: string, tx?: Knex) => { + try { + const certTemplate = await (tx || db.replicaNode())(TableName.SshCertificateTemplate) + .join( + TableName.SshCertificateAuthority, + `${TableName.SshCertificateAuthority}.id`, + `${TableName.SshCertificateTemplate}.sshCaId` + ) + .join(TableName.Project, `${TableName.Project}.id`, `${TableName.SshCertificateAuthority}.projectId`) + .where(`${TableName.SshCertificateTemplate}.id`, "=", id) + .select(selectAllTableCols(TableName.SshCertificateTemplate)) + .select( + db.ref("projectId").withSchema(TableName.SshCertificateAuthority), + db.ref("friendlyName").as("caName").withSchema(TableName.SshCertificateAuthority), + db.ref("status").as("caStatus").withSchema(TableName.SshCertificateAuthority) + ) + .first(); + + return certTemplate; + } catch (error) { + throw new DatabaseError({ error, name: "Get SSH certificate template by ID" }); + } + }; + + /** + * Returns the SSH certificate template named [name] within project with id [projectId] + */ + const getByName = async (name: string, projectId: string, tx?: Knex) => { + try { + const certTemplate = await (tx || db.replicaNode())(TableName.SshCertificateTemplate) + .join( + TableName.SshCertificateAuthority, + `${TableName.SshCertificateAuthority}.id`, + `${TableName.SshCertificateTemplate}.sshCaId` + ) + .join(TableName.Project, `${TableName.Project}.id`, `${TableName.SshCertificateAuthority}.projectId`) + .where(`${TableName.SshCertificateTemplate}.name`, "=", name) + .where(`${TableName.Project}.id`, "=", projectId) + .select(selectAllTableCols(TableName.SshCertificateTemplate)) + .select( + db.ref("projectId").withSchema(TableName.SshCertificateAuthority), + db.ref("friendlyName").as("caName").withSchema(TableName.SshCertificateAuthority), + db.ref("status").as("caStatus").withSchema(TableName.SshCertificateAuthority) + ) + .first(); + + return certTemplate; + } catch (error) { + throw new DatabaseError({ error, name: "Get SSH certificate template by name" }); + } + }; + + return { ...sshCertificateTemplateOrm, getById, getByName }; +}; diff --git a/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-schema.ts b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-schema.ts new file mode 100644 index 000000000..fb7a95203 --- /dev/null +++ b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-schema.ts @@ -0,0 +1,15 @@ +import { SshCertificateTemplatesSchema } from "@app/db/schemas"; + +export const sanitizedSshCertificateTemplate = SshCertificateTemplatesSchema.pick({ + id: true, + sshCaId: true, + status: true, + name: true, + ttl: true, + maxTTL: true, + allowedUsers: true, + allowedHosts: true, + allowCustomKeyIds: true, + allowUserCertificates: true, + allowHostCertificates: true +}); diff --git a/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-service.ts b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-service.ts new file mode 100644 index 000000000..a31b9b858 --- /dev/null +++ b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-service.ts @@ -0,0 +1,249 @@ +import { ForbiddenError } from "@casl/ability"; +import ms from "ms"; + +import { ProjectType } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; + +import { TSshCertificateAuthorityDALFactory } from "../ssh/ssh-certificate-authority-dal"; +import { TSshCertificateTemplateDALFactory } from "./ssh-certificate-template-dal"; +import { + SshCertTemplateStatus, + TCreateSshCertTemplateDTO, + TDeleteSshCertTemplateDTO, + TGetSshCertTemplateDTO, + TUpdateSshCertTemplateDTO +} from "./ssh-certificate-template-types"; + +type TSshCertificateTemplateServiceFactoryDep = { + sshCertificateTemplateDAL: Pick< + TSshCertificateTemplateDALFactory, + "transaction" | "getByName" | "create" | "updateById" | "deleteById" | "getById" + >; + sshCertificateAuthorityDAL: Pick; + permissionService: Pick; +}; + +export type TSshCertificateTemplateServiceFactory = ReturnType; + +export const sshCertificateTemplateServiceFactory = ({ + sshCertificateTemplateDAL, + sshCertificateAuthorityDAL, + permissionService +}: TSshCertificateTemplateServiceFactoryDep) => { + const createSshCertTemplate = async ({ + sshCaId, + name, + ttl, + maxTTL, + allowUserCertificates, + allowHostCertificates, + allowedUsers, + allowedHosts, + allowCustomKeyIds, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TCreateSshCertTemplateDTO) => { + const ca = await sshCertificateAuthorityDAL.findById(sshCaId); + if (!ca) { + throw new NotFoundError({ + message: `SSH CA with ID ${sshCaId} not found` + }); + } + + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.SshCertificateTemplates + ); + + if (ms(ttl) > ms(maxTTL)) { + throw new BadRequestError({ + message: "TTL cannot be greater than max TTL" + }); + } + + const newCertificateTemplate = await sshCertificateTemplateDAL.transaction(async (tx) => { + const existingTemplate = await sshCertificateTemplateDAL.getByName(name, ca.projectId, tx); + if (existingTemplate) { + throw new BadRequestError({ + message: `SSH certificate template with name ${name} already exists` + }); + } + + const certificateTemplate = await sshCertificateTemplateDAL.create( + { + sshCaId, + name, + ttl, + maxTTL, + allowUserCertificates, + allowHostCertificates, + allowedUsers, + allowedHosts, + allowCustomKeyIds, + status: SshCertTemplateStatus.ACTIVE + }, + tx + ); + + return certificateTemplate; + }); + + return { certificateTemplate: newCertificateTemplate, ca }; + }; + + const updateSshCertTemplate = async ({ + id, + status, + name, + ttl, + maxTTL, + allowUserCertificates, + allowHostCertificates, + allowedUsers, + allowedHosts, + allowCustomKeyIds, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdateSshCertTemplateDTO) => { + const certTemplate = await sshCertificateTemplateDAL.getById(id); + if (!certTemplate) { + throw new NotFoundError({ + message: `SSH certificate template with ID ${id} not found` + }); + } + + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + certTemplate.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.SshCertificateTemplates + ); + + const updatedCertificateTemplate = await sshCertificateTemplateDAL.transaction(async (tx) => { + if (name) { + const existingTemplate = await sshCertificateTemplateDAL.getByName(name, certTemplate.projectId, tx); + if (existingTemplate && existingTemplate.id !== id) { + throw new BadRequestError({ + message: `SSH certificate template with name ${name} already exists` + }); + } + } + + if (ms(ttl || certTemplate.ttl) > ms(maxTTL || certTemplate.maxTTL)) { + throw new BadRequestError({ + message: "TTL cannot be greater than max TTL" + }); + } + + const certificateTemplate = await sshCertificateTemplateDAL.updateById( + id, + { + status, + name, + ttl, + maxTTL, + allowUserCertificates, + allowHostCertificates, + allowedUsers, + allowedHosts, + allowCustomKeyIds + }, + tx + ); + + return certificateTemplate; + }); + + return { + certificateTemplate: updatedCertificateTemplate, + projectId: certTemplate.projectId + }; + }; + + const deleteSshCertTemplate = async ({ + id, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TDeleteSshCertTemplateDTO) => { + const certificateTemplate = await sshCertificateTemplateDAL.getById(id); + if (!certificateTemplate) { + throw new NotFoundError({ + message: `SSH certificate template with ID ${id} not found` + }); + } + + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + certificateTemplate.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.SshCertificateTemplates + ); + + await sshCertificateTemplateDAL.deleteById(certificateTemplate.id); + + return certificateTemplate; + }; + + const getSshCertTemplate = async ({ id, actorId, actorAuthMethod, actor, actorOrgId }: TGetSshCertTemplateDTO) => { + const certTemplate = await sshCertificateTemplateDAL.getById(id); + if (!certTemplate) { + throw new NotFoundError({ + message: `SSH certificate template with ID ${id} not found` + }); + } + + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + certTemplate.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.SshCertificateTemplates + ); + + return certTemplate; + }; + + return { + createSshCertTemplate, + updateSshCertTemplate, + deleteSshCertTemplate, + getSshCertTemplate + }; +}; diff --git a/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-types.ts b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-types.ts new file mode 100644 index 000000000..64de1bf0c --- /dev/null +++ b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-types.ts @@ -0,0 +1,39 @@ +import { TProjectPermission } from "@app/lib/types"; + +export enum SshCertTemplateStatus { + ACTIVE = "active", + DISABLED = "disabled" +} + +export type TCreateSshCertTemplateDTO = { + sshCaId: string; + name: string; + ttl: string; + maxTTL: string; + allowUserCertificates: boolean; + allowHostCertificates: boolean; + allowedUsers: string[]; + allowedHosts: string[]; + allowCustomKeyIds: boolean; +} & Omit; + +export type TUpdateSshCertTemplateDTO = { + id: string; + status?: SshCertTemplateStatus; + name?: string; + ttl?: string; + maxTTL?: string; + allowUserCertificates?: boolean; + allowHostCertificates?: boolean; + allowedUsers?: string[]; + allowedHosts?: string[]; + allowCustomKeyIds?: boolean; +} & Omit; + +export type TGetSshCertTemplateDTO = { + id: string; +} & Omit; + +export type TDeleteSshCertTemplateDTO = { + id: string; +} & Omit; diff --git a/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-validators.ts b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-validators.ts new file mode 100644 index 000000000..373bbc640 --- /dev/null +++ b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-validators.ts @@ -0,0 +1,14 @@ +// Validates usernames or wildcard (*) +export const isValidUserPattern = (value: string): boolean => { + // Matches valid Linux usernames or a wildcard (*) + const userRegex = /^(?:\*|[a-z_][a-z0-9_-]{0,31})$/; + return userRegex.test(value); +}; + +// Validates hostnames, wildcard domains, or IP addresses +export const isValidHostPattern = (value: string): boolean => { + // Matches FQDNs, wildcard domains (*.example.com), IPv4, and IPv6 addresses + const hostRegex = + /^(?:\*|\*\.[a-z0-9-]+(?:\.[a-z0-9-]+)*|[a-z0-9-]+(?:\.[a-z0-9-]+)*|\d{1,3}(\.\d{1,3}){3}|([a-fA-F0-9:]+:+)+[a-fA-F0-9]+(?:%[a-zA-Z0-9]+)?)$/; + return hostRegex.test(value); +}; diff --git a/backend/src/ee/services/ssh-certificate/ssh-certificate-body-dal.ts b/backend/src/ee/services/ssh-certificate/ssh-certificate-body-dal.ts new file mode 100644 index 000000000..c3d16a39e --- /dev/null +++ b/backend/src/ee/services/ssh-certificate/ssh-certificate-body-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TSshCertificateBodyDALFactory = ReturnType; + +export const sshCertificateBodyDALFactory = (db: TDbClient) => { + const sshCertificateBodyOrm = ormify(db, TableName.SshCertificateBody); + return sshCertificateBodyOrm; +}; diff --git a/backend/src/ee/services/ssh-certificate/ssh-certificate-dal.ts b/backend/src/ee/services/ssh-certificate/ssh-certificate-dal.ts new file mode 100644 index 000000000..9c5bd1d3e --- /dev/null +++ b/backend/src/ee/services/ssh-certificate/ssh-certificate-dal.ts @@ -0,0 +1,38 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +export type TSshCertificateDALFactory = ReturnType; + +export const sshCertificateDALFactory = (db: TDbClient) => { + const sshCertificateOrm = ormify(db, TableName.SshCertificate); + + const countSshCertificatesInProject = async (projectId: string) => { + try { + interface CountResult { + count: string; + } + + const query = db + .replicaNode()(TableName.SshCertificate) + .join( + TableName.SshCertificateAuthority, + `${TableName.SshCertificate}.sshCaId`, + `${TableName.SshCertificateAuthority}.id` + ) + .join(TableName.Project, `${TableName.SshCertificateAuthority}.projectId`, `${TableName.Project}.id`) + .where(`${TableName.Project}.id`, projectId); + + const count = await query.count("*").first(); + + return parseInt((count as unknown as CountResult).count || "0", 10); + } catch (error) { + throw new DatabaseError({ error, name: "Count all SSH certificates in project" }); + } + }; + return { + ...sshCertificateOrm, + countSshCertificatesInProject + }; +}; diff --git a/backend/src/ee/services/ssh-certificate/ssh-certificate-schema.ts b/backend/src/ee/services/ssh-certificate/ssh-certificate-schema.ts new file mode 100644 index 000000000..9c3b7a392 --- /dev/null +++ b/backend/src/ee/services/ssh-certificate/ssh-certificate-schema.ts @@ -0,0 +1,14 @@ +import { SshCertificatesSchema } from "@app/db/schemas"; + +export const sanitizedSshCertificate = SshCertificatesSchema.pick({ + id: true, + sshCaId: true, + sshCertificateTemplateId: true, + serialNumber: true, + certType: true, + publicKey: true, + principals: true, + keyId: true, + notBefore: true, + notAfter: true +}); diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-dal.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-dal.ts new file mode 100644 index 000000000..c906efa91 --- /dev/null +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TSshCertificateAuthorityDALFactory = ReturnType; + +export const sshCertificateAuthorityDALFactory = (db: TDbClient) => { + const sshCaOrm = ormify(db, TableName.SshCertificateAuthority); + return sshCaOrm; +}; diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts new file mode 100644 index 000000000..f59a5d5bf --- /dev/null +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts @@ -0,0 +1,376 @@ +import { execFile } from "child_process"; +import crypto from "crypto"; +import { promises as fs } from "fs"; +import ms from "ms"; +import os from "os"; +import path from "path"; +import { promisify } from "util"; + +import { TSshCertificateTemplates } from "@app/db/schemas"; +import { BadRequestError } from "@app/lib/errors"; +import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; + +import { + isValidHostPattern, + isValidUserPattern +} from "../ssh-certificate-template/ssh-certificate-template-validators"; +import { SshCertType, TCreateSshCertDTO } from "./ssh-certificate-authority-types"; + +const execFileAsync = promisify(execFile); + +/* eslint-disable no-bitwise */ +export const createSshCertSerialNumber = () => { + const randomBytes = crypto.randomBytes(8); // 8 bytes = 64 bits + randomBytes[0] &= 0x7f; // Ensure the most significant bit is 0 (to stay within unsigned range) + return BigInt(`0x${randomBytes.toString("hex")}`).toString(10); // Convert to decimal +}; + +/** + * Return a pair of SSH CA keys based on the specified key algorithm [keyAlgorithm]. + * We use this function because the key format generated by `ssh-keygen` is unique. + */ +export const createSshKeyPair = async (keyAlgorithm: CertKeyAlgorithm) => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ssh-key-")); + const privateKeyFile = path.join(tempDir, "id_key"); + const publicKeyFile = `${privateKeyFile}.pub`; + + let keyType: string; + let keyBits: string; + + switch (keyAlgorithm) { + case CertKeyAlgorithm.RSA_2048: + keyType = "rsa"; + keyBits = "2048"; + break; + case CertKeyAlgorithm.RSA_4096: + keyType = "rsa"; + keyBits = "4096"; + break; + case CertKeyAlgorithm.ECDSA_P256: + keyType = "ecdsa"; + keyBits = "256"; + break; + case CertKeyAlgorithm.ECDSA_P384: + keyType = "ecdsa"; + keyBits = "384"; + break; + default: + throw new BadRequestError({ + message: "Failed to produce SSH CA key pair generation command due to unrecognized key algorithm" + }); + } + + try { + // Generate the SSH key pair + // The "-N ''" sets an empty passphrase + // The keys are created in the temporary directory + await execFileAsync("ssh-keygen", ["-t", keyType, "-b", keyBits, "-f", privateKeyFile, "-N", ""]); + + // Read the generated keys + const publicKey = await fs.readFile(publicKeyFile, "utf8"); + const privateKey = await fs.readFile(privateKeyFile, "utf8"); + + return { publicKey, privateKey }; + } finally { + // Cleanup the temporary directory and all its contents + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); + } +}; + +/** + * Return the SSH public key for the given SSH private key. + */ +export const getSshPublicKey = async (privateKey: string) => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ssh-key-")); + const privateKeyFile = path.join(tempDir, "id_key"); + try { + await fs.writeFile(privateKeyFile, privateKey, { mode: 0o600 }); + + // Run ssh-keygen to extract the public key + const { stdout } = await execFileAsync("ssh-keygen", ["-y", "-f", privateKeyFile], { encoding: "utf8" }); + return stdout.trim(); + } finally { + // Ensure that files and the temporary directory are cleaned up + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); + } +}; + +/** + * Validate the requested SSH certificate type based on the SSH certificate template configuration. + */ +export const validateSshCertificateType = (template: TSshCertificateTemplates, certType: SshCertType) => { + if (!template.allowUserCertificates && certType === SshCertType.USER) { + throw new BadRequestError({ message: "Failed to validate user certificate type due to template restriction" }); + } + + if (!template.allowHostCertificates && certType === SshCertType.HOST) { + throw new BadRequestError({ message: "Failed to validate host certificate type due to template restriction" }); + } +}; + +/** + * Validate the requested SSH certificate principals based on the SSH certificate template configuration. + */ +export const validateSshCertificatePrincipals = ( + certType: SshCertType, + template: TSshCertificateTemplates, + principals: string[] +) => { + /** + * Validate and sanitize a principal string + */ + const validatePrincipal = (principal: string) => { + const sanitized = principal.trim(); + + // basic checks for empty or control characters + if (sanitized.length === 0) { + throw new BadRequestError({ + message: "Principal cannot be an empty string." + }); + } + + if (/\r|\n|\t|\0/.test(sanitized)) { + throw new BadRequestError({ + message: `Principal '${sanitized}' contains invalid whitespace or control characters.` + }); + } + + // disallow whitespace anywhere + if (/\s/.test(sanitized)) { + throw new BadRequestError({ + message: `Principal '${sanitized}' cannot contain whitespace.` + }); + } + + // restrict allowed characters to letters, digits, dot, underscore, and hyphen + if (!/^[A-Za-z0-9._-]+$/.test(sanitized)) { + throw new BadRequestError({ + message: `Principal '${sanitized}' contains invalid characters. Allowed: alphanumeric, '.', '_', '-'.` + }); + } + + // disallow leading hyphen to avoid potential argument-like inputs + if (sanitized.startsWith("-")) { + throw new BadRequestError({ + message: `Principal '${sanitized}' cannot start with a hyphen.` + }); + } + + // length restriction (adjust as needed) + if (sanitized.length > 64) { + throw new BadRequestError({ + message: `Principal '${sanitized}' is too long.` + }); + } + + return sanitized; + }; + + // Sanitize and validate all principals using the helper + const sanitizedPrincipals = principals.map(validatePrincipal); + + switch (certType) { + case SshCertType.USER: { + if (template.allowedUsers.length === 0) { + throw new BadRequestError({ + message: "No allowed users are configured in the SSH certificate template." + }); + } + + const allowsAllUsers = template.allowedUsers.includes("*") ?? false; + + sanitizedPrincipals.forEach((principal) => { + if (principal === "*") { + throw new BadRequestError({ + message: `Principal '*' is not allowed for user certificates.` + }); + } + if (allowsAllUsers && !isValidUserPattern(principal)) { + throw new BadRequestError({ + message: `Principal '${principal}' does not match a valid user pattern.` + }); + } + if (!allowsAllUsers && !template.allowedUsers.includes(principal)) { + throw new BadRequestError({ + message: `Principal '${principal}' is not in the list of allowed users.` + }); + } + }); + break; + } + case SshCertType.HOST: { + if (template.allowedHosts.length === 0) { + throw new BadRequestError({ + message: "No allowed hosts are configured in the SSH certificate template." + }); + } + + const allowsAllHosts = template.allowedHosts.includes("*") ?? false; + + sanitizedPrincipals.forEach((principal) => { + if (principal.includes("*")) { + throw new BadRequestError({ + message: `Principal '${principal}' with wildcards is not allowed for host certificates.` + }); + } + if (allowsAllHosts && !isValidHostPattern(principal)) { + throw new BadRequestError({ + message: `Principal '${principal}' does not match a valid host pattern.` + }); + } + + if ( + !allowsAllHosts && + !template.allowedHosts.some((allowedHost) => { + if (allowedHost.startsWith("*.")) { + const baseDomain = allowedHost.slice(2); // Remove the leading "*." + return principal.endsWith(`.${baseDomain}`); + } + return principal === allowedHost; + }) + ) { + throw new BadRequestError({ + message: `Principal '${principal}' is not in the list of allowed hosts or domains.` + }); + } + }); + break; + } + default: + throw new BadRequestError({ + message: "Failed to validate SSH certificate principals due to unrecognized requested certificate type" + }); + } +}; + +/** + * Validate the requested SSH certificate TTL based on the SSH certificate template configuration. + */ +export const validateSshCertificateTtl = (template: TSshCertificateTemplates, ttl?: string) => { + if (!ttl) { + // use default template ttl + return Math.ceil(ms(template.ttl) / 1000); + } + + if (ms(ttl) > ms(template.maxTTL)) { + throw new BadRequestError({ + message: "Failed TTL validation due to TTL being greater than configured max TTL on template" + }); + } + + return Math.ceil(ms(ttl) / 1000); +}; + +/** + * Validate the requested SSH certificate key ID to ensure + * that it only contains alphanumeric characters with no spaces. + */ +export const validateSshCertificateKeyId = (keyId: string) => { + const regex = /^[A-Za-z0-9-]+$/; + if (!regex.test(keyId)) { + throw new BadRequestError({ + message: + "Failed to validate Key ID because it can only contain alphanumeric characters and hyphens, with no spaces." + }); + } + + if (keyId.length > 50) { + throw new BadRequestError({ + message: "keyId can only be up to 50 characters long." + }); + } +}; + +/** + * Validate the format of the SSH public key + */ +const validateSshPublicKey = async (publicKey: string) => { + const validPrefixes = ["ssh-rsa", "ssh-ed25519", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384"]; + const startsWithValidPrefix = validPrefixes.some((prefix) => publicKey.startsWith(`${prefix} `)); + if (!startsWithValidPrefix) { + throw new BadRequestError({ message: "Failed to validate SSH public key format: unsupported key type." }); + } + + // write the key to a temp file and run `ssh-keygen -l -f` + // check to see if OpenSSH can read/interpret the public key + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ssh-pubkey-")); + const pubKeyFile = path.join(tempDir, "key.pub"); + + try { + await fs.writeFile(pubKeyFile, publicKey, { mode: 0o600 }); + await execFileAsync("ssh-keygen", ["-l", "-f", pubKeyFile]); + } catch (error) { + throw new BadRequestError({ + message: "Failed to validate SSH public key format: could not be parsed." + }); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); + } +}; + +/** + * Create an SSH certificate for a user or host. + */ +export const createSshCert = async ({ + template, + caPrivateKey, + clientPublicKey, + keyId, + principals, + requestedTtl, + certType +}: TCreateSshCertDTO) => { + // validate if the requested [certType] is allowed under the template configuration + validateSshCertificateType(template, certType); + + // validate if the requested [principals] are valid for the given [certType] under the template configuration + validateSshCertificatePrincipals(certType, template, principals); + + // validate if the requested TTL is valid under the template configuration + const ttl = validateSshCertificateTtl(template, requestedTtl); + + validateSshCertificateKeyId(keyId); + await validateSshPublicKey(clientPublicKey); + + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ssh-cert-")); + + const publicKeyFile = path.join(tempDir, "user_key.pub"); + const privateKeyFile = path.join(tempDir, "ca_key"); + const signedPublicKeyFile = path.join(tempDir, "user_key-cert.pub"); + + const serialNumber = createSshCertSerialNumber(); + + // Build `ssh-keygen` arguments for signing + // Using an array avoids shell injection issues + const sshKeygenArgs = [ + certType === "host" ? "-h" : null, // host certificate if needed + "-s", + privateKeyFile, // path to SSH CA private key + "-I", + keyId, // identity (key ID) + "-n", + principals.join(","), // principals + "-V", + `+${ttl}s`, // validity (TTL in seconds) + "-z", + serialNumber, // serial number + publicKeyFile // public key file to sign + ].filter(Boolean) as string[]; + + try { + // Write public and private keys to the temp directory + await fs.writeFile(publicKeyFile, clientPublicKey, { mode: 0o600 }); + await fs.writeFile(privateKeyFile, caPrivateKey, { mode: 0o600 }); + + // Execute the signing process + await execFileAsync("ssh-keygen", sshKeygenArgs, { encoding: "utf8" }); + + // Read the signed public key from the generated cert file + const signedPublicKey = await fs.readFile(signedPublicKeyFile, "utf8"); + + return { serialNumber, signedPublicKey, ttl }; + } finally { + // Cleanup the temporary directory and all its contents + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); + } +}; diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-schema.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-schema.ts new file mode 100644 index 000000000..9ff76efbc --- /dev/null +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-schema.ts @@ -0,0 +1,9 @@ +import { SshCertificateAuthoritiesSchema } from "@app/db/schemas"; + +export const sanitizedSshCa = SshCertificateAuthoritiesSchema.pick({ + id: true, + projectId: true, + friendlyName: true, + status: true, + keyAlgorithm: true +}); diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-secret-dal.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-secret-dal.ts new file mode 100644 index 000000000..9423a0ff2 --- /dev/null +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-secret-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TSshCertificateAuthoritySecretDALFactory = ReturnType; + +export const sshCertificateAuthoritySecretDALFactory = (db: TDbClient) => { + const sshCaSecretOrm = ormify(db, TableName.SshCertificateAuthoritySecret); + return sshCaSecretOrm; +}; diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts new file mode 100644 index 000000000..0b408c9dd --- /dev/null +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts @@ -0,0 +1,523 @@ +import { ForbiddenError } from "@casl/ability"; + +import { ProjectType } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { TSshCertificateAuthorityDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-dal"; +import { TSshCertificateAuthoritySecretDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-secret-dal"; +import { TSshCertificateBodyDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-body-dal"; +import { TSshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-dal"; +import { TSshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; + +import { SshCertTemplateStatus } from "../ssh-certificate-template/ssh-certificate-template-types"; +import { createSshCert, createSshKeyPair, getSshPublicKey } from "./ssh-certificate-authority-fns"; +import { + SshCaStatus, + TCreateSshCaDTO, + TDeleteSshCaDTO, + TGetSshCaCertificateTemplatesDTO, + TGetSshCaDTO, + TGetSshCaPublicKeyDTO, + TIssueSshCredsDTO, + TSignSshKeyDTO, + TUpdateSshCaDTO +} from "./ssh-certificate-authority-types"; + +type TSshCertificateAuthorityServiceFactoryDep = { + sshCertificateAuthorityDAL: Pick< + TSshCertificateAuthorityDALFactory, + "transaction" | "create" | "findById" | "updateById" | "deleteById" | "findOne" + >; + sshCertificateAuthoritySecretDAL: Pick; + sshCertificateTemplateDAL: Pick; + sshCertificateDAL: Pick; + sshCertificateBodyDAL: Pick; + kmsService: Pick< + TKmsServiceFactory, + "generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey" | "getOrgKmsKeyId" | "createCipherPairWithDataKey" + >; + permissionService: Pick; +}; + +export type TSshCertificateAuthorityServiceFactory = ReturnType; + +export const sshCertificateAuthorityServiceFactory = ({ + sshCertificateAuthorityDAL, + sshCertificateAuthoritySecretDAL, + sshCertificateTemplateDAL, + sshCertificateDAL, + sshCertificateBodyDAL, + kmsService, + permissionService +}: TSshCertificateAuthorityServiceFactoryDep) => { + /** + * Generates a new SSH CA + */ + const createSshCa = async ({ + projectId, + friendlyName, + keyAlgorithm, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TCreateSshCaDTO) => { + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.SshCertificateAuthorities + ); + + const newCa = await sshCertificateAuthorityDAL.transaction(async (tx) => { + const ca = await sshCertificateAuthorityDAL.create( + { + projectId, + friendlyName, + status: SshCaStatus.ACTIVE, + keyAlgorithm + }, + tx + ); + + const { publicKey, privateKey } = await createSshKeyPair(keyAlgorithm); + + const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + + await sshCertificateAuthoritySecretDAL.create( + { + sshCaId: ca.id, + encryptedPrivateKey: secretManagerEncryptor({ plainText: Buffer.from(privateKey, "utf8") }).cipherTextBlob + }, + tx + ); + + return { ...ca, publicKey }; + }); + + return newCa; + }; + + /** + * Return SSH CA with id [caId] + */ + const getSshCaById = async ({ caId, actor, actorId, actorAuthMethod, actorOrgId }: TGetSshCaDTO) => { + const ca = await sshCertificateAuthorityDAL.findById(caId); + if (!ca) throw new NotFoundError({ message: `SSH CA with ID '${caId}' not found` }); + + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.SshCertificateAuthorities + ); + + const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: ca.id }); + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: ca.projectId + }); + + const decryptedCaPrivateKey = secretManagerDecryptor({ + cipherTextBlob: sshCaSecret.encryptedPrivateKey + }); + + const publicKey = await getSshPublicKey(decryptedCaPrivateKey.toString("utf-8")); + + return { ...ca, publicKey }; + }; + + /** + * Return public key of SSH CA with id [caId] + */ + const getSshCaPublicKey = async ({ caId }: TGetSshCaPublicKeyDTO) => { + const ca = await sshCertificateAuthorityDAL.findById(caId); + if (!ca) throw new NotFoundError({ message: `SSH CA with ID '${caId}' not found` }); + + const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: ca.id }); + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: ca.projectId + }); + + const decryptedCaPrivateKey = secretManagerDecryptor({ + cipherTextBlob: sshCaSecret.encryptedPrivateKey + }); + + const publicKey = await getSshPublicKey(decryptedCaPrivateKey.toString("utf-8")); + + return publicKey; + }; + + /** + * Update SSH CA with id [caId] + * Note: Used to enable/disable CA + */ + const updateSshCaById = async ({ + caId, + friendlyName, + status, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TUpdateSshCaDTO) => { + const ca = await sshCertificateAuthorityDAL.findById(caId); + if (!ca) throw new NotFoundError({ message: `SSH CA with ID '${caId}' not found` }); + + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.SshCertificateAuthorities + ); + + const updatedCa = await sshCertificateAuthorityDAL.updateById(caId, { friendlyName, status }); + + const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: ca.id }); + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: ca.projectId + }); + + const decryptedCaPrivateKey = secretManagerDecryptor({ + cipherTextBlob: sshCaSecret.encryptedPrivateKey + }); + + const publicKey = await getSshPublicKey(decryptedCaPrivateKey.toString("utf-8")); + + return { ...updatedCa, publicKey }; + }; + + /** + * Delete SSH CA with id [caId] + */ + const deleteSshCaById = async ({ caId, actor, actorId, actorAuthMethod, actorOrgId }: TDeleteSshCaDTO) => { + const ca = await sshCertificateAuthorityDAL.findById(caId); + if (!ca) throw new NotFoundError({ message: `SSH CA with ID '${caId}' not found` }); + + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.SshCertificateAuthorities + ); + + const deletedCa = await sshCertificateAuthorityDAL.deleteById(caId); + + return deletedCa; + }; + + /** + * Return SSH certificate and corresponding new SSH public-private key pair where + * SSH public key is signed using CA behind SSH certificate with name [templateName]. + */ + const issueSshCreds = async ({ + certificateTemplateId, + keyAlgorithm, + certType, + principals, + ttl: requestedTtl, + keyId: requestedKeyId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TIssueSshCredsDTO) => { + const sshCertificateTemplate = await sshCertificateTemplateDAL.getById(certificateTemplateId); + if (!sshCertificateTemplate) { + throw new NotFoundError({ + message: "No SSH certificate template found with specified name" + }); + } + + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + sshCertificateTemplate.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.SshCertificates + ); + + if (sshCertificateTemplate.caStatus === SshCaStatus.DISABLED) { + throw new BadRequestError({ + message: "SSH CA is disabled" + }); + } + + if (sshCertificateTemplate.status === SshCertTemplateStatus.DISABLED) { + throw new BadRequestError({ + message: "SSH certificate template is disabled" + }); + } + + // set [keyId] depending on if [allowCustomKeyIds] is true or false + const keyId = sshCertificateTemplate.allowCustomKeyIds + ? requestedKeyId ?? `${actor}-${actorId}` + : `${actor}-${actorId}`; + + const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: sshCertificateTemplate.sshCaId }); + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: sshCertificateTemplate.projectId + }); + + const decryptedCaPrivateKey = secretManagerDecryptor({ + cipherTextBlob: sshCaSecret.encryptedPrivateKey + }); + + // create user key pair + const { publicKey, privateKey } = await createSshKeyPair(keyAlgorithm); + + const { serialNumber, signedPublicKey, ttl } = await createSshCert({ + template: sshCertificateTemplate, + caPrivateKey: decryptedCaPrivateKey.toString("utf8"), + clientPublicKey: publicKey, + keyId, + principals, + requestedTtl, + certType + }); + + const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: sshCertificateTemplate.projectId + }); + + const encryptedCertificate = secretManagerEncryptor({ + plainText: Buffer.from(signedPublicKey, "utf8") + }).cipherTextBlob; + + await sshCertificateDAL.transaction(async (tx) => { + const cert = await sshCertificateDAL.create( + { + sshCaId: sshCertificateTemplate.sshCaId, + sshCertificateTemplateId: sshCertificateTemplate.id, + serialNumber, + certType, + principals, + keyId, + notBefore: new Date(), + notAfter: new Date(Date.now() + ttl * 1000) + }, + tx + ); + + await sshCertificateBodyDAL.create( + { + sshCertId: cert.id, + encryptedCertificate + }, + tx + ); + }); + + return { + serialNumber, + signedPublicKey, + privateKey, + publicKey, + certificateTemplate: sshCertificateTemplate, + ttl, + keyId + }; + }; + + /** + * Return SSH certificate by signing SSH public key [publicKey] + * using CA behind SSH certificate template with name [templateName] + */ + const signSshKey = async ({ + certificateTemplateId, + publicKey, + certType, + principals, + ttl: requestedTtl, + keyId: requestedKeyId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TSignSshKeyDTO) => { + const sshCertificateTemplate = await sshCertificateTemplateDAL.getById(certificateTemplateId); + if (!sshCertificateTemplate) { + throw new NotFoundError({ + message: "No SSH certificate template found with specified name" + }); + } + + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + sshCertificateTemplate.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.SshCertificates + ); + + if (sshCertificateTemplate.caStatus === SshCaStatus.DISABLED) { + throw new BadRequestError({ + message: "SSH CA is disabled" + }); + } + + if (sshCertificateTemplate.status === SshCertTemplateStatus.DISABLED) { + throw new BadRequestError({ + message: "SSH certificate template is disabled" + }); + } + + // set [keyId] depending on if [allowCustomKeyIds] is true or false + const keyId = sshCertificateTemplate.allowCustomKeyIds + ? requestedKeyId ?? `${actor}-${actorId}` + : `${actor}-${actorId}`; + + const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: sshCertificateTemplate.sshCaId }); + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: sshCertificateTemplate.projectId + }); + + const decryptedCaPrivateKey = secretManagerDecryptor({ + cipherTextBlob: sshCaSecret.encryptedPrivateKey + }); + + const { serialNumber, signedPublicKey, ttl } = await createSshCert({ + template: sshCertificateTemplate, + caPrivateKey: decryptedCaPrivateKey.toString("utf8"), + clientPublicKey: publicKey, + keyId, + principals, + requestedTtl, + certType + }); + + const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: sshCertificateTemplate.projectId + }); + + const encryptedCertificate = secretManagerEncryptor({ + plainText: Buffer.from(signedPublicKey, "utf8") + }).cipherTextBlob; + + await sshCertificateDAL.transaction(async (tx) => { + const cert = await sshCertificateDAL.create( + { + sshCaId: sshCertificateTemplate.sshCaId, + sshCertificateTemplateId: sshCertificateTemplate.id, + serialNumber, + certType, + principals, + keyId, + notBefore: new Date(), + notAfter: new Date(Date.now() + ttl * 1000) + }, + tx + ); + + await sshCertificateBodyDAL.create( + { + sshCertId: cert.id, + encryptedCertificate + }, + tx + ); + }); + + return { serialNumber, signedPublicKey, certificateTemplate: sshCertificateTemplate, ttl, keyId }; + }; + + const getSshCaCertificateTemplates = async ({ + caId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TGetSshCaCertificateTemplatesDTO) => { + const ca = await sshCertificateAuthorityDAL.findById(caId); + if (!ca) throw new NotFoundError({ message: `SSH CA with ID '${caId}' not found` }); + + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.SshCertificateTemplates + ); + + const certificateTemplates = await sshCertificateTemplateDAL.find({ sshCaId: caId }); + + return { + certificateTemplates, + ca + }; + }; + + return { + issueSshCreds, + signSshKey, + createSshCa, + getSshCaById, + getSshCaPublicKey, + updateSshCaById, + deleteSshCaById, + getSshCaCertificateTemplates + }; +}; diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-types.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-types.ts new file mode 100644 index 000000000..3f202ebf0 --- /dev/null +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-types.ts @@ -0,0 +1,68 @@ +import { TSshCertificateTemplates } from "@app/db/schemas"; +import { TProjectPermission } from "@app/lib/types"; +import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; + +export enum SshCaStatus { + ACTIVE = "active", + DISABLED = "disabled" +} + +export enum SshCertType { + USER = "user", + HOST = "host" +} + +export type TCreateSshCaDTO = { + friendlyName: string; + keyAlgorithm: CertKeyAlgorithm; +} & TProjectPermission; + +export type TGetSshCaDTO = { + caId: string; +} & Omit; + +export type TGetSshCaPublicKeyDTO = { + caId: string; +}; + +export type TUpdateSshCaDTO = { + caId: string; + friendlyName?: string; + status?: SshCaStatus; +} & Omit; + +export type TDeleteSshCaDTO = { + caId: string; +} & Omit; + +export type TIssueSshCredsDTO = { + certificateTemplateId: string; + keyAlgorithm: CertKeyAlgorithm; + certType: SshCertType; + principals: string[]; + ttl?: string; + keyId?: string; +} & Omit; + +export type TSignSshKeyDTO = { + certificateTemplateId: string; + publicKey: string; + certType: SshCertType; + principals: string[]; + ttl?: string; + keyId?: string; +} & Omit; + +export type TGetSshCaCertificateTemplatesDTO = { + caId: string; +} & Omit; + +export type TCreateSshCertDTO = { + template: TSshCertificateTemplates; + caPrivateKey: string; + clientPublicKey: string; + keyId: string; + principals: string[]; + requestedTtl?: string; + certType: SshCertType; +}; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 99822da29..293815d7a 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1,3 +1,6 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps"; + export const GROUPS = { CREATE: { name: "The name of the group to create.", @@ -19,7 +22,9 @@ export const GROUPS = { offset: "The offset to start from. If you enter 10, it will start from the 10th user.", limit: "The number of users to return.", username: "The username to search for.", - search: "The text string that user email or name will be filtered by." + search: "The text string that user email or name will be filtered by.", + filterUsers: + "Whether to filter the list of returned users. 'existingMembers' will only return existing users in the group, 'nonMembers' will only return users not in the group, undefined will return all users in the organization." }, ADD_USER: { id: "The ID of the group to add the user to.", @@ -349,6 +354,52 @@ export const OIDC_AUTH = { } } as const; +export const JWT_AUTH = { + LOGIN: { + identityId: "The ID of the identity to login." + }, + ATTACH: { + identityId: "The ID of the identity to attach the configuration onto.", + configurationType: "The configuration for validating JWTs. Must be one of: 'jwks', 'static'", + jwksUrl: + "The URL of the JWKS endpoint. Required if configurationType is 'jwks'. This endpoint must serve JSON Web Key Sets (JWKS) containing the public keys used to verify JWT signatures.", + jwksCaCert: "The PEM-encoded CA certificate for validating the TLS connection to the JWKS endpoint.", + publicKeys: + "A list of PEM-encoded public keys used to verify JWT signatures. Required if configurationType is 'static'. Each key must be in RSA or ECDSA format and properly PEM-encoded with BEGIN/END markers.", + boundIssuer: "The unique identifier of the JWT provider.", + boundAudiences: "The list of intended recipients.", + boundClaims: "The attributes that should be present in the JWT for it to be valid.", + boundSubject: "The expected principal that is the subject of the JWT.", + accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from.", + accessTokenTTL: "The lifetime for an access token in seconds.", + accessTokenMaxTTL: "The maximum lifetime for an access token in seconds.", + accessTokenNumUsesLimit: "The maximum number of times that an access token can be used." + }, + UPDATE: { + identityId: "The ID of the identity to update the auth method for.", + configurationType: "The new configuration for validating JWTs. Must be one of: 'jwks', 'static'", + jwksUrl: + "The new URL of the JWKS endpoint. This endpoint must serve JSON Web Key Sets (JWKS) containing the public keys used to verify JWT signatures.", + jwksCaCert: "The new PEM-encoded CA certificate for validating the TLS connection to the JWKS endpoint.", + publicKeys: + "A new list of PEM-encoded public keys used to verify JWT signatures. Each key must be in RSA or ECDSA format and properly PEM-encoded with BEGIN/END markers.", + boundIssuer: "The new unique identifier of the JWT provider.", + boundAudiences: "The new list of intended recipients.", + boundClaims: "The new attributes that should be present in the JWT for it to be valid.", + boundSubject: "The new expected principal that is the subject of the JWT.", + accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from.", + accessTokenTTL: "The new lifetime for an access token in seconds.", + accessTokenMaxTTL: "The new maximum lifetime for an access token in seconds.", + accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used." + }, + RETRIEVE: { + identityId: "The ID of the identity to retrieve the auth method for." + }, + REVOKE: { + identityId: "The ID of the identity to revoke the auth method for." + } +} as const; + export const ORGANIZATIONS = { LIST_USER_MEMBERSHIPS: { organizationId: "The ID of the organization to get memberships from." @@ -380,7 +431,8 @@ export const ORGANIZATIONS = { search: "The text string that identity membership names will be filtered by." }, GET_PROJECTS: { - organizationId: "The ID of the organization to get projects from." + organizationId: "The ID of the organization to get projects from.", + type: "The type of project to filter by." }, LIST_GROUPS: { organizationId: "The ID of the organization to list groups for." @@ -443,6 +495,17 @@ export const PROJECTS = { LIST_INTEGRATION_AUTHORIZATION: { workspaceId: "The ID of the project to list integration auths for." }, + LIST_SSH_CAS: { + projectId: "The ID of the project to list SSH CAs for." + }, + LIST_SSH_CERTIFICATES: { + projectId: "The ID of the project to list SSH certificates for.", + offset: "The offset to start from. If you enter 10, it will start from the 10th SSH certificate.", + limit: "The number of SSH certificates to return." + }, + LIST_SSH_CERTIFICATE_TEMPLATES: { + projectId: "The ID of the project to list SSH certificate templates for." + }, LIST_CAS: { slug: "The slug of the project to list CAs for.", status: "The status of the CA to filter by.", @@ -1032,6 +1095,9 @@ export const INTEGRATION_AUTH = { DELETE_BY_ID: { integrationAuthId: "The ID of integration authentication object to delete." }, + UPDATE_BY_ID: { + integrationAuthId: "The ID of integration authentication object to update." + }, CREATE_ACCESS_TOKEN: { workspaceId: "The ID of the project to create the integration auth for.", integration: "The slug of integration for the auth object.", @@ -1074,6 +1140,7 @@ export const INTEGRATION = { shouldAutoRedeploy: "Used by Render to trigger auto deploy.", secretGCPLabel: "The label for GCP secrets.", secretAWSTag: "The tags for AWS secrets.", + azureLabel: "Define which label to assign to secrets created in Azure App Configuration.", githubVisibility: "Define where the secrets from the Github Integration should be visible. Option 'selected' lets you directly define which repositories to sync secrets to.", githubVisibilityRepoIds: @@ -1088,11 +1155,13 @@ export const INTEGRATION = { }, UPDATE: { integrationId: "The ID of the integration object.", + region: "AWS region to sync secrets to.", app: "The name of the external integration providers app entity that you want to sync secrets with. Used in Netlify, GitHub, Vercel integrations.", appId: "The ID of the external integration providers app entity that you want to sync secrets with. Used in Netlify, GitHub, Vercel integrations.", isActive: "Whether the integration should be active or disabled.", secretPath: "The path of the secrets to sync secrets from.", + path: "Path to save the synced secrets. Used by Gitlab, AWS Parameter Store, Vault.", owner: "External integration providers service entity owner. Used in Github.", targetEnvironment: "The target environment of the integration provider. Used in cloudflare pages, TeamCity, Gitlab integrations.", @@ -1132,6 +1201,84 @@ export const AUDIT_LOG_STREAMS = { } }; +export const SSH_CERTIFICATE_AUTHORITIES = { + CREATE: { + projectId: "The ID of the project to create the SSH CA in.", + friendlyName: "A friendly name for the SSH CA.", + keyAlgorithm: "The type of public key algorithm and size, in bits, of the key pair for the SSH CA." + }, + GET: { + sshCaId: "The ID of the SSH CA to get." + }, + GET_PUBLIC_KEY: { + sshCaId: "The ID of the SSH CA to get the public key for." + }, + UPDATE: { + sshCaId: "The ID of the SSH CA to update.", + friendlyName: "A friendly name for the SSH CA to update to.", + status: "The status of the SSH CA to update to. This can be one of active or disabled." + }, + DELETE: { + sshCaId: "The ID of the SSH CA to delete." + }, + GET_CERTIFICATE_TEMPLATES: { + sshCaId: "The ID of the SSH CA to get the certificate templates for." + }, + SIGN_SSH_KEY: { + certificateTemplateId: "The ID of the SSH certificate template to sign the SSH public key with.", + publicKey: "The SSH public key to sign.", + certType: "The type of certificate to issue. This can be one of user or host.", + principals: "The list of principals (usernames, hostnames) to include in the certificate.", + ttl: "The time to live for the certificate such as 1m, 1h, 1d, ... If not specified, the default TTL for the template will be used.", + keyId: "The key ID to include in the certificate. If not specified, a default key ID will be generated.", + serialNumber: "The serial number of the issued SSH certificate.", + signedKey: "The SSH certificate or signed SSH public key." + }, + ISSUE_SSH_CREDENTIALS: { + certificateTemplateId: "The ID of the SSH certificate template to issue the SSH credentials with.", + keyAlgorithm: "The type of public key algorithm and size, in bits, of the key pair for the SSH CA.", + certType: "The type of certificate to issue. This can be one of user or host.", + principals: "The list of principals (usernames, hostnames) to include in the certificate.", + ttl: "The time to live for the certificate such as 1m, 1h, 1d, ... If not specified, the default TTL for the template will be used.", + keyId: "The key ID to include in the certificate. If not specified, a default key ID will be generated.", + serialNumber: "The serial number of the issued SSH certificate.", + signedKey: "The SSH certificate or signed SSH public key.", + privateKey: "The private key corresponding to the issued SSH certificate.", + publicKey: "The public key of the issued SSH certificate." + } +}; + +export const SSH_CERTIFICATE_TEMPLATES = { + GET: { + certificateTemplateId: "The ID of the SSH certificate template to get." + }, + CREATE: { + sshCaId: "The ID of the SSH CA to associate the certificate template with.", + name: "The name of the certificate template.", + ttl: "The default time to live for issued certificates such as 1m, 1h, 1d, 1y, ...", + maxTTL: "The maximum time to live for issued certificates such as 1m, 1h, 1d, 1y, ...", + allowedUsers: "The list of allowed users for certificates issued under this template.", + allowedHosts: "The list of allowed hosts for certificates issued under this template.", + allowUserCertificates: "Whether or not to allow user certificates to be issued under this template.", + allowHostCertificates: "Whether or not to allow host certificates to be issued under this template.", + allowCustomKeyIds: "Whether or not to allow custom key IDs for certificates issued under this template." + }, + UPDATE: { + certificateTemplateId: "The ID of the SSH certificate template to update.", + name: "The name of the certificate template.", + ttl: "The default time to live for issued certificates such as 1m, 1h, 1d, 1y, ...", + maxTTL: "The maximum time to live for issued certificates such as 1m, 1h, 1d, 1y, ...", + allowedUsers: "The list of allowed users for certificates issued under this template.", + allowedHosts: "The list of allowed hosts for certificates issued under this template.", + allowUserCertificates: "Whether or not to allow user certificates to be issued under this template.", + allowHostCertificates: "Whether or not to allow host certificates to be issued under this template.", + allowCustomKeyIds: "Whether or not to allow custom key IDs for certificates issued under this template." + }, + DELETE: { + certificateTemplateId: "The ID of the SSH certificate template to delete." + } +}; + export const CERTIFICATE_AUTHORITIES = { CREATE: { projectSlug: "Slug of the project to create the CA in.", @@ -1461,3 +1608,34 @@ export const ProjectTemplates = { templateId: "The ID of the project template to be deleted." } }; + +export const AppConnections = { + GET_BY_ID: (app: AppConnection) => ({ + connectionId: `The ID of the ${APP_CONNECTION_NAME_MAP[app]} Connection to retrieve.` + }), + GET_BY_NAME: (app: AppConnection) => ({ + connectionName: `The name of the ${APP_CONNECTION_NAME_MAP[app]} Connection to retrieve.` + }), + CREATE: (app: AppConnection) => { + const appName = APP_CONNECTION_NAME_MAP[app]; + return { + name: `The name of the ${appName} Connection to create. Must be slug-friendly.`, + description: `An optional description for the ${appName} Connection.`, + credentials: `The credentials used to connect with ${appName}.`, + method: `The method used to authenticate with ${appName}.` + }; + }, + UPDATE: (app: AppConnection) => { + const appName = APP_CONNECTION_NAME_MAP[app]; + return { + connectionId: `The ID of the ${appName} Connection to be updated.`, + name: `The updated name of the ${appName} Connection. Must be slug-friendly.`, + description: `The updated description of the ${appName} Connection.`, + credentials: `The credentials used to connect with ${appName}.`, + method: `The method used to authenticate with ${appName}.` + }; + }, + DELETE: (app: AppConnection) => ({ + connectionId: `The ID of the ${APP_CONNECTION_NAME_MAP[app]} connection to be deleted.` + }) +}; diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 66c5f3d98..7b6f356fd 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -166,8 +166,7 @@ const envSchema = z OTEL_COLLECTOR_BASIC_AUTH_PASSWORD: zpStr(z.string().optional()), OTEL_EXPORT_TYPE: z.enum(["prometheus", "otlp"]).optional(), - PLAIN_API_KEY: zpStr(z.string().optional()), - PLAIN_WISH_LABEL_IDS: zpStr(z.string().optional()), + PYLON_API_KEY: zpStr(z.string().optional()), DISABLE_AUDIT_LOG_GENERATION: zodStrBool.default("false"), SSL_CLIENT_CERTIFICATE_HEADER_KEY: zpStr(z.string().optional()).default("x-ssl-client-cert"), WORKFLOW_SLACK_CLIENT_ID: zpStr(z.string().optional()), @@ -181,7 +180,24 @@ const envSchema = z HSM_SLOT: z.coerce.number().optional().default(0), USE_PG_QUEUE: zodStrBool.default("false"), - SHOULD_INIT_PG_QUEUE: zodStrBool.default("false") + SHOULD_INIT_PG_QUEUE: zodStrBool.default("false"), + + /* App Connections ----------------------------------------------------------------------------- */ + + // aws + INF_APP_CONNECTION_AWS_ACCESS_KEY_ID: zpStr(z.string().optional()), + INF_APP_CONNECTION_AWS_SECRET_ACCESS_KEY: zpStr(z.string().optional()), + + // github oauth + INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID: zpStr(z.string().optional()), + INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_SECRET: zpStr(z.string().optional()), + + // github app + INF_APP_CONNECTION_GITHUB_APP_CLIENT_ID: zpStr(z.string().optional()), + INF_APP_CONNECTION_GITHUB_APP_CLIENT_SECRET: zpStr(z.string().optional()), + INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY: zpStr(z.string().optional()), + INF_APP_CONNECTION_GITHUB_APP_SLUG: zpStr(z.string().optional()), + INF_APP_CONNECTION_GITHUB_APP_ID: zpStr(z.string().optional()) }) // To ensure that basic encryption is always possible. .refine( diff --git a/backend/src/lib/fn/string.ts b/backend/src/lib/fn/string.ts index 26e8f27df..1dc2bbfed 100644 --- a/backend/src/lib/fn/string.ts +++ b/backend/src/lib/fn/string.ts @@ -14,3 +14,5 @@ export const prefixWithSlash = (str: string) => { if (str.startsWith("/")) return str; return `/${str}`; }; + +export const startsWithVowel = (str: string) => /^[aeiou]/i.test(str); diff --git a/backend/src/lib/knex/index.ts b/backend/src/lib/knex/index.ts index f55d8e6e6..0022ee8ea 100644 --- a/backend/src/lib/knex/index.ts +++ b/backend/src/lib/knex/index.ts @@ -20,11 +20,12 @@ export const withTransaction = (db: Knex, dal: K) => ({ export type TFindFilter = Partial & { $in?: Partial<{ [k in keyof R]: R[k][] }>; + $notNull?: Array; $search?: Partial<{ [k in keyof R]: R[k] }>; $complex?: TKnexDynamicOperator; }; export const buildFindFilter = - ({ $in, $search, $complex, ...filter }: TFindFilter) => + ({ $in, $notNull, $search, $complex, ...filter }: TFindFilter) => (bd: Knex.QueryBuilder) => { void bd.where(filter); if ($in) { @@ -34,6 +35,13 @@ export const buildFindFilter = } }); } + + if ($notNull?.length) { + $notNull.forEach((key) => { + void bd.whereNotNull(key as never); + }); + } + if ($search) { Object.entries($search).forEach(([key, val]) => { if (val) { diff --git a/backend/src/lib/types/index.ts b/backend/src/lib/types/index.ts index 6ebf91f36..b8b272017 100644 --- a/backend/src/lib/types/index.ts +++ b/backend/src/lib/types/index.ts @@ -43,6 +43,8 @@ export type RequiredKeys = { export type PickRequired = Pick>; +export type DiscriminativePick = T extends unknown ? Pick : never; + export enum EnforcementLevel { Hard = "hard", Soft = "soft" diff --git a/backend/src/main.ts b/backend/src/main.ts index ca85625c2..850298f89 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -57,7 +57,11 @@ const run = async () => { const smtp = smtpServiceFactory(formatSmtpConfig()); - const queue = queueServiceFactory(appCfg.REDIS_URL, appCfg.DB_CONNECTION_URI); + const queue = queueServiceFactory(appCfg.REDIS_URL, { + dbConnectionUrl: appCfg.DB_CONNECTION_URI, + dbRootCert: appCfg.DB_ROOT_CERT + }); + await queue.initialize(); const keyStore = keyStoreFactory(appCfg.REDIS_URL); diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 8479a249c..330193052 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -187,7 +187,10 @@ export type TQueueJobTypes = { }; export type TQueueServiceFactory = ReturnType; -export const queueServiceFactory = (redisUrl: string, dbConnectionUrl: string) => { +export const queueServiceFactory = ( + redisUrl: string, + { dbConnectionUrl, dbRootCert }: { dbConnectionUrl: string; dbRootCert?: string } +) => { const connection = new Redis(redisUrl, { maxRetriesPerRequest: null }); const queueContainer = {} as Record< QueueName, @@ -198,7 +201,13 @@ export const queueServiceFactory = (redisUrl: string, dbConnectionUrl: string) = connectionString: dbConnectionUrl, archiveCompletedAfterSeconds: 60, archiveFailedAfterSeconds: 1000, // we want to keep failed jobs for a longer time so that it can be retried - deleteAfterSeconds: 30 + deleteAfterSeconds: 30, + ssl: dbRootCert + ? { + rejectUnauthorized: true, + ca: Buffer.from(dbRootCert, "base64").toString("ascii") + } + : false }); const queueContainerPg = {} as Record; @@ -308,6 +317,13 @@ export const queueServiceFactory = (redisUrl: string, dbConnectionUrl: string) = } }; + const getRepeatableJobs = (name: QueueName, startOffset?: number, endOffset?: number) => { + const q = queueContainer[name]; + if (!q) throw new Error(`Queue '${name}' not initialized`); + + return q.getRepeatableJobs(startOffset, endOffset); + }; + const stopRepeatableJobByJobId = async (name: T, jobId: string) => { const q = queueContainer[name]; const job = await q.getJob(jobId); @@ -317,6 +333,11 @@ export const queueServiceFactory = (redisUrl: string, dbConnectionUrl: string) = return q.removeRepeatableByKey(job.repeatJobKey); }; + const stopRepeatableJobByKey = async (name: T, repeatJobKey: string) => { + const q = queueContainer[name]; + return q.removeRepeatableByKey(repeatJobKey); + }; + const stopJobById = async (name: T, jobId: string) => { const q = queueContainer[name]; const job = await q.getJob(jobId); @@ -340,8 +361,10 @@ export const queueServiceFactory = (redisUrl: string, dbConnectionUrl: string) = shutdown, stopRepeatableJob, stopRepeatableJobByJobId, + stopRepeatableJobByKey, clearQueue, stopJobById, + getRepeatableJobs, startPg, queuePg }; diff --git a/backend/src/server/lib/schemas.ts b/backend/src/server/lib/schemas.ts new file mode 100644 index 000000000..ed97cb7d0 --- /dev/null +++ b/backend/src/server/lib/schemas.ts @@ -0,0 +1,23 @@ +import slugify from "@sindresorhus/slugify"; +import { z } from "zod"; + +interface SlugSchemaInputs { + min?: number; + max?: number; + field?: string; +} + +export const slugSchema = ({ min = 1, max = 32, field = "Slug" }: SlugSchemaInputs = {}) => { + return z + .string() + .trim() + .min(min, { + message: `${field} field must be at least ${min} lowercase character${min === 1 ? "" : "s"}` + }) + .max(max, { + message: `${field} field must be at most ${max} lowercase character${max === 1 ? "" : "s"}` + }) + .refine((v) => slugify(v, { lowercase: true }) === v, { + message: `${field} field can only contain lowercase letters, numbers, and hyphens` + }); +}; diff --git a/backend/src/server/plugins/error-handler.ts b/backend/src/server/plugins/error-handler.ts index ac4803c98..7f9e16197 100644 --- a/backend/src/server/plugins/error-handler.ts +++ b/backend/src/server/plugins/error-handler.ts @@ -1,8 +1,10 @@ import { ForbiddenError, PureAbility } from "@casl/ability"; +import opentelemetry from "@opentelemetry/api"; import fastifyPlugin from "fastify-plugin"; import jwt from "jsonwebtoken"; import { ZodError } from "zod"; +import { getConfig } from "@app/lib/config/env"; import { BadRequestError, DatabaseError, @@ -35,8 +37,30 @@ enum HttpStatusCodes { } export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider) => { + const appCfg = getConfig(); + + const apiMeter = opentelemetry.metrics.getMeter("API"); + const errorHistogram = apiMeter.createHistogram("API_errors", { + description: "API errors by type, status code, and name", + unit: "1" + }); + server.setErrorHandler((error, req, res) => { req.log.error(error); + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + const { method } = req; + const route = req.routerPath; + const errorType = + error instanceof jwt.JsonWebTokenError ? "TokenError" : error.constructor.name || "UnknownError"; + + errorHistogram.record(1, { + route, + method, + type: errorType, + name: error.name + }); + } + if (error instanceof BadRequestError) { void res .status(HttpStatusCodes.BadRequest) @@ -52,13 +76,20 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider message: error.message, error: error.name }); - } else if (error instanceof DatabaseError || error instanceof InternalServerError) { + } else if (error instanceof DatabaseError) { void res.status(HttpStatusCodes.InternalServerError).send({ reqId: req.id, statusCode: HttpStatusCodes.InternalServerError, message: "Something went wrong", error: error.name }); + } else if (error instanceof InternalServerError) { + void res.status(HttpStatusCodes.InternalServerError).send({ + reqId: req.id, + statusCode: HttpStatusCodes.InternalServerError, + message: error.message ?? "Something went wrong", + error: error.name + }); } else if (error instanceof GatewayTimeoutError) { void res.status(HttpStatusCodes.GatewayTimeout).send({ reqId: req.id, diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 4f07579bd..eafd3467f 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -75,6 +75,13 @@ import { snapshotDALFactory } from "@app/ee/services/secret-snapshot/snapshot-da import { snapshotFolderDALFactory } from "@app/ee/services/secret-snapshot/snapshot-folder-dal"; import { snapshotSecretDALFactory } from "@app/ee/services/secret-snapshot/snapshot-secret-dal"; import { snapshotSecretV2DALFactory } from "@app/ee/services/secret-snapshot/snapshot-secret-v2-dal"; +import { sshCertificateAuthorityDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-dal"; +import { sshCertificateAuthoritySecretDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-secret-dal"; +import { sshCertificateAuthorityServiceFactory } from "@app/ee/services/ssh/ssh-certificate-authority-service"; +import { sshCertificateBodyDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-body-dal"; +import { sshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-dal"; +import { sshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal"; +import { sshCertificateTemplateServiceFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-service"; import { trustedIpDALFactory } from "@app/ee/services/trusted-ip/trusted-ip-dal"; import { trustedIpServiceFactory } from "@app/ee/services/trusted-ip/trusted-ip-service"; import { TKeyStoreFactory } from "@app/keystore/keystore"; @@ -84,6 +91,8 @@ import { readLimit } from "@app/server/config/rateLimiter"; import { accessTokenQueueServiceFactory } from "@app/services/access-token-queue/access-token-queue"; import { apiKeyDALFactory } from "@app/services/api-key/api-key-dal"; import { apiKeyServiceFactory } from "@app/services/api-key/api-key-service"; +import { appConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; +import { appConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; import { authDALFactory } from "@app/services/auth/auth-dal"; import { authLoginServiceFactory } from "@app/services/auth/auth-login-service"; import { authPaswordServiceFactory } from "@app/services/auth/auth-password-service"; @@ -121,6 +130,8 @@ import { identityAzureAuthDALFactory } from "@app/services/identity-azure-auth/i import { identityAzureAuthServiceFactory } from "@app/services/identity-azure-auth/identity-azure-auth-service"; import { identityGcpAuthDALFactory } from "@app/services/identity-gcp-auth/identity-gcp-auth-dal"; import { identityGcpAuthServiceFactory } from "@app/services/identity-gcp-auth/identity-gcp-auth-service"; +import { identityJwtAuthDALFactory } from "@app/services/identity-jwt-auth/identity-jwt-auth-dal"; +import { identityJwtAuthServiceFactory } from "@app/services/identity-jwt-auth/identity-jwt-auth-service"; import { identityKubernetesAuthDALFactory } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-dal"; import { identityKubernetesAuthServiceFactory } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-service"; import { identityOidcAuthDALFactory } from "@app/services/identity-oidc-auth/identity-oidc-auth-dal"; @@ -298,12 +309,14 @@ export const registerRoutes = async ( const identityAwsAuthDAL = identityAwsAuthDALFactory(db); const identityGcpAuthDAL = identityGcpAuthDALFactory(db); const identityOidcAuthDAL = identityOidcAuthDALFactory(db); + const identityJwtAuthDAL = identityJwtAuthDALFactory(db); const identityAzureAuthDAL = identityAzureAuthDALFactory(db); const auditLogDAL = auditLogDALFactory(auditLogDb ?? db); const auditLogStreamDAL = auditLogStreamDALFactory(db); const trustedIpDAL = trustedIpDALFactory(db); const telemetryDAL = telemetryDALFactory(db); + const appConnectionDAL = appConnectionDALFactory(db); // ee db layer ops const permissionDAL = permissionDALFactory(db); @@ -342,6 +355,12 @@ export const registerRoutes = async ( const dynamicSecretDAL = dynamicSecretDALFactory(db); const dynamicSecretLeaseDAL = dynamicSecretLeaseDALFactory(db); + const sshCertificateDAL = sshCertificateDALFactory(db); + const sshCertificateBodyDAL = sshCertificateBodyDALFactory(db); + const sshCertificateAuthorityDAL = sshCertificateAuthorityDALFactory(db); + const sshCertificateAuthoritySecretDAL = sshCertificateAuthoritySecretDALFactory(db); + const sshCertificateTemplateDAL = sshCertificateTemplateDALFactory(db); + const kmsDAL = kmskeyDALFactory(db); const internalKmsDAL = internalKmsDALFactory(db); const externalKmsDAL = externalKmsDALFactory(db); @@ -414,7 +433,8 @@ export const registerRoutes = async ( permissionService, secretApprovalPolicyDAL, licenseService, - userDAL + userDAL, + secretApprovalRequestDAL }); const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL, orgMembershipDAL }); @@ -534,7 +554,11 @@ export const registerRoutes = async ( const orgService = orgServiceFactory({ userAliasDAL, + queueService, identityMetadataDAL, + secretDAL, + secretV2BridgeDAL, + folderDAL, licenseService, samlConfigDAL, orgRoleDAL, @@ -555,6 +579,7 @@ export const registerRoutes = async ( groupDAL, orgBotDAL, oidcConfigDAL, + loginService, projectBotService }); const signupService = authSignupServiceFactory({ @@ -703,6 +728,22 @@ export const registerRoutes = async ( queueService }); + const sshCertificateAuthorityService = sshCertificateAuthorityServiceFactory({ + sshCertificateAuthorityDAL, + sshCertificateAuthoritySecretDAL, + sshCertificateTemplateDAL, + sshCertificateDAL, + sshCertificateBodyDAL, + kmsService, + permissionService + }); + + const sshCertificateTemplateService = sshCertificateTemplateServiceFactory({ + sshCertificateTemplateDAL, + sshCertificateAuthorityDAL, + permissionService + }); + const certificateAuthorityService = certificateAuthorityServiceFactory({ certificateAuthorityDAL, certificateAuthorityCertDAL, @@ -753,7 +794,8 @@ export const registerRoutes = async ( pkiAlertDAL, pkiCollectionDAL, permissionService, - smtpService + smtpService, + projectDAL }); const pkiCollectionService = pkiCollectionServiceFactory({ @@ -761,7 +803,8 @@ export const registerRoutes = async ( pkiCollectionItemDAL, certificateAuthorityDAL, certificateDAL, - permissionService + permissionService, + projectDAL }); const projectTemplateService = projectTemplateServiceFactory({ @@ -770,10 +813,58 @@ export const registerRoutes = async ( projectTemplateDAL }); + const integrationAuthService = integrationAuthServiceFactory({ + integrationAuthDAL, + integrationDAL, + permissionService, + projectBotService, + kmsService + }); + + const secretQueueService = secretQueueFactory({ + keyStore, + queueService, + secretDAL, + folderDAL, + integrationAuthService, + projectBotService, + integrationDAL, + secretImportDAL, + projectEnvDAL, + webhookDAL, + orgDAL, + auditLogService, + userDAL, + projectMembershipDAL, + smtpService, + projectDAL, + projectBotDAL, + secretVersionDAL, + secretBlindIndexDAL, + secretTagDAL, + secretVersionTagDAL, + kmsService, + secretVersionV2BridgeDAL, + secretV2BridgeDAL, + secretVersionTagV2BridgeDAL, + secretRotationDAL, + integrationAuthDAL, + snapshotDAL, + snapshotSecretV2BridgeDAL, + secretApprovalRequestDAL, + projectKeyDAL, + projectUserMembershipRoleDAL, + orgService + }); + const projectService = projectServiceFactory({ permissionService, projectDAL, + secretDAL, + secretV2BridgeDAL, + queueService, projectQueue: projectQueueService, + projectBotService, identityProjectDAL, identityOrgMembershipDAL, projectKeyDAL, @@ -789,6 +880,9 @@ export const registerRoutes = async ( certificateDAL, pkiAlertDAL, pkiCollectionDAL, + sshCertificateAuthorityDAL, + sshCertificateDAL, + sshCertificateTemplateDAL, projectUserMembershipRoleDAL, identityProjectMembershipRoleDAL, keyStore, @@ -853,48 +947,6 @@ export const registerRoutes = async ( projectDAL }); - const integrationAuthService = integrationAuthServiceFactory({ - integrationAuthDAL, - integrationDAL, - permissionService, - projectBotService, - kmsService - }); - const secretQueueService = secretQueueFactory({ - keyStore, - queueService, - secretDAL, - folderDAL, - integrationAuthService, - projectBotService, - integrationDAL, - secretImportDAL, - projectEnvDAL, - webhookDAL, - orgDAL, - auditLogService, - userDAL, - projectMembershipDAL, - smtpService, - projectDAL, - projectBotDAL, - secretVersionDAL, - secretBlindIndexDAL, - secretTagDAL, - secretVersionTagDAL, - kmsService, - secretVersionV2BridgeDAL, - secretV2BridgeDAL, - secretVersionTagV2BridgeDAL, - secretRotationDAL, - integrationAuthDAL, - snapshotDAL, - snapshotSecretV2BridgeDAL, - secretApprovalRequestDAL, - projectKeyDAL, - projectUserMembershipRoleDAL, - orgService - }); const secretImportService = secretImportServiceFactory({ licenseService, projectBotService, @@ -994,7 +1046,10 @@ export const registerRoutes = async ( projectEnvDAL, projectMembershipDAL, projectDAL, - userDAL + userDAL, + accessApprovalRequestDAL, + additionalPrivilegeDAL: projectUserAdditionalPrivilegeDAL, + accessApprovalRequestReviewerDAL }); const accessApprovalRequestService = accessApprovalRequestServiceFactory({ @@ -1180,6 +1235,15 @@ export const registerRoutes = async ( orgBotDAL }); + const identityJwtAuthService = identityJwtAuthServiceFactory({ + identityJwtAuthDAL, + permissionService, + identityAccessTokenDAL, + identityOrgMembershipDAL, + licenseService, + kmsService + }); + const dynamicSecretProviders = buildDynamicSecretProviders(); const dynamicSecretQueueService = dynamicSecretLeaseQueueServiceFactory({ queueService, @@ -1211,6 +1275,7 @@ export const registerRoutes = async ( auditLogDAL, queueService, secretVersionDAL, + secretDAL, secretFolderVersionDAL: folderVersionDAL, snapshotDAL, identityAccessTokenDAL, @@ -1238,7 +1303,8 @@ export const registerRoutes = async ( }); const userEngagementService = userEngagementServiceFactory({ - userDAL + userDAL, + orgDAL }); const slackService = slackServiceFactory({ @@ -1256,7 +1322,8 @@ export const registerRoutes = async ( const cmekService = cmekServiceFactory({ kmsDAL, kmsService, - permissionService + permissionService, + projectDAL }); const externalMigrationQueue = externalMigrationQueueFactory({ @@ -1288,6 +1355,13 @@ export const registerRoutes = async ( externalGroupOrgRoleMappingDAL }); + const appConnectionService = appConnectionServiceFactory({ + appConnectionDAL, + permissionService, + kmsService, + licenseService + }); + await superAdminService.initServerCfg(); // setup the communication with license key server @@ -1342,6 +1416,7 @@ export const registerRoutes = async ( identityAwsAuth: identityAwsAuthService, identityAzureAuth: identityAzureAuthService, identityOidcAuth: identityOidcAuthService, + identityJwtAuth: identityJwtAuthService, accessApprovalPolicy: accessApprovalPolicyService, accessApprovalRequest: accessApprovalRequestService, secretApprovalPolicy: secretApprovalPolicyService, @@ -1355,6 +1430,8 @@ export const registerRoutes = async ( auditLog: auditLogService, auditLogStream: auditLogStreamService, certificate: certificateService, + sshCertificateAuthority: sshCertificateAuthorityService, + sshCertificateTemplate: sshCertificateTemplateService, certificateAuthority: certificateAuthorityService, certificateTemplate: certificateTemplateService, certificateAuthorityCrl: certificateAuthorityCrlService, @@ -1381,7 +1458,8 @@ export const registerRoutes = async ( migration: migrationService, externalGroupOrgRoleMapping: externalGroupOrgRoleMappingService, projectTemplate: projectTemplateService, - totp: totpService + totp: totpService, + appConnection: appConnectionService }); const cronJobs: CronJob[] = []; diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index 69a648d9e..67aee3a1f 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -220,6 +220,7 @@ export const SanitizedProjectSchema = ProjectsSchema.pick({ id: true, name: true, description: true, + type: true, slug: true, autoCapitalization: true, orgId: true, 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 new file mode 100644 index 000000000..d18639786 --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts @@ -0,0 +1,74 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AwsConnectionListItemSchema, SanitizedAwsConnectionSchema } from "@app/services/app-connection/aws"; +import { GitHubConnectionListItemSchema, SanitizedGitHubConnectionSchema } from "@app/services/app-connection/github"; +import { AuthMode } from "@app/services/auth/auth-type"; + +// can't use discriminated due to multiple schemas for certain apps +const SanitizedAppConnectionSchema = z.union([ + ...SanitizedAwsConnectionSchema.options, + ...SanitizedGitHubConnectionSchema.options +]); + +const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ + AwsConnectionListItemSchema, + GitHubConnectionListItemSchema +]); + +export const registerAppConnectionRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/options", + config: { + rateLimit: readLimit + }, + schema: { + description: "List the available App Connection Options.", + response: { + 200: z.object({ + appConnectionOptions: AppConnectionOptionsSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: () => { + const appConnectionOptions = server.services.appConnection.listAppConnectionOptions(); + return { appConnectionOptions }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + description: "List all the App Connections for the current organization.", + response: { + 200: z.object({ appConnections: SanitizedAppConnectionSchema.array() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const appConnections = await server.services.appConnection.listAppConnectionsByOrg(req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.GET_APP_CONNECTIONS, + metadata: { + count: appConnections.length, + connectionIds: appConnections.map((connection) => connection.id) + } + } + }); + + return { appConnections }; + } + }); +}; diff --git a/backend/src/server/routes/v1/app-connection-routers/apps/app-connection-endpoints.ts b/backend/src/server/routes/v1/app-connection-routers/apps/app-connection-endpoints.ts new file mode 100644 index 000000000..ec3b633a1 --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/apps/app-connection-endpoints.ts @@ -0,0 +1,274 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { AppConnections } from "@app/lib/api-docs"; +import { startsWithVowel } from "@app/lib/fn"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps"; +import { TAppConnection, TAppConnectionInput } from "@app/services/app-connection/app-connection-types"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerAppConnectionEndpoints = ({ + server, + app, + createSchema, + updateSchema, + responseSchema +}: { + app: AppConnection; + server: FastifyZodProvider; + createSchema: z.ZodType<{ + name: string; + method: I["method"]; + credentials: I["credentials"]; + description?: string | null; + }>; + updateSchema: z.ZodType<{ name?: string; credentials?: I["credentials"]; description?: string | null }>; + responseSchema: z.ZodTypeAny; +}) => { + const appName = APP_CONNECTION_NAME_MAP[app]; + + server.route({ + method: "GET", + url: `/`, + config: { + rateLimit: readLimit + }, + schema: { + description: `List the ${appName} Connections for the current organization.`, + response: { + 200: z.object({ appConnections: responseSchema.array() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const appConnections = (await server.services.appConnection.listAppConnectionsByOrg(req.permission, app)) as T[]; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.GET_APP_CONNECTIONS, + metadata: { + app, + count: appConnections.length, + connectionIds: appConnections.map((connection) => connection.id) + } + } + }); + + return { appConnections }; + } + }); + + server.route({ + method: "GET", + url: "/:connectionId", + config: { + rateLimit: readLimit + }, + schema: { + description: `Get the specified ${appName} Connection by ID.`, + params: z.object({ + connectionId: z.string().uuid().describe(AppConnections.GET_BY_ID(app).connectionId) + }), + response: { + 200: z.object({ appConnection: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { connectionId } = req.params; + + const appConnection = (await server.services.appConnection.findAppConnectionById( + app, + connectionId, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.GET_APP_CONNECTION, + metadata: { + connectionId + } + } + }); + + return { appConnection }; + } + }); + + server.route({ + method: "GET", + url: `/name/:connectionName`, + config: { + rateLimit: readLimit + }, + schema: { + description: `Get the specified ${appName} Connection by name.`, + params: z.object({ + connectionName: z + .string() + .min(0, "Connection name required") + .describe(AppConnections.GET_BY_NAME(app).connectionName) + }), + response: { + 200: z.object({ appConnection: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { connectionName } = req.params; + + const appConnection = (await server.services.appConnection.findAppConnectionByName( + app, + connectionName, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.GET_APP_CONNECTION, + metadata: { + connectionId: appConnection.id + } + } + }); + + return { appConnection }; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + description: `Create ${ + startsWithVowel(appName) ? "an" : "a" + } ${appName} Connection for the current organization.`, + body: createSchema, + response: { + 200: z.object({ appConnection: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { name, method, credentials, description } = req.body; + + const appConnection = (await server.services.appConnection.createAppConnection( + { name, method, app, credentials, description }, + req.permission + )) as TAppConnection; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.CREATE_APP_CONNECTION, + metadata: { + name, + method, + app, + connectionId: appConnection.id + } + } + }); + + return { appConnection }; + } + }); + + server.route({ + method: "PATCH", + url: "/:connectionId", + config: { + rateLimit: writeLimit + }, + schema: { + description: `Update the specified ${appName} Connection.`, + params: z.object({ + connectionId: z.string().uuid().describe(AppConnections.UPDATE(app).connectionId) + }), + body: updateSchema, + response: { + 200: z.object({ appConnection: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { name, credentials, description } = req.body; + const { connectionId } = req.params; + + const appConnection = (await server.services.appConnection.updateAppConnection( + { name, credentials, connectionId, description }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.UPDATE_APP_CONNECTION, + metadata: { + name, + description, + credentialsUpdated: Boolean(credentials), + connectionId + } + } + }); + + return { appConnection }; + } + }); + + server.route({ + method: "DELETE", + url: `/:connectionId`, + config: { + rateLimit: writeLimit + }, + schema: { + description: `Delete the specified ${appName} Connection.`, + params: z.object({ + connectionId: z.string().uuid().describe(AppConnections.DELETE(app).connectionId) + }), + response: { + 200: z.object({ appConnection: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { connectionId } = req.params; + + const appConnection = (await server.services.appConnection.deleteAppConnection( + app, + connectionId, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.DELETE_APP_CONNECTION, + metadata: { + connectionId + } + } + }); + + return { appConnection }; + } + }); +}; diff --git a/backend/src/server/routes/v1/app-connection-routers/apps/aws-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/apps/aws-connection-router.ts new file mode 100644 index 000000000..189ca4fbd --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/apps/aws-connection-router.ts @@ -0,0 +1,17 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + CreateAwsConnectionSchema, + SanitizedAwsConnectionSchema, + UpdateAwsConnectionSchema +} from "@app/services/app-connection/aws"; + +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerAwsConnectionRouter = async (server: FastifyZodProvider) => + registerAppConnectionEndpoints({ + app: AppConnection.AWS, + server, + responseSchema: SanitizedAwsConnectionSchema, + createSchema: CreateAwsConnectionSchema, + updateSchema: UpdateAwsConnectionSchema + }); diff --git a/backend/src/server/routes/v1/app-connection-routers/apps/github-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/apps/github-connection-router.ts new file mode 100644 index 000000000..273d4b9e1 --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/apps/github-connection-router.ts @@ -0,0 +1,17 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + CreateGitHubConnectionSchema, + SanitizedGitHubConnectionSchema, + UpdateGitHubConnectionSchema +} from "@app/services/app-connection/github"; + +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerGitHubConnectionRouter = async (server: FastifyZodProvider) => + registerAppConnectionEndpoints({ + app: AppConnection.GitHub, + server, + responseSchema: SanitizedGitHubConnectionSchema, + createSchema: CreateGitHubConnectionSchema, + updateSchema: UpdateGitHubConnectionSchema + }); diff --git a/backend/src/server/routes/v1/app-connection-routers/apps/index.ts b/backend/src/server/routes/v1/app-connection-routers/apps/index.ts new file mode 100644 index 000000000..b56a65f50 --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/apps/index.ts @@ -0,0 +1,8 @@ +import { registerAwsConnectionRouter } from "@app/server/routes/v1/app-connection-routers/apps/aws-connection-router"; +import { registerGitHubConnectionRouter } from "@app/server/routes/v1/app-connection-routers/apps/github-connection-router"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const APP_CONNECTION_REGISTER_MAP: Record Promise> = { + [AppConnection.AWS]: registerAwsConnectionRouter, + [AppConnection.GitHub]: registerGitHubConnectionRouter +}; diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts new file mode 100644 index 000000000..720057449 --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -0,0 +1,2 @@ +export * from "./app-connection-router"; +export * from "./apps"; diff --git a/backend/src/server/routes/v1/cmek-router.ts b/backend/src/server/routes/v1/cmek-router.ts index 18d13e67f..e3982f3d6 100644 --- a/backend/src/server/routes/v1/cmek-router.ts +++ b/backend/src/server/routes/v1/cmek-router.ts @@ -1,4 +1,3 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { InternalKmsSchema, KmsKeysSchema } from "@app/db/schemas"; @@ -8,19 +7,12 @@ import { getBase64SizeInBytes, isBase64 } from "@app/lib/base64"; import { SymmetricEncryption } from "@app/lib/crypto/cipher"; import { OrderByDirection } from "@app/lib/types"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { CmekOrderBy } from "@app/services/cmek/cmek-types"; -const keyNameSchema = z - .string() - .trim() - .min(1) - .max(32) - .toLowerCase() - .refine((v) => slugify(v) === v, { - message: "Name must be slug friendly" - }); +const keyNameSchema = slugSchema({ min: 1, max: 32, field: "Name" }); const keyDescriptionSchema = z.string().trim().max(500).optional(); const base64Schema = z.string().superRefine((val, ctx) => { diff --git a/backend/src/server/routes/v1/external-group-org-role-mapping-router.ts b/backend/src/server/routes/v1/external-group-org-role-mapping-router.ts index 032deda7d..67db5de6f 100644 --- a/backend/src/server/routes/v1/external-group-org-role-mapping-router.ts +++ b/backend/src/server/routes/v1/external-group-org-role-mapping-router.ts @@ -1,9 +1,9 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { ExternalGroupOrgRoleMappingsSchema } from "@app/db/schemas/external-group-org-role-mappings"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -48,13 +48,7 @@ export const registerExternalGroupOrgRoleMappingRouter = async (server: FastifyZ mappings: z .object({ groupName: z.string().trim().min(1), - roleSlug: z - .string() - .min(1) - .toLowerCase() - .refine((v) => slugify(v) === v, { - message: "Role must be a valid slug" - }) + roleSlug: slugSchema({ max: 64 }) }) .array() }), diff --git a/backend/src/server/routes/v1/identity-jwt-auth-router.ts b/backend/src/server/routes/v1/identity-jwt-auth-router.ts new file mode 100644 index 000000000..d60bb969d --- /dev/null +++ b/backend/src/server/routes/v1/identity-jwt-auth-router.ts @@ -0,0 +1,386 @@ +import { z } from "zod"; + +import { IdentityJwtAuthsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { JWT_AUTH } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { TIdentityTrustedIp } from "@app/services/identity/identity-types"; +import { JwtConfigurationType } from "@app/services/identity-jwt-auth/identity-jwt-auth-types"; +import { + validateJwtAuthAudiencesField, + validateJwtBoundClaimsField +} from "@app/services/identity-jwt-auth/identity-jwt-auth-validators"; + +const IdentityJwtAuthResponseSchema = IdentityJwtAuthsSchema.omit({ + encryptedJwksCaCert: true, + encryptedPublicKeys: true +}).extend({ + jwksCaCert: z.string(), + publicKeys: z.string().array() +}); + +const CreateBaseSchema = z.object({ + boundIssuer: z.string().trim().default("").describe(JWT_AUTH.ATTACH.boundIssuer), + boundAudiences: validateJwtAuthAudiencesField.describe(JWT_AUTH.ATTACH.boundAudiences), + boundClaims: validateJwtBoundClaimsField.describe(JWT_AUTH.ATTACH.boundClaims), + boundSubject: z.string().trim().default("").describe(JWT_AUTH.ATTACH.boundSubject), + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]) + .describe(JWT_AUTH.ATTACH.accessTokenTrustedIps), + accessTokenTTL: z + .number() + .int() + .min(1) + .max(315360000) + .refine((value) => value !== 0, { + message: "accessTokenTTL must have a non zero number" + }) + .default(2592000) + .describe(JWT_AUTH.ATTACH.accessTokenTTL), + accessTokenMaxTTL: z + .number() + .int() + .max(315360000) + .refine((value) => value !== 0, { + message: "accessTokenMaxTTL must have a non zero number" + }) + .default(2592000) + .describe(JWT_AUTH.ATTACH.accessTokenMaxTTL), + accessTokenNumUsesLimit: z.number().int().min(0).default(0).describe(JWT_AUTH.ATTACH.accessTokenNumUsesLimit) +}); + +const UpdateBaseSchema = z + .object({ + boundIssuer: z.string().trim().default("").describe(JWT_AUTH.UPDATE.boundIssuer), + boundAudiences: validateJwtAuthAudiencesField.describe(JWT_AUTH.UPDATE.boundAudiences), + boundClaims: validateJwtBoundClaimsField.describe(JWT_AUTH.UPDATE.boundClaims), + boundSubject: z.string().trim().default("").describe(JWT_AUTH.UPDATE.boundSubject), + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]) + .describe(JWT_AUTH.UPDATE.accessTokenTrustedIps), + accessTokenTTL: z + .number() + .int() + .min(1) + .max(315360000) + .refine((value) => value !== 0, { + message: "accessTokenTTL must have a non zero number" + }) + .default(2592000) + .describe(JWT_AUTH.UPDATE.accessTokenTTL), + accessTokenMaxTTL: z + .number() + .int() + .max(315360000) + .refine((value) => value !== 0, { + message: "accessTokenMaxTTL must have a non zero number" + }) + .default(2592000) + .describe(JWT_AUTH.UPDATE.accessTokenMaxTTL), + accessTokenNumUsesLimit: z.number().int().min(0).default(0).describe(JWT_AUTH.UPDATE.accessTokenNumUsesLimit) + }) + .partial(); + +const JwksConfigurationSchema = z.object({ + configurationType: z.literal(JwtConfigurationType.JWKS).describe(JWT_AUTH.ATTACH.configurationType), + jwksUrl: z.string().trim().url().describe(JWT_AUTH.ATTACH.jwksUrl), + jwksCaCert: z.string().trim().default("").describe(JWT_AUTH.ATTACH.jwksCaCert), + publicKeys: z.string().array().optional().default([]).describe(JWT_AUTH.ATTACH.publicKeys) +}); + +const StaticConfigurationSchema = z.object({ + configurationType: z.literal(JwtConfigurationType.STATIC).describe(JWT_AUTH.ATTACH.configurationType), + jwksUrl: z.string().trim().optional().default("").describe(JWT_AUTH.ATTACH.jwksUrl), + jwksCaCert: z.string().trim().optional().default("").describe(JWT_AUTH.ATTACH.jwksCaCert), + publicKeys: z.string().min(1).array().min(1).describe(JWT_AUTH.ATTACH.publicKeys) +}); + +export const registerIdentityJwtAuthRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/jwt-auth/login", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Login with JWT Auth", + body: z.object({ + identityId: z.string().trim().describe(JWT_AUTH.LOGIN.identityId), + jwt: z.string().trim() + }), + response: { + 200: z.object({ + accessToken: z.string(), + expiresIn: z.coerce.number(), + accessTokenMaxTTL: z.coerce.number(), + tokenType: z.literal("Bearer") + }) + } + }, + handler: async (req) => { + const { identityJwtAuth, accessToken, identityAccessToken, identityMembershipOrg } = + await server.services.identityJwtAuth.login({ + identityId: req.body.identityId, + jwt: req.body.jwt + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityMembershipOrg?.orgId, + event: { + type: EventType.LOGIN_IDENTITY_JWT_AUTH, + metadata: { + identityId: identityJwtAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + identityJwtAuthId: identityJwtAuth.id + } + } + }); + return { + accessToken, + tokenType: "Bearer" as const, + expiresIn: identityJwtAuth.accessTokenTTL, + accessTokenMaxTTL: identityJwtAuth.accessTokenMaxTTL + }; + } + }); + + server.route({ + method: "POST", + url: "/jwt-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Attach JWT Auth configuration onto identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim().describe(JWT_AUTH.ATTACH.identityId) + }), + body: z.discriminatedUnion("configurationType", [ + JwksConfigurationSchema.merge(CreateBaseSchema), + StaticConfigurationSchema.merge(CreateBaseSchema) + ]), + response: { + 200: z.object({ + identityJwtAuth: IdentityJwtAuthResponseSchema + }) + } + }, + handler: async (req) => { + const identityJwtAuth = await server.services.identityJwtAuth.attachJwtAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityJwtAuth.orgId, + event: { + type: EventType.ADD_IDENTITY_JWT_AUTH, + metadata: { + identityId: identityJwtAuth.identityId, + configurationType: identityJwtAuth.configurationType, + jwksUrl: identityJwtAuth.jwksUrl, + jwksCaCert: identityJwtAuth.jwksCaCert, + publicKeys: identityJwtAuth.publicKeys, + boundIssuer: identityJwtAuth.boundIssuer, + boundAudiences: identityJwtAuth.boundAudiences, + boundClaims: identityJwtAuth.boundClaims as Record, + boundSubject: identityJwtAuth.boundSubject, + accessTokenTTL: identityJwtAuth.accessTokenTTL, + accessTokenMaxTTL: identityJwtAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityJwtAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityJwtAuth.accessTokenNumUsesLimit + } + } + }); + + return { + identityJwtAuth + }; + } + }); + + server.route({ + method: "PATCH", + url: "/jwt-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update JWT Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim().describe(JWT_AUTH.UPDATE.identityId) + }), + body: z.discriminatedUnion("configurationType", [ + JwksConfigurationSchema.merge(UpdateBaseSchema), + StaticConfigurationSchema.merge(UpdateBaseSchema) + ]), + response: { + 200: z.object({ + identityJwtAuth: IdentityJwtAuthResponseSchema + }) + } + }, + handler: async (req) => { + const identityJwtAuth = await server.services.identityJwtAuth.updateJwtAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + ...req.body, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityJwtAuth.orgId, + event: { + type: EventType.UPDATE_IDENTITY_JWT_AUTH, + metadata: { + identityId: identityJwtAuth.identityId, + configurationType: identityJwtAuth.configurationType, + jwksUrl: identityJwtAuth.jwksUrl, + jwksCaCert: identityJwtAuth.jwksCaCert, + publicKeys: identityJwtAuth.publicKeys, + boundIssuer: identityJwtAuth.boundIssuer, + boundAudiences: identityJwtAuth.boundAudiences, + boundClaims: identityJwtAuth.boundClaims as Record, + boundSubject: identityJwtAuth.boundSubject, + accessTokenTTL: identityJwtAuth.accessTokenTTL, + accessTokenMaxTTL: identityJwtAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityJwtAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityJwtAuth.accessTokenNumUsesLimit + } + } + }); + + return { identityJwtAuth }; + } + }); + + server.route({ + method: "GET", + url: "/jwt-auth/identities/:identityId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Retrieve JWT Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().describe(JWT_AUTH.RETRIEVE.identityId) + }), + response: { + 200: z.object({ + identityJwtAuth: IdentityJwtAuthResponseSchema + }) + } + }, + handler: async (req) => { + const identityJwtAuth = await server.services.identityJwtAuth.getJwtAuth({ + identityId: req.params.identityId, + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityJwtAuth.orgId, + event: { + type: EventType.GET_IDENTITY_JWT_AUTH, + metadata: { + identityId: identityJwtAuth.identityId + } + } + }); + + return { identityJwtAuth }; + } + }); + + server.route({ + method: "DELETE", + url: "/jwt-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Delete JWT Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().describe(JWT_AUTH.REVOKE.identityId) + }), + response: { + 200: z.object({ + identityJwtAuth: IdentityJwtAuthResponseSchema.omit({ + publicKeys: true, + jwksCaCert: true + }) + }) + } + }, + handler: async (req) => { + const identityJwtAuth = await server.services.identityJwtAuth.revokeJwtAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityJwtAuth.orgId, + event: { + type: EventType.REVOKE_IDENTITY_JWT_AUTH, + metadata: { + identityId: identityJwtAuth.identityId + } + } + }); + + return { identityJwtAuth }; + } + }); +}; diff --git a/backend/src/server/routes/v1/identity-router.ts b/backend/src/server/routes/v1/identity-router.ts index 15e6eabef..5ec688061 100644 --- a/backend/src/server/routes/v1/identity-router.ts +++ b/backend/src/server/routes/v1/identity-router.ts @@ -328,7 +328,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { identity: IdentitiesSchema.pick({ name: true, id: true }).extend({ authMethods: z.array(z.string()) }), - project: SanitizedProjectSchema.pick({ name: true, id: true }) + project: SanitizedProjectSchema.pick({ name: true, id: true, type: true }) }) ) }) diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index f9edfc18c..7fae1d1f9 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -1,3 +1,4 @@ +import { APP_CONNECTION_REGISTER_MAP, registerAppConnectionRouter } from "@app/server/routes/v1/app-connection-routers"; import { registerCmekRouter } from "@app/server/routes/v1/cmek-router"; import { registerDashboardRouter } from "@app/server/routes/v1/dashboard-router"; @@ -12,6 +13,7 @@ import { registerIdentityAccessTokenRouter } from "./identity-access-token-route import { registerIdentityAwsAuthRouter } from "./identity-aws-iam-auth-router"; import { registerIdentityAzureAuthRouter } from "./identity-azure-auth-router"; import { registerIdentityGcpAuthRouter } from "./identity-gcp-auth-router"; +import { registerIdentityJwtAuthRouter } from "./identity-jwt-auth-router"; import { registerIdentityKubernetesRouter } from "./identity-kubernetes-auth-router"; import { registerIdentityOidcAuthRouter } from "./identity-oidc-auth-router"; import { registerIdentityRouter } from "./identity-router"; @@ -54,6 +56,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await authRouter.register(registerIdentityAwsAuthRouter); await authRouter.register(registerIdentityAzureAuthRouter); await authRouter.register(registerIdentityOidcAuthRouter); + await authRouter.register(registerIdentityJwtAuthRouter); }, { prefix: "/auth" } ); @@ -108,4 +111,14 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerDashboardRouter, { prefix: "/dashboard" }); await server.register(registerCmekRouter, { prefix: "/kms" }); await server.register(registerExternalGroupOrgRoleMappingRouter, { prefix: "/external-group-mappings" }); + + await server.register( + async (appConnectionsRouter) => { + await appConnectionsRouter.register(registerAppConnectionRouter); + for await (const [app, router] of Object.entries(APP_CONNECTION_REGISTER_MAP)) { + await appConnectionsRouter.register(router, { prefix: `/${app}` }); + } + }, + { prefix: "/app-connections" } + ); }; diff --git a/backend/src/server/routes/v1/integration-auth-router.ts b/backend/src/server/routes/v1/integration-auth-router.ts index 575544cc7..ffeb748b8 100644 --- a/backend/src/server/routes/v1/integration-auth-router.ts +++ b/backend/src/server/routes/v1/integration-auth-router.ts @@ -6,6 +6,7 @@ import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { OctopusDeployScope } from "@app/services/integration-auth/integration-auth-types"; +import { Integrations } from "@app/services/integration-auth/integration-list"; import { integrationAuthPubSchema } from "../sanitizedSchemas"; @@ -82,6 +83,67 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) } }); + server.route({ + method: "PATCH", + url: "/:integrationAuthId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update the integration authentication object required for syncing secrets.", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + integrationAuthId: z.string().trim().describe(INTEGRATION_AUTH.UPDATE_BY_ID.integrationAuthId) + }), + body: z.object({ + integration: z.nativeEnum(Integrations).optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.integration), + accessId: z.string().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.accessId), + accessToken: z.string().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.accessToken), + awsAssumeIamRoleArn: z + .string() + .url() + .trim() + .optional() + .describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.awsAssumeIamRoleArn), + url: z.string().url().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.url), + namespace: z.string().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.namespace), + refreshToken: z.string().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.refreshToken) + }), + response: { + 200: z.object({ + integrationAuth: integrationAuthPubSchema + }) + } + }, + handler: async (req) => { + const integrationAuth = await server.services.integrationAuth.updateIntegrationAuth({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + integrationAuthId: req.params.integrationAuthId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: integrationAuth.projectId, + event: { + type: EventType.UPDATE_INTEGRATION_AUTH, + metadata: { + integration: integrationAuth.integration + } + } + }); + return { integrationAuth }; + } + }); + server.route({ method: "DELETE", url: "/", @@ -1123,4 +1185,50 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) return { spaces }; } }); + + server.route({ + method: "GET", + url: "/:integrationAuthId/circleci/organizations", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + integrationAuthId: z.string().trim() + }), + response: { + 200: z.object({ + organizations: z + .object({ + name: z.string(), + slug: z.string(), + projects: z + .object({ + name: z.string(), + id: z.string() + }) + .array(), + contexts: z + .object({ + name: z.string(), + id: z.string() + }) + .array() + }) + .array() + }) + } + }, + handler: async (req) => { + const organizations = await server.services.integrationAuth.getCircleCIOrganizations({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.integrationAuthId + }); + return { organizations }; + } + }); }; diff --git a/backend/src/server/routes/v1/integration-router.ts b/backend/src/server/routes/v1/integration-router.ts index 40141e2c0..059d24463 100644 --- a/backend/src/server/routes/v1/integration-router.ts +++ b/backend/src/server/routes/v1/integration-router.ts @@ -141,7 +141,9 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => { targetEnvironment: z.string().trim().optional().describe(INTEGRATION.UPDATE.targetEnvironment), owner: z.string().trim().optional().describe(INTEGRATION.UPDATE.owner), environment: z.string().trim().optional().describe(INTEGRATION.UPDATE.environment), - metadata: IntegrationMetadataSchema.optional() + path: z.string().trim().optional().describe(INTEGRATION.UPDATE.path), + metadata: IntegrationMetadataSchema.optional(), + region: z.string().trim().optional().describe(INTEGRATION.UPDATE.region) }), response: { 200: z.object({ diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index 07f795779..104898099 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -1,4 +1,3 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { @@ -14,8 +13,10 @@ import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-t import { AUDIT_LOGS, ORGANIZATIONS } from "@app/lib/api-docs"; import { getLastMidnightDateISO } from "@app/lib/fn"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { ActorType, AuthMode, MfaMethod } from "@app/services/auth/auth-type"; +import { sanitizedOrganizationSchema } from "@app/services/org/org-schema"; import { integrationAuthPubSchema } from "../sanitizedSchemas"; @@ -29,9 +30,11 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { schema: { response: { 200: z.object({ - organizations: OrganizationsSchema.extend({ - orgAuthMethod: z.string() - }).array() + organizations: sanitizedOrganizationSchema + .extend({ + orgAuthMethod: z.string() + }) + .array() }) } }, @@ -243,22 +246,10 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { params: z.object({ organizationId: z.string().trim() }), body: z.object({ name: z.string().trim().max(64, { message: "Name must be 64 or fewer characters" }).optional(), - slug: z - .string() - .trim() - .max(64, { message: "Slug must be 64 or fewer characters" }) - .regex(/^[a-zA-Z0-9-]+$/, "Slug must only contain alphanumeric characters or hyphens") - .optional(), + slug: slugSchema({ max: 64 }).optional(), authEnforced: z.boolean().optional(), scimEnabled: z.boolean().optional(), - defaultMembershipRoleSlug: z - .string() - .min(1) - .trim() - .refine((v) => slugify(v) === v, { - message: "Membership role must be a valid slug" - }) - .optional(), + defaultMembershipRoleSlug: slugSchema({ max: 64, field: "Default Membership Role" }).optional(), enforceMfa: z.boolean().optional(), selectedMfaMethod: z.nativeEnum(MfaMethod).optional() }), diff --git a/backend/src/server/routes/v1/project-env-router.ts b/backend/src/server/routes/v1/project-env-router.ts index c5ded83e4..705016696 100644 --- a/backend/src/server/routes/v1/project-env-router.ts +++ b/backend/src/server/routes/v1/project-env-router.ts @@ -1,10 +1,10 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { ProjectEnvironmentsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ENVIRONMENTS } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -124,13 +124,7 @@ export const registerProjectEnvRouter = async (server: FastifyZodProvider) => { body: z.object({ name: z.string().trim().describe(ENVIRONMENTS.CREATE.name), position: z.number().min(1).optional().describe(ENVIRONMENTS.CREATE.position), - slug: z - .string() - .trim() - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .describe(ENVIRONMENTS.CREATE.slug) + slug: slugSchema({ max: 64 }).describe(ENVIRONMENTS.CREATE.slug) }), response: { 200: z.object({ @@ -188,14 +182,7 @@ export const registerProjectEnvRouter = async (server: FastifyZodProvider) => { id: z.string().trim().describe(ENVIRONMENTS.UPDATE.id) }), body: z.object({ - slug: z - .string() - .trim() - .optional() - .refine((v) => !v || slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .describe(ENVIRONMENTS.UPDATE.slug), + slug: slugSchema({ max: 64 }).optional().describe(ENVIRONMENTS.UPDATE.slug), name: z.string().trim().optional().describe(ENVIRONMENTS.UPDATE.name), position: z.number().optional().describe(ENVIRONMENTS.UPDATE.position) }), diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index f27462d02..68d13842c 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -5,6 +5,7 @@ import { ProjectMembershipsSchema, ProjectRolesSchema, ProjectSlackConfigsSchema, + ProjectType, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; @@ -135,7 +136,10 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { includeRoles: z .enum(["true", "false"]) .default("false") - .transform((value) => value === "true") + .transform((value) => value === "true"), + type: z + .enum([ProjectType.SecretManager, ProjectType.KMS, ProjectType.CertificateManager, ProjectType.SSH, "all"]) + .optional() }), response: { 200: z.object({ @@ -154,7 +158,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actor: req.permission.type, - actorOrgId: req.permission.orgId + actorOrgId: req.permission.orgId, + type: req.query.type }); return { workspaces }; } diff --git a/backend/src/server/routes/v1/secret-tag-router.ts b/backend/src/server/routes/v1/secret-tag-router.ts index 7d696999e..ed9837084 100644 --- a/backend/src/server/routes/v1/secret-tag-router.ts +++ b/backend/src/server/routes/v1/secret-tag-router.ts @@ -1,9 +1,9 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { SecretTagsSchema } from "@app/db/schemas"; import { SECRET_TAGS } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -111,14 +111,7 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => { projectId: z.string().trim().describe(SECRET_TAGS.CREATE.projectId) }), body: z.object({ - slug: z - .string() - .toLowerCase() - .trim() - .describe(SECRET_TAGS.CREATE.slug) - .refine((v) => slugify(v) === v, { - message: "Invalid slug. Slug can only contain alphanumeric characters and hyphens." - }), + slug: slugSchema({ max: 64 }).describe(SECRET_TAGS.CREATE.slug), color: z.string().trim().describe(SECRET_TAGS.CREATE.color) }), response: { @@ -153,14 +146,7 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => { tagId: z.string().trim().describe(SECRET_TAGS.UPDATE.tagId) }), body: z.object({ - slug: z - .string() - .toLowerCase() - .trim() - .describe(SECRET_TAGS.UPDATE.slug) - .refine((v) => slugify(v) === v, { - message: "Invalid slug. Slug can only contain alphanumeric characters and hyphens." - }), + slug: slugSchema({ max: 64 }).describe(SECRET_TAGS.UPDATE.slug), color: z.string().trim().describe(SECRET_TAGS.UPDATE.color) }), response: { diff --git a/backend/src/server/routes/v1/slack-router.ts b/backend/src/server/routes/v1/slack-router.ts index 0601e2d1f..f05aa18f0 100644 --- a/backend/src/server/routes/v1/slack-router.ts +++ b/backend/src/server/routes/v1/slack-router.ts @@ -1,10 +1,10 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { SlackIntegrationsSchema, WorkflowIntegrationsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { getConfig } from "@app/lib/config/env"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -35,12 +35,7 @@ export const registerSlackRouter = async (server: FastifyZodProvider) => { } ], querystring: z.object({ - slug: z - .string() - .trim() - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }), + slug: slugSchema({ max: 64 }), description: z.string().optional() }), response: { @@ -288,13 +283,7 @@ export const registerSlackRouter = async (server: FastifyZodProvider) => { id: z.string() }), body: z.object({ - slug: z - .string() - .trim() - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .optional(), + slug: slugSchema({ max: 64 }).optional(), description: z.string().optional() }), response: { diff --git a/backend/src/server/routes/v1/user-engagement-router.ts b/backend/src/server/routes/v1/user-engagement-router.ts index e3ce6532e..1a13dbc6e 100644 --- a/backend/src/server/routes/v1/user-engagement-router.ts +++ b/backend/src/server/routes/v1/user-engagement-router.ts @@ -21,7 +21,7 @@ export const registerUserEngagementRouter = async (server: FastifyZodProvider) = }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - return server.services.userEngagement.createUserWish(req.permission.id, req.body.text); + return server.services.userEngagement.createUserWish(req.permission.id, req.permission.orgId, req.body.text); } }); }; diff --git a/backend/src/server/routes/v2/organization-router.ts b/backend/src/server/routes/v2/organization-router.ts index 5d34bc702..8ca105ad4 100644 --- a/backend/src/server/routes/v2/organization-router.ts +++ b/backend/src/server/routes/v2/organization-router.ts @@ -5,10 +5,12 @@ import { OrgMembershipsSchema, ProjectMembershipsSchema, ProjectsSchema, + ProjectType, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; import { ORGANIZATIONS } from "@app/lib/api-docs"; +import { getConfig } from "@app/lib/config/env"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { ActorType, AuthMode } from "@app/services/auth/auth-type"; @@ -78,6 +80,9 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { params: z.object({ organizationId: z.string().trim().describe(ORGANIZATIONS.GET_PROJECTS.organizationId) }), + querystring: z.object({ + type: z.nativeEnum(ProjectType).optional().describe(ORGANIZATIONS.GET_PROJECTS.type) + }), response: { 200: z.object({ workspaces: z @@ -104,7 +109,8 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { actorId: req.permission.id, actorOrgId: req.permission.orgId, actorAuthMethod: req.permission.authMethod, - orgId: req.params.organizationId + orgId: req.params.organizationId, + type: req.query.type }); return { workspaces }; @@ -281,7 +287,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { lastName: true, id: true }).merge(UserEncryptionKeysSchema.pick({ publicKey: true })), - project: ProjectsSchema.pick({ name: true, id: true }), + project: ProjectsSchema.pick({ name: true, id: true, type: true }), roles: z.array( z.object({ id: z.string(), @@ -358,21 +364,35 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - organization: OrganizationsSchema + organization: OrganizationsSchema, + accessToken: z.string() }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), - handler: async (req) => { + handler: async (req, res) => { if (req.auth.actor !== ActorType.USER) return; - const organization = await server.services.org.deleteOrganizationById( - req.permission.id, - req.params.organizationId, - req.permission.authMethod, - req.permission.orgId - ); - return { organization }; + const cfg = getConfig(); + + const { organization, tokens } = await server.services.org.deleteOrganizationById({ + userId: req.permission.id, + orgId: req.params.organizationId, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + authorizationHeader: req.headers.authorization, + userAgentHeader: req.headers["user-agent"], + ipAddress: req.realIp + }); + + void res.setCookie("jid", tokens.refreshToken, { + httpOnly: true, + path: "/", + sameSite: "strict", + secure: cfg.HTTPS_ENABLED + }); + + return { organization, accessToken: tokens.accessToken }; } }); }; diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index 0e271eb0e..84d2ee6cd 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -1,4 +1,3 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { @@ -6,12 +5,17 @@ import { CertificatesSchema, PkiAlertsSchema, PkiCollectionsSchema, - ProjectKeysSchema + ProjectKeysSchema, + ProjectType } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { InfisicalProjectTemplate } from "@app/ee/services/project-template/project-template-types"; +import { sanitizedSshCa } from "@app/ee/services/ssh/ssh-certificate-authority-schema"; +import { sanitizedSshCertificate } from "@app/ee/services/ssh-certificate/ssh-certificate-schema"; +import { sanitizedSshCertificateTemplate } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-schema"; import { PROJECTS } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -27,14 +31,6 @@ const projectWithEnv = SanitizedProjectSchema.extend({ environments: z.object({ name: z.string(), slug: z.string(), id: z.string() }).array() }); -const slugSchema = z - .string() - .min(5) - .max(36) - .refine((v) => slugify(v) === v, { - message: "Slug must be at least 5 character but no more than 36" - }); - export const registerProjectRouter = async (server: FastifyZodProvider) => { /* Get project key */ server.route({ @@ -162,24 +158,13 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { body: z.object({ projectName: z.string().trim().describe(PROJECTS.CREATE.projectName), projectDescription: z.string().trim().optional().describe(PROJECTS.CREATE.projectDescription), - slug: z - .string() - .min(5) - .max(36) - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .optional() - .describe(PROJECTS.CREATE.slug), + slug: slugSchema({ min: 5, max: 36 }).optional().describe(PROJECTS.CREATE.slug), kmsKeyId: z.string().optional(), - template: z - .string() - .refine((v) => slugify(v) === v, { - message: "Template name must be in slug format" - }) + template: slugSchema({ field: "Template Name", max: 64 }) .optional() .default(InfisicalProjectTemplate.Default) - .describe(PROJECTS.CREATE.template) + .describe(PROJECTS.CREATE.template), + type: z.nativeEnum(ProjectType).default(ProjectType.SecretManager) }), response: { 200: z.object({ @@ -198,7 +183,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { workspaceDescription: req.body.projectDescription, slug: req.body.slug, kmsKeyId: req.body.kmsKeyId, - template: req.body.template + template: req.body.template, + type: req.body.type }); await server.services.telemetry.sendPostHogEvents({ @@ -244,7 +230,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { } ], params: z.object({ - slug: slugSchema.describe("The slug of the project to delete.") + slug: slugSchema({ min: 5, max: 36 }).describe("The slug of the project to delete.") }), response: { 200: SanitizedProjectSchema @@ -278,7 +264,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }, schema: { params: z.object({ - slug: slugSchema.describe("The slug of the project to get.") + slug: slugSchema({ min: 5, max: 36 }).describe("The slug of the project to get.") }), response: { 200: projectWithEnv @@ -311,7 +297,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }, schema: { params: z.object({ - slug: slugSchema.describe("The slug of the project to update.") + slug: slugSchema({ min: 5, max: 36 }).describe("The slug of the project to update.") }), body: z.object({ name: z.string().trim().optional().describe(PROJECTS.UPDATE.name), @@ -354,7 +340,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }, schema: { params: z.object({ - slug: slugSchema.describe(PROJECTS.LIST_CAS.slug) + slug: slugSchema({ min: 5, max: 36 }).describe(PROJECTS.LIST_CAS.slug) }), querystring: z.object({ status: z.enum([CaStatus.ACTIVE, CaStatus.PENDING_CERTIFICATE]).optional().describe(PROJECTS.LIST_CAS.status), @@ -395,7 +381,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }, schema: { params: z.object({ - slug: slugSchema.describe(PROJECTS.LIST_CERTIFICATES.slug) + slug: slugSchema({ min: 5, max: 36 }).describe(PROJECTS.LIST_CERTIFICATES.slug) }), querystring: z.object({ friendlyName: z.string().optional().describe(PROJECTS.LIST_CERTIFICATES.friendlyName), @@ -517,4 +503,101 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { return { certificateTemplates }; } }); + + server.route({ + method: "GET", + url: "/:projectId/ssh-certificates", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + projectId: z.string().trim().describe(PROJECTS.LIST_SSH_CAS.projectId) + }), + querystring: z.object({ + offset: z.coerce.number().default(0).describe(PROJECTS.LIST_SSH_CERTIFICATES.offset), + limit: z.coerce.number().default(25).describe(PROJECTS.LIST_SSH_CERTIFICATES.limit) + }), + response: { + 200: z.object({ + certificates: z.array(sanitizedSshCertificate), + totalCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { certificates, totalCount } = await server.services.project.listProjectSshCertificates({ + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + projectId: req.params.projectId, + offset: req.query.offset, + limit: req.query.limit + }); + + return { certificates, totalCount }; + } + }); + + server.route({ + method: "GET", + url: "/:projectId/ssh-certificate-templates", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + projectId: z.string().trim().describe(PROJECTS.LIST_SSH_CERTIFICATE_TEMPLATES.projectId) + }), + response: { + 200: z.object({ + certificateTemplates: z.array(sanitizedSshCertificateTemplate) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { certificateTemplates } = await server.services.project.listProjectSshCertificateTemplates({ + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + projectId: req.params.projectId + }); + + return { certificateTemplates }; + } + }); + + server.route({ + method: "GET", + url: "/:projectId/ssh-cas", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + projectId: z.string().trim().describe(PROJECTS.LIST_SSH_CAS.projectId) + }), + response: { + 200: z.object({ + cas: z.array(sanitizedSshCa) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const cas = await server.services.project.listProjectSshCas({ + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + projectId: req.params.projectId + }); + + return { cas }; + } + }); }; diff --git a/backend/src/server/routes/v2/user-router.ts b/backend/src/server/routes/v2/user-router.ts index a52a45fa9..851d9c4ff 100644 --- a/backend/src/server/routes/v2/user-router.ts +++ b/backend/src/server/routes/v2/user-router.ts @@ -1,10 +1,11 @@ import { z } from "zod"; -import { AuthTokenSessionsSchema, OrganizationsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; +import { AuthTokenSessionsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; import { ApiKeysSchema } from "@app/db/schemas/api-keys"; import { authRateLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMethod, AuthMode, MfaMethod } from "@app/services/auth/auth-type"; +import { sanitizedOrganizationSchema } from "@app/services/org/org-schema"; export const registerUserRouter = async (server: FastifyZodProvider) => { server.route({ @@ -134,7 +135,7 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { description: "Return organizations that current user is part of", response: { 200: z.object({ - organizations: OrganizationsSchema.array() + organizations: sanitizedOrganizationSchema.array() }) } }, diff --git a/backend/src/services/app-connection/app-connection-dal.ts b/backend/src/services/app-connection/app-connection-dal.ts new file mode 100644 index 000000000..f74f7cf06 --- /dev/null +++ b/backend/src/services/app-connection/app-connection-dal.ts @@ -0,0 +1,11 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TAppConnectionDALFactory = ReturnType; + +export const appConnectionDALFactory = (db: TDbClient) => { + const appConnectionOrm = ormify(db, TableName.AppConnection); + + return { ...appConnectionOrm }; +}; diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts new file mode 100644 index 000000000..d69b7dec1 --- /dev/null +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -0,0 +1,4 @@ +export enum AppConnection { + GitHub = "github", + AWS = "aws" +} diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts new file mode 100644 index 000000000..787839cf7 --- /dev/null +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -0,0 +1,92 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { TAppConnectionServiceFactoryDep } from "@app/services/app-connection/app-connection-service"; +import { TAppConnection, TAppConnectionConfig } from "@app/services/app-connection/app-connection-types"; +import { + AwsConnectionMethod, + getAwsAppConnectionListItem, + validateAwsConnectionCredentials +} from "@app/services/app-connection/aws"; +import { + getGitHubConnectionListItem, + GitHubConnectionMethod, + validateGitHubConnectionCredentials +} from "@app/services/app-connection/github"; +import { KmsDataKey } from "@app/services/kms/kms-types"; + +export const listAppConnectionOptions = () => { + return [getAwsAppConnectionListItem(), getGitHubConnectionListItem()].sort((a, b) => a.name.localeCompare(b.name)); +}; + +export const encryptAppConnectionCredentials = async ({ + orgId, + credentials, + kmsService +}: { + orgId: string; + credentials: TAppConnection["credentials"]; + kmsService: TAppConnectionServiceFactoryDep["kmsService"]; +}) => { + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId + }); + + const { cipherTextBlob: encryptedCredentialsBlob } = encryptor({ + plainText: Buffer.from(JSON.stringify(credentials)) + }); + + return encryptedCredentialsBlob; +}; + +export const decryptAppConnectionCredentials = async ({ + orgId, + encryptedCredentials, + kmsService +}: { + orgId: string; + encryptedCredentials: Buffer; + kmsService: TAppConnectionServiceFactoryDep["kmsService"]; +}) => { + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId + }); + + const decryptedPlainTextBlob = decryptor({ + cipherTextBlob: encryptedCredentials + }); + + return JSON.parse(decryptedPlainTextBlob.toString()) as TAppConnection["credentials"]; +}; + +export const validateAppConnectionCredentials = async ( + appConnection: TAppConnectionConfig +): Promise => { + const { app } = appConnection; + switch (app) { + case AppConnection.AWS: { + return validateAwsConnectionCredentials(appConnection); + } + case AppConnection.GitHub: + return validateGitHubConnectionCredentials(appConnection); + default: + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + throw new Error(`Unhandled App Connection ${app}`); + } +}; + +export const getAppConnectionMethodName = (method: TAppConnection["method"]) => { + switch (method) { + case GitHubConnectionMethod.App: + return "GitHub App"; + case GitHubConnectionMethod.OAuth: + return "OAuth"; + case AwsConnectionMethod.AccessKey: + return "Access Key"; + case AwsConnectionMethod.AssumeRole: + return "Assume Role"; + default: + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + throw new Error(`Unhandled App Connection Method: ${method}`); + } +}; diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts new file mode 100644 index 000000000..f473b1e38 --- /dev/null +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -0,0 +1,6 @@ +import { AppConnection } from "./app-connection-enums"; + +export const APP_CONNECTION_NAME_MAP: Record = { + [AppConnection.AWS]: "AWS", + [AppConnection.GitHub]: "GitHub" +}; diff --git a/backend/src/services/app-connection/app-connection-schemas.ts b/backend/src/services/app-connection/app-connection-schemas.ts new file mode 100644 index 000000000..ce5e877fd --- /dev/null +++ b/backend/src/services/app-connection/app-connection-schemas.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; + +import { AppConnectionsSchema } from "@app/db/schemas/app-connections"; +import { AppConnections } from "@app/lib/api-docs"; +import { slugSchema } from "@app/server/lib/schemas"; + +import { AppConnection } from "./app-connection-enums"; + +export const BaseAppConnectionSchema = AppConnectionsSchema.omit({ + encryptedCredentials: true, + app: true, + method: true +}); + +export const GenericCreateAppConnectionFieldsSchema = (app: AppConnection) => + z.object({ + name: slugSchema({ field: "name" }).describe(AppConnections.CREATE(app).name), + description: z + .string() + .trim() + .max(256, "Description cannot exceed 256 characters") + .nullish() + .describe(AppConnections.CREATE(app).description) + }); + +export const GenericUpdateAppConnectionFieldsSchema = (app: AppConnection) => + z.object({ + name: slugSchema({ field: "name" }).describe(AppConnections.UPDATE(app).name).optional(), + description: z + .string() + .trim() + .max(256, "Description cannot exceed 256 characters") + .nullish() + .describe(AppConnections.UPDATE(app).description) + }); diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts new file mode 100644 index 000000000..9b9f16626 --- /dev/null +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -0,0 +1,360 @@ +import { ForbiddenError } from "@casl/ability"; + +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { DiscriminativePick, OrgServiceActor } from "@app/lib/types"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + decryptAppConnectionCredentials, + encryptAppConnectionCredentials, + getAppConnectionMethodName, + listAppConnectionOptions, + validateAppConnectionCredentials +} from "@app/services/app-connection/app-connection-fns"; +import { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps"; +import { + TAppConnection, + TAppConnectionConfig, + TCreateAppConnectionDTO, + TUpdateAppConnectionDTO, + TValidateAppConnectionCredentials +} from "@app/services/app-connection/app-connection-types"; +import { ValidateAwsConnectionCredentialsSchema } from "@app/services/app-connection/aws"; +import { ValidateGitHubConnectionCredentialsSchema } from "@app/services/app-connection/github"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; + +import { TAppConnectionDALFactory } from "./app-connection-dal"; + +export type TAppConnectionServiceFactoryDep = { + appConnectionDAL: TAppConnectionDALFactory; + permissionService: Pick; + kmsService: Pick; + licenseService: Pick; // TODO: remove once launched +}; + +export type TAppConnectionServiceFactory = ReturnType; + +const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record = { + [AppConnection.AWS]: ValidateAwsConnectionCredentialsSchema, + [AppConnection.GitHub]: ValidateGitHubConnectionCredentialsSchema +}; + +export const appConnectionServiceFactory = ({ + appConnectionDAL, + permissionService, + kmsService, + licenseService +}: TAppConnectionServiceFactoryDep) => { + // app connections are disabled for public until launch + const checkAppServicesAvailability = async (orgId: string) => { + const subscription = await licenseService.getPlan(orgId); + + if (!subscription.appConnections) throw new BadRequestError({ message: "App Connections are not available yet." }); + }; + + const listAppConnectionsByOrg = async (actor: OrgServiceActor, app?: AppConnection) => { + await checkAppServicesAvailability(actor.orgId); + + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.AppConnections); + + const appConnections = await appConnectionDAL.find( + app + ? { orgId: actor.orgId, app } + : { + orgId: actor.orgId + } + ); + + return Promise.all( + appConnections + .sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())) + .map(async ({ encryptedCredentials, ...connection }) => { + const credentials = await decryptAppConnectionCredentials({ + encryptedCredentials, + kmsService, + orgId: connection.orgId + }); + + return { + ...connection, + credentials + } as TAppConnection; + }) + ); + }; + + const findAppConnectionById = async (app: AppConnection, connectionId: string, actor: OrgServiceActor) => { + await checkAppServicesAvailability(actor.orgId); + + const appConnection = await appConnectionDAL.findById(connectionId); + + if (!appConnection) throw new NotFoundError({ message: `Could not find App Connection with ID ${connectionId}` }); + + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + appConnection.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.AppConnections); + + if (appConnection.app !== app) + throw new BadRequestError({ message: `App Connection with ID ${connectionId} is not for App "${app}"` }); + + return { + ...appConnection, + credentials: await decryptAppConnectionCredentials({ + encryptedCredentials: appConnection.encryptedCredentials, + orgId: appConnection.orgId, + kmsService + }) + } as TAppConnection; + }; + + const findAppConnectionByName = async (app: AppConnection, connectionName: string, actor: OrgServiceActor) => { + await checkAppServicesAvailability(actor.orgId); + + const appConnection = await appConnectionDAL.findOne({ name: connectionName, orgId: actor.orgId }); + + if (!appConnection) + throw new NotFoundError({ message: `Could not find App Connection with name ${connectionName}` }); + + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + appConnection.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.AppConnections); + + if (appConnection.app !== app) + throw new BadRequestError({ message: `App Connection with name ${connectionName} is not for App "${app}"` }); + + return { + ...appConnection, + credentials: await decryptAppConnectionCredentials({ + encryptedCredentials: appConnection.encryptedCredentials, + orgId: appConnection.orgId, + kmsService + }) + } as TAppConnection; + }; + + const createAppConnection = async ( + { method, app, credentials, ...params }: TCreateAppConnectionDTO, + actor: OrgServiceActor + ) => { + await checkAppServicesAvailability(actor.orgId); + + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.AppConnections); + + const appConnection = await appConnectionDAL.transaction(async (tx) => { + const isConflictingName = Boolean( + await appConnectionDAL.findOne( + { + name: params.name, + orgId: actor.orgId + }, + tx + ) + ); + + if (isConflictingName) + throw new BadRequestError({ + message: `An App Connection with the name "${params.name}" already exists` + }); + + const validatedCredentials = await validateAppConnectionCredentials({ + app, + credentials, + method, + orgId: actor.orgId + } as TAppConnectionConfig); + + const encryptedCredentials = await encryptAppConnectionCredentials({ + credentials: validatedCredentials, + orgId: actor.orgId, + kmsService + }); + + const connection = await appConnectionDAL.create( + { + orgId: actor.orgId, + encryptedCredentials, + method, + app, + ...params + }, + tx + ); + + return { + ...connection, + credentials: validatedCredentials + }; + }); + + return appConnection; + }; + + const updateAppConnection = async ( + { connectionId, credentials, ...params }: TUpdateAppConnectionDTO, + actor: OrgServiceActor + ) => { + await checkAppServicesAvailability(actor.orgId); + + const appConnection = await appConnectionDAL.findById(connectionId); + + if (!appConnection) throw new NotFoundError({ message: `Could not find App Connection with ID ${connectionId}` }); + + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + appConnection.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.AppConnections); + + const updatedAppConnection = await appConnectionDAL.transaction(async (tx) => { + if (params.name && appConnection.name !== params.name) { + const isConflictingName = Boolean( + await appConnectionDAL.findOne( + { + name: params.name, + orgId: appConnection.orgId + }, + tx + ) + ); + + if (isConflictingName) + throw new BadRequestError({ + message: `An App Connection with the name "${params.name}" already exists` + }); + } + + let encryptedCredentials: undefined | Buffer; + + if (credentials) { + const { app, method } = appConnection as DiscriminativePick; + + if ( + !VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[app].safeParse({ + method, + credentials + }).success + ) + throw new BadRequestError({ + message: `Invalid credential format for ${ + APP_CONNECTION_NAME_MAP[app] + } Connection with method ${getAppConnectionMethodName(method)}` + }); + + const validatedCredentials = await validateAppConnectionCredentials({ + app, + orgId: actor.orgId, + credentials, + method + } as TAppConnectionConfig); + + if (!validatedCredentials) + throw new BadRequestError({ message: "Unable to validate connection - check credentials" }); + + encryptedCredentials = await encryptAppConnectionCredentials({ + credentials: validatedCredentials, + orgId: actor.orgId, + kmsService + }); + } + + const updatedConnection = await appConnectionDAL.updateById( + connectionId, + { + orgId: actor.orgId, + encryptedCredentials, + ...params + }, + tx + ); + + return updatedConnection; + }); + + return { + ...updatedAppConnection, + credentials: await decryptAppConnectionCredentials({ + encryptedCredentials: updatedAppConnection.encryptedCredentials, + orgId: updatedAppConnection.orgId, + kmsService + }) + } as TAppConnection; + }; + + const deleteAppConnection = async (app: AppConnection, connectionId: string, actor: OrgServiceActor) => { + await checkAppServicesAvailability(actor.orgId); + + const appConnection = await appConnectionDAL.findById(connectionId); + + if (!appConnection) throw new NotFoundError({ message: `Could not find App Connection with ID ${connectionId}` }); + + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + appConnection.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.AppConnections); + + if (appConnection.app !== app) + throw new BadRequestError({ message: `App Connection with ID ${connectionId} is not for App "${app}"` }); + + // TODO: specify delete error message if due to existing dependencies + + const deletedAppConnection = await appConnectionDAL.deleteById(connectionId); + + return { + ...deletedAppConnection, + credentials: await decryptAppConnectionCredentials({ + encryptedCredentials: deletedAppConnection.encryptedCredentials, + orgId: deletedAppConnection.orgId, + kmsService + }) + } as TAppConnection; + }; + + return { + listAppConnectionOptions, + listAppConnectionsByOrg, + findAppConnectionById, + findAppConnectionByName, + createAppConnection, + updateAppConnection, + deleteAppConnection + }; +}; diff --git a/backend/src/services/app-connection/app-connection-types.ts b/backend/src/services/app-connection/app-connection-types.ts new file mode 100644 index 000000000..e3983cf91 --- /dev/null +++ b/backend/src/services/app-connection/app-connection-types.ts @@ -0,0 +1,31 @@ +import { + TAwsConnection, + TAwsConnectionConfig, + TAwsConnectionInput, + TValidateAwsConnectionCredentials +} from "@app/services/app-connection/aws"; +import { + TGitHubConnection, + TGitHubConnectionConfig, + TGitHubConnectionInput, + TValidateGitHubConnectionCredentials +} from "@app/services/app-connection/github"; + +export type TAppConnection = { id: string } & (TAwsConnection | TGitHubConnection); + +export type TAppConnectionInput = { id: string } & (TAwsConnectionInput | TGitHubConnectionInput); + +export type TCreateAppConnectionDTO = Pick< + TAppConnectionInput, + "credentials" | "method" | "name" | "app" | "description" +>; + +export type TUpdateAppConnectionDTO = Partial> & { + connectionId: string; +}; + +export type TAppConnectionConfig = TAwsConnectionConfig | TGitHubConnectionConfig; + +export type TValidateAppConnectionCredentials = + | TValidateAwsConnectionCredentials + | TValidateGitHubConnectionCredentials; diff --git a/backend/src/services/app-connection/aws/aws-connection-enums.ts b/backend/src/services/app-connection/aws/aws-connection-enums.ts new file mode 100644 index 000000000..0b571de0c --- /dev/null +++ b/backend/src/services/app-connection/aws/aws-connection-enums.ts @@ -0,0 +1,4 @@ +export enum AwsConnectionMethod { + AssumeRole = "assume-role", + AccessKey = "access-key" +} diff --git a/backend/src/services/app-connection/aws/aws-connection-fns.ts b/backend/src/services/app-connection/aws/aws-connection-fns.ts new file mode 100644 index 000000000..36008bc58 --- /dev/null +++ b/backend/src/services/app-connection/aws/aws-connection-fns.ts @@ -0,0 +1,105 @@ +import { AssumeRoleCommand, STSClient } from "@aws-sdk/client-sts"; +import AWS from "aws-sdk"; +import { randomUUID } from "crypto"; + +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, InternalServerError } from "@app/lib/errors"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +import { AwsConnectionMethod } from "./aws-connection-enums"; +import { TAwsConnectionConfig } from "./aws-connection-types"; + +export const getAwsAppConnectionListItem = () => { + const { INF_APP_CONNECTION_AWS_ACCESS_KEY_ID } = getConfig(); + + return { + name: "AWS" as const, + app: AppConnection.AWS as const, + methods: Object.values(AwsConnectionMethod) as [AwsConnectionMethod.AssumeRole, AwsConnectionMethod.AccessKey], + accessKeyId: INF_APP_CONNECTION_AWS_ACCESS_KEY_ID + }; +}; + +export const getAwsConnectionConfig = async (appConnection: TAwsConnectionConfig, region = "us-east-1") => { + const appCfg = getConfig(); + + let accessKeyId: string; + let secretAccessKey: string; + let sessionToken: undefined | string; + + const { method, credentials, orgId } = appConnection; + + switch (method) { + case AwsConnectionMethod.AssumeRole: { + const client = new STSClient({ + region, + credentials: + appCfg.INF_APP_CONNECTION_AWS_ACCESS_KEY_ID && appCfg.INF_APP_CONNECTION_AWS_SECRET_ACCESS_KEY + ? { + accessKeyId: appCfg.INF_APP_CONNECTION_AWS_ACCESS_KEY_ID, + secretAccessKey: appCfg.INF_APP_CONNECTION_AWS_SECRET_ACCESS_KEY + } + : undefined // if hosting on AWS + }); + + const command = new AssumeRoleCommand({ + RoleArn: credentials.roleArn, + RoleSessionName: `infisical-app-connection-${randomUUID()}`, + DurationSeconds: 900, // 15 mins + ExternalId: orgId + }); + + const assumeRes = await client.send(command); + + if (!assumeRes.Credentials?.AccessKeyId || !assumeRes.Credentials?.SecretAccessKey) { + throw new BadRequestError({ message: "Failed to assume role - verify credentials and role configuration" }); + } + + accessKeyId = assumeRes.Credentials.AccessKeyId; + secretAccessKey = assumeRes.Credentials.SecretAccessKey; + sessionToken = assumeRes.Credentials?.SessionToken; + break; + } + case AwsConnectionMethod.AccessKey: { + accessKeyId = credentials.accessKeyId; + secretAccessKey = credentials.secretAccessKey; + break; + } + default: + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + throw new InternalServerError({ message: `Unsupported AWS connection method: ${method}` }); + } + + return new AWS.Config({ + region, + credentials: { + accessKeyId, + secretAccessKey, + sessionToken + } + }); +}; + +export const validateAwsConnectionCredentials = async (appConnection: TAwsConnectionConfig) => { + const awsConfig = await getAwsConnectionConfig(appConnection); + const sts = new AWS.STS(awsConfig); + let resp: Awaited["promise"]>>; + + try { + resp = await sts.getCallerIdentity().promise(); + } catch (e: unknown) { + throw new BadRequestError({ + message: `Unable to validate connection - verify credentials` + }); + } + + if (resp.$response.httpResponse.statusCode !== 200) + throw new InternalServerError({ + message: `Unable to validate credentials: ${ + resp.$response.error?.message ?? + `AWS responded with a status code of ${resp.$response.httpResponse.statusCode}. Verify credentials and try again.` + }` + }); + + return appConnection.credentials; +}; diff --git a/backend/src/services/app-connection/aws/aws-connection-schemas.ts b/backend/src/services/app-connection/aws/aws-connection-schemas.ts new file mode 100644 index 000000000..914e92671 --- /dev/null +++ b/backend/src/services/app-connection/aws/aws-connection-schemas.ts @@ -0,0 +1,82 @@ +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 { AwsConnectionMethod } from "./aws-connection-enums"; + +export const AwsConnectionAssumeRoleCredentialsSchema = z.object({ + roleArn: z.string().trim().min(1, "Role ARN required") +}); + +export const AwsConnectionAccessTokenCredentialsSchema = z.object({ + accessKeyId: z.string().trim().min(1, "Access Key ID required"), + secretAccessKey: z.string().trim().min(1, "Secret Access Key required") +}); + +const BaseAwsConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.AWS) }); + +export const AwsConnectionSchema = z.intersection( + BaseAwsConnectionSchema, + z.discriminatedUnion("method", [ + z.object({ + method: z.literal(AwsConnectionMethod.AssumeRole), + credentials: AwsConnectionAssumeRoleCredentialsSchema + }), + z.object({ + method: z.literal(AwsConnectionMethod.AccessKey), + credentials: AwsConnectionAccessTokenCredentialsSchema + }) + ]) +); + +export const SanitizedAwsConnectionSchema = z.discriminatedUnion("method", [ + BaseAwsConnectionSchema.extend({ + method: z.literal(AwsConnectionMethod.AssumeRole), + credentials: AwsConnectionAssumeRoleCredentialsSchema.omit({ roleArn: true }) + }), + BaseAwsConnectionSchema.extend({ + method: z.literal(AwsConnectionMethod.AccessKey), + credentials: AwsConnectionAccessTokenCredentialsSchema.omit({ secretAccessKey: true }) + }) +]); + +export const ValidateAwsConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z.literal(AwsConnectionMethod.AssumeRole).describe(AppConnections?.CREATE(AppConnection.AWS).method), + credentials: AwsConnectionAssumeRoleCredentialsSchema.describe(AppConnections.CREATE(AppConnection.AWS).credentials) + }), + z.object({ + method: z.literal(AwsConnectionMethod.AccessKey).describe(AppConnections?.CREATE(AppConnection.AWS).method), + credentials: AwsConnectionAccessTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.AWS).credentials + ) + }) +]); + +export const CreateAwsConnectionSchema = ValidateAwsConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.AWS) +); + +export const UpdateAwsConnectionSchema = z + .object({ + credentials: z + .union([AwsConnectionAccessTokenCredentialsSchema, AwsConnectionAssumeRoleCredentialsSchema]) + .optional() + .describe(AppConnections.UPDATE(AppConnection.AWS).credentials) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.AWS)); + +export const AwsConnectionListItemSchema = z.object({ + name: z.literal("AWS"), + app: z.literal(AppConnection.AWS), + // the below is preferable but currently breaks mintlify + // methods: z.tuple([z.literal(AwsConnectionMethod.AssumeRole), z.literal(AwsConnectionMethod.AccessKey)]), + methods: z.nativeEnum(AwsConnectionMethod).array(), + accessKeyId: z.string().optional() +}); diff --git a/backend/src/services/app-connection/aws/aws-connection-types.ts b/backend/src/services/app-connection/aws/aws-connection-types.ts new file mode 100644 index 000000000..a0b74c3d0 --- /dev/null +++ b/backend/src/services/app-connection/aws/aws-connection-types.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +import { + AwsConnectionSchema, + CreateAwsConnectionSchema, + ValidateAwsConnectionCredentialsSchema +} from "./aws-connection-schemas"; + +export type TAwsConnection = z.infer; + +export type TAwsConnectionInput = z.infer & { + app: AppConnection.AWS; +}; + +export type TValidateAwsConnectionCredentials = typeof ValidateAwsConnectionCredentialsSchema; + +export type TAwsConnectionConfig = DiscriminativePick & { + orgId: string; +}; diff --git a/backend/src/services/app-connection/aws/index.ts b/backend/src/services/app-connection/aws/index.ts new file mode 100644 index 000000000..4608a3483 --- /dev/null +++ b/backend/src/services/app-connection/aws/index.ts @@ -0,0 +1,4 @@ +export * from "./aws-connection-enums"; +export * from "./aws-connection-fns"; +export * from "./aws-connection-schemas"; +export * from "./aws-connection-types"; diff --git a/backend/src/services/app-connection/github/github-connection-enums.ts b/backend/src/services/app-connection/github/github-connection-enums.ts new file mode 100644 index 000000000..77a4eebac --- /dev/null +++ b/backend/src/services/app-connection/github/github-connection-enums.ts @@ -0,0 +1,4 @@ +export enum GitHubConnectionMethod { + OAuth = "oauth", + App = "github-app" +} diff --git a/backend/src/services/app-connection/github/github-connection-fns.ts b/backend/src/services/app-connection/github/github-connection-fns.ts new file mode 100644 index 000000000..01fa7846f --- /dev/null +++ b/backend/src/services/app-connection/github/github-connection-fns.ts @@ -0,0 +1,129 @@ +import { AxiosResponse } from "axios"; + +import { getConfig } from "@app/lib/config/env"; +import { request } from "@app/lib/config/request"; +import { BadRequestError, ForbiddenRequestError, InternalServerError } from "@app/lib/errors"; +import { getAppConnectionMethodName } from "@app/services/app-connection/app-connection-fns"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; + +import { AppConnection } from "../app-connection-enums"; +import { GitHubConnectionMethod } from "./github-connection-enums"; +import { TGitHubConnectionConfig } from "./github-connection-types"; + +export const getGitHubConnectionListItem = () => { + const { INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID, INF_APP_CONNECTION_GITHUB_APP_SLUG } = getConfig(); + + return { + name: "GitHub" as const, + app: AppConnection.GitHub as const, + methods: Object.values(GitHubConnectionMethod) as [GitHubConnectionMethod.App, GitHubConnectionMethod.OAuth], + oauthClientId: INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID, + appClientSlug: INF_APP_CONNECTION_GITHUB_APP_SLUG + }; +}; + +type TokenRespData = { + access_token: string; + scope: string; + token_type: string; +}; + +export const validateGitHubConnectionCredentials = async (config: TGitHubConnectionConfig) => { + const { credentials, method } = config; + + const { + INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID, + INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_SECRET, + INF_APP_CONNECTION_GITHUB_APP_CLIENT_ID, + INF_APP_CONNECTION_GITHUB_APP_CLIENT_SECRET, + SITE_URL + } = getConfig(); + + const { clientId, clientSecret } = + method === GitHubConnectionMethod.App + ? { + clientId: INF_APP_CONNECTION_GITHUB_APP_CLIENT_ID, + clientSecret: INF_APP_CONNECTION_GITHUB_APP_CLIENT_SECRET + } + : // oauth + { + clientId: INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID, + clientSecret: INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_SECRET + }; + + if (!clientId || !clientSecret) { + throw new InternalServerError({ + message: `GitHub ${getAppConnectionMethodName(method)} environment variables have not been configured` + }); + } + + let tokenResp: AxiosResponse; + + try { + tokenResp = await request.get("https://github.com/login/oauth/access_token", { + params: { + client_id: clientId, + client_secret: clientSecret, + code: credentials.code, + redirect_uri: `${SITE_URL}/app-connections/github/oauth/callback` + }, + headers: { + Accept: "application/json", + "Accept-Encoding": "application/json" + } + }); + } catch (e: unknown) { + throw new BadRequestError({ + message: `Unable to validate connection - verify credentials` + }); + } + + if (tokenResp.status !== 200) { + throw new BadRequestError({ + message: `Unable to validate credentials: GitHub responded with a status code of ${tokenResp.status} (${tokenResp.statusText}). Verify credentials and try again.` + }); + } + + if (method === GitHubConnectionMethod.App) { + const installationsResp = await request.get<{ + installations: { + id: number; + account: { + login: string; + }; + }[]; + }>(IntegrationUrls.GITHUB_USER_INSTALLATIONS, { + headers: { + Accept: "application/json", + Authorization: `Bearer ${tokenResp.data.access_token}`, + "Accept-Encoding": "application/json" + } + }); + + const matchingInstallation = installationsResp.data.installations.find( + (installation) => installation.id === +credentials.installationId + ); + + if (!matchingInstallation) { + throw new ForbiddenRequestError({ + message: "User does not have access to the provided installation" + }); + } + } + + switch (method) { + case GitHubConnectionMethod.App: + return { + // access token not needed for GitHub App + installationId: credentials.installationId + }; + case GitHubConnectionMethod.OAuth: + return { + accessToken: tokenResp.data.access_token + }; + default: + throw new InternalServerError({ + message: `Unhandled GitHub connection method: ${method as GitHubConnectionMethod}` + }); + } +}; diff --git a/backend/src/services/app-connection/github/github-connection-schemas.ts b/backend/src/services/app-connection/github/github-connection-schemas.ts new file mode 100644 index 000000000..5adb211ba --- /dev/null +++ b/backend/src/services/app-connection/github/github-connection-schemas.ts @@ -0,0 +1,93 @@ +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 { GitHubConnectionMethod } from "./github-connection-enums"; + +export const GitHubConnectionOAuthInputCredentialsSchema = z.object({ + code: z.string().trim().min(1, "OAuth code required") +}); + +export const GitHubConnectionAppInputCredentialsSchema = z.object({ + code: z.string().trim().min(1, "GitHub App code required"), + installationId: z.string().min(1, "GitHub App Installation ID required") +}); + +export const GitHubConnectionOAuthOutputCredentialsSchema = z.object({ + accessToken: z.string() +}); + +export const GitHubConnectionAppOutputCredentialsSchema = z.object({ + installationId: z.string() +}); + +export const ValidateGitHubConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z.literal(GitHubConnectionMethod.App).describe(AppConnections.CREATE(AppConnection.GitHub).method), + credentials: GitHubConnectionAppInputCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.GitHub).credentials + ) + }), + z.object({ + method: z.literal(GitHubConnectionMethod.OAuth).describe(AppConnections.CREATE(AppConnection.GitHub).method), + credentials: GitHubConnectionOAuthInputCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.GitHub).credentials + ) + }) +]); + +export const CreateGitHubConnectionSchema = ValidateGitHubConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.GitHub) +); + +export const UpdateGitHubConnectionSchema = z + .object({ + credentials: z + .union([GitHubConnectionAppInputCredentialsSchema, GitHubConnectionOAuthInputCredentialsSchema]) + .optional() + .describe(AppConnections.UPDATE(AppConnection.GitHub).credentials) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.GitHub)); + +const BaseGitHubConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.GitHub) }); + +export const GitHubAppConnectionSchema = z.intersection( + BaseGitHubConnectionSchema, + z.discriminatedUnion("method", [ + z.object({ + method: z.literal(GitHubConnectionMethod.App), + credentials: GitHubConnectionAppOutputCredentialsSchema + }), + z.object({ + method: z.literal(GitHubConnectionMethod.OAuth), + credentials: GitHubConnectionOAuthOutputCredentialsSchema + }) + ]) +); + +export const SanitizedGitHubConnectionSchema = z.discriminatedUnion("method", [ + BaseGitHubConnectionSchema.extend({ + method: z.literal(GitHubConnectionMethod.App), + credentials: GitHubConnectionAppOutputCredentialsSchema.omit({ installationId: true }) + }), + BaseGitHubConnectionSchema.extend({ + method: z.literal(GitHubConnectionMethod.OAuth), + credentials: GitHubConnectionOAuthOutputCredentialsSchema.omit({ accessToken: true }) + }) +]); + +export const GitHubConnectionListItemSchema = z.object({ + name: z.literal("GitHub"), + app: z.literal(AppConnection.GitHub), + // the below is preferable but currently breaks mintlify + // methods: z.tuple([z.literal(GitHubConnectionMethod.GitHubApp), z.literal(GitHubConnectionMethod.OAuth)]), + methods: z.nativeEnum(GitHubConnectionMethod).array(), + oauthClientId: z.string().optional(), + appClientSlug: z.string().optional() +}); diff --git a/backend/src/services/app-connection/github/github-connection-types.ts b/backend/src/services/app-connection/github/github-connection-types.ts new file mode 100644 index 000000000..5a9b13c00 --- /dev/null +++ b/backend/src/services/app-connection/github/github-connection-types.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateGitHubConnectionSchema, + GitHubAppConnectionSchema, + ValidateGitHubConnectionCredentialsSchema +} from "./github-connection-schemas"; + +export type TGitHubConnection = z.infer; + +export type TGitHubConnectionInput = z.infer & { + app: AppConnection.GitHub; +}; + +export type TValidateGitHubConnectionCredentials = typeof ValidateGitHubConnectionCredentialsSchema; + +export type TGitHubConnectionConfig = DiscriminativePick; diff --git a/backend/src/services/app-connection/github/index.ts b/backend/src/services/app-connection/github/index.ts new file mode 100644 index 000000000..35915046b --- /dev/null +++ b/backend/src/services/app-connection/github/index.ts @@ -0,0 +1,4 @@ +export * from "./github-connection-enums"; +export * from "./github-connection-fns"; +export * from "./github-connection-schemas"; +export * from "./github-connection-types"; diff --git a/backend/src/services/auth-token/auth-token-dal.ts b/backend/src/services/auth-token/auth-token-dal.ts index c058c13e8..221b691cf 100644 --- a/backend/src/services/auth-token/auth-token-dal.ts +++ b/backend/src/services/auth-token/auth-token-dal.ts @@ -12,9 +12,12 @@ export type TTokenDALFactory = ReturnType; export const tokenDALFactory = (db: TDbClient) => { const authOrm = ormify(db, TableName.AuthTokens); - const findOneTokenSession = async (filter: Partial): Promise => { + const findOneTokenSession = async ( + filter: Partial, + tx?: Knex + ): Promise => { try { - const doc = await db.replicaNode()(TableName.AuthTokenSession).where(filter).first(); + const doc = await (tx || db.replicaNode())(TableName.AuthTokenSession).where(filter).first(); return doc; } catch (error) { throw new DatabaseError({ error, name: "FindOneTokenSession" }); @@ -54,10 +57,11 @@ export const tokenDALFactory = (db: TDbClient) => { const insertTokenSession = async ( userId: string, ip: string, - userAgent: string + userAgent: string, + tx?: Knex ): Promise => { try { - const [session] = await db(TableName.AuthTokenSession) + const [session] = await (tx || db)(TableName.AuthTokenSession) .insert({ userId, ip, diff --git a/backend/src/services/auth-token/auth-token-service.ts b/backend/src/services/auth-token/auth-token-service.ts index 321abb5b3..c0bb7dc17 100644 --- a/backend/src/services/auth-token/auth-token-service.ts +++ b/backend/src/services/auth-token/auth-token-service.ts @@ -1,6 +1,7 @@ import crypto from "node:crypto"; import bcrypt from "bcrypt"; +import { Knex } from "knex"; import { TAuthTokens, TAuthTokenSessions } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; @@ -123,14 +124,13 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAu return deletedToken?.[0]; }; - const getUserTokenSession = async ({ - userId, - ip, - userAgent - }: TIssueAuthTokenDTO): Promise => { - let session = await tokenDAL.findOneTokenSession({ userId, ip, userAgent }); + const getUserTokenSession = async ( + { userId, ip, userAgent }: TIssueAuthTokenDTO, + tx?: Knex + ): Promise => { + let session = await tokenDAL.findOneTokenSession({ userId, ip, userAgent }, tx); if (!session) { - session = await tokenDAL.insertTokenSession(userId, ip, userAgent); + session = await tokenDAL.insertTokenSession(userId, ip, userAgent, tx); } return session; }; diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index dea41e60b..8dfe69643 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -1,5 +1,6 @@ import bcrypt from "bcrypt"; import jwt from "jsonwebtoken"; +import { Knex } from "knex"; import { TUsers, UserDeviceSchema } from "@app/db/schemas"; import { isAuthMethodSaml } from "@app/ee/services/permission/permission-fns"; @@ -50,13 +51,13 @@ export const authLoginServiceFactory = ({ * Not exported. This is to update user device list * If new device is found. Will be saved and a mail will be send */ - const updateUserDeviceSession = async (user: TUsers, ip: string, userAgent: string) => { + const updateUserDeviceSession = async (user: TUsers, ip: string, userAgent: string, tx?: Knex) => { const devices = await UserDeviceSchema.parseAsync(user.devices || []); const isDeviceSeen = devices.some((device) => device.ip === ip && device.userAgent === userAgent); if (!isDeviceSeen) { const newDeviceList = devices.concat([{ ip, userAgent }]); - await userDAL.updateById(user.id, { devices: JSON.stringify(newDeviceList) }); + await userDAL.updateById(user.id, { devices: JSON.stringify(newDeviceList) }, tx); if (user.email) { await smtpService.sendMail({ template: SmtpTemplates.NewDeviceJoin, @@ -97,30 +98,36 @@ export const authLoginServiceFactory = ({ * Check user device and send mail if new device * generate the auth and refresh token. fn shared by mfa verification and login verification with mfa disabled */ - const generateUserTokens = async ({ - user, - ip, - userAgent, - organizationId, - authMethod, - isMfaVerified, - mfaMethod - }: { - user: TUsers; - ip: string; - userAgent: string; - organizationId?: string; - authMethod: AuthMethod; - isMfaVerified?: boolean; - mfaMethod?: MfaMethod; - }) => { - const cfg = getConfig(); - await updateUserDeviceSession(user, ip, userAgent); - const tokenSession = await tokenService.getUserTokenSession({ - userAgent, + const generateUserTokens = async ( + { + user, ip, - userId: user.id - }); + userAgent, + organizationId, + authMethod, + isMfaVerified, + mfaMethod + }: { + user: TUsers; + ip: string; + userAgent: string; + organizationId?: string; + authMethod: AuthMethod; + isMfaVerified?: boolean; + mfaMethod?: MfaMethod; + }, + tx?: Knex + ) => { + const cfg = getConfig(); + await updateUserDeviceSession(user, ip, userAgent, tx); + const tokenSession = await tokenService.getUserTokenSession( + { + userAgent, + ip, + userId: user.id + }, + tx + ); if (!tokenSession) throw new Error("Failed to create token"); const accessToken = jwt.sign( diff --git a/backend/src/services/certificate-authority/certificate-authority-fns.ts b/backend/src/services/certificate-authority/certificate-authority-fns.ts index efb582d88..d2c87e772 100644 --- a/backend/src/services/certificate-authority/certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/certificate-authority-fns.ts @@ -15,7 +15,7 @@ import { /* eslint-disable no-bitwise */ export const createSerialNumber = () => { - const randomBytes = crypto.randomBytes(20); + const randomBytes = crypto.randomBytes(20); // 20 bytes = 160 bits randomBytes[0] &= 0x7f; // ensure the first bit is 0 return randomBytes.toString("hex"); }; diff --git a/backend/src/services/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts index 06efcf9e3..f61a39a00 100644 --- a/backend/src/services/certificate-authority/certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/certificate-authority-service.ts @@ -5,7 +5,7 @@ import crypto, { KeyObject } from "crypto"; import ms from "ms"; import { z } from "zod"; -import { TCertificateAuthorities, TCertificateTemplates } from "@app/db/schemas"; +import { ProjectType, TCertificateAuthorities, TCertificateTemplates } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { getConfig } from "@app/lib/config/env"; @@ -77,7 +77,10 @@ type TCertificateAuthorityServiceFactoryDep = { certificateBodyDAL: Pick; pkiCollectionDAL: Pick; pkiCollectionItemDAL: Pick; - projectDAL: Pick; + projectDAL: Pick< + TProjectDALFactory, + "findProjectBySlug" | "findOne" | "updateById" | "findById" | "transaction" | "getProjectFromSplitId" + >; kmsService: Pick; permissionService: Pick; }; @@ -123,14 +126,24 @@ export const certificateAuthorityServiceFactory = ({ }: TCreateCaDTO) => { const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); + let projectId = project.id; - const { permission } = await permissionService.getProjectPermission( + const certManagerProjectFromSplit = await projectDAL.getProjectFromSplitId( + projectId, + ProjectType.CertificateManager + ); + if (certManagerProjectFromSplit) { + projectId = certManagerProjectFromSplit.id; + } + + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, - project.id, + projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.CertificateManager); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Create, @@ -161,7 +174,7 @@ export const certificateAuthorityServiceFactory = ({ const ca = await certificateAuthorityDAL.create( { - projectId: project.id, + projectId, type, organization, ou, @@ -185,7 +198,7 @@ export const certificateAuthorityServiceFactory = ({ ); const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ - projectId: project.id, + projectId, projectDAL, kmsService }); @@ -323,13 +336,14 @@ export const certificateAuthorityServiceFactory = ({ const ca = await certificateAuthorityDAL.findById(caId); if (!ca) throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, ca.projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.CertificateManager); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Edit, @@ -348,13 +362,14 @@ export const certificateAuthorityServiceFactory = ({ const ca = await certificateAuthorityDAL.findById(caId); if (!ca) throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, ca.projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.CertificateManager); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Delete, @@ -434,13 +449,14 @@ export const certificateAuthorityServiceFactory = ({ if (!ca.activeCaCertId) throw new BadRequestError({ message: "CA does not have a certificate installed" }); - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, ca.projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.CertificateManager); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Create, @@ -819,13 +835,14 @@ export const certificateAuthorityServiceFactory = ({ const ca = await certificateAuthorityDAL.findById(caId); if (!ca) throw new NotFoundError({ message: "CA not found" }); - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, ca.projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.CertificateManager); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Create, @@ -965,13 +982,14 @@ export const certificateAuthorityServiceFactory = ({ const ca = await certificateAuthorityDAL.findById(caId); if (!ca) throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, ca.projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.CertificateManager); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Create, @@ -1127,13 +1145,14 @@ export const certificateAuthorityServiceFactory = ({ throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); } - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, ca.projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.CertificateManager); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Certificates); @@ -1455,13 +1474,14 @@ export const certificateAuthorityServiceFactory = ({ } if (!dto.isInternal) { - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( dto.actor, dto.actorId, ca.projectId, dto.actorAuthMethod, dto.actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.CertificateManager); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Create, diff --git a/backend/src/services/certificate-template/certificate-template-service.ts b/backend/src/services/certificate-template/certificate-template-service.ts index ead3e85cb..c8224d28f 100644 --- a/backend/src/services/certificate-template/certificate-template-service.ts +++ b/backend/src/services/certificate-template/certificate-template-service.ts @@ -2,7 +2,7 @@ import { ForbiddenError } from "@casl/ability"; import * as x509 from "@peculiar/x509"; import bcrypt from "bcrypt"; -import { TCertificateTemplateEstConfigsUpdate } from "@app/db/schemas"; +import { ProjectType, TCertificateTemplateEstConfigsUpdate } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; @@ -67,13 +67,14 @@ export const certificateTemplateServiceFactory = ({ message: `CA with ID ${caId} not found` }); } - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, ca.projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.CertificateManager); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Create, @@ -128,13 +129,14 @@ export const certificateTemplateServiceFactory = ({ }); } - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, certTemplate.projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.CertificateManager); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Edit, @@ -185,13 +187,14 @@ export const certificateTemplateServiceFactory = ({ }); } - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, certTemplate.projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.CertificateManager); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Delete, @@ -252,13 +255,14 @@ export const certificateTemplateServiceFactory = ({ }); } - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, certTemplate.projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.CertificateManager); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Edit, @@ -336,13 +340,14 @@ export const certificateTemplateServiceFactory = ({ }); } - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, certTemplate.projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.CertificateManager); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Edit, diff --git a/backend/src/services/certificate/certificate-service.ts b/backend/src/services/certificate/certificate-service.ts index 8dc2de901..3a96ecf91 100644 --- a/backend/src/services/certificate/certificate-service.ts +++ b/backend/src/services/certificate/certificate-service.ts @@ -1,6 +1,7 @@ import { ForbiddenError } from "@casl/ability"; import * as x509 from "@peculiar/x509"; +import { ProjectType } from "@app/db/schemas"; import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; @@ -49,13 +50,14 @@ export const certificateServiceFactory = ({ const cert = await certificateDAL.findOne({ serialNumber }); const ca = await certificateAuthorityDAL.findById(cert.caId); - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, ca.projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.CertificateManager); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates); @@ -72,13 +74,14 @@ export const certificateServiceFactory = ({ const cert = await certificateDAL.findOne({ serialNumber }); const ca = await certificateAuthorityDAL.findById(cert.caId); - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, ca.projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.CertificateManager); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Certificates); @@ -106,13 +109,14 @@ export const certificateServiceFactory = ({ const cert = await certificateDAL.findOne({ serialNumber }); const ca = await certificateAuthorityDAL.findById(cert.caId); - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, ca.projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.CertificateManager); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Certificates); diff --git a/backend/src/services/cmek/cmek-service.ts b/backend/src/services/cmek/cmek-service.ts index c8e1b932a..4f14cab66 100644 --- a/backend/src/services/cmek/cmek-service.ts +++ b/backend/src/services/cmek/cmek-service.ts @@ -1,5 +1,6 @@ import { ForbiddenError } from "@casl/ability"; +import { ProjectType } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionCmekActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; @@ -14,24 +15,33 @@ import { import { TKmsKeyDALFactory } from "@app/services/kms/kms-key-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "../project/project-dal"; + type TCmekServiceFactoryDep = { kmsService: TKmsServiceFactory; kmsDAL: TKmsKeyDALFactory; permissionService: TPermissionServiceFactory; + projectDAL: Pick; }; export type TCmekServiceFactory = ReturnType; -export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TCmekServiceFactoryDep) => { - const createCmek = async ({ projectId, ...dto }: TCreateCmekDTO, actor: OrgServiceActor) => { - const { permission } = await permissionService.getProjectPermission( +export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService, projectDAL }: TCmekServiceFactoryDep) => { + const createCmek = async ({ projectId: preSplitProjectId, ...dto }: TCreateCmekDTO, actor: OrgServiceActor) => { + let projectId = preSplitProjectId; + const cmekProjectFromSplit = await projectDAL.getProjectFromSplitId(projectId, ProjectType.KMS); + if (cmekProjectFromSplit) { + projectId = cmekProjectFromSplit.id; + } + + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor.type, actor.id, projectId, actor.authMethod, actor.orgId ); - + ForbidOnInvalidProjectType(ProjectType.KMS); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionCmekActions.Create, ProjectPermissionSub.Cmek); const cmek = await kmsService.generateKmsKey({ @@ -50,13 +60,14 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TC if (!key.projectId || key.isReserved) throw new BadRequestError({ message: "Key is not customer managed" }); - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor.type, actor.id, key.projectId, actor.authMethod, actor.orgId ); + ForbidOnInvalidProjectType(ProjectType.KMS); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionCmekActions.Edit, ProjectPermissionSub.Cmek); @@ -72,13 +83,14 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TC if (!key.projectId || key.isReserved) throw new BadRequestError({ message: "Key is not customer managed" }); - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor.type, actor.id, key.projectId, actor.authMethod, actor.orgId ); + ForbidOnInvalidProjectType(ProjectType.KMS); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionCmekActions.Delete, ProjectPermissionSub.Cmek); @@ -87,7 +99,16 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TC return cmek; }; - const listCmeksByProjectId = async ({ projectId, ...filters }: TListCmeksByProjectIdDTO, actor: OrgServiceActor) => { + const listCmeksByProjectId = async ( + { projectId: preSplitProjectId, ...filters }: TListCmeksByProjectIdDTO, + actor: OrgServiceActor + ) => { + let projectId = preSplitProjectId; + const cmekProjectFromSplit = await projectDAL.getProjectFromSplitId(preSplitProjectId, ProjectType.KMS); + if (cmekProjectFromSplit) { + projectId = cmekProjectFromSplit.id; + } + const { permission } = await permissionService.getProjectPermission( actor.type, actor.id, @@ -112,7 +133,7 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TC if (key.isDisabled) throw new BadRequestError({ message: "Key is disabled" }); - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor.type, actor.id, key.projectId, @@ -120,6 +141,7 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TC actor.orgId ); + ForbidOnInvalidProjectType(ProjectType.KMS); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionCmekActions.Encrypt, ProjectPermissionSub.Cmek); const encrypt = await kmsService.encryptWithKmsKey({ kmsId: keyId }); @@ -138,13 +160,14 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TC if (key.isDisabled) throw new BadRequestError({ message: "Key is disabled" }); - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor.type, actor.id, key.projectId, actor.authMethod, actor.orgId ); + ForbidOnInvalidProjectType(ProjectType.KMS); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionCmekActions.Decrypt, ProjectPermissionSub.Cmek); diff --git a/backend/src/services/identity-access-token/identity-access-token-dal.ts b/backend/src/services/identity-access-token/identity-access-token-dal.ts index f12bd8c15..57517c706 100644 --- a/backend/src/services/identity-access-token/identity-access-token-dal.ts +++ b/backend/src/services/identity-access-token/identity-access-token-dal.ts @@ -37,7 +37,7 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => { ) .leftJoin(TableName.IdentityOidcAuth, `${TableName.Identity}.id`, `${TableName.IdentityOidcAuth}.identityId`) .leftJoin(TableName.IdentityTokenAuth, `${TableName.Identity}.id`, `${TableName.IdentityTokenAuth}.identityId`) - + .leftJoin(TableName.IdentityJwtAuth, `${TableName.Identity}.id`, `${TableName.IdentityJwtAuth}.identityId`) .select(selectAllTableCols(TableName.IdentityAccessToken)) .select( db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityUniversalAuth).as("accessTokenTrustedIpsUa"), @@ -47,6 +47,7 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => { db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityKubernetesAuth).as("accessTokenTrustedIpsK8s"), db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityOidcAuth).as("accessTokenTrustedIpsOidc"), db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityTokenAuth).as("accessTokenTrustedIpsToken"), + db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityJwtAuth).as("accessTokenTrustedIpsJwt"), db.ref("name").withSchema(TableName.Identity) ) .first(); @@ -61,7 +62,8 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => { trustedIpsAzureAuth: doc.accessTokenTrustedIpsAzure, trustedIpsKubernetesAuth: doc.accessTokenTrustedIpsK8s, trustedIpsOidcAuth: doc.accessTokenTrustedIpsOidc, - trustedIpsAccessTokenAuth: doc.accessTokenTrustedIpsToken + trustedIpsAccessTokenAuth: doc.accessTokenTrustedIpsToken, + trustedIpsAccessJwtAuth: doc.accessTokenTrustedIpsJwt }; } catch (error) { throw new DatabaseError({ error, name: "IdAccessTokenFindOne" }); diff --git a/backend/src/services/identity-access-token/identity-access-token-service.ts b/backend/src/services/identity-access-token/identity-access-token-service.ts index a59d1e959..47d1791d2 100644 --- a/backend/src/services/identity-access-token/identity-access-token-service.ts +++ b/backend/src/services/identity-access-token/identity-access-token-service.ts @@ -171,7 +171,8 @@ export const identityAccessTokenServiceFactory = ({ [IdentityAuthMethod.AZURE_AUTH]: identityAccessToken.trustedIpsAzureAuth, [IdentityAuthMethod.KUBERNETES_AUTH]: identityAccessToken.trustedIpsKubernetesAuth, [IdentityAuthMethod.OIDC_AUTH]: identityAccessToken.trustedIpsOidcAuth, - [IdentityAuthMethod.TOKEN_AUTH]: identityAccessToken.trustedIpsAccessTokenAuth + [IdentityAuthMethod.TOKEN_AUTH]: identityAccessToken.trustedIpsAccessTokenAuth, + [IdentityAuthMethod.JWT_AUTH]: identityAccessToken.trustedIpsAccessJwtAuth }; const trustedIps = trustedIpsMap[identityAccessToken.authMethod as IdentityAuthMethod]; diff --git a/backend/src/services/identity-jwt-auth/identity-jwt-auth-dal.ts b/backend/src/services/identity-jwt-auth/identity-jwt-auth-dal.ts new file mode 100644 index 000000000..5e6d13be6 --- /dev/null +++ b/backend/src/services/identity-jwt-auth/identity-jwt-auth-dal.ts @@ -0,0 +1,11 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TIdentityJwtAuthDALFactory = ReturnType; + +export const identityJwtAuthDALFactory = (db: TDbClient) => { + const jwtAuthOrm = ormify(db, TableName.IdentityJwtAuth); + + return jwtAuthOrm; +}; diff --git a/backend/src/services/identity-jwt-auth/identity-jwt-auth-fns.ts b/backend/src/services/identity-jwt-auth/identity-jwt-auth-fns.ts new file mode 100644 index 000000000..57aa933d6 --- /dev/null +++ b/backend/src/services/identity-jwt-auth/identity-jwt-auth-fns.ts @@ -0,0 +1,13 @@ +import picomatch from "picomatch"; + +export const doesFieldValueMatchJwtPolicy = (fieldValue: string | boolean | number, policyValue: string) => { + if (typeof fieldValue === "boolean") { + return fieldValue === (policyValue === "true"); + } + + if (typeof fieldValue === "number") { + return fieldValue === parseInt(policyValue, 10); + } + + return policyValue === fieldValue || picomatch.isMatch(fieldValue, policyValue); +}; diff --git a/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts b/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts new file mode 100644 index 000000000..5f8fc5ff6 --- /dev/null +++ b/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts @@ -0,0 +1,534 @@ +import { ForbiddenError } from "@casl/ability"; +import https from "https"; +import jwt from "jsonwebtoken"; +import { JwksClient } from "jwks-rsa"; + +import { IdentityAuthMethod, TIdentityJwtAuthsUpdate } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; +import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; + +import { ActorType, AuthTokenType } from "../auth/auth-type"; +import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; +import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; +import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; +import { TKmsServiceFactory } from "../kms/kms-service"; +import { KmsDataKey } from "../kms/kms-types"; +import { TIdentityJwtAuthDALFactory } from "./identity-jwt-auth-dal"; +import { doesFieldValueMatchJwtPolicy } from "./identity-jwt-auth-fns"; +import { + JwtConfigurationType, + TAttachJwtAuthDTO, + TGetJwtAuthDTO, + TLoginJwtAuthDTO, + TRevokeJwtAuthDTO, + TUpdateJwtAuthDTO +} from "./identity-jwt-auth-types"; + +type TIdentityJwtAuthServiceFactoryDep = { + identityJwtAuthDAL: TIdentityJwtAuthDALFactory; + identityOrgMembershipDAL: Pick; + identityAccessTokenDAL: Pick; + permissionService: Pick; + licenseService: Pick; + kmsService: Pick; +}; + +export type TIdentityJwtAuthServiceFactory = ReturnType; + +export const identityJwtAuthServiceFactory = ({ + identityJwtAuthDAL, + identityOrgMembershipDAL, + permissionService, + licenseService, + identityAccessTokenDAL, + kmsService +}: TIdentityJwtAuthServiceFactoryDep) => { + const login = async ({ identityId, jwt: jwtValue }: TLoginJwtAuthDTO) => { + const identityJwtAuth = await identityJwtAuthDAL.findOne({ identityId }); + if (!identityJwtAuth) { + throw new NotFoundError({ message: "JWT auth method not found for identity, did you configure JWT auth?" }); + } + + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ + identityId: identityJwtAuth.identityId + }); + if (!identityMembershipOrg) { + throw new NotFoundError({ + message: `Identity organization membership for identity with ID '${identityJwtAuth.identityId}' not found` + }); + } + + const { decryptor: orgDataKeyDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: identityMembershipOrg.orgId + }); + + const decodedToken = jwt.decode(jwtValue, { complete: true }); + if (!decodedToken) { + throw new UnauthorizedError({ + message: "Invalid JWT" + }); + } + + let tokenData: Record = {}; + + if (identityJwtAuth.configurationType === JwtConfigurationType.JWKS) { + const decryptedJwksCaCert = orgDataKeyDecryptor({ + cipherTextBlob: identityJwtAuth.encryptedJwksCaCert + }).toString(); + const requestAgent = new https.Agent({ ca: decryptedJwksCaCert, rejectUnauthorized: !!decryptedJwksCaCert }); + const client = new JwksClient({ + jwksUri: identityJwtAuth.jwksUrl, + requestAgent + }); + + const { kid } = decodedToken.header; + const jwtSigningKey = await client.getSigningKey(kid); + + try { + tokenData = jwt.verify(jwtValue, jwtSigningKey.getPublicKey()) as Record; + } catch (error) { + if (error instanceof jwt.JsonWebTokenError) { + throw new UnauthorizedError({ + message: `Access denied: ${error.message}` + }); + } + + throw error; + } + } else { + const decryptedPublicKeys = orgDataKeyDecryptor({ cipherTextBlob: identityJwtAuth.encryptedPublicKeys }) + .toString() + .split(","); + + const errors: string[] = []; + let isMatchAnyKey = false; + for (const publicKey of decryptedPublicKeys) { + try { + tokenData = jwt.verify(jwtValue, publicKey) as Record; + isMatchAnyKey = true; + } catch (error) { + if (error instanceof jwt.JsonWebTokenError) { + errors.push(error.message); + } + } + } + + if (!isMatchAnyKey) { + throw new UnauthorizedError({ + message: `Access denied: JWT verification failed with all keys. Errors - ${errors.join("; ")}` + }); + } + } + + if (identityJwtAuth.boundIssuer) { + if (tokenData.iss !== identityJwtAuth.boundIssuer) { + throw new ForbiddenRequestError({ + message: "Access denied: issuer mismatch" + }); + } + } + + if (identityJwtAuth.boundSubject) { + if (!tokenData.sub) { + throw new UnauthorizedError({ + message: "Access denied: token has no subject field" + }); + } + + if (!doesFieldValueMatchJwtPolicy(tokenData.sub, identityJwtAuth.boundSubject)) { + throw new ForbiddenRequestError({ + message: "Access denied: subject not allowed" + }); + } + } + + if (identityJwtAuth.boundAudiences) { + if (!tokenData.aud) { + throw new UnauthorizedError({ + message: "Access denied: token has no audience field" + }); + } + + if ( + !identityJwtAuth.boundAudiences + .split(", ") + .some((policyValue) => doesFieldValueMatchJwtPolicy(tokenData.aud, policyValue)) + ) { + throw new UnauthorizedError({ + message: "Access denied: token audience not allowed" + }); + } + } + + if (identityJwtAuth.boundClaims) { + Object.keys(identityJwtAuth.boundClaims).forEach((claimKey) => { + const claimValue = (identityJwtAuth.boundClaims as Record)[claimKey]; + + if (!tokenData[claimKey]) { + throw new UnauthorizedError({ + message: `Access denied: token has no ${claimKey} field` + }); + } + + // handle both single and multi-valued claims + if ( + !claimValue.split(", ").some((claimEntry) => doesFieldValueMatchJwtPolicy(tokenData[claimKey], claimEntry)) + ) { + throw new UnauthorizedError({ + message: `Access denied: claim mismatch for field ${claimKey}` + }); + } + }); + } + + const identityAccessToken = await identityJwtAuthDAL.transaction(async (tx) => { + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityJwtAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityJwtAuth.accessTokenTTL, + accessTokenMaxTTL: identityJwtAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityJwtAuth.accessTokenNumUsesLimit, + authMethod: IdentityAuthMethod.JWT_AUTH + }, + tx + ); + + return newToken; + }); + + const appCfg = getConfig(); + const accessToken = jwt.sign( + { + identityId: identityJwtAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + { + expiresIn: + Number(identityAccessToken.accessTokenMaxTTL) === 0 + ? undefined + : Number(identityAccessToken.accessTokenMaxTTL) + } + ); + + return { accessToken, identityJwtAuth, identityAccessToken, identityMembershipOrg }; + }; + + const attachJwtAuth = async ({ + identityId, + configurationType, + jwksUrl, + jwksCaCert, + publicKeys, + boundIssuer, + boundAudiences, + boundClaims, + boundSubject, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TAttachJwtAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) { + if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + } + if (identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.JWT_AUTH)) { + throw new BadRequestError({ + message: "Failed to add JWT Auth to already configured identity" + }); + } + + if (accessTokenMaxTTL > 0 && accessTokenTTL > accessTokenMaxTTL) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { + if ( + !plan.ipAllowlisting && + accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" && + accessTokenTrustedIp.ipAddress !== "::/0" + ) + throw new BadRequestError({ + message: + "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + const { encryptor: orgDataKeyEncryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: actorOrgId + }); + + const { cipherTextBlob: encryptedJwksCaCert } = orgDataKeyEncryptor({ + plainText: Buffer.from(jwksCaCert) + }); + + const { cipherTextBlob: encryptedPublicKeys } = orgDataKeyEncryptor({ + plainText: Buffer.from(publicKeys.join(",")) + }); + + const identityJwtAuth = await identityJwtAuthDAL.transaction(async (tx) => { + const doc = await identityJwtAuthDAL.create( + { + identityId: identityMembershipOrg.identityId, + configurationType, + jwksUrl, + encryptedJwksCaCert, + encryptedPublicKeys, + boundIssuer, + boundAudiences, + boundClaims, + boundSubject, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps) + }, + tx + ); + + return doc; + }); + return { ...identityJwtAuth, orgId: identityMembershipOrg.orgId, jwksCaCert, publicKeys }; + }; + + const updateJwtAuth = async ({ + identityId, + configurationType, + jwksUrl, + jwksCaCert, + publicKeys, + boundIssuer, + boundAudiences, + boundClaims, + boundSubject, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdateJwtAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + + if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.JWT_AUTH)) { + throw new BadRequestError({ + message: "Failed to update JWT Auth" + }); + } + + const identityJwtAuth = await identityJwtAuthDAL.findOne({ identityId }); + + if ( + (accessTokenMaxTTL || identityJwtAuth.accessTokenMaxTTL) > 0 && + (accessTokenTTL || identityJwtAuth.accessTokenMaxTTL) > (accessTokenMaxTTL || identityJwtAuth.accessTokenMaxTTL) + ) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { + if ( + !plan.ipAllowlisting && + accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" && + accessTokenTrustedIp.ipAddress !== "::/0" + ) + throw new BadRequestError({ + message: + "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + const updateQuery: TIdentityJwtAuthsUpdate = { + boundIssuer, + configurationType, + jwksUrl, + boundAudiences, + boundClaims, + boundSubject, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: reformattedAccessTokenTrustedIps + ? JSON.stringify(reformattedAccessTokenTrustedIps) + : undefined + }; + + const { encryptor: orgDataKeyEncryptor, decryptor: orgDataKeyDecryptor } = + await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: actorOrgId + }); + + if (jwksCaCert !== undefined) { + const { cipherTextBlob: encryptedJwksCaCert } = orgDataKeyEncryptor({ + plainText: Buffer.from(jwksCaCert) + }); + + updateQuery.encryptedJwksCaCert = encryptedJwksCaCert; + } + + if (publicKeys) { + const { cipherTextBlob: encryptedPublicKeys } = orgDataKeyEncryptor({ + plainText: Buffer.from(publicKeys.join(",")) + }); + + updateQuery.encryptedPublicKeys = encryptedPublicKeys; + } + + const updatedJwtAuth = await identityJwtAuthDAL.updateById(identityJwtAuth.id, updateQuery); + const decryptedJwksCaCert = orgDataKeyDecryptor({ cipherTextBlob: updatedJwtAuth.encryptedJwksCaCert }).toString(); + const decryptedPublicKeys = orgDataKeyDecryptor({ cipherTextBlob: updatedJwtAuth.encryptedPublicKeys }) + .toString() + .split(","); + + return { + ...updatedJwtAuth, + orgId: identityMembershipOrg.orgId, + jwksCaCert: decryptedJwksCaCert, + publicKeys: decryptedPublicKeys + }; + }; + + const getJwtAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetJwtAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + + if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.JWT_AUTH)) { + throw new BadRequestError({ + message: "The identity does not have JWT Auth attached" + }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); + + const identityJwtAuth = await identityJwtAuthDAL.findOne({ identityId }); + + const { decryptor: orgDataKeyDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: actorOrgId + }); + + const decryptedJwksCaCert = orgDataKeyDecryptor({ cipherTextBlob: identityJwtAuth.encryptedJwksCaCert }).toString(); + const decryptedPublicKeys = orgDataKeyDecryptor({ cipherTextBlob: identityJwtAuth.encryptedPublicKeys }) + .toString() + .split(","); + + return { + ...identityJwtAuth, + orgId: identityMembershipOrg.orgId, + jwksCaCert: decryptedJwksCaCert, + publicKeys: decryptedPublicKeys + }; + }; + + const revokeJwtAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TRevokeJwtAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) { + throw new NotFoundError({ message: "Failed to find identity" }); + } + + if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.JWT_AUTH)) { + throw new BadRequestError({ + message: "The identity does not have JWT auth" + }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission( + ActorType.IDENTITY, + identityMembershipOrg.identityId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + + if (!isAtLeastAsPrivileged(permission, rolePermission)) { + throw new ForbiddenRequestError({ + message: "Failed to revoke JWT auth of identity with more privileged role" + }); + } + + const revokedIdentityJwtAuth = await identityJwtAuthDAL.transaction(async (tx) => { + const deletedJwtAuth = await identityJwtAuthDAL.delete({ identityId }, tx); + await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.JWT_AUTH }, tx); + + return { ...deletedJwtAuth?.[0], orgId: identityMembershipOrg.orgId }; + }); + + return revokedIdentityJwtAuth; + }; + + return { + login, + attachJwtAuth, + updateJwtAuth, + getJwtAuth, + revokeJwtAuth + }; +}; diff --git a/backend/src/services/identity-jwt-auth/identity-jwt-auth-types.ts b/backend/src/services/identity-jwt-auth/identity-jwt-auth-types.ts new file mode 100644 index 000000000..a6881f0e5 --- /dev/null +++ b/backend/src/services/identity-jwt-auth/identity-jwt-auth-types.ts @@ -0,0 +1,51 @@ +import { TProjectPermission } from "@app/lib/types"; + +export enum JwtConfigurationType { + JWKS = "jwks", + STATIC = "static" +} + +export type TAttachJwtAuthDTO = { + identityId: string; + configurationType: JwtConfigurationType; + jwksUrl: string; + jwksCaCert: string; + publicKeys: string[]; + boundIssuer: string; + boundAudiences: string; + boundClaims: Record; + boundSubject: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { ipAddress: string }[]; +} & Omit; + +export type TUpdateJwtAuthDTO = { + identityId: string; + configurationType?: JwtConfigurationType; + jwksUrl?: string; + jwksCaCert?: string; + publicKeys?: string[]; + boundIssuer?: string; + boundAudiences?: string; + boundClaims?: Record; + boundSubject?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { ipAddress: string }[]; +} & Omit; + +export type TGetJwtAuthDTO = { + identityId: string; +} & Omit; + +export type TRevokeJwtAuthDTO = { + identityId: string; +} & Omit; + +export type TLoginJwtAuthDTO = { + identityId: string; + jwt: string; +}; diff --git a/backend/src/services/identity-jwt-auth/identity-jwt-auth-validators.ts b/backend/src/services/identity-jwt-auth/identity-jwt-auth-validators.ts new file mode 100644 index 000000000..515c2ac7e --- /dev/null +++ b/backend/src/services/identity-jwt-auth/identity-jwt-auth-validators.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; + +export const validateJwtAuthAudiencesField = z + .string() + .trim() + .default("") + .transform((data) => { + if (data === "") return ""; + return data + .split(",") + .map((id) => id.trim()) + .join(", "); + }); + +export const validateJwtBoundClaimsField = z.record(z.string()).transform((data) => { + const formattedClaims: Record = {}; + Object.keys(data).forEach((key) => { + formattedClaims[key] = data[key] + .split(",") + .map((id) => id.trim()) + .join(", "); + }); + + return formattedClaims; +}); diff --git a/backend/src/services/identity-project/identity-project-dal.ts b/backend/src/services/identity-project/identity-project-dal.ts index fd8eaa15d..bc4f4a303 100644 --- a/backend/src/services/identity-project/identity-project-dal.ts +++ b/backend/src/services/identity-project/identity-project-dal.ts @@ -102,6 +102,7 @@ export const identityProjectDALFactory = (db: TDbClient) => { db.ref("temporaryAccessEndTime").withSchema(TableName.IdentityProjectMembershipRole), db.ref("projectId").withSchema(TableName.IdentityProjectMembership), db.ref("name").as("projectName").withSchema(TableName.Project), + db.ref("type").as("projectType").withSchema(TableName.Project), db.ref("id").as("uaId").withSchema(TableName.IdentityUniversalAuth), db.ref("id").as("gcpId").withSchema(TableName.IdentityGcpAuth), db.ref("id").as("awsId").withSchema(TableName.IdentityAwsAuth), @@ -126,7 +127,8 @@ export const identityProjectDALFactory = (db: TDbClient) => { createdAt, updatedAt, projectId, - projectName + projectName, + projectType }) => ({ id, identityId, @@ -147,7 +149,8 @@ export const identityProjectDALFactory = (db: TDbClient) => { }, project: { id: projectId, - name: projectName + name: projectName, + type: projectType } }), key: "id", diff --git a/backend/src/services/identity/identity-fns.ts b/backend/src/services/identity/identity-fns.ts index 49cf4d119..2d77e6544 100644 --- a/backend/src/services/identity/identity-fns.ts +++ b/backend/src/services/identity/identity-fns.ts @@ -7,7 +7,8 @@ export const buildAuthMethods = ({ kubernetesId, oidcId, azureId, - tokenId + tokenId, + jwtId }: { uaId?: string; gcpId?: string; @@ -16,6 +17,7 @@ export const buildAuthMethods = ({ oidcId?: string; azureId?: string; tokenId?: string; + jwtId?: string; }) => { return [ ...[uaId ? IdentityAuthMethod.UNIVERSAL_AUTH : null], @@ -24,6 +26,7 @@ export const buildAuthMethods = ({ ...[kubernetesId ? IdentityAuthMethod.KUBERNETES_AUTH : null], ...[oidcId ? IdentityAuthMethod.OIDC_AUTH : null], ...[azureId ? IdentityAuthMethod.AZURE_AUTH : null], - ...[tokenId ? IdentityAuthMethod.TOKEN_AUTH : null] + ...[tokenId ? IdentityAuthMethod.TOKEN_AUTH : null], + ...[jwtId ? IdentityAuthMethod.JWT_AUTH : null] ].filter((authMethod) => authMethod) as IdentityAuthMethod[]; }; diff --git a/backend/src/services/identity/identity-org-dal.ts b/backend/src/services/identity/identity-org-dal.ts index bbdf96a2b..92a6795d0 100644 --- a/backend/src/services/identity/identity-org-dal.ts +++ b/backend/src/services/identity/identity-org-dal.ts @@ -6,6 +6,7 @@ import { TIdentityAwsAuths, TIdentityAzureAuths, TIdentityGcpAuths, + TIdentityJwtAuths, TIdentityKubernetesAuths, TIdentityOidcAuths, TIdentityOrgMemberships, @@ -70,6 +71,11 @@ export const identityOrgDALFactory = (db: TDbClient) => { `${TableName.IdentityOrgMembership}.identityId`, `${TableName.IdentityTokenAuth}.identityId` ) + .leftJoin( + TableName.IdentityJwtAuth, + `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.IdentityJwtAuth}.identityId` + ) .select( selectAllTableCols(TableName.IdentityOrgMembership), @@ -81,6 +87,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { db.ref("id").as("oidcId").withSchema(TableName.IdentityOidcAuth), db.ref("id").as("azureId").withSchema(TableName.IdentityAzureAuth), db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth), + db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth), db.ref("name").withSchema(TableName.Identity) ); @@ -183,6 +190,11 @@ export const identityOrgDALFactory = (db: TDbClient) => { "paginatedIdentity.identityId", `${TableName.IdentityTokenAuth}.identityId` ) + .leftJoin( + TableName.IdentityJwtAuth, + "paginatedIdentity.identityId", + `${TableName.IdentityJwtAuth}.identityId` + ) .select( db.ref("id").withSchema("paginatedIdentity"), @@ -200,7 +212,8 @@ export const identityOrgDALFactory = (db: TDbClient) => { db.ref("id").as("kubernetesId").withSchema(TableName.IdentityKubernetesAuth), db.ref("id").as("oidcId").withSchema(TableName.IdentityOidcAuth), db.ref("id").as("azureId").withSchema(TableName.IdentityAzureAuth), - db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth) + db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth), + db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth) ) // cr stands for custom role .select(db.ref("id").as("crId").withSchema(TableName.OrgRoles)) @@ -237,6 +250,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { uaId, awsId, gcpId, + jwtId, kubernetesId, oidcId, azureId, @@ -271,7 +285,8 @@ export const identityOrgDALFactory = (db: TDbClient) => { kubernetesId, oidcId, azureId, - tokenId + tokenId, + jwtId }) } }), diff --git a/backend/src/services/integration-auth/integration-app-types.ts b/backend/src/services/integration-auth/integration-app-types.ts new file mode 100644 index 000000000..1ddd2e4d2 --- /dev/null +++ b/backend/src/services/integration-auth/integration-app-types.ts @@ -0,0 +1,5 @@ +export type TCircleCIContext = { + id: string; + name: string; + created_at: string; +}; diff --git a/backend/src/services/integration-auth/integration-auth-service.ts b/backend/src/services/integration-auth/integration-auth-service.ts index be1a8d53c..e2957be98 100644 --- a/backend/src/services/integration-auth/integration-auth-service.ts +++ b/backend/src/services/integration-auth/integration-auth-service.ts @@ -4,13 +4,21 @@ import { Octokit } from "@octokit/rest"; import { Client as OctopusClient, SpaceRepository as OctopusSpaceRepository } from "@octopusdeploy/api-client"; import AWS from "aws-sdk"; -import { SecretEncryptionAlgo, SecretKeyEncoding, TIntegrationAuths, TIntegrationAuthsInsert } from "@app/db/schemas"; +import { + ProjectType, + SecretEncryptionAlgo, + SecretKeyEncoding, + TIntegrationAuths, + TIntegrationAuthsInsert +} from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { getConfig } from "@app/lib/config/env"; import { request } from "@app/lib/config/request"; import { decryptSymmetric128BitHexKeyUTF8, encryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto"; import { BadRequestError, InternalServerError, NotFoundError } from "@app/lib/errors"; +import { groupBy } from "@app/lib/fn"; +import { logger } from "@app/lib/logger"; import { TGenericPermission, TProjectPermission } from "@app/lib/types"; import { TIntegrationDALFactory } from "../integration/integration-dal"; @@ -18,6 +26,7 @@ import { TKmsServiceFactory } from "../kms/kms-service"; import { KmsDataKey } from "../kms/kms-types"; import { TProjectBotServiceFactory } from "../project-bot/project-bot-service"; import { getApps } from "./integration-app-list"; +import { TCircleCIContext } from "./integration-app-types"; import { TIntegrationAuthDALFactory } from "./integration-auth-dal"; import { IntegrationAuthMetadataSchema, TIntegrationAuthMetadata } from "./integration-auth-schema"; import { @@ -25,6 +34,7 @@ import { TBitbucketEnvironment, TBitbucketWorkspace, TChecklyGroups, + TCircleCIOrganization, TDeleteIntegrationAuthByIdDTO, TDeleteIntegrationAuthsDTO, TDuplicateGithubIntegrationAuthDTO, @@ -36,6 +46,7 @@ import { TIntegrationAuthBitbucketEnvironmentsDTO, TIntegrationAuthBitbucketWorkspaceDTO, TIntegrationAuthChecklyGroupsDTO, + TIntegrationAuthCircleCIOrganizationDTO, TIntegrationAuthGithubEnvsDTO, TIntegrationAuthGithubOrgsDTO, TIntegrationAuthHerokuPipelinesDTO, @@ -55,6 +66,7 @@ import { TOctopusDeployVariableSet, TSaveIntegrationAccessTokenDTO, TTeamCityBuildConfig, + TUpdateIntegrationAuthDTO, TVercelBranches } from "./integration-auth-types"; import { getIntegrationOptions, Integrations, IntegrationUrls } from "./integration-list"; @@ -144,13 +156,14 @@ export const integrationAuthServiceFactory = ({ if (!Object.values(Integrations).includes(integration as Integrations)) throw new BadRequestError({ message: "Invalid integration" }); - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Integrations); const tokenExchange = await exchangeCode({ integration, code, url, installationId }); @@ -253,13 +266,14 @@ export const integrationAuthServiceFactory = ({ if (!Object.values(Integrations).includes(integration as Integrations)) throw new BadRequestError({ message: "Invalid integration" }); - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Integrations); const updateDoc: TIntegrationAuthsInsert = { @@ -368,6 +382,148 @@ export const integrationAuthServiceFactory = ({ return integrationAuthDAL.create(updateDoc); }; + const updateIntegrationAuth = async ({ + integrationAuthId, + refreshToken, + actorId, + integration: newIntegration, + url, + actor, + actorOrgId, + actorAuthMethod, + accessId, + namespace, + accessToken, + awsAssumeIamRoleArn + }: TUpdateIntegrationAuthDTO) => { + const integrationAuth = await integrationAuthDAL.findById(integrationAuthId); + if (!integrationAuth) { + throw new NotFoundError({ message: `Integration auth with id ${integrationAuthId} not found.` }); + } + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Integrations); + + const { projectId } = integrationAuth; + const integration = newIntegration || integrationAuth.integration; + + const updateDoc: TIntegrationAuthsInsert = { + projectId, + integration, + namespace, + url, + algorithm: SecretEncryptionAlgo.AES_256_GCM, + keyEncoding: SecretKeyEncoding.UTF8, + ...(integration === Integrations.GCP_SECRET_MANAGER + ? { + metadata: { + authMethod: "serviceAccount" + } + } + : {}) + }; + + const { shouldUseSecretV2Bridge, botKey } = await projectBotService.getBotKey(projectId); + if (shouldUseSecretV2Bridge) { + const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + if (refreshToken) { + const tokenDetails = await exchangeRefresh( + integration, + refreshToken, + url, + updateDoc.metadata as Record + ); + const refreshEncToken = secretManagerEncryptor({ + plainText: Buffer.from(tokenDetails.refreshToken) + }).cipherTextBlob; + updateDoc.encryptedRefresh = refreshEncToken; + + const accessEncToken = secretManagerEncryptor({ + plainText: Buffer.from(tokenDetails.accessToken) + }).cipherTextBlob; + updateDoc.encryptedAccess = accessEncToken; + updateDoc.accessExpiresAt = tokenDetails.accessExpiresAt; + } + + if (!refreshToken && (accessId || accessToken || awsAssumeIamRoleArn)) { + if (accessToken) { + const accessEncToken = secretManagerEncryptor({ + plainText: Buffer.from(accessToken) + }).cipherTextBlob; + updateDoc.encryptedAccess = accessEncToken; + updateDoc.encryptedAwsAssumeIamRoleArn = null; + } + if (accessId) { + const accessEncToken = secretManagerEncryptor({ + plainText: Buffer.from(accessId) + }).cipherTextBlob; + updateDoc.encryptedAccessId = accessEncToken; + updateDoc.encryptedAwsAssumeIamRoleArn = null; + } + if (awsAssumeIamRoleArn) { + const awsAssumeIamRoleArnEncrypted = secretManagerEncryptor({ + plainText: Buffer.from(awsAssumeIamRoleArn) + }).cipherTextBlob; + updateDoc.encryptedAwsAssumeIamRoleArn = awsAssumeIamRoleArnEncrypted; + updateDoc.encryptedAccess = null; + updateDoc.encryptedAccessId = null; + } + } + } else { + if (!botKey) throw new NotFoundError({ message: `Project bot key for project with ID '${projectId}' not found` }); + if (refreshToken) { + const tokenDetails = await exchangeRefresh( + integration, + refreshToken, + url, + updateDoc.metadata as Record + ); + const refreshEncToken = encryptSymmetric128BitHexKeyUTF8(tokenDetails.refreshToken, botKey); + updateDoc.refreshIV = refreshEncToken.iv; + updateDoc.refreshTag = refreshEncToken.tag; + updateDoc.refreshCiphertext = refreshEncToken.ciphertext; + const accessEncToken = encryptSymmetric128BitHexKeyUTF8(tokenDetails.accessToken, botKey); + updateDoc.accessIV = accessEncToken.iv; + updateDoc.accessTag = accessEncToken.tag; + updateDoc.accessCiphertext = accessEncToken.ciphertext; + + updateDoc.accessExpiresAt = tokenDetails.accessExpiresAt; + } + + if (!refreshToken && (accessId || accessToken || awsAssumeIamRoleArn)) { + if (accessToken) { + const accessEncToken = encryptSymmetric128BitHexKeyUTF8(accessToken, botKey); + updateDoc.accessIV = accessEncToken.iv; + updateDoc.accessTag = accessEncToken.tag; + updateDoc.accessCiphertext = accessEncToken.ciphertext; + } + if (accessId) { + const accessEncToken = encryptSymmetric128BitHexKeyUTF8(accessId, botKey); + updateDoc.accessIdIV = accessEncToken.iv; + updateDoc.accessIdTag = accessEncToken.tag; + updateDoc.accessIdCiphertext = accessEncToken.ciphertext; + } + if (awsAssumeIamRoleArn) { + const awsAssumeIamRoleArnEnc = encryptSymmetric128BitHexKeyUTF8(awsAssumeIamRoleArn, botKey); + updateDoc.awsAssumeIamRoleArnCipherText = awsAssumeIamRoleArnEnc.ciphertext; + updateDoc.awsAssumeIamRoleArnIV = awsAssumeIamRoleArnEnc.iv; + updateDoc.awsAssumeIamRoleArnTag = awsAssumeIamRoleArnEnc.tag; + } + } + } + + return integrationAuthDAL.updateById(integrationAuthId, updateDoc); + }; + // helper function const getIntegrationAccessToken = async ( integrationAuth: TIntegrationAuths, @@ -1427,6 +1583,120 @@ export const integrationAuthServiceFactory = ({ return []; }; + const getCircleCIOrganizations = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + id + }: TIntegrationAuthCircleCIOrganizationDTO) => { + const integrationAuth = await integrationAuthDAL.findById(id); + if (!integrationAuth) throw new NotFoundError({ message: `Integration auth with ID '${id}' not found` }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); + const { shouldUseSecretV2Bridge, botKey } = await projectBotService.getBotKey(integrationAuth.projectId); + const { accessToken } = await getIntegrationAccessToken(integrationAuth, shouldUseSecretV2Bridge, botKey); + + const { data: organizations }: { data: TCircleCIOrganization[] } = await request.get( + `${IntegrationUrls.CIRCLECI_API_URL}/v2/me/collaborations`, + { + headers: { + "Circle-Token": `${accessToken}`, + "Accept-Encoding": "application/json" + } + } + ); + + let projects: { + orgName: string; + projectName: string; + projectId?: string; + }[] = []; + + try { + const projectRes = ( + await request.get<{ reponame: string; username: string; vcs_url: string }[]>( + `${IntegrationUrls.CIRCLECI_API_URL}/v1.1/projects`, + { + headers: { + "Circle-Token": accessToken, + "Accept-Encoding": "application/json" + } + } + ) + ).data; + + projects = projectRes.map((a) => ({ + orgName: a.username, // username maps to unique organization name in CircleCI + projectName: a.reponame, // reponame maps to project name within an organization in CircleCI + projectId: a.vcs_url.split("/").pop() // vcs_url maps to the project id in CircleCI + })); + } catch (error) { + logger.error(error); + } + + const projectsByOrg = groupBy( + projects.map((p) => ({ + orgName: p.orgName, + name: p.projectName, + id: p.projectId as string + })), + (p) => p.orgName + ); + + const getOrgContexts = async (orgSlug: string) => { + type NextPageToken = string | null | undefined; + + try { + const contexts: TCircleCIContext[] = []; + let nextPageToken: NextPageToken; + + while (nextPageToken !== null) { + // eslint-disable-next-line no-await-in-loop + const { data } = await request.get<{ + items: TCircleCIContext[]; + next_page_token: NextPageToken; + }>(`${IntegrationUrls.CIRCLECI_API_URL}/v2/context`, { + headers: { + "Circle-Token": accessToken, + "Accept-Encoding": "application/json" + }, + params: new URLSearchParams({ + "owner-slug": orgSlug, + ...(nextPageToken ? { "page-token": nextPageToken } : {}) + }) + }); + + contexts.push(...data.items); + nextPageToken = data.next_page_token; + } + + return contexts?.map((context) => ({ + name: context.name, + id: context.id + })); + } catch (error) { + logger.error(error); + } + }; + + return Promise.all( + organizations.map(async (org) => ({ + name: org.name, + slug: org.slug, + projects: projectsByOrg[org.name] ?? [], + contexts: (await getOrgContexts(org.slug)) ?? [] + })) + ); + }; + const deleteIntegrationAuths = async ({ projectId, integration, @@ -1615,6 +1885,7 @@ export const integrationAuthServiceFactory = ({ getIntegrationAuth, oauthExchange, saveIntegrationToken, + updateIntegrationAuth, deleteIntegrationAuthById, deleteIntegrationAuths, getIntegrationAuthTeams, @@ -1638,6 +1909,7 @@ export const integrationAuthServiceFactory = ({ getTeamcityBuildConfigs, getBitbucketWorkspaces, getBitbucketEnvironments, + getCircleCIOrganizations, getIntegrationAccessToken, duplicateIntegrationAuth, getOctopusDeploySpaces, diff --git a/backend/src/services/integration-auth/integration-auth-types.ts b/backend/src/services/integration-auth/integration-auth-types.ts index 80e8d6c36..68d7bf5b9 100644 --- a/backend/src/services/integration-auth/integration-auth-types.ts +++ b/backend/src/services/integration-auth/integration-auth-types.ts @@ -22,6 +22,11 @@ export type TSaveIntegrationAccessTokenDTO = { awsAssumeIamRoleArn?: string; } & TProjectPermission; +export type TUpdateIntegrationAuthDTO = Omit & { + integrationAuthId: string; + integration?: string; +}; + export type TDeleteIntegrationAuthsDTO = TProjectPermission & { integration: string; projectId: string; @@ -123,6 +128,10 @@ export type TGetIntegrationAuthTeamCityBuildConfigDTO = { appId: string; } & Omit; +export type TIntegrationAuthCircleCIOrganizationDTO = { + id: string; +} & Omit; + export type TVercelBranches = { ref: string; lastCommit: string; @@ -184,6 +193,14 @@ export type TTeamCityBuildConfig = { webUrl: string; }; +export type TCircleCIOrganization = { + id: string; + vcsType: string; + name: string; + avatarUrl: string; + slug: string; +}; + export type TIntegrationsWithEnvironment = TIntegrations & { environment?: | { @@ -210,6 +227,11 @@ export enum OctopusDeployScope { // add tenant, variable set, etc. } +export enum CircleCiScope { + Project = "project", + Context = "context" +} + export type TOctopusDeployVariableSet = { Id: string; OwnerId: string; diff --git a/backend/src/services/integration-auth/integration-list.ts b/backend/src/services/integration-auth/integration-list.ts index 45cbdaea9..d6da2194d 100644 --- a/backend/src/services/integration-auth/integration-list.ts +++ b/backend/src/services/integration-auth/integration-list.ts @@ -76,7 +76,6 @@ export enum IntegrationUrls { RAILWAY_API_URL = "https://backboard.railway.app/graphql/v2", FLYIO_API_URL = "https://api.fly.io/graphql", CIRCLECI_API_URL = "https://circleci.com/api", - DATABRICKS_API_URL = "https:/xxxx.com/api", TRAVISCI_API_URL = "https://api.travis-ci.com", SUPABASE_API_URL = "https://api.supabase.com", LARAVELFORGE_API_URL = "https://forge.laravel.com", @@ -218,9 +217,9 @@ export const getIntegrationOptions = async () => { docsLink: "" }, { - name: "Circle CI", + name: "CircleCI", slug: "circleci", - image: "Circle CI.png", + image: "CircleCI.png", isAvailable: true, type: "pat", clientId: "", diff --git a/backend/src/services/integration-auth/integration-sync-secret-fns.ts b/backend/src/services/integration-auth/integration-sync-secret-fns.ts new file mode 100644 index 000000000..df8b990af --- /dev/null +++ b/backend/src/services/integration-auth/integration-sync-secret-fns.ts @@ -0,0 +1,35 @@ +export const isAzureKeyVaultReference = (uri: string) => { + const tryJsonDecode = () => { + try { + return (JSON.parse(uri) as { uri: string }).uri || uri; + } catch { + return uri; + } + }; + + const cleanUri = tryJsonDecode(); + + if (!cleanUri.startsWith("https://")) { + return false; + } + + if (!cleanUri.includes(".vault.azure.net/secrets/")) { + return false; + } + + // 3. Check for non-empty string between https:// and .vault.azure.net/secrets/ + const parts = cleanUri.split(".vault.azure.net/secrets/"); + const vaultName = parts[0].replace("https://", ""); + if (!vaultName) { + return false; + } + + // 4. Check for non-empty secret name + const secretParts = parts[1].split("/"); + const secretName = secretParts[0]; + if (!secretName) { + return false; + } + + return true; +}; diff --git a/backend/src/services/integration-auth/integration-sync-secret.ts b/backend/src/services/integration-auth/integration-sync-secret.ts index c147150f0..912f1fafd 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -39,13 +39,19 @@ import { TCreateManySecretsRawFn, TUpdateManySecretsRawFn } from "@app/services/ import { TIntegrationDALFactory } from "../integration/integration-dal"; import { IntegrationMetadataSchema } from "../integration/integration-schema"; import { IntegrationAuthMetadataSchema } from "./integration-auth-schema"; -import { OctopusDeployScope, TIntegrationsWithEnvironment, TOctopusDeployVariableSet } from "./integration-auth-types"; +import { + CircleCiScope, + OctopusDeployScope, + TIntegrationsWithEnvironment, + TOctopusDeployVariableSet +} from "./integration-auth-types"; import { IntegrationInitialSyncBehavior, IntegrationMappingBehavior, Integrations, IntegrationUrls } from "./integration-list"; +import { isAzureKeyVaultReference } from "./integration-sync-secret-fns"; const getSecretKeyValuePair = (secrets: Record) => Object.keys(secrets).reduce>((prev, key) => { @@ -299,10 +305,16 @@ const syncSecretsAzureAppConfig = async ({ value: string; } - const getCompleteAzureAppConfigValues = async (url: string) => { + if (!integration.app || !integration.app.endsWith(".azconfig.io")) + throw new BadRequestError({ + message: "Invalid Azure App Configuration URL provided." + }); + + const getCompleteAzureAppConfigValues = async (baseURL: string, url: string) => { let result: AzureAppConfigKeyValue[] = []; while (url) { const res = await request.get(url, { + baseURL, headers: { Authorization: `Bearer ${accessToken}` }, @@ -313,17 +325,20 @@ const syncSecretsAzureAppConfig = async ({ }); result = result.concat(res.data.items); - url = res.data.nextLink; + url = res.data?.["@nextLink"]; } return result; }; const metadata = IntegrationMetadataSchema.parse(integration.metadata); + + const azureAppConfigValuesUrl = `/kv?api-version=2023-11-01&key=${metadata.secretPrefix}*${ + metadata.azureLabel ? `&label=${metadata.azureLabel}` : "&label=%00" + }`; + const azureAppConfigSecrets = ( - await getCompleteAzureAppConfigValues( - `${integration.app}/kv?api-version=2023-11-01&key=${metadata.secretPrefix || ""}*` - ) + await getCompleteAzureAppConfigValues(integration.app, azureAppConfigValuesUrl) ).reduce( (accum, entry) => { accum[entry.key] = entry.value; @@ -405,14 +420,24 @@ const syncSecretsAzureAppConfig = async ({ } // create or update secrets on Azure App Config + for await (const key of Object.keys(secrets)) { if (!(key in azureAppConfigSecrets) || secrets[key]?.value !== azureAppConfigSecrets[key]) { await request.put( `${integration.app}/kv/${key}?api-version=2023-11-01`, { - value: secrets[key]?.value + value: secrets[key]?.value, + ...(isAzureKeyVaultReference(secrets[key]?.value || "") && { + content_type: "application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8" + }) }, { + ...(metadata.azureLabel && { + params: { + label: metadata.azureLabel + } + }), + headers: { Authorization: `Bearer ${accessToken}` }, @@ -432,6 +457,11 @@ const syncSecretsAzureAppConfig = async ({ headers: { Authorization: `Bearer ${accessToken}` }, + ...(metadata.azureLabel && { + params: { + label: metadata.azureLabel + } + }), // we force IPV4 because docker setup fails with ipv6 httpsAgent: new https.Agent({ family: 4 @@ -1375,14 +1405,24 @@ const syncSecretsHeroku = async ({ * Sync/push [secrets] to Vercel project named [integration.app] */ const syncSecretsVercel = async ({ + createManySecretsRawFn, integration, integrationAuth, - secrets, + secrets: infisicalSecrets, accessToken }: { - integration: TIntegrations; + createManySecretsRawFn: (params: TCreateManySecretsRawFn) => Promise>; + integration: TIntegrations & { + projectId: string; + environment: { + id: string; + name: string; + slug: string; + }; + secretPath: string; + }; integrationAuth: TIntegrationAuths; - secrets: Record; + secrets: Record; accessToken: string; }) => { interface VercelSecret { @@ -1455,80 +1495,119 @@ const syncSecretsVercel = async ({ } } - const updateSecrets: VercelSecret[] = []; - const deleteSecrets: VercelSecret[] = []; - const newSecrets: VercelSecret[] = []; + const metadata = IntegrationMetadataSchema.parse(integration.metadata); - // Identify secrets to create - Object.keys(secrets).forEach((key) => { - if (!(key in res)) { - // case: secret has been created - newSecrets.push({ - key, - value: secrets[key].value, - type: "encrypted", - target: [integration.targetEnvironment as string], - ...(integration.path - ? { - gitBranch: integration.path - } - : {}) - }); + // Default to overwrite target for old integrations that doesn't have a initial sync behavior set. + if (!metadata.initialSyncBehavior) { + metadata.initialSyncBehavior = IntegrationInitialSyncBehavior.OVERWRITE_TARGET; + } + + const secretsToAddToInfisical: { [key: string]: VercelSecret } = {}; + + Object.keys(res).forEach((vercelKey) => { + if (!integration.lastUsed) { + // first time using integration + // -> apply initial sync behavior + switch (metadata.initialSyncBehavior) { + // Override all the secrets in Vercel + case IntegrationInitialSyncBehavior.OVERWRITE_TARGET: { + if (!(vercelKey in infisicalSecrets)) infisicalSecrets[vercelKey] = null; + break; + } + case IntegrationInitialSyncBehavior.PREFER_SOURCE: { + // if the vercel secret is not in infisical, we need to add it to infisical + if (!(vercelKey in infisicalSecrets)) { + infisicalSecrets[vercelKey] = { + value: res[vercelKey].value + }; + secretsToAddToInfisical[vercelKey] = res[vercelKey]; + } + break; + } + default: { + throw new Error(`Invalid initial sync behavior: ${metadata.initialSyncBehavior}`); + } + } + } else if (!(vercelKey in infisicalSecrets)) { + infisicalSecrets[vercelKey] = null; } }); - // Identify secrets to update and delete - Object.keys(res).forEach((key) => { - if (key in secrets) { - if (res[key].value !== secrets[key].value) { - // case: secret value has changed - updateSecrets.push({ - id: res[key].id, - key, - value: secrets[key].value, - type: res[key].type, - target: res[key].target.includes(integration.targetEnvironment as string) - ? [...res[key].target] - : [...res[key].target, integration.targetEnvironment as string], - ...(integration.path - ? { - gitBranch: integration.path - } - : {}) - }); - } - } else { - // case: secret has been deleted - deleteSecrets.push({ - id: res[key].id, - key, - value: res[key].value, - type: "encrypted", // value doesn't matter - target: [integration.targetEnvironment as string], - ...(integration.path - ? { - gitBranch: integration.path - } - : {}) - }); - } - }); - - // Sync/push new secrets - if (newSecrets.length > 0) { - await request.post(`${IntegrationUrls.VERCEL_API_URL}/v10/projects/${integration.app}/env`, newSecrets, { - params, - headers: { - Authorization: `Bearer ${accessToken}`, - "Accept-Encoding": "application/json" - } + if (Object.keys(secretsToAddToInfisical).length) { + await createManySecretsRawFn({ + projectId: integration.projectId, + environment: integration.environment.slug, + path: integration.secretPath, + secrets: Object.keys(secretsToAddToInfisical).map((key) => ({ + secretName: key, + secretValue: secretsToAddToInfisical[key].value, + type: SecretType.Shared, + secretComment: "" + })) }); } - for await (const secret of updateSecrets) { - if (secret.type !== "sensitive") { - const { id, ...updatedSecret } = secret; - await request.patch(`${IntegrationUrls.VERCEL_API_URL}/v9/projects/${integration.app}/env/${id}`, updatedSecret, { + // update and create logic + for await (const key of Object.keys(infisicalSecrets)) { + if (!(key in res) || infisicalSecrets[key]?.value !== res[key].value) { + // if the key is not in the vercel res, we need to create it + if (!(key in res)) { + await request.post( + `${IntegrationUrls.VERCEL_API_URL}/v10/projects/${integration.app}/env`, + { + key, + value: infisicalSecrets[key]?.value, + type: "encrypted", + target: [integration.targetEnvironment as string], + ...(integration.path + ? { + gitBranch: integration.path + } + : {}) + }, + { + params, + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + } + ); + + // Else if the key already exists and its not sensitive, we need to update it + } else if (res[key].type !== "sensitive") { + await request.patch( + `${IntegrationUrls.VERCEL_API_URL}/v9/projects/${integration.app}/env/${res[key].id}`, + { + key, + value: infisicalSecrets[key]?.value, + type: res[key].type, + target: res[key].target.includes(integration.targetEnvironment as string) + ? [...res[key].target] + : [...res[key].target, integration.targetEnvironment as string], + ...(integration.path + ? { + gitBranch: integration.path + } + : {}) + }, + { + params, + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + } + ); + } + } + } + + // delete logic + for await (const key of Object.keys(res)) { + if (infisicalSecrets[key] === null) { + // case: delete secret + await request.delete(`${IntegrationUrls.VERCEL_API_URL}/v9/projects/${integration.app}/env/${res[key].id}`, { params, headers: { Authorization: `Bearer ${accessToken}`, @@ -1537,16 +1616,6 @@ const syncSecretsVercel = async ({ }); } } - - for await (const secret of deleteSecrets) { - await request.delete(`${IntegrationUrls.VERCEL_API_URL}/v9/projects/${integration.app}/env/${secret.id}`, { - params, - headers: { - Authorization: `Bearer ${accessToken}`, - "Accept-Encoding": "application/json" - } - }); - } }; /** @@ -2245,102 +2314,174 @@ const syncSecretsCircleCI = async ({ secrets: Record; accessToken: string; }) => { - const getProjectSlug = async () => { - const requestConfig = { - headers: { - "Circle-Token": accessToken, - "Accept-Encoding": "application/json" - } - }; - - try { - const projectDetails = ( - await request.get<{ slug: string }>( - `${IntegrationUrls.CIRCLECI_API_URL}/v2/project/${integration.appId}`, - requestConfig + if (integration.scope === CircleCiScope.Context) { + // sync secrets to CircleCI + await Promise.all( + Object.keys(secrets).map(async (key) => + request.put( + `${IntegrationUrls.CIRCLECI_API_URL}/v2/context/${integration.appId}/environment-variable/${key}`, + { + value: secrets[key].value + }, + { + headers: { + "Circle-Token": accessToken, + "Content-Type": "application/json" + } + } ) - ).data; + ) + ); - return projectDetails.slug; - } catch (err) { - if (err instanceof AxiosError) { - if (err.response?.data?.message !== "Not Found") { - throw new Error("Failed to get project slug from CircleCI during first attempt."); - } - } - } + // get secrets from CircleCI + const getSecretsRes = async () => { + type EnvVars = { + variable: string; + created_at: string; + updated_at: string; + context_id: string; + }; - // For backwards compatibility with old CircleCI integrations where we don't keep track of the organization name, so we can't filter by organization - try { - const circleCiOrganization = ( - await request.get<{ slug: string; name: string }[]>( - `${IntegrationUrls.CIRCLECI_API_URL}/v2/me/collaborations`, - requestConfig - ) - ).data; + let nextPageToken: string | null | undefined; + const envVars: EnvVars[] = []; - // Case 1: This is a new integration where the organization name is stored under `integration.owner` - if (integration.owner) { - const org = circleCiOrganization.find((o) => o.name === integration.owner); - if (org) { - return `${org.slug}/${integration.app}`; - } - } - - // Case 2: This is an old integration where the organization name is not stored, so we have to assume the first organization is the correct one - return `${circleCiOrganization[0].slug}/${integration.app}`; - } catch (err) { - throw new Error("Failed to get project slug from CircleCI during second attempt."); - } - }; - - const projectSlug = await getProjectSlug(); - - // sync secrets to CircleCI - await Promise.all( - Object.keys(secrets).map(async (key) => - request.post( - `${IntegrationUrls.CIRCLECI_API_URL}/v2/project/${projectSlug}/envvar`, - { - name: key, - value: secrets[key].value - }, - { + while (nextPageToken !== null) { + const res = await request.get<{ + items: EnvVars[]; + next_page_token: string | null; + }>(`${IntegrationUrls.CIRCLECI_API_URL}/v2/context/${integration.appId}/environment-variable`, { headers: { "Circle-Token": accessToken, - "Content-Type": "application/json" - } - } - ) - ) - ); + "Accept-Encoding": "application/json" + }, + params: nextPageToken + ? new URLSearchParams({ + "page-token": nextPageToken + }) + : undefined + }); - // get secrets from CircleCI - const getSecretsRes = ( - await request.get<{ items: { name: string }[] }>( - `${IntegrationUrls.CIRCLECI_API_URL}/v2/project/${projectSlug}/envvar`, - { + envVars.push(...res.data.items); + nextPageToken = res.data.next_page_token; + } + + return envVars; + }; + + // delete secrets from CircleCI + await Promise.all( + (await getSecretsRes()).map(async (sec) => { + if (!(sec.variable in secrets)) { + return request.delete( + `${IntegrationUrls.CIRCLECI_API_URL}/v2/context/${integration.appId}/environment-variable/${sec.variable}`, + { + headers: { + "Circle-Token": accessToken, + "Content-Type": "application/json" + } + } + ); + } + }) + ); + } else { + const getProjectSlug = async () => { + const requestConfig = { headers: { "Circle-Token": accessToken, "Accept-Encoding": "application/json" } - } - ) - ).data?.items; + }; - // delete secrets from CircleCI - await Promise.all( - getSecretsRes.map(async (sec) => { - if (!(sec.name in secrets)) { - return request.delete(`${IntegrationUrls.CIRCLECI_API_URL}/v2/project/${projectSlug}/envvar/${sec.name}`, { + try { + const projectDetails = ( + await request.get<{ slug: string }>( + `${IntegrationUrls.CIRCLECI_API_URL}/v2/project/${integration.appId}`, + requestConfig + ) + ).data; + + return projectDetails.slug; + } catch (err) { + if (err instanceof AxiosError) { + if (err.response?.data?.message !== "Not Found") { + throw new Error("Failed to get project slug from CircleCI during first attempt."); + } + } + } + + // For backwards compatibility with old CircleCI integrations where we don't keep track of the organization name, so we can't filter by organization + try { + const circleCiOrganization = ( + await request.get<{ slug: string; name: string }[]>( + `${IntegrationUrls.CIRCLECI_API_URL}/v2/me/collaborations`, + requestConfig + ) + ).data; + + // Case 1: This is a new integration where the organization name is stored under `integration.owner` + if (integration.owner) { + const org = circleCiOrganization.find((o) => o.name === integration.owner); + if (org) { + return `${org.slug}/${integration.app}`; + } + } + + // Case 2: This is an old integration where the organization name is not stored, so we have to assume the first organization is the correct one + return `${circleCiOrganization[0].slug}/${integration.app}`; + } catch (err) { + throw new Error("Failed to get project slug from CircleCI during second attempt."); + } + }; + + const projectSlug = await getProjectSlug(); + + // sync secrets to CircleCI + await Promise.all( + Object.keys(secrets).map(async (key) => + request.post( + `${IntegrationUrls.CIRCLECI_API_URL}/v2/project/${projectSlug}/envvar`, + { + name: key, + value: secrets[key].value + }, + { + headers: { + "Circle-Token": accessToken, + "Content-Type": "application/json" + } + } + ) + ) + ); + + // get secrets from CircleCI + const getSecretsRes = ( + await request.get<{ items: { name: string }[] }>( + `${IntegrationUrls.CIRCLECI_API_URL}/v2/project/${projectSlug}/envvar`, + { headers: { "Circle-Token": accessToken, - "Content-Type": "application/json" + "Accept-Encoding": "application/json" } - }); - } - }) - ); + } + ) + ).data?.items; + + // delete secrets from CircleCI + await Promise.all( + getSecretsRes.map(async (sec) => { + if (!(sec.name in secrets)) { + return request.delete(`${IntegrationUrls.CIRCLECI_API_URL}/v2/project/${projectSlug}/envvar/${sec.name}`, { + headers: { + "Circle-Token": accessToken, + "Content-Type": "application/json" + } + }); + } + }) + ); + } }; /** @@ -4377,7 +4518,8 @@ export const syncIntegrationSecrets = async ({ integration, integrationAuth, secrets, - accessToken + accessToken, + createManySecretsRawFn }); break; case Integrations.NETLIFY: diff --git a/backend/src/services/integration/integration-schema.ts b/backend/src/services/integration/integration-schema.ts index d047a0c11..de4790188 100644 --- a/backend/src/services/integration/integration-schema.ts +++ b/backend/src/services/integration/integration-schema.ts @@ -35,6 +35,8 @@ export const IntegrationMetadataSchema = z.object({ .optional() .describe(INTEGRATION.CREATE.metadata.secretAWSTag), + azureLabel: z.string().optional().describe(INTEGRATION.CREATE.metadata.azureLabel), + githubVisibility: z .union([z.literal("selected"), z.literal("private"), z.literal("all")]) .optional() diff --git a/backend/src/services/integration/integration-service.ts b/backend/src/services/integration/integration-service.ts index 1db10405d..36a4156e7 100644 --- a/backend/src/services/integration/integration-service.ts +++ b/backend/src/services/integration/integration-service.ts @@ -1,5 +1,6 @@ import { ForbiddenError, subject } from "@casl/ability"; +import { ProjectType } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { NotFoundError } from "@app/lib/errors"; @@ -80,13 +81,14 @@ export const integrationServiceFactory = ({ if (!integrationAuth) throw new NotFoundError({ message: `Integration auth with ID '${integrationAuthId}' not found` }); - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, integrationAuth.projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Integrations); ForbiddenError.from(permission).throwUnlessCan( @@ -151,18 +153,21 @@ export const integrationServiceFactory = ({ isActive, environment, secretPath, - metadata + region, + metadata, + path }: TUpdateIntegrationDTO) => { const integration = await integrationDAL.findById(id); if (!integration) throw new NotFoundError({ message: `Integration with ID '${id}' not found` }); - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, integration.projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Integrations); const newEnvironment = environment || integration.environment.slug; @@ -192,7 +197,9 @@ export const integrationServiceFactory = ({ appId, targetEnvironment, owner, + region, secretPath, + path, metadata: { ...(integration.metadata as object), ...metadata @@ -289,13 +296,14 @@ export const integrationServiceFactory = ({ const integration = await integrationDAL.findById(id); if (!integration) throw new NotFoundError({ message: `Integration with ID '${id}' not found` }); - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, integration.projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Integrations); const integrationAuth = await integrationAuthDAL.findById(integration.integrationAuthId); diff --git a/backend/src/services/integration/integration-types.ts b/backend/src/services/integration/integration-types.ts index a27c4f6ac..f662affd8 100644 --- a/backend/src/services/integration/integration-types.ts +++ b/backend/src/services/integration/integration-types.ts @@ -49,6 +49,8 @@ export type TUpdateIntegrationDTO = { appId?: string; isActive?: boolean; secretPath?: string; + region?: string; + path?: string; targetEnvironment?: string; owner?: string; environment?: string; diff --git a/backend/src/services/org/org-schema.ts b/backend/src/services/org/org-schema.ts new file mode 100644 index 000000000..8f5b85403 --- /dev/null +++ b/backend/src/services/org/org-schema.ts @@ -0,0 +1,16 @@ +import { OrganizationsSchema } from "@app/db/schemas"; + +export const sanitizedOrganizationSchema = OrganizationsSchema.pick({ + id: true, + name: true, + customerId: true, + slug: true, + createdAt: true, + updatedAt: true, + authEnforced: true, + scimEnabled: true, + kmsDefaultKeyId: true, + defaultMembershipRole: true, + enforceMfa: true, + selectedMfaMethod: true +}); diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 9741220f8..73b8c04e5 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -15,7 +15,6 @@ import { TProjectUserMembershipRolesInsert, TUsers } from "@app/db/schemas"; -import { TProjects } from "@app/db/schemas/projects"; import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TOidcConfigDALFactory } from "@app/ee/services/oidc/oidc-config-dal"; @@ -32,11 +31,13 @@ import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedErro import { groupBy } from "@app/lib/fn"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { isDisposableEmail } from "@app/lib/validator"; +import { TQueueServiceFactory } from "@app/queue"; import { getDefaultOrgMembershipRoleForUpdateOrg } from "@app/services/org/org-role-fns"; import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; -import { ActorAuthMethod, ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type"; +import { TAuthLoginFactory } from "../auth/auth-login-service"; +import { ActorAuthMethod, ActorType, AuthMethod, AuthModeJwtTokenPayload, AuthTokenType } from "../auth/auth-type"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; import { TokenType } from "../auth-token/auth-token-types"; import { TIdentityMetadataDALFactory } from "../identity/identity-metadata-dal"; @@ -48,6 +49,10 @@ import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; import { TProjectRoleDALFactory } from "../project-role/project-role-dal"; +import { TSecretDALFactory } from "../secret/secret-dal"; +import { fnDeleteProjectSecretReminders } from "../secret/secret-fns"; +import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; +import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; import { TIncidentContactsDALFactory } from "./incident-contacts-dal"; @@ -70,6 +75,9 @@ import { type TOrgServiceFactoryDep = { userAliasDAL: Pick; + secretDAL: Pick; + secretV2BridgeDAL: Pick; + folderDAL: Pick; orgDAL: TOrgDALFactory; orgBotDAL: TOrgBotDALFactory; orgRoleDAL: TOrgRoleDALFactory; @@ -98,6 +106,8 @@ type TOrgServiceFactoryDep = { projectBotDAL: Pick; projectUserMembershipRoleDAL: Pick; projectBotService: Pick; + queueService: Pick; + loginService: Pick; }; export type TOrgServiceFactory = ReturnType; @@ -105,6 +115,9 @@ export type TOrgServiceFactory = ReturnType; export const orgServiceFactory = ({ userAliasDAL, orgDAL, + secretDAL, + secretV2BridgeDAL, + folderDAL, userDAL, groupDAL, orgRoleDAL, @@ -125,7 +138,9 @@ export const orgServiceFactory = ({ projectBotDAL, projectUserMembershipRoleDAL, identityMetadataDAL, - projectBotService + projectBotService, + queueService, + loginService }: TOrgServiceFactoryDep) => { /* * Get organization details by the organization id @@ -196,26 +211,18 @@ export const orgServiceFactory = ({ return org; }; - const findAllWorkspaces = async ({ actor, actorId, orgId }: TFindAllWorkspacesDTO) => { - const organizationWorkspaceIds = new Set((await projectDAL.find({ orgId })).map((workspace) => workspace.id)); - - let workspaces: (TProjects & { organization: string } & { - environments: { - id: string; - slug: string; - name: string; - }[]; - })[]; - + const findAllWorkspaces = async ({ actor, actorId, orgId, type }: TFindAllWorkspacesDTO) => { if (actor === ActorType.USER) { - workspaces = await projectDAL.findAllProjects(actorId); - } else if (actor === ActorType.IDENTITY) { - workspaces = await projectDAL.findAllProjectsByIdentity(actorId); - } else { - throw new BadRequestError({ message: "Invalid actor type" }); + const workspaces = await projectDAL.findAllProjects(actorId, orgId, type || "all"); + return workspaces; } - return workspaces.filter((workspace) => organizationWorkspaceIds.has(workspace.id)); + if (actor === ActorType.IDENTITY) { + const workspaces = await projectDAL.findAllProjectsByIdentity(actorId, type); + return workspaces; + } + + throw new BadRequestError({ message: "Invalid actor type" }); }; const addGhostUser = async (orgId: string, tx?: Knex) => { @@ -428,24 +435,88 @@ export const orgServiceFactory = ({ /* * Delete organization by id * */ - const deleteOrganizationById = async ( - userId: string, - orgId: string, - actorAuthMethod: ActorAuthMethod, - actorOrgId: string | undefined - ) => { + const deleteOrganizationById = async ({ + userId, + authorizationHeader, + userAgentHeader, + ipAddress, + orgId, + actorAuthMethod, + actorOrgId + }: { + userId: string; + authorizationHeader?: string; + userAgentHeader?: string; + ipAddress: string; + orgId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string | undefined; + }) => { const { membership } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); - if ((membership.role as OrgMembershipRole) !== OrgMembershipRole.Admin) + if ((membership.role as OrgMembershipRole) !== OrgMembershipRole.Admin) { throw new ForbiddenRequestError({ name: "DeleteOrganizationById", message: "Insufficient privileges" }); - - const organization = await orgDAL.deleteById(orgId); - if (organization.customerId) { - await licenseService.removeOrgCustomer(organization.customerId); } - return organization; + + if (!authorizationHeader) { + throw new UnauthorizedError({ name: "Authorization header not set on request." }); + } + + if (!userAgentHeader) { + throw new BadRequestError({ name: "User agent not set on request." }); + } + + const cfg = getConfig(); + const authToken = authorizationHeader.replace("Bearer ", ""); + + const decodedToken = jwt.verify(authToken, cfg.AUTH_SECRET) as AuthModeJwtTokenPayload; + if (!decodedToken.authMethod) throw new UnauthorizedError({ name: "Auth method not found on existing token" }); + + const response = await orgDAL.transaction(async (tx) => { + const projects = await projectDAL.find({ orgId }, { tx }); + + for await (const project of projects) { + await fnDeleteProjectSecretReminders(project.id, { + secretDAL, + secretV2BridgeDAL, + queueService, + projectBotService, + folderDAL + }); + } + + const deletedOrg = await orgDAL.deleteById(orgId, tx); + + if (deletedOrg.customerId) { + await licenseService.removeOrgCustomer(deletedOrg.customerId); + } + + // Generate new tokens without the organization ID present + const user = await userDAL.findById(userId, tx); + const { access: accessToken, refresh: refreshToken } = await loginService.generateUserTokens( + { + user, + authMethod: decodedToken.authMethod, + ip: ipAddress, + userAgent: userAgentHeader, + isMfaVerified: decodedToken.isMfaVerified, + mfaMethod: decodedToken.mfaMethod + }, + tx + ); + + return { + organization: deletedOrg, + tokens: { + accessToken, + refreshToken + } + }; + }); + + return response; }; /* * Org membership management diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index 05df9429e..1df4b422b 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -1,3 +1,4 @@ +import { ProjectType } from "@app/db/schemas"; import { TOrgPermission } from "@app/lib/types"; import { ActorAuthMethod, ActorType, MfaMethod } from "../auth/auth-type"; @@ -55,6 +56,7 @@ export type TFindAllWorkspacesDTO = { actorOrgId: string | undefined; actorAuthMethod: ActorAuthMethod; orgId: string; + type?: ProjectType; }; export type TUpdateOrgDTO = { diff --git a/backend/src/services/pki-alert/pki-alert-service.ts b/backend/src/services/pki-alert/pki-alert-service.ts index 1e7d26825..f002c393b 100644 --- a/backend/src/services/pki-alert/pki-alert-service.ts +++ b/backend/src/services/pki-alert/pki-alert-service.ts @@ -1,5 +1,6 @@ import { ForbiddenError } from "@casl/ability"; +import { ProjectType } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; @@ -8,6 +9,7 @@ import { TPkiCollectionDALFactory } from "@app/services/pki-collection/pki-colle import { pkiItemTypeToNameMap } from "@app/services/pki-collection/pki-collection-types"; import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; +import { TProjectDALFactory } from "../project/project-dal"; import { TPkiAlertDALFactory } from "./pki-alert-dal"; import { TCreateAlertDTO, TDeleteAlertDTO, TGetAlertByIdDTO, TUpdateAlertDTO } from "./pki-alert-types"; @@ -19,6 +21,7 @@ type TPkiAlertServiceFactoryDep = { pkiCollectionDAL: Pick; permissionService: Pick; smtpService: Pick; + projectDAL: Pick; }; export type TPkiAlertServiceFactory = ReturnType; @@ -27,7 +30,8 @@ export const pkiAlertServiceFactory = ({ pkiAlertDAL, pkiCollectionDAL, permissionService, - smtpService + smtpService, + projectDAL }: TPkiAlertServiceFactoryDep) => { const sendPkiItemExpiryNotices = async () => { const allAlertItems = await pkiAlertDAL.getExpiringPkiCollectionItemsForAlerting(); @@ -63,7 +67,7 @@ export const pkiAlertServiceFactory = ({ }; const createPkiAlert = async ({ - projectId, + projectId: preSplitProjectId, name, pkiCollectionId, alertBeforeDays, @@ -73,13 +77,23 @@ export const pkiAlertServiceFactory = ({ actor, actorOrgId }: TCreateAlertDTO) => { - const { permission } = await permissionService.getProjectPermission( + let projectId = preSplitProjectId; + const certManagerProjectFromSplit = await projectDAL.getProjectFromSplitId( + projectId, + ProjectType.CertificateManager + ); + if (certManagerProjectFromSplit) { + projectId = certManagerProjectFromSplit.id; + } + + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.CertificateManager); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.PkiAlerts); @@ -128,13 +142,14 @@ export const pkiAlertServiceFactory = ({ let alert = await pkiAlertDAL.findById(alertId); if (!alert) throw new NotFoundError({ message: `Alert with ID '${alertId}' not found` }); - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, alert.projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.CertificateManager); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.PkiAlerts); @@ -160,13 +175,14 @@ export const pkiAlertServiceFactory = ({ let alert = await pkiAlertDAL.findById(alertId); if (!alert) throw new NotFoundError({ message: `Alert with ID '${alertId}' not found` }); - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, alert.projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.CertificateManager); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.PkiAlerts); alert = await pkiAlertDAL.deleteById(alertId); diff --git a/backend/src/services/pki-collection/pki-collection-service.ts b/backend/src/services/pki-collection/pki-collection-service.ts index ef849c54f..93b3b65b6 100644 --- a/backend/src/services/pki-collection/pki-collection-service.ts +++ b/backend/src/services/pki-collection/pki-collection-service.ts @@ -1,12 +1,13 @@ import { ForbiddenError } from "@casl/ability"; -import { TPkiCollectionItems } from "@app/db/schemas"; +import { ProjectType, TPkiCollectionItems } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; +import { TProjectDALFactory } from "../project/project-dal"; import { TPkiCollectionDALFactory } from "./pki-collection-dal"; import { transformPkiCollectionItem } from "./pki-collection-fns"; import { TPkiCollectionItemDALFactory } from "./pki-collection-item-dal"; @@ -30,6 +31,7 @@ type TPkiCollectionServiceFactoryDep = { certificateAuthorityDAL: Pick; certificateDAL: Pick; permissionService: Pick; + projectDAL: Pick; }; export type TPkiCollectionServiceFactory = ReturnType; @@ -39,24 +41,35 @@ export const pkiCollectionServiceFactory = ({ pkiCollectionItemDAL, certificateAuthorityDAL, certificateDAL, - permissionService + permissionService, + projectDAL }: TPkiCollectionServiceFactoryDep) => { const createPkiCollection = async ({ name, description, - projectId, + projectId: preSplitProjectId, actorId, actorAuthMethod, actor, actorOrgId }: TCreatePkiCollectionDTO) => { - const { permission } = await permissionService.getProjectPermission( + let projectId = preSplitProjectId; + const certManagerProjectFromSplit = await projectDAL.getProjectFromSplitId( + projectId, + ProjectType.CertificateManager + ); + if (certManagerProjectFromSplit) { + projectId = certManagerProjectFromSplit.id; + } + + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.CertificateManager); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Create, @@ -106,13 +119,14 @@ export const pkiCollectionServiceFactory = ({ let pkiCollection = await pkiCollectionDAL.findById(collectionId); if (!pkiCollection) throw new NotFoundError({ message: `PKI collection with ID '${collectionId}' not found` }); - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, pkiCollection.projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.CertificateManager); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.PkiCollections); pkiCollection = await pkiCollectionDAL.updateById(collectionId, { @@ -133,13 +147,14 @@ export const pkiCollectionServiceFactory = ({ let pkiCollection = await pkiCollectionDAL.findById(collectionId); if (!pkiCollection) throw new NotFoundError({ message: `PKI collection with ID '${collectionId}' not found` }); - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, pkiCollection.projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.CertificateManager); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Delete, @@ -205,13 +220,14 @@ export const pkiCollectionServiceFactory = ({ const pkiCollection = await pkiCollectionDAL.findById(collectionId); if (!pkiCollection) throw new NotFoundError({ message: `PKI collection with ID '${collectionId}' not found` }); - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, pkiCollection.projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.CertificateManager); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Create, @@ -298,13 +314,14 @@ export const pkiCollectionServiceFactory = ({ if (!pkiCollectionItem) throw new NotFoundError({ message: `PKI collection item with ID '${itemId}' not found` }); - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, pkiCollection.projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.CertificateManager); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Delete, diff --git a/backend/src/services/project-env/project-env-service.ts b/backend/src/services/project-env/project-env-service.ts index a54e8de43..5c3fdba32 100644 --- a/backend/src/services/project-env/project-env-service.ts +++ b/backend/src/services/project-env/project-env-service.ts @@ -1,5 +1,6 @@ import { ForbiddenError } from "@casl/ability"; +import { ProjectType } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; @@ -41,13 +42,14 @@ export const projectEnvServiceFactory = ({ name, slug }: TCreateEnvDTO) => { - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Environments); const lock = await keyStore @@ -129,13 +131,14 @@ export const projectEnvServiceFactory = ({ id, position }: TUpdateEnvDTO) => { - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Environments); const lock = await keyStore @@ -192,13 +195,14 @@ export const projectEnvServiceFactory = ({ }; const deleteEnvironment = async ({ projectId, actor, actorId, actorOrgId, actorAuthMethod, id }: TDeleteEnvDTO) => { - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Environments); const lock = await keyStore diff --git a/backend/src/services/project-membership/project-membership-dal.ts b/backend/src/services/project-membership/project-membership-dal.ts index bfd0c6f85..61b703e70 100644 --- a/backend/src/services/project-membership/project-membership-dal.ts +++ b/backend/src/services/project-membership/project-membership-dal.ts @@ -217,20 +217,33 @@ export const projectMembershipDALFactory = (db: TDbClient) => { db.ref("temporaryAccessStartTime").withSchema(TableName.ProjectUserMembershipRole), db.ref("temporaryAccessEndTime").withSchema(TableName.ProjectUserMembershipRole), db.ref("name").as("projectName").withSchema(TableName.Project), - db.ref("id").as("projectId").withSchema(TableName.Project) + db.ref("id").as("projectId").withSchema(TableName.Project), + db.ref("type").as("projectType").withSchema(TableName.Project) ) .where({ isGhost: false }); const members = sqlNestRelationships({ data: docs, - parentMapper: ({ email, firstName, username, lastName, publicKey, isGhost, id, projectId, projectName }) => ({ + parentMapper: ({ + email, + firstName, + username, + lastName, + publicKey, + isGhost, + id, + projectId, + projectName, + projectType + }) => ({ id, userId, projectId, user: { email, username, firstName, lastName, id: userId, publicKey, isGhost }, project: { id: projectId, - name: projectName + name: projectName, + type: projectType } }), key: "id", diff --git a/backend/src/services/project/project-dal.ts b/backend/src/services/project/project-dal.ts index e5e447145..896e1b858 100644 --- a/backend/src/services/project/project-dal.ts +++ b/backend/src/services/project/project-dal.ts @@ -1,7 +1,14 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { ProjectsSchema, ProjectUpgradeStatus, ProjectVersion, TableName, TProjectsUpdate } from "@app/db/schemas"; +import { + ProjectsSchema, + ProjectType, + ProjectUpgradeStatus, + ProjectVersion, + TableName, + TProjectsUpdate +} from "@app/db/schemas"; import { BadRequestError, DatabaseError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; @@ -12,12 +19,18 @@ export type TProjectDALFactory = ReturnType; export const projectDALFactory = (db: TDbClient) => { const projectOrm = ormify(db, TableName.Project); - const findAllProjects = async (userId: string) => { + const findAllProjects = async (userId: string, orgId: string, projectType: ProjectType | "all") => { try { const workspaces = await db .replicaNode()(TableName.ProjectMembership) .where({ userId }) .join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`) + .where(`${TableName.Project}.orgId`, orgId) + .andWhere((qb) => { + if (projectType !== "all") { + void qb.where(`${TableName.Project}.type`, projectType); + } + }) .leftJoin(TableName.Environment, `${TableName.Environment}.projectId`, `${TableName.Project}.id`) .select( selectAllTableCols(TableName.Project), @@ -31,14 +44,17 @@ export const projectDALFactory = (db: TDbClient) => { { column: `${TableName.Environment}.position`, order: "asc" } ]); - const groups: string[] = await db(TableName.UserGroupMembership) - .where({ userId }) - .select(selectAllTableCols(TableName.UserGroupMembership)) - .pluck("groupId"); + const groups = db(TableName.UserGroupMembership).where({ userId }).select("groupId"); const groupWorkspaces = await db(TableName.GroupProjectMembership) .whereIn("groupId", groups) .join(TableName.Project, `${TableName.GroupProjectMembership}.projectId`, `${TableName.Project}.id`) + .where(`${TableName.Project}.orgId`, orgId) + .andWhere((qb) => { + if (projectType !== "all") { + void qb.where(`${TableName.Project}.type`, projectType); + } + }) .whereNotIn( `${TableName.Project}.id`, workspaces.map(({ id }) => id) @@ -108,12 +124,17 @@ export const projectDALFactory = (db: TDbClient) => { } }; - const findAllProjectsByIdentity = async (identityId: string) => { + const findAllProjectsByIdentity = async (identityId: string, projectType?: ProjectType) => { try { const workspaces = await db .replicaNode()(TableName.IdentityProjectMembership) .where({ identityId }) .join(TableName.Project, `${TableName.IdentityProjectMembership}.projectId`, `${TableName.Project}.id`) + .andWhere((qb) => { + if (projectType) { + void qb.where(`${TableName.Project}.type`, projectType); + } + }) .leftJoin(TableName.Environment, `${TableName.Environment}.projectId`, `${TableName.Project}.id`) .select( selectAllTableCols(TableName.Project), @@ -315,6 +336,22 @@ export const projectDALFactory = (db: TDbClient) => { }; }; + const getProjectFromSplitId = async (projectId: string, projectType: ProjectType) => { + try { + const project = await db(TableName.ProjectSplitBackfillIds) + .where({ + sourceProjectId: projectId, + destinationProjectType: projectType + }) + .join(TableName.Project, `${TableName.Project}.id`, `${TableName.ProjectSplitBackfillIds}.destinationProjectId`) + .select(selectAllTableCols(TableName.Project)) + .first(); + return project; + } catch (error) { + throw new DatabaseError({ error, name: `Failed to find split project with id ${projectId}` }); + } + }; + return { ...projectOrm, findAllProjects, @@ -325,6 +362,7 @@ export const projectDALFactory = (db: TDbClient) => { findProjectByFilter, findProjectBySlug, findProjectWithOrg, - checkProjectUpgradeStatus + checkProjectUpgradeStatus, + getProjectFromSplitId }; }; diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index e9f750d9d..fc302c4c8 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -1,19 +1,23 @@ import { ForbiddenError } from "@casl/ability"; import slugify from "@sindresorhus/slugify"; -import { ProjectMembershipRole, ProjectVersion, TProjectEnvironments } from "@app/db/schemas"; +import { ProjectMembershipRole, ProjectType, ProjectVersion, TProjectEnvironments } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { TProjectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-service"; import { InfisicalProjectTemplate } from "@app/ee/services/project-template/project-template-types"; +import { TSshCertificateAuthorityDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-dal"; +import { TSshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-dal"; +import { TSshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal"; import { TKeyStoreFactory } from "@app/keystore/keystore"; import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TProjectPermission } from "@app/lib/types"; +import { TQueueServiceFactory } from "@app/queue"; import { ActorType } from "../auth/auth-type"; import { TCertificateDALFactory } from "../certificate/certificate-dal"; @@ -28,13 +32,17 @@ import { TOrgServiceFactory } from "../org/org-service"; import { TPkiAlertDALFactory } from "../pki-alert/pki-alert-dal"; import { TPkiCollectionDALFactory } from "../pki-collection/pki-collection-dal"; import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; +import { TProjectBotServiceFactory } from "../project-bot/project-bot-service"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; import { TProjectRoleDALFactory } from "../project-role/project-role-dal"; import { getPredefinedRoles } from "../project-role/project-role-fns"; +import { TSecretDALFactory } from "../secret/secret-dal"; +import { fnDeleteProjectSecretReminders } from "../secret/secret-fns"; import { ROOT_FOLDER_NAME, TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; +import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal"; import { TProjectSlackConfigDALFactory } from "../slack/project-slack-config-dal"; import { TSlackIntegrationDALFactory } from "../slack/slack-integration-dal"; import { TUserDALFactory } from "../user/user-dal"; @@ -52,6 +60,9 @@ import { TListProjectCertificateTemplatesDTO, TListProjectCertsDTO, TListProjectsDTO, + TListProjectSshCasDTO, + TListProjectSshCertificatesDTO, + TListProjectSshCertificateTemplatesDTO, TLoadProjectKmsBackupDTO, TToggleProjectAutoCapitalizationDTO, TUpdateAuditLogsRetentionDTO, @@ -74,7 +85,10 @@ type TProjectServiceFactoryDep = { projectDAL: TProjectDALFactory; projectQueue: TProjectQueueFactory; userDAL: TUserDALFactory; - folderDAL: TSecretFolderDALFactory; + projectBotService: Pick; + folderDAL: Pick; + secretDAL: Pick; + secretV2BridgeDAL: Pick; projectEnvDAL: Pick; identityOrgMembershipDAL: TIdentityOrgDALFactory; identityProjectDAL: TIdentityProjectDALFactory; @@ -89,9 +103,14 @@ type TProjectServiceFactoryDep = { certificateTemplateDAL: Pick; pkiAlertDAL: Pick; pkiCollectionDAL: Pick; + sshCertificateAuthorityDAL: Pick; + sshCertificateDAL: Pick; + sshCertificateTemplateDAL: Pick; permissionService: TPermissionServiceFactory; orgService: Pick; licenseService: Pick; + queueService: Pick; + orgDAL: Pick; keyStore: Pick; projectBotDAL: Pick; @@ -112,9 +131,13 @@ export type TProjectServiceFactory = ReturnType; export const projectServiceFactory = ({ projectDAL, + secretDAL, + secretV2BridgeDAL, projectQueue, projectKeyDAL, permissionService, + queueService, + projectBotService, orgDAL, userDAL, folderDAL, @@ -132,6 +155,9 @@ export const projectServiceFactory = ({ certificateTemplateDAL, pkiCollectionDAL, pkiAlertDAL, + sshCertificateAuthorityDAL, + sshCertificateDAL, + sshCertificateTemplateDAL, keyStore, kmsService, projectBotDAL, @@ -153,10 +179,10 @@ export const projectServiceFactory = ({ kmsKeyId, tx: trx, createDefaultEnvs = true, - template = InfisicalProjectTemplate.Default + template = InfisicalProjectTemplate.Default, + type = ProjectType.SecretManager }: TCreateProjectDTO) => { const organization = await orgDAL.findOne({ id: actorOrgId }); - const { permission, membership: orgMembership } = await permissionService.getOrgPermission( actor, actorId, @@ -206,6 +232,7 @@ export const projectServiceFactory = ({ const project = await projectDAL.create( { name: workspaceName, + type, description: workspaceDescription, orgId: organization.id, slug: projectSlug || slugify(`${workspaceName}-${alphaNumericNanoId(4)}`), @@ -423,6 +450,14 @@ export const projectServiceFactory = ({ await userDAL.deleteById(projectGhostUser.id, tx); } + await fnDeleteProjectSecretReminders(project.id, { + secretDAL, + secretV2BridgeDAL, + queueService, + projectBotService, + folderDAL + }); + return delProject; }); @@ -430,11 +465,22 @@ export const projectServiceFactory = ({ return deletedProject; }; - const getProjects = async ({ actorId, includeRoles, actorAuthMethod, actorOrgId }: TListProjectsDTO) => { - const workspaces = await projectDAL.findAllProjects(actorId); + const getProjects = async ({ + actorId, + includeRoles, + actorAuthMethod, + actorOrgId, + type = ProjectType.SecretManager + }: TListProjectsDTO) => { + const workspaces = await projectDAL.findAllProjects(actorId, actorOrgId, type); if (includeRoles) { - const { permission } = await permissionService.getUserOrgPermission(actorId, actorOrgId, actorAuthMethod); + const { permission } = await permissionService.getUserOrgPermission( + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); // `includeRoles` is specifically used by organization admins when inviting new users to the organizations to avoid looping redundant api calls. ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Member); @@ -681,11 +727,19 @@ export const projectServiceFactory = ({ actor }: TListProjectCasDTO) => { const project = await projectDAL.findProjectByFilter(filter); + let projectId = project.id; + const certManagerProjectFromSplit = await projectDAL.getProjectFromSplitId( + projectId, + ProjectType.CertificateManager + ); + if (certManagerProjectFromSplit) { + projectId = certManagerProjectFromSplit.id; + } const { permission } = await permissionService.getProjectPermission( actor, actorId, - project.id, + projectId, actorAuthMethod, actorOrgId ); @@ -697,7 +751,7 @@ export const projectServiceFactory = ({ const cas = await certificateAuthorityDAL.find( { - projectId: project.id, + projectId, ...(status && { status }), ...(friendlyName && { friendlyName }), ...(commonName && { commonName }) @@ -723,18 +777,27 @@ export const projectServiceFactory = ({ actor }: TListProjectCertsDTO) => { const project = await projectDAL.findProjectByFilter(filter); + let projectId = project.id; + const certManagerProjectFromSplit = await projectDAL.getProjectFromSplitId( + projectId, + ProjectType.CertificateManager + ); + if (certManagerProjectFromSplit) { + projectId = certManagerProjectFromSplit.id; + } - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, - project.id, + projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.CertificateManager); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates); - const cas = await certificateAuthorityDAL.find({ projectId: project.id }); + const cas = await certificateAuthorityDAL.find({ projectId }); const certificates = await certificateDAL.find( { @@ -748,7 +811,7 @@ export const projectServiceFactory = ({ ); const count = await certificateDAL.countCertificatesInProject({ - projectId: project.id, + projectId, friendlyName, commonName }); @@ -763,19 +826,29 @@ export const projectServiceFactory = ({ * Return list of (PKI) alerts configured for project */ const listProjectAlerts = async ({ - projectId, + projectId: preSplitProjectId, actor, actorId, actorAuthMethod, actorOrgId }: TListProjectAlertsDTO) => { - const { permission } = await permissionService.getProjectPermission( + let projectId = preSplitProjectId; + const certManagerProjectFromSplit = await projectDAL.getProjectFromSplitId( + projectId, + ProjectType.CertificateManager + ); + if (certManagerProjectFromSplit) { + projectId = certManagerProjectFromSplit.id; + } + + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.CertificateManager); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PkiAlerts); @@ -790,19 +863,28 @@ export const projectServiceFactory = ({ * Return list of PKI collections for project */ const listProjectPkiCollections = async ({ - projectId, + projectId: preSplitProjectId, actor, actorId, actorAuthMethod, actorOrgId }: TListProjectAlertsDTO) => { - const { permission } = await permissionService.getProjectPermission( + let projectId = preSplitProjectId; + const certManagerProjectFromSplit = await projectDAL.getProjectFromSplitId( + projectId, + ProjectType.CertificateManager + ); + if (certManagerProjectFromSplit) { + projectId = certManagerProjectFromSplit.id; + } + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.CertificateManager); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PkiCollections); @@ -817,19 +899,29 @@ export const projectServiceFactory = ({ * Return list of certificate templates for project */ const listProjectCertificateTemplates = async ({ - projectId, + projectId: preSplitProjectId, actorId, actorOrgId, actorAuthMethod, actor }: TListProjectCertificateTemplatesDTO) => { - const { permission } = await permissionService.getProjectPermission( + let projectId = preSplitProjectId; + const certManagerProjectFromSplit = await projectDAL.getProjectFromSplitId( + projectId, + ProjectType.CertificateManager + ); + if (certManagerProjectFromSplit) { + projectId = certManagerProjectFromSplit.id; + } + + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.CertificateManager); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Read, @@ -843,6 +935,118 @@ export const projectServiceFactory = ({ }; }; + /** + * Return list of SSH CAs for project + */ + const listProjectSshCas = async ({ + actorId, + actorOrgId, + actorAuthMethod, + actor, + projectId + }: TListProjectSshCasDTO) => { + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.SshCertificateAuthorities + ); + + const cas = await sshCertificateAuthorityDAL.find( + { + projectId + }, + { sort: [["updatedAt", "desc"]] } + ); + + return cas; + }; + + /** + * Return list of SSH certificates for project + */ + const listProjectSshCertificates = async ({ + limit = 25, + offset = 0, + actorId, + actorOrgId, + actorAuthMethod, + actor, + projectId + }: TListProjectSshCertificatesDTO) => { + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificates); + + const cas = await sshCertificateAuthorityDAL.find({ + projectId + }); + + const certificates = await sshCertificateDAL.find( + { + $in: { + sshCaId: cas.map((ca) => ca.id) + } + }, + { offset, limit, sort: [["updatedAt", "desc"]] } + ); + + const count = await sshCertificateDAL.countSshCertificatesInProject(projectId); + + return { certificates, totalCount: count }; + }; + + /** + * Return list of SSH certificate templates for project + */ + const listProjectSshCertificateTemplates = async ({ + actorId, + actorOrgId, + actorAuthMethod, + actor, + projectId + }: TListProjectSshCertificateTemplatesDTO) => { + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.SshCertificateTemplates + ); + + const cas = await sshCertificateAuthorityDAL.find({ + projectId + }); + + const certificateTemplates = await sshCertificateTemplateDAL.find({ + $in: { + sshCaId: cas.map((ca) => ca.id) + } + }); + + return { certificateTemplates }; + }; + const updateProjectKmsKey = async ({ projectId, kms, @@ -1076,6 +1280,9 @@ export const projectServiceFactory = ({ listProjectAlerts, listProjectPkiCollections, listProjectCertificateTemplates, + listProjectSshCas, + listProjectSshCertificates, + listProjectSshCertificateTemplates, updateVersionLimit, updateAuditLogsRetention, updateProjectKmsKey, diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index b826f2a6a..2c6b8e2da 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -1,6 +1,6 @@ import { Knex } from "knex"; -import { TProjectKeys } from "@app/db/schemas"; +import { ProjectType, TProjectKeys } from "@app/db/schemas"; import { TProjectPermission } from "@app/lib/types"; import { ActorAuthMethod, ActorType } from "../auth/auth-type"; @@ -35,6 +35,7 @@ export type TCreateProjectDTO = { createDefaultEnvs?: boolean; template?: string; tx?: Knex; + type?: ProjectType; }; export type TDeleteProjectBySlugDTO = { @@ -84,6 +85,7 @@ export type TDeleteProjectDTO = { export type TListProjectsDTO = { includeRoles: boolean; + type?: ProjectType | "all"; } & Omit; export type TUpgradeProjectDTO = { @@ -130,6 +132,13 @@ export type TGetProjectKmsKey = TProjectPermission; export type TListProjectCertificateTemplatesDTO = TProjectPermission; +export type TListProjectSshCasDTO = TProjectPermission; +export type TListProjectSshCertificateTemplatesDTO = TProjectPermission; +export type TListProjectSshCertificatesDTO = { + offset: number; + limit: number; +} & TProjectPermission; + export type TGetProjectSlackConfig = TProjectPermission; export type TUpdateProjectSlackConfig = { diff --git a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts index dab70806f..aa1ed9d25 100644 --- a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts +++ b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts @@ -5,6 +5,7 @@ import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; import { TIdentityUaClientSecretDALFactory } from "../identity-ua/identity-ua-client-secret-dal"; +import { TSecretDALFactory } from "../secret/secret-dal"; import { TSecretVersionDALFactory } from "../secret/secret-version-dal"; import { TSecretFolderVersionDALFactory } from "../secret-folder/secret-folder-version-dal"; import { TSecretSharingDALFactory } from "../secret-sharing/secret-sharing-dal"; @@ -16,6 +17,7 @@ type TDailyResourceCleanUpQueueServiceFactoryDep = { identityUniversalAuthClientSecretDAL: Pick; secretVersionDAL: Pick; secretVersionV2DAL: Pick; + secretDAL: Pick; secretFolderVersionDAL: Pick; snapshotDAL: Pick; secretSharingDAL: Pick; @@ -30,6 +32,7 @@ export const dailyResourceCleanUpQueueServiceFactory = ({ snapshotDAL, secretVersionDAL, secretFolderVersionDAL, + secretDAL, identityAccessTokenDAL, secretSharingDAL, secretVersionV2DAL, @@ -37,6 +40,7 @@ export const dailyResourceCleanUpQueueServiceFactory = ({ }: TDailyResourceCleanUpQueueServiceFactoryDep) => { queueService.start(QueueName.DailyResourceCleanUp, async () => { logger.info(`${QueueName.DailyResourceCleanUp}: queue task started`); + await secretDAL.pruneSecretReminders(queueService); await auditLogDAL.pruneAuditLog(); await identityAccessTokenDAL.removeExpiredTokens(); await identityUniversalAuthClientSecretDAL.removeExpiredClientSecrets(); diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index d787520a2..99bd81d6d 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -2,7 +2,7 @@ import { ForbiddenError, subject } from "@casl/ability"; import path from "path"; import { v4 as uuidv4, validate as uuidValidate } from "uuid"; -import { TSecretFoldersInsert } from "@app/db/schemas"; +import { ProjectType, TSecretFoldersInsert } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service"; @@ -52,13 +52,14 @@ export const secretFolderServiceFactory = ({ environment, path: secretPath }: TCreateFolderDTO) => { - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Create, @@ -150,13 +151,14 @@ export const secretFolderServiceFactory = ({ throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); } - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, project.id, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); folders.forEach(({ environment, path: secretPath }) => { ForbiddenError.from(permission).throwUnlessCan( @@ -259,13 +261,14 @@ export const secretFolderServiceFactory = ({ path: secretPath, id }: TUpdateFolderDTO) => { - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Edit, @@ -339,13 +342,14 @@ export const secretFolderServiceFactory = ({ path: secretPath, idOrName }: TDeleteFolderDTO) => { - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Delete, diff --git a/backend/src/services/secret-import/secret-import-service.ts b/backend/src/services/secret-import/secret-import-service.ts index 25e78fb65..bb2c54372 100644 --- a/backend/src/services/secret-import/secret-import-service.ts +++ b/backend/src/services/secret-import/secret-import-service.ts @@ -2,7 +2,7 @@ import path from "node:path"; import { ForbiddenError, subject } from "@casl/ability"; -import { TableName } from "@app/db/schemas"; +import { ProjectType, TableName } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; @@ -73,13 +73,14 @@ export const secretImportServiceFactory = ({ isReplication, path: secretPath }: TCreateSecretImportDTO) => { - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); // check if user has permission to import into destination path ForbiddenError.from(permission).throwUnlessCan( @@ -189,13 +190,15 @@ export const secretImportServiceFactory = ({ data, id }: TUpdateSecretImportDTO) => { - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); + ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Edit, subject(ProjectPermissionSub.SecretImports, { environment, secretPath }) @@ -283,13 +286,15 @@ export const secretImportServiceFactory = ({ actorAuthMethod, id }: TDeleteSecretImportDTO) => { - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); + ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Delete, subject(ProjectPermissionSub.SecretImports, { environment, secretPath }) diff --git a/backend/src/services/secret-tag/secret-tag-service.ts b/backend/src/services/secret-tag/secret-tag-service.ts index 6cae3997a..02d2b5af0 100644 --- a/backend/src/services/secret-tag/secret-tag-service.ts +++ b/backend/src/services/secret-tag/secret-tag-service.ts @@ -1,5 +1,6 @@ import { ForbiddenError } from "@casl/ability"; +import { ProjectType } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; @@ -23,7 +24,7 @@ export type TSecretTagServiceFactory = ReturnType { const createTag = async ({ slug, actor, color, actorId, actorOrgId, actorAuthMethod, projectId }: TCreateTagDTO) => { - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, @@ -31,6 +32,7 @@ export const secretTagServiceFactory = ({ secretTagDAL, permissionService }: TSe actorOrgId ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Tags); + ForbidOnInvalidProjectType(ProjectType.SecretManager); const existingTag = await secretTagDAL.findOne({ slug, projectId }); if (existingTag) throw new BadRequestError({ message: "Tag already exist" }); @@ -54,7 +56,7 @@ export const secretTagServiceFactory = ({ secretTagDAL, permissionService }: TSe if (existingTag && existingTag.id !== tag.id) throw new BadRequestError({ message: "Tag already exist" }); } - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, tag.projectId, @@ -62,6 +64,7 @@ export const secretTagServiceFactory = ({ secretTagDAL, permissionService }: TSe actorOrgId ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Tags); + ForbidOnInvalidProjectType(ProjectType.SecretManager); const updatedTag = await secretTagDAL.updateById(tag.id, { color, slug }); return updatedTag; @@ -71,7 +74,7 @@ export const secretTagServiceFactory = ({ secretTagDAL, permissionService }: TSe const tag = await secretTagDAL.findById(id); if (!tag) throw new NotFoundError({ message: `Tag with ID '${id}' not found` }); - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, tag.projectId, @@ -79,6 +82,7 @@ export const secretTagServiceFactory = ({ secretTagDAL, permissionService }: TSe actorOrgId ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Tags); + ForbidOnInvalidProjectType(ProjectType.SecretManager); const deletedTag = await secretTagDAL.deleteById(tag.id); return deletedTag; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index 0803bda35..e13a77432 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -1,7 +1,7 @@ import { ForbiddenError, PureAbility, subject } from "@casl/ability"; import { z } from "zod"; -import { ProjectMembershipRole, SecretsV2Schema, SecretType, TableName } from "@app/db/schemas"; +import { ProjectMembershipRole, ProjectType, SecretsV2Schema, SecretType, TableName } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service"; @@ -188,13 +188,14 @@ export const secretV2BridgeServiceFactory = ({ secretPath, ...inputSecret }: TCreateSecretDTO) => { - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); if (!folder) @@ -310,13 +311,14 @@ export const secretV2BridgeServiceFactory = ({ secretPath, ...inputSecret }: TUpdateSecretDTO) => { - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); if (inputSecret.newSecretName === "") { throw new BadRequestError({ message: "New secret name cannot be empty" }); @@ -494,13 +496,14 @@ export const secretV2BridgeServiceFactory = ({ secretPath, ...inputSecret }: TDeleteSecretDTO) => { - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); if (!folder) @@ -1081,13 +1084,14 @@ export const secretV2BridgeServiceFactory = ({ projectId, secrets: inputSecrets }: TCreateManySecretDTO) => { - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); if (!folder) @@ -1221,13 +1225,14 @@ export const secretV2BridgeServiceFactory = ({ secretPath, secrets: inputSecrets }: TUpdateManySecretDTO) => { - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); if (!folder) @@ -1427,13 +1432,14 @@ export const secretV2BridgeServiceFactory = ({ actorAuthMethod, actorOrgId }: TDeleteManySecretDTO) => { - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); if (!folder) @@ -1569,13 +1575,14 @@ export const secretV2BridgeServiceFactory = ({ actorOrgId, actorAuthMethod }: TBackFillSecretReferencesDTO) => { - const { hasRole } = await permissionService.getProjectPermission( + const { hasRole, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); if (!hasRole(ProjectMembershipRole.Admin)) throw new ForbiddenRequestError({ message: "Only admins are allowed to take this action" }); @@ -1616,13 +1623,14 @@ export const secretV2BridgeServiceFactory = ({ actorAuthMethod, actorOrgId }: TMoveSecretsDTO) => { - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); const sourceFolder = await folderDAL.findBySecretPath(projectId, sourceEnvironment, sourceSecretPath); if (!sourceFolder) { diff --git a/backend/src/services/secret-v2-bridge/secret-version-dal.ts b/backend/src/services/secret-v2-bridge/secret-version-dal.ts index a0bce5371..7772b8518 100644 --- a/backend/src/services/secret-v2-bridge/secret-version-dal.ts +++ b/backend/src/services/secret-v2-bridge/secret-version-dal.ts @@ -20,7 +20,8 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => { .join(TableName.SecretV2, `${TableName.SecretV2}.id`, `${TableName.SecretVersionV2}.secretId`) .join( (tx || db)(TableName.SecretVersionV2) - .groupBy("folderId", "secretId") + .where(`${TableName.SecretVersionV2}.folderId`, folderId) + .groupBy("secretId") .max("version") .select("secretId") .as("latestVersion"), diff --git a/backend/src/services/secret/secret-dal.ts b/backend/src/services/secret/secret-dal.ts index 0d4ae0cda..cbaf7ddcd 100644 --- a/backend/src/services/secret/secret-dal.ts +++ b/backend/src/services/secret/secret-dal.ts @@ -5,6 +5,8 @@ import { TDbClient } from "@app/db"; import { SecretsSchema, SecretType, TableName, TSecrets, TSecretsUpdate } from "@app/db/schemas"; import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; +import { logger } from "@app/lib/logger"; +import { QueueName, TQueueServiceFactory } from "@app/queue"; export type TSecretDALFactory = ReturnType; @@ -339,6 +341,94 @@ export const secretDALFactory = (db: TDbClient) => { } }; + const pruneSecretReminders = async (queueService: TQueueServiceFactory) => { + const REMINDER_PRUNE_BATCH_SIZE = 5_000; + const MAX_RETRY_ON_FAILURE = 3; + let numberOfRetryOnFailure = 0; + let deletedReminderCount = 0; + + logger.info(`${QueueName.DailyResourceCleanUp}: secret reminders started`); + + try { + const repeatableJobs = await queueService.getRepeatableJobs(QueueName.SecretReminder); + const reminderJobs = repeatableJobs + .map((job) => ({ secretId: job.id?.replace("reminder-", "") as string, jobKey: job.key })) + .filter(Boolean); + + if (reminderJobs.length === 0) { + logger.info(`${QueueName.DailyResourceCleanUp}: no reminder jobs found`); + return; + } + + for (let offset = 0; offset < reminderJobs.length; offset += REMINDER_PRUNE_BATCH_SIZE) { + try { + const batchIds = reminderJobs.slice(offset, offset + REMINDER_PRUNE_BATCH_SIZE).map((r) => r.secretId); + + const payload = { + $in: { + id: batchIds + } + }; + + const opts = { + limit: REMINDER_PRUNE_BATCH_SIZE + }; + + // Find existing secrets with pagination + // eslint-disable-next-line no-await-in-loop + const [secrets, secretsV2] = await Promise.all([ + ormify(db, TableName.Secret).find(payload, opts), + ormify(db, TableName.SecretV2).find(payload, opts) + ]); + + const foundSecretIds = new Set([ + ...secrets.map((secret) => secret.id), + ...secretsV2.map((secret) => secret.id) + ]); + + // Find IDs that don't exist in either table + const secretIdsNotFound = batchIds.filter((secretId) => !foundSecretIds.has(secretId)); + + // Delete reminders for non-existent secrets + for (const secretId of secretIdsNotFound) { + const jobKey = reminderJobs.find((r) => r.secretId === secretId)?.jobKey; + + if (jobKey) { + // eslint-disable-next-line no-await-in-loop + await queueService.stopRepeatableJobByKey(QueueName.SecretReminder, jobKey); + deletedReminderCount += 1; + } + } + + numberOfRetryOnFailure = 0; + } catch (error) { + numberOfRetryOnFailure += 1; + logger.error(error, `Failed to process batch at offset ${offset}`); + + if (numberOfRetryOnFailure >= MAX_RETRY_ON_FAILURE) { + break; + } + + // Retry the current batch + offset -= REMINDER_PRUNE_BATCH_SIZE; + + // eslint-disable-next-line no-promise-executor-return, @typescript-eslint/no-loop-func, no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, 500 * numberOfRetryOnFailure)); + } + + // Small delay between batches + // eslint-disable-next-line no-promise-executor-return, @typescript-eslint/no-loop-func, no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } catch (error) { + logger.error(error, "Failed to complete secret reminder pruning"); + } finally { + logger.info( + `${QueueName.DailyResourceCleanUp}: secret reminders completed. Deleted ${deletedReminderCount} reminders` + ); + } + }; + return { ...secretOrm, update, @@ -352,6 +442,7 @@ export const secretDALFactory = (db: TDbClient) => { findByBlindIndexes, upsertSecretReferences, findReferencedSecretReferences, - findAllProjectSecretValues + findAllProjectSecretValues, + pruneSecretReminders }; }; diff --git a/backend/src/services/secret/secret-fns.ts b/backend/src/services/secret/secret-fns.ts index 65691fcbb..6336c479d 100644 --- a/backend/src/services/secret/secret-fns.ts +++ b/backend/src/services/secret/secret-fns.ts @@ -19,9 +19,11 @@ import { decryptSymmetric128BitHexKeyUTF8, encryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto"; +import { daysToMillisecond, secondsToMillis } from "@app/lib/dates"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { groupBy, unique } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; import { fnSecretBulkInsert as fnSecretV2BridgeBulkInsert, fnSecretBulkUpdate as fnSecretV2BridgeBulkUpdate, @@ -31,8 +33,10 @@ import { import { ActorAuthMethod, ActorType } from "../auth/auth-type"; import { KmsDataKey } from "../kms/kms-types"; import { getBotKeyFnFactory } from "../project-bot/project-bot-fns"; +import { TProjectBotServiceFactory } from "../project-bot/project-bot-service"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; +import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal"; import { TSecretDALFactory } from "./secret-dal"; import { TCreateManySecretsRawFn, @@ -1138,3 +1142,49 @@ export const decryptSecretWithBot = ( secretComment }; }; + +type TFnDeleteProjectSecretReminders = { + secretDAL: Pick; + secretV2BridgeDAL: Pick; + queueService: Pick; + projectBotService: Pick; + folderDAL: Pick; +}; + +export const fnDeleteProjectSecretReminders = async ( + projectId: string, + { secretDAL, secretV2BridgeDAL, queueService, projectBotService, folderDAL }: TFnDeleteProjectSecretReminders +) => { + const projectFolders = await folderDAL.findByProjectId(projectId); + const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId, false); + + const projectSecrets = shouldUseSecretV2Bridge + ? await secretV2BridgeDAL.find({ + $in: { folderId: projectFolders.map((folder) => folder.id) }, + $notNull: ["reminderRepeatDays"] + }) + : await secretDAL.find({ + $in: { folderId: projectFolders.map((folder) => folder.id) }, + $notNull: ["secretReminderRepeatDays"] + }); + + const appCfg = getConfig(); + for await (const secret of projectSecrets) { + const repeatDays = shouldUseSecretV2Bridge + ? (secret as { reminderRepeatDays: number }).reminderRepeatDays + : (secret as { secretReminderRepeatDays: number }).secretReminderRepeatDays; + + // We're using the queue service directly to get around conflicting imports. + if (repeatDays) { + await queueService.stopRepeatableJob( + QueueName.SecretReminder, + QueueJobs.SecretReminder, + { + // on prod it this will be in days, in development this will be second + every: appCfg.NODE_ENV === "development" ? secondsToMillis(repeatDays) : daysToMillisecond(repeatDays) + }, + `reminder-${secret.id}` + ); + } + } +}; diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index 84a8584ee..9b2f80225 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -1,4 +1,5 @@ /* eslint-disable no-await-in-loop */ +import opentelemetry from "@opentelemetry/api"; import { AxiosError } from "axios"; import { @@ -158,6 +159,12 @@ export const secretQueueFactory = ({ projectUserMembershipRoleDAL, projectKeyDAL }: TSecretQueueFactoryDep) => { + const integrationMeter = opentelemetry.metrics.getMeter("Integrations"); + const errorHistogram = integrationMeter.createHistogram("integration_secret_sync_errors", { + description: "Integration secret sync errors", + unit: "1" + }); + const removeSecretReminder = async (dto: TRemoveSecretReminderDTO) => { const appCfg = getConfig(); await queueService.stopRepeatableJob( @@ -248,7 +255,9 @@ export const secretQueueFactory = ({ ? secondsToMillis(newSecret.secretReminderRepeatDays) : daysToMillisecond(newSecret.secretReminderRepeatDays), immediately: true - } + }, + removeOnComplete: true, + removeOnFail: true } ); } catch (err) { @@ -931,6 +940,19 @@ export const secretQueueFactory = ({ `Secret integration sync error [projectId=${job.data.projectId}] [environment=${environment}] [secretPath=${job.data.secretPath}]` ); + const appCfg = getConfig(); + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + errorHistogram.record(1, { + version: 1, + integration: integration.integration, + integrationId: integration.id, + type: err instanceof AxiosError ? "AxiosError" : err?.constructor?.name || "UnknownError", + status: err instanceof AxiosError ? err.response?.status : undefined, + name: err instanceof Error ? err.name : undefined, + projectId: integration.projectId + }); + } + const message = // eslint-disable-next-line no-nested-ternary (err instanceof AxiosError diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index d62d09f7a..fbf90a7f8 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -4,6 +4,7 @@ import { ForbiddenError, subject } from "@casl/ability"; import { ProjectMembershipRole, + ProjectType, ProjectUpgradeStatus, SecretEncryptionAlgo, SecretKeyEncoding, @@ -186,13 +187,15 @@ export const secretServiceFactory = ({ projectId, ...inputSecret }: TCreateSecretDTO) => { - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); + ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Create, subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) @@ -301,13 +304,15 @@ export const secretServiceFactory = ({ projectId, ...inputSecret }: TUpdateSecretDTO) => { - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); + ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Edit, subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) @@ -443,13 +448,15 @@ export const secretServiceFactory = ({ projectId, ...inputSecret }: TDeleteSecretDTO) => { - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); + ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Delete, subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) @@ -484,8 +491,8 @@ export const secretServiceFactory = ({ secretDAL }); - const deletedSecret = await secretDAL.transaction(async (tx) => - fnSecretBulkDelete({ + const deletedSecret = await secretDAL.transaction(async (tx) => { + const secrets = await fnSecretBulkDelete({ projectId, folderId, actorId, @@ -498,8 +505,19 @@ export const secretServiceFactory = ({ } ], tx - }) - ); + }); + + for await (const secret of secrets) { + if (secret.secretReminderRepeatDays !== null && secret.secretReminderRepeatDays !== undefined) { + await secretQueueService.removeSecretReminder({ + repeatDays: secret.secretReminderRepeatDays, + secretId: secret.id + }); + } + } + + return secrets; + }); if (inputSecret.type === SecretType.Shared) { await snapshotService.performSnapshot(folderId); @@ -732,13 +750,14 @@ export const secretServiceFactory = ({ projectId, secrets: inputSecrets }: TCreateBulkSecretDTO) => { - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Create, subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) @@ -817,13 +836,15 @@ export const secretServiceFactory = ({ projectId, secrets: inputSecrets }: TUpdateBulkSecretDTO) => { - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); + ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Edit, subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) @@ -923,13 +944,14 @@ export const secretServiceFactory = ({ actorAuthMethod, actorOrgId }: TDeleteBulkSecretDTO) => { - const { permission } = await permissionService.getProjectPermission( + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( actor, actorId, projectId, actorAuthMethod, actorOrgId ); + ForbidOnInvalidProjectType(ProjectType.SecretManager); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Delete, subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) @@ -960,8 +982,8 @@ export const secretServiceFactory = ({ secretDAL }); - const secretsDeleted = await secretDAL.transaction(async (tx) => - fnSecretBulkDelete({ + const secretsDeleted = await secretDAL.transaction(async (tx) => { + const secrets = await fnSecretBulkDelete({ secretDAL, secretQueueService, inputSecrets: inputSecrets.map(({ type, secretName }) => ({ @@ -972,8 +994,19 @@ export const secretServiceFactory = ({ folderId, actorId, tx - }) - ); + }); + + for await (const secret of secrets) { + if (secret.secretReminderRepeatDays !== null && secret.secretReminderRepeatDays !== undefined) { + await secretQueueService.removeSecretReminder({ + repeatDays: secret.secretReminderRepeatDays, + secretId: secret.id + }); + } + } + + return secrets; + }); await snapshotService.performSnapshot(folderId); await secretQueueService.syncSecrets({ diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index bdf2fe18c..a2ed85749 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -53,6 +53,13 @@ export const smtpServiceFactory = (cfg: TSmtpConfig) => { const smtp = createTransport(cfg); const isSmtpOn = Boolean(cfg.host); + handlebars.registerHelper("emailFooter", () => { + const { SITE_URL } = getConfig(); + return new handlebars.SafeString( + `

Email sent via Infisical at ${SITE_URL}

` + ); + }); + const sendMail = async ({ substitutions, recipients, template, subjectLine }: TSmtpSendMail) => { const appCfg = getConfig(); const html = await fs.readFile(path.resolve(__dirname, "./templates/", template), "utf8"); diff --git a/backend/src/services/smtp/templates/accessApprovalRequest.handlebars b/backend/src/services/smtp/templates/accessApprovalRequest.handlebars index 82c66ce5f..3c0811a1c 100644 --- a/backend/src/services/smtp/templates/accessApprovalRequest.handlebars +++ b/backend/src/services/smtp/templates/accessApprovalRequest.handlebars @@ -45,6 +45,8 @@ View the request and approve or deny it here.

+ + {{emailFooter}} \ No newline at end of file diff --git a/backend/src/services/smtp/templates/accessSecretRequestBypassed.handlebars b/backend/src/services/smtp/templates/accessSecretRequestBypassed.handlebars index 3313d352f..8c82df289 100644 --- a/backend/src/services/smtp/templates/accessSecretRequestBypassed.handlebars +++ b/backend/src/services/smtp/templates/accessSecretRequestBypassed.handlebars @@ -11,8 +11,11 @@

A secret approval request has been bypassed in the project "{{projectName}}".

- {{requesterFullName}} ({{requesterEmail}}) has merged - a secret to environment {{environment}} at secret path {{secretPath}} + {{requesterFullName}} + ({{requesterEmail}}) has merged a secret to environment + {{environment}} + at secret path + {{secretPath}} without obtaining the required approvals.

@@ -24,5 +27,7 @@ To review this action, please visit the request panel here.

+ + {{emailFooter}} \ No newline at end of file diff --git a/backend/src/services/smtp/templates/emailMfa.handlebars b/backend/src/services/smtp/templates/emailMfa.handlebars index 936195c34..4c948b08c 100644 --- a/backend/src/services/smtp/templates/emailMfa.handlebars +++ b/backend/src/services/smtp/templates/emailMfa.handlebars @@ -1,4 +1,3 @@ - @@ -14,6 +13,8 @@

{{code}}

The MFA code will be valid for 2 minutes.

Not you? Contact {{#if isCloud}}Infisical{{else}}your administrator{{/if}} immediately.

+ + {{emailFooter}} \ No newline at end of file diff --git a/backend/src/services/smtp/templates/emailVerification.handlebars b/backend/src/services/smtp/templates/emailVerification.handlebars index ad9694d5c..4a989626e 100644 --- a/backend/src/services/smtp/templates/emailVerification.handlebars +++ b/backend/src/services/smtp/templates/emailVerification.handlebars @@ -10,6 +10,8 @@

Confirm your email address

Your confirmation code is below — enter it in the browser window where you've started confirming your email.

{{code}}

+ + {{emailFooter}} \ No newline at end of file diff --git a/backend/src/services/smtp/templates/externalImportFailed.handlebars b/backend/src/services/smtp/templates/externalImportFailed.handlebars index c7869af27..1755052c1 100644 --- a/backend/src/services/smtp/templates/externalImportFailed.handlebars +++ b/backend/src/services/smtp/templates/externalImportFailed.handlebars @@ -16,6 +16,7 @@

Error: {{error}}

+ {{emailFooter}} \ No newline at end of file diff --git a/backend/src/services/smtp/templates/externalImportStarted.handlebars b/backend/src/services/smtp/templates/externalImportStarted.handlebars index 551f972cc..90026f762 100644 --- a/backend/src/services/smtp/templates/externalImportStarted.handlebars +++ b/backend/src/services/smtp/templates/externalImportStarted.handlebars @@ -12,6 +12,8 @@ {{provider}} to Infisical is in progress. The import process may take up to 30 minutes, and you will receive once the import has finished or if it fails.

+ + {{emailFooter}} \ No newline at end of file diff --git a/backend/src/services/smtp/templates/externalImportSuccessful.handlebars b/backend/src/services/smtp/templates/externalImportSuccessful.handlebars index 51a1c465e..a918e9ec7 100644 --- a/backend/src/services/smtp/templates/externalImportSuccessful.handlebars +++ b/backend/src/services/smtp/templates/externalImportSuccessful.handlebars @@ -9,6 +9,8 @@

An import from {{provider}} to Infisical was successful

An import from {{provider}} was successful. Your data is now available in Infisical.

+ + {{emailFooter}} \ No newline at end of file diff --git a/backend/src/services/smtp/templates/historicalSecretLeakIncident.handlebars b/backend/src/services/smtp/templates/historicalSecretLeakIncident.handlebars index 0798538fb..4a918ee0d 100644 --- a/backend/src/services/smtp/templates/historicalSecretLeakIncident.handlebars +++ b/backend/src/services/smtp/templates/historicalSecretLeakIncident.handlebars @@ -1,21 +1,21 @@ - - - - - Incident alert: secrets potentially leaked - + + + + Incident alert: secrets potentially leaked + - -

Infisical has uncovered {{numberOfSecrets}} secret(s) from historical commits to your repo

-

View leaked secrets

+ +

Infisical has uncovered {{numberOfSecrets}} secret(s) from historical commits to your repo

+

View leaked secrets

-

If these are production secrets, please rotate them immediately.

+

If these are production secrets, please rotate them immediately.

-

Once you have taken action, be sure to update the status of the risk in your Infisical - dashboard.

- +

Once you have taken action, be sure to update the status of the risk in your + Infisical dashboard.

+ + {{emailFooter}} + \ No newline at end of file diff --git a/backend/src/services/smtp/templates/integrationSyncFailed.handlebars b/backend/src/services/smtp/templates/integrationSyncFailed.handlebars index 5c5d76693..2aff820fa 100644 --- a/backend/src/services/smtp/templates/integrationSyncFailed.handlebars +++ b/backend/src/services/smtp/templates/integrationSyncFailed.handlebars @@ -26,6 +26,8 @@ {{#if syncMessage}}

Reason: {{syncMessage}}

{{/if}} + + {{emailFooter}} \ No newline at end of file diff --git a/backend/src/services/smtp/templates/newDevice.handlebars b/backend/src/services/smtp/templates/newDevice.handlebars index 6c7f2e9f6..197e0b7a7 100644 --- a/backend/src/services/smtp/templates/newDevice.handlebars +++ b/backend/src/services/smtp/templates/newDevice.handlebars @@ -1,4 +1,3 @@ - @@ -13,7 +12,11 @@

Timestamp: {{timestamp}}

IP address: {{ip}}

User agent: {{userAgent}}

-

If you believe that this login is suspicious, please contact {{#if isCloud}}Infisical{{else}}your administrator{{/if}} or reset your password immediately.

+

If you believe that this login is suspicious, please contact + {{#if isCloud}}Infisical{{else}}your administrator{{/if}} + or reset your password immediately.

+ + {{emailFooter}} \ No newline at end of file diff --git a/backend/src/services/smtp/templates/organizationInvitation.handlebars b/backend/src/services/smtp/templates/organizationInvitation.handlebars index c3ac9556d..da429477b 100644 --- a/backend/src/services/smtp/templates/organizationInvitation.handlebars +++ b/backend/src/services/smtp/templates/organizationInvitation.handlebars @@ -12,5 +12,7 @@ Click to join

What is Infisical?

Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets and configs.

+ + {{emailFooter}} diff --git a/backend/src/services/smtp/templates/passwordReset.handlebars b/backend/src/services/smtp/templates/passwordReset.handlebars index 6499a629c..1cb2ae8ce 100644 --- a/backend/src/services/smtp/templates/passwordReset.handlebars +++ b/backend/src/services/smtp/templates/passwordReset.handlebars @@ -1,14 +1,16 @@ - - - - + + + Account Recovery - - + +

Reset your password

Someone requested a password reset.

Reset password -

If you didn't initiate this request, please contact {{#if isCloud}}us immediately at team@infisical.com.{{else}}your administrator immediately.{{/if}}

- +

If you didn't initiate this request, please contact + {{#if isCloud}}us immediately at team@infisical.com.{{else}}your administrator immediately.{{/if}}

+ + {{emailFooter}} + \ No newline at end of file diff --git a/backend/src/services/smtp/templates/pkiExpirationAlert.handlebars b/backend/src/services/smtp/templates/pkiExpirationAlert.handlebars index 77d2543ae..f9013e24d 100644 --- a/backend/src/services/smtp/templates/pkiExpirationAlert.handlebars +++ b/backend/src/services/smtp/templates/pkiExpirationAlert.handlebars @@ -27,5 +27,7 @@

Please take necessary actions to renew these items before they expire.

For more details, please log in to your Infisical account and check your PKI management section.

+ + {{emailFooter}} \ No newline at end of file diff --git a/backend/src/services/smtp/templates/scimUserProvisioned.handlebars b/backend/src/services/smtp/templates/scimUserProvisioned.handlebars index b1482aa17..ba04d7201 100644 --- a/backend/src/services/smtp/templates/scimUserProvisioned.handlebars +++ b/backend/src/services/smtp/templates/scimUserProvisioned.handlebars @@ -1,16 +1,18 @@ - - - - + + + Organization Invitation - - + +

Join your organization on Infisical

You've been invited to join the Infisical organization — {{organizationName}}

Join now

What is Infisical?

-

Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets and configs.

- +

Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets + and configs.

+ + {{emailFooter}} + \ No newline at end of file diff --git a/backend/src/services/smtp/templates/secretApprovalRequestNeedsReview.handlebars b/backend/src/services/smtp/templates/secretApprovalRequestNeedsReview.handlebars index 9dd6fe747..c12c08460 100644 --- a/backend/src/services/smtp/templates/secretApprovalRequestNeedsReview.handlebars +++ b/backend/src/services/smtp/templates/secretApprovalRequestNeedsReview.handlebars @@ -17,6 +17,8 @@ View the request and approve or deny it here.

+ + {{emailFooter}} \ No newline at end of file diff --git a/backend/src/services/smtp/templates/secretLeakIncident.handlebars b/backend/src/services/smtp/templates/secretLeakIncident.handlebars index c3c5f353a..d0d9a617c 100644 --- a/backend/src/services/smtp/templates/secretLeakIncident.handlebars +++ b/backend/src/services/smtp/templates/secretLeakIncident.handlebars @@ -1,25 +1,27 @@ - - - - - Incident alert: secret leaked - + + + + Incident alert: secret leaked + - -

Infisical has uncovered {{numberOfSecrets}} secret(s) from your recent push

-

View leaked secrets

-

You are receiving this notification because one or more secret leaks have been detected in a recent commit pushed - by {{pusher_name}} ({{pusher_email}}). If - these are test secrets, please add `infisical-scan:ignore` at the end of the line containing the secret as comment - in the given programming. This will prevent future notifications from being sent out for those secret(s).

+ +

Infisical has uncovered {{numberOfSecrets}} secret(s) from your recent push

+

View leaked secrets

+

You are receiving this notification because one or more secret leaks have been detected in a recent commit pushed + by + {{pusher_name}} + ({{pusher_email}}). If these are test secrets, please add `infisical-scan:ignore` at the end of the line + containing the secret as comment in the given programming. This will prevent future notifications from being sent + out for those secret(s).

-

If these are production secrets, please rotate them immediately.

+

If these are production secrets, please rotate them immediately.

-

Once you have taken action, be sure to update the status of the risk in your Infisical - dashboard.

- +

Once you have taken action, be sure to update the status of the risk in your + Infisical dashboard.

+ + {{emailFooter}} + \ No newline at end of file diff --git a/backend/src/services/smtp/templates/secretReminder.handlebars b/backend/src/services/smtp/templates/secretReminder.handlebars index 2a0efcac8..d64c4bf42 100644 --- a/backend/src/services/smtp/templates/secretReminder.handlebars +++ b/backend/src/services/smtp/templates/secretReminder.handlebars @@ -13,6 +13,8 @@ {{#if reminderNote}}

Here's the note included with the reminder: {{reminderNote}}

{{/if}} + + {{emailFooter}} \ No newline at end of file diff --git a/backend/src/services/smtp/templates/signupEmailVerification.handlebars b/backend/src/services/smtp/templates/signupEmailVerification.handlebars index 3ba18619f..39f47ae48 100644 --- a/backend/src/services/smtp/templates/signupEmailVerification.handlebars +++ b/backend/src/services/smtp/templates/signupEmailVerification.handlebars @@ -1,17 +1,19 @@ - - - - + + + Code - + - +

Confirm your email address

Your confirmation code is below — enter it in the browser window where you've started signing up for Infisical.

{{code}}

-

Questions about setting up Infisical? {{#if isCloud}}Email us at support@infisical.com{{else}}Contact your administrator{{/if}}.

- +

Questions about setting up Infisical? + {{#if isCloud}}Email us at support@infisical.com{{else}}Contact your administrator{{/if}}.

+ + {{emailFooter}} + \ No newline at end of file diff --git a/backend/src/services/smtp/templates/unlockAccount.handlebars b/backend/src/services/smtp/templates/unlockAccount.handlebars index 36664be87..b65cb5625 100644 --- a/backend/src/services/smtp/templates/unlockAccount.handlebars +++ b/backend/src/services/smtp/templates/unlockAccount.handlebars @@ -11,6 +11,8 @@

Your account has been temporarily locked due to multiple failed login attempts. To unlock your account, follow the link here

If these attempts were not made by you, reset your password immediately.

+ + {{emailFooter}} \ No newline at end of file diff --git a/backend/src/services/smtp/templates/workspaceInvitation.handlebars b/backend/src/services/smtp/templates/workspaceInvitation.handlebars index b82b8b2c2..fde75a6d6 100644 --- a/backend/src/services/smtp/templates/workspaceInvitation.handlebars +++ b/backend/src/services/smtp/templates/workspaceInvitation.handlebars @@ -11,5 +11,7 @@

What is Infisical?

Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets and configs.

+ + {{emailFooter}} diff --git a/backend/src/services/user-engagement/user-engagement-service.ts b/backend/src/services/user-engagement/user-engagement-service.ts index 5d7b54929..b14672903 100644 --- a/backend/src/services/user-engagement/user-engagement-service.ts +++ b/backend/src/services/user-engagement/user-engagement-service.ts @@ -1,87 +1,44 @@ -import { PlainClient } from "@team-plain/typescript-sdk"; +import axios from "axios"; import { getConfig } from "@app/lib/config/env"; import { InternalServerError } from "@app/lib/errors"; +import { TOrgDALFactory } from "../org/org-dal"; import { TUserDALFactory } from "../user/user-dal"; type TUserEngagementServiceFactoryDep = { userDAL: Pick; + orgDAL: Pick; }; export type TUserEngagementServiceFactory = ReturnType; -export const userEngagementServiceFactory = ({ userDAL }: TUserEngagementServiceFactoryDep) => { - const createUserWish = async (userId: string, text: string) => { +export const userEngagementServiceFactory = ({ userDAL, orgDAL }: TUserEngagementServiceFactoryDep) => { + const createUserWish = async (userId: string, orgId: string, text: string) => { const user = await userDAL.findById(userId); + const org = await orgDAL.findById(orgId); const appCfg = getConfig(); - if (!appCfg.PLAIN_API_KEY) { + if (!appCfg.PYLON_API_KEY) { throw new InternalServerError({ - message: "Plain is not configured." + message: "Pylon is not configured." }); } - const client = new PlainClient({ - apiKey: appCfg.PLAIN_API_KEY - }); - - const customerUpsertRes = await client.upsertCustomer({ - identifier: { - emailAddress: user.email - }, - onCreate: { - fullName: `${user.firstName} ${user.lastName}`, - shortName: user.firstName, - email: { - email: user.email as string, - isVerified: user.isEmailVerified as boolean - }, - - externalId: user.id - }, - - onUpdate: { - fullName: { - value: `${user.firstName} ${user.lastName}` - }, - shortName: { - value: user.firstName - }, - email: { - email: user.email as string, - isVerified: user.isEmailVerified as boolean - }, - externalId: { - value: user.id - } + const request = axios.create({ + baseURL: "https://api.usepylon.com", + headers: { + Authorization: `Bearer ${appCfg.PYLON_API_KEY}` } }); - if (customerUpsertRes.error) { - throw new InternalServerError({ message: customerUpsertRes.error.message }); - } - - const createThreadRes = await client.createThread({ - title: "Wish", - customerIdentifier: { - externalId: customerUpsertRes.data.customer.externalId - }, - components: [ - { - componentText: { - text - } - } - ], - labelTypeIds: appCfg.PLAIN_WISH_LABEL_IDS?.split(",") + await request.post("/issues", { + title: `New Wish From: ${user.firstName} ${user.lastName} (${org.name})`, + body_html: text, + requester_email: user.email, + requester_name: `${user.firstName} ${user.lastName} (${org.name})`, + tags: ["wish"] }); - - if (createThreadRes.error) { - throw new InternalServerError({ - message: createThreadRes.error.message - }); - } }; return { createUserWish diff --git a/cli/go.mod b/cli/go.mod index b55a49c63..637bd904d 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -10,7 +10,7 @@ require ( github.com/fatih/semgroup v1.2.0 github.com/gitleaks/go-gitdiff v0.8.0 github.com/h2non/filetype v1.1.3 - github.com/infisical/go-sdk v0.4.3 + github.com/infisical/go-sdk v0.4.7 github.com/mattn/go-isatty v0.0.20 github.com/muesli/ansi v0.0.0-20221106050444-61f0cd9a192a github.com/muesli/mango-cobra v1.2.0 @@ -23,8 +23,8 @@ require ( github.com/spf13/cobra v1.6.1 github.com/spf13/viper v1.8.1 github.com/stretchr/testify v1.9.0 - golang.org/x/crypto v0.25.0 - golang.org/x/term v0.22.0 + golang.org/x/crypto v0.31.0 + golang.org/x/term v0.27.0 gopkg.in/yaml.v2 v2.4.0 ) @@ -93,9 +93,9 @@ require ( go.opentelemetry.io/otel/trace v1.24.0 // indirect golang.org/x/net v0.27.0 // indirect golang.org/x/oauth2 v0.21.0 // indirect - golang.org/x/sync v0.7.0 // indirect - golang.org/x/sys v0.22.0 // indirect - golang.org/x/text v0.16.0 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/sys v0.28.0 // indirect + golang.org/x/text v0.21.0 // indirect golang.org/x/time v0.5.0 // indirect google.golang.org/api v0.188.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240701130421-f6361c86f094 // indirect diff --git a/cli/go.sum b/cli/go.sum index 537a146cb..79fe1fd7f 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -265,8 +265,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1: github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/infisical/go-sdk v0.4.3 h1:O5ZJ2eCBAZDE9PIAfBPq9Utb2CgQKrhmj9R0oFTRu4U= -github.com/infisical/go-sdk v0.4.3/go.mod h1:6fWzAwTPIoKU49mQ2Oxu+aFnJu9n7k2JcNrZjzhHM2M= +github.com/infisical/go-sdk v0.4.7 h1:+cxIdDfciMh0Syxbxbqjhvz9/ShnN1equ2zqlVQYGtw= +github.com/infisical/go-sdk v0.4.7/go.mod h1:6fWzAwTPIoKU49mQ2Oxu+aFnJu9n7k2JcNrZjzhHM2M= github.com/jedib0t/go-pretty v4.3.0+incompatible h1:CGs8AVhEKg/n9YbUenWmNStRW2PHJzaeDodcfvRAbIo= github.com/jedib0t/go-pretty v4.3.0+incompatible/go.mod h1:XemHduiw8R651AF9Pt4FwCTKeG3oo7hrHJAoznj9nag= github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= @@ -453,8 +453,8 @@ golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5 golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= -golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -564,8 +564,8 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -620,16 +620,16 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= -golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= -golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= -golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= +golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -643,8 +643,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/cli/packages/cmd/ssh.go b/cli/packages/cmd/ssh.go new file mode 100644 index 000000000..d7c1f1e26 --- /dev/null +++ b/cli/packages/cmd/ssh.go @@ -0,0 +1,609 @@ +/* +Copyright (c) 2023 Infisical Inc. +*/ +package cmd + +import ( + "context" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "time" + + "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/config" + "github.com/Infisical/infisical-merge/packages/util" + infisicalSdk "github.com/infisical/go-sdk" + infisicalSdkUtil "github.com/infisical/go-sdk/packages/util" + "github.com/spf13/cobra" + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" +) + +var sshCmd = &cobra.Command{ + Example: `infisical ssh`, + Short: "Used to issue SSH credentials", + Use: "ssh", + DisableFlagsInUseLine: true, + Args: cobra.NoArgs, +} + +var sshIssueCredentialsCmd = &cobra.Command{ + Example: `ssh issue-credentials`, + Short: "Used to issue SSH credentials against a certificate template", + Use: "issue-credentials", + DisableFlagsInUseLine: true, + Args: cobra.NoArgs, + Run: issueCredentials, +} + +var sshSignKeyCmd = &cobra.Command{ + Example: `ssh sign-key`, + Short: "Used to sign a SSH public key against a certificate template", + Use: "sign-key", + DisableFlagsInUseLine: true, + Args: cobra.NoArgs, + Run: signKey, +} + +var algoToFileName = map[infisicalSdkUtil.CertKeyAlgorithm]string{ + infisicalSdkUtil.RSA2048: "id_rsa_2048", + infisicalSdkUtil.RSA4096: "id_rsa_4096", + infisicalSdkUtil.ECDSAP256: "id_ecdsa_p256", + infisicalSdkUtil.ECDSAP384: "id_ecdsa_p384", +} + +func isValidKeyAlgorithm(algo infisicalSdkUtil.CertKeyAlgorithm) bool { + _, exists := algoToFileName[algo] + return exists +} + +func isValidCertType(certType infisicalSdkUtil.SshCertType) bool { + switch certType { + case infisicalSdkUtil.UserCert, infisicalSdkUtil.HostCert: + return true + default: + return false + } +} + +func writeToFile(filePath string, content string, perm os.FileMode) error { + // Ensure the directory exists + dir := filepath.Dir(filePath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", dir, err) + } + + // Write the content to the file + err := os.WriteFile(filePath, []byte(content), perm) + if err != nil { + return fmt.Errorf("failed to write to file %s: %w", filePath, err) + } + + return nil +} + +func addCredentialsToAgent(privateKeyContent, certContent string) error { + // Parse the private key + privateKey, err := ssh.ParseRawPrivateKey([]byte(privateKeyContent)) + if err != nil { + return fmt.Errorf("failed to parse private key: %w", err) + } + + // Parse the certificate + pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(certContent)) + if err != nil { + return fmt.Errorf("failed to parse certificate: %w", err) + } + + cert, ok := pubKey.(*ssh.Certificate) + if !ok { + return fmt.Errorf("parsed key is not a certificate") + } + // Calculate LifetimeSecs based on certificate's valid-to time + validUntil := time.Unix(int64(cert.ValidBefore), 0) + now := time.Now() + + // Handle ValidBefore as either a timestamp or an enumeration + // SSH certificates use ValidBefore as a timestamp unless set to 0 or ~0 + if cert.ValidBefore == ssh.CertTimeInfinity { + // If certificate never expires, set default lifetime to 1 year (can adjust as needed) + validUntil = now.Add(365 * 24 * time.Hour) + } + + // Calculate the duration until expiration + lifetime := validUntil.Sub(now) + if lifetime <= 0 { + return fmt.Errorf("certificate is already expired") + } + + // Convert duration to seconds + lifetimeSecs := uint32(lifetime.Seconds()) + + // Connect to the SSH agent + socket := os.Getenv("SSH_AUTH_SOCK") + if socket == "" { + return fmt.Errorf("SSH_AUTH_SOCK not set") + } + + conn, err := net.Dial("unix", socket) + if err != nil { + return fmt.Errorf("failed to connect to SSH agent: %w", err) + } + defer conn.Close() + + agentClient := agent.NewClient(conn) + + // Add the key with certificate to the agent + err = agentClient.Add(agent.AddedKey{ + PrivateKey: privateKey, + Certificate: cert, + Comment: "Added via Infisical CLI", + LifetimeSecs: lifetimeSecs, + }) + if err != nil { + return fmt.Errorf("failed to add key to agent: %w", err) + } + + return nil +} + +func issueCredentials(cmd *cobra.Command, args []string) { + + token, err := util.GetInfisicalToken(cmd) + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + var infisicalToken string + + if token != nil && (token.Type == util.SERVICE_TOKEN_IDENTIFIER || token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER) { + infisicalToken = token.Token + } else { + util.RequireLogin() + util.RequireLocalWorkspaceFile() + + 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 + } + + certificateTemplateId, err := cmd.Flags().GetString("certificateTemplateId") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + if certificateTemplateId == "" { + util.PrintErrorMessageAndExit("You must set the --certificateTemplateId flag") + } + + principalsStr, err := cmd.Flags().GetString("principals") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + // Check if the input string is empty before splitting + if principalsStr == "" { + util.HandleError(fmt.Errorf("no principals provided"), "The 'principals' flag cannot be empty") + } + + // Convert the comma-delimited string into a slice of strings + principals := strings.Split(principalsStr, ",") + for i, principal := range principals { + principals[i] = strings.TrimSpace(principal) + } + + keyAlgorithm, err := cmd.Flags().GetString("keyAlgorithm") + if err != nil { + util.HandleError(err, "Unable to parse keyAlgorithm flag") + } + + if !isValidKeyAlgorithm(infisicalSdkUtil.CertKeyAlgorithm(keyAlgorithm)) { + util.HandleError(fmt.Errorf("invalid keyAlgorithm: %s", keyAlgorithm), + "Valid values: RSA_2048, RSA_4096, EC_prime256v1, EC_secp384r1") + } + + certType, err := cmd.Flags().GetString("certType") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + if !isValidCertType(infisicalSdkUtil.SshCertType(certType)) { + util.HandleError(fmt.Errorf("invalid certType: %s", certType), + "Valid values: user, host") + } + + ttl, err := cmd.Flags().GetString("ttl") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + keyId, err := cmd.Flags().GetString("keyId") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + outFilePath, err := cmd.Flags().GetString("outFilePath") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + addToAgent, err := cmd.Flags().GetBool("addToAgent") + if err != nil { + util.HandleError(err, "Unable to parse addToAgent flag") + } + + if outFilePath == "" && addToAgent == false { + util.PrintErrorMessageAndExit("You must provide either --outFilePath or --addToAgent flag to use this command") + } + + var ( + outputDir string + privateKeyPath string + publicKeyPath string + signedKeyPath string + ) + + if outFilePath != "" { + // Expand ~ to home directory if present + 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) + } + + // Check if outFilePath ends with "-cert.pub" + if strings.HasSuffix(outFilePath, "-cert.pub") { + // Treat outFilePath as the signed key path + signedKeyPath = outFilePath + + // Derive the base name by removing "-cert.pub" + baseName := strings.TrimSuffix(filepath.Base(outFilePath), "-cert.pub") + + // Set the output directory + outputDir = filepath.Dir(outFilePath) + + // Define private and public key paths + privateKeyPath = filepath.Join(outputDir, baseName) + publicKeyPath = filepath.Join(outputDir, baseName+".pub") + } else { + // Treat outFilePath as a directory + outputDir = outFilePath + + // Check if the directory exists; if not, create it + 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") + } + } + } + + // Define file names based on key algorithm + fileName := algoToFileName[infisicalSdkUtil.CertKeyAlgorithm(keyAlgorithm)] + + // Define file paths + privateKeyPath = filepath.Join(outputDir, fileName) + publicKeyPath = filepath.Join(outputDir, fileName+".pub") + signedKeyPath = filepath.Join(outputDir, fileName+"-cert.pub") + + // If outFilePath ends with "-cert.pub", ensure the signedKeyPath is set + if strings.HasSuffix(outFilePath, "-cert.pub") { + // Ensure the signedKeyPath was set + if signedKeyPath == "" { + util.HandleError(fmt.Errorf("signedKeyPath is not set correctly"), "Internal error") + } + } else { + // Ensure all paths are set + if privateKeyPath == "" || publicKeyPath == "" || signedKeyPath == "" { + util.HandleError(fmt.Errorf("file paths are not set correctly"), "Internal error") + } + } + + infisicalClient := infisicalSdk.NewInfisicalClient(context.Background(), infisicalSdk.Config{ + SiteUrl: config.INFISICAL_URL, + UserAgent: api.USER_AGENT, + AutoTokenRefresh: false, + }) + infisicalClient.Auth().SetAccessToken(infisicalToken) + + creds, err := infisicalClient.Ssh().IssueCredentials(infisicalSdk.IssueSshCredsOptions{ + CertificateTemplateID: certificateTemplateId, + Principals: principals, + KeyAlgorithm: infisicalSdkUtil.CertKeyAlgorithm(keyAlgorithm), + CertType: infisicalSdkUtil.SshCertType(certType), + TTL: ttl, + KeyID: keyId, + }) + + if err != nil { + util.HandleError(err, "Failed to issue SSH credentials") + } + + if outFilePath != "" { + // If signedKeyPath wasn't set in the directory scenario, set it now + if signedKeyPath == "" { + fileName := algoToFileName[infisicalSdkUtil.CertKeyAlgorithm(keyAlgorithm)] + signedKeyPath = filepath.Join(outputDir, fileName+"-cert.pub") + } + + if privateKeyPath == "" { + privateKeyPath = filepath.Join(outputDir, algoToFileName[infisicalSdkUtil.CertKeyAlgorithm(keyAlgorithm)]) + } + err = writeToFile(privateKeyPath, creds.PrivateKey, 0600) + if err != nil { + util.HandleError(err, "Failed to write Private Key to file") + } + + if publicKeyPath == "" { + publicKeyPath = privateKeyPath + ".pub" + } + err = writeToFile(publicKeyPath, creds.PublicKey, 0644) + if err != nil { + util.HandleError(err, "Failed to write Public Key to file") + } + + err = writeToFile(signedKeyPath, creds.SignedKey, 0644) + if err != nil { + util.HandleError(err, "Failed to write Signed Key to file") + } + + fmt.Println("Successfully wrote SSH certificate to:", signedKeyPath) + } + + // Add SSH credentials to the SSH agent if needed + if addToAgent { + // Call the helper function to handle add-to-agent flow + err := addCredentialsToAgent(creds.PrivateKey, creds.SignedKey) + if err != nil { + util.HandleError(err, "Failed to add keys to SSH agent") + } else { + fmt.Println("The SSH key and certificate have been successfully added to your ssh-agent.") + } + } +} + +func signKey(cmd *cobra.Command, args []string) { + + token, err := util.GetInfisicalToken(cmd) + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + var infisicalToken string + + if token != nil && (token.Type == util.SERVICE_TOKEN_IDENTIFIER || token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER) { + infisicalToken = token.Token + } else { + util.RequireLogin() + util.RequireLocalWorkspaceFile() + + 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 + } + + certificateTemplateId, err := cmd.Flags().GetString("certificateTemplateId") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + if certificateTemplateId == "" { + util.PrintErrorMessageAndExit("You must set the --certificateTemplateId flag") + } + + publicKey, err := cmd.Flags().GetString("publicKey") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + publicKeyFilePath, err := cmd.Flags().GetString("publicKeyFilePath") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + if publicKey == "" && publicKeyFilePath == "" { + util.HandleError(fmt.Errorf("either --publicKey or --publicKeyFilePath must be provided"), "Invalid input") + } + + if publicKey != "" && publicKeyFilePath != "" { + util.HandleError(fmt.Errorf("only one of --publicKey or --publicKeyFile can be provided"), "Invalid input") + } + + if publicKeyFilePath != "" { + if strings.HasPrefix(publicKeyFilePath, "~") { + // Expand the tilde (~) to the user's home directory + homeDir, err := os.UserHomeDir() + if err != nil { + util.HandleError(err, "Failed to resolve home directory") + } + publicKeyFilePath = strings.Replace(publicKeyFilePath, "~", homeDir, 1) + } + + // Ensure the file has a .pub extension + if !strings.HasSuffix(publicKeyFilePath, ".pub") { + util.HandleError(fmt.Errorf("public key file must have a .pub extension"), "Invalid input") + } + + content, err := os.ReadFile(publicKeyFilePath) + if err != nil { + util.HandleError(err, "Failed to read public key file") + } + + publicKey = strings.TrimSpace(string(content)) + } + + if strings.TrimSpace(publicKey) == "" { + util.HandleError(fmt.Errorf("Public key is empty"), "Invalid input") + } + + principalsStr, err := cmd.Flags().GetString("principals") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + // Check if the input string is empty before splitting + if principalsStr == "" { + util.HandleError(fmt.Errorf("no principals provided"), "The 'principals' flag cannot be empty") + } + + // Convert the comma-delimited string into a slice of strings + principals := strings.Split(principalsStr, ",") + for i, principal := range principals { + principals[i] = strings.TrimSpace(principal) + } + + certType, err := cmd.Flags().GetString("certType") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + if !isValidCertType(infisicalSdkUtil.SshCertType(certType)) { + util.HandleError(fmt.Errorf("invalid certType: %s", certType), + "Valid values: user, host") + } + + ttl, err := cmd.Flags().GetString("ttl") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + keyId, err := cmd.Flags().GetString("keyId") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + outFilePath, err := cmd.Flags().GetString("outFilePath") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + var ( + outputDir string + signedKeyPath string + ) + + if outFilePath == "" { + // Use current working directory + if err != nil { + util.HandleError(err, "Failed to get current working directory") + } + + // check if public key path exists + if publicKeyFilePath == "" { + util.PrintErrorMessageAndExit("--outFilePath must be specified when --publicKeyFilePath is not provided") + } + + outputDir = filepath.Dir(publicKeyFilePath) + // Derive the base name by removing "-cert.pub" + baseName := strings.TrimSuffix(filepath.Base(publicKeyFilePath), ".pub") + signedKeyPath = filepath.Join(outputDir, baseName+"-cert.pub") + } else { + // Expand ~ to home directory if present + 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) + } + + // Check if outFilePath ends with "-cert.pub" + if !strings.HasSuffix(outFilePath, "-cert.pub") { + util.PrintErrorMessageAndExit("--outFilePath must end with -cert.pub") + } + + // Extract the directory from outFilePath + outputDir = filepath.Dir(outFilePath) + + // Validate the output directory + info, err := os.Stat(outputDir) + if os.IsNotExist(err) { + // Directory does not exist; attempt to create it + err = os.MkdirAll(outputDir, 0755) + if err != nil { + util.HandleError(err, "Failed to create output directory") + } + } else if err != nil { + // Other errors accessing the directory + util.HandleError(err, "Failed to access output directory") + } else if !info.IsDir() { + // Path exists but is not a directory + util.PrintErrorMessageAndExit("The provided --outFilePath's directory is not valid") + } + + signedKeyPath = outFilePath + } + + infisicalClient := infisicalSdk.NewInfisicalClient(context.Background(), infisicalSdk.Config{ + SiteUrl: config.INFISICAL_URL, + UserAgent: api.USER_AGENT, + AutoTokenRefresh: false, + }) + infisicalClient.Auth().SetAccessToken(infisicalToken) + + creds, err := infisicalClient.Ssh().SignKey(infisicalSdk.SignSshPublicKeyOptions{ + CertificateTemplateID: certificateTemplateId, + PublicKey: publicKey, + Principals: principals, + CertType: infisicalSdkUtil.SshCertType(certType), + TTL: ttl, + KeyID: keyId, + }) + + if err != nil { + util.HandleError(err, "Failed to sign SSH public key") + } + + err = writeToFile(signedKeyPath, creds.SignedKey, 0644) + if err != nil { + util.HandleError(err, "Failed to write Signed Key to file") + } + + fmt.Println("Successfully wrote SSH certificate to:", signedKeyPath) +} + +func init() { + sshSignKeyCmd.Flags().String("token", "", "Issue SSH certificate using machine identity access token") + sshSignKeyCmd.Flags().String("certificateTemplateId", "", "The ID of the SSH certificate template to issue the SSH certificate for") + sshSignKeyCmd.Flags().String("publicKey", "", "The public key to sign") + sshSignKeyCmd.Flags().String("publicKeyFilePath", "", "The file path to the public key file to sign") + sshSignKeyCmd.Flags().String("outFilePath", "", "The path to write the SSH certificate to such as ~/.ssh/id_rsa-cert.pub. If not provided, the credentials will be saved to the directory of the specified public key file path or the current working directory") + sshSignKeyCmd.Flags().String("principals", "", "The principals that the certificate should be signed for") + sshSignKeyCmd.Flags().String("certType", string(infisicalSdkUtil.UserCert), "The cert type for the created certificate") + sshSignKeyCmd.Flags().String("ttl", "", "The ttl for the created certificate") + sshSignKeyCmd.Flags().String("keyId", "", "The keyId that the created certificate should have") + sshCmd.AddCommand(sshSignKeyCmd) + + sshIssueCredentialsCmd.Flags().String("token", "", "Issue SSH credentials using machine identity access token") + sshIssueCredentialsCmd.Flags().String("certificateTemplateId", "", "The ID of the SSH certificate template to issue SSH credentials for") + sshIssueCredentialsCmd.Flags().String("principals", "", "The principals to issue SSH credentials for") + sshIssueCredentialsCmd.Flags().String("keyAlgorithm", string(infisicalSdkUtil.RSA2048), "The key algorithm to issue SSH credentials for") + sshIssueCredentialsCmd.Flags().String("certType", string(infisicalSdkUtil.UserCert), "The cert type to issue SSH credentials for") + sshIssueCredentialsCmd.Flags().String("ttl", "", "The ttl to issue SSH credentials for") + sshIssueCredentialsCmd.Flags().String("keyId", "", "The keyId to issue SSH credentials for") + sshIssueCredentialsCmd.Flags().String("outFilePath", "", "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") + sshIssueCredentialsCmd.Flags().Bool("addToAgent", false, "Whether to add issued SSH credentials to the SSH agent") + sshCmd.AddCommand(sshIssueCredentialsCmd) + rootCmd.AddCommand(sshCmd) +} diff --git a/company/handbook/onboarding.mdx b/company/handbook/onboarding.mdx index bcf47a339..f86c15039 100644 --- a/company/handbook/onboarding.mdx +++ b/company/handbook/onboarding.mdx @@ -19,7 +19,7 @@ Every new joiner has an onboarding buddy who should ideally be in the the same t 1. Join the weekly all-hands meeting. It typically happens on Monday's at 8:30am PT. 2. Ship something together on day one – even if tiny! It feels great to hit the ground running, with a development environment all ready to go. 3. Check out the [Areas of Responsibility (AoR) Table](https://docs.google.com/spreadsheets/d/1RnXlGFg83Sgu0dh7ycuydsSobmFfI3A0XkGw7vrVxEI/edit?usp=sharing). This is helpful to know who you can ask about particular areas of Infisical. Feel free to add yourself to the areas you'd be most interesting to dive into. -4. Read the [Infisical Strategy Doc](https://docs.google.com/document/d/1RaJd3RoS2QpWLFHlgfHaXnHqCCwRt6mCGZkbJ75J_D0/edit?usp=sharing). +4. Read the [Infisical Strategy Doc](https://docs.google.com/document/d/1uV9IaahYwbZ5OuzDTFdQMSa1P0mpMOnetGB-xqf4G40). 5. Update your LinkedIn profile with one of [Infisical's official banners](https://drive.google.com/drive/u/0/folders/1oSNWjbpRl9oNYwxM_98IqzKs9fAskrb2) (if you want to). You can also coordinate your social posts in the #marketing Slack channel, so that we can boost it from Infisical's official social media accounts. 6. Over the first few weeks, feel free to schedule 1:1s with folks on the team to get to know them a bit better. 7. Change your Slack username in the users channel to `[NAME] (Infisical)`. diff --git a/docs/api-reference/endpoints/app-connections/aws/create.mdx b/docs/api-reference/endpoints/app-connections/aws/create.mdx new file mode 100644 index 000000000..2fd1602ed --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/aws/create.mdx @@ -0,0 +1,9 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/aws" +--- + + + Check out the configuration docs for [AWS Connections](/integrations/app-connections/aws) to learn how to obtain + the required credentials. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/aws/delete.mdx b/docs/api-reference/endpoints/app-connections/aws/delete.mdx new file mode 100644 index 000000000..e6030257f --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/aws/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/aws/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/aws/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/aws/get-by-id.mdx new file mode 100644 index 000000000..0a057cc1b --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/aws/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/aws/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/aws/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/aws/get-by-name.mdx new file mode 100644 index 000000000..d18994f7c --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/aws/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/aws/name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/aws/list.mdx b/docs/api-reference/endpoints/app-connections/aws/list.mdx new file mode 100644 index 000000000..5ea0c50a0 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/aws/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/aws" +--- diff --git a/docs/api-reference/endpoints/app-connections/aws/update.mdx b/docs/api-reference/endpoints/app-connections/aws/update.mdx new file mode 100644 index 000000000..4fd3a4a00 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/aws/update.mdx @@ -0,0 +1,9 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/aws/{connectionId}" +--- + + + Check out the configuration docs for [AWS Connections](/integrations/app-connections/aws) to learn how to obtain + the required credentials. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/github/create.mdx b/docs/api-reference/endpoints/app-connections/github/create.mdx new file mode 100644 index 000000000..1e06fd64f --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github/create.mdx @@ -0,0 +1,10 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/github" +--- + + + GitHub Connections must be created through the Infisical UI. + Check out the configuration docs for [GitHub Connections](/integrations/app-connections/github) for a step-by-step + guide. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/github/delete.mdx b/docs/api-reference/endpoints/app-connections/github/delete.mdx new file mode 100644 index 000000000..6b4f2e676 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/github/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/github/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/github/get-by-id.mdx new file mode 100644 index 000000000..c85d41d37 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/github/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/github/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/github/get-by-name.mdx new file mode 100644 index 000000000..95ddbd6e9 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/github/name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/github/list.mdx b/docs/api-reference/endpoints/app-connections/github/list.mdx new file mode 100644 index 000000000..c4b13b8eb --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/github" +--- diff --git a/docs/api-reference/endpoints/app-connections/github/update.mdx b/docs/api-reference/endpoints/app-connections/github/update.mdx new file mode 100644 index 000000000..7e2326c60 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github/update.mdx @@ -0,0 +1,10 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/github/{connectionId}" +--- + + + GitHub Connections must be updated through the Infisical UI. + Check out the configuration docs for [GitHub Connections](/integrations/app-connections/github) for a step-by-step + guide. + diff --git a/docs/api-reference/endpoints/app-connections/list.mdx b/docs/api-reference/endpoints/app-connections/list.mdx new file mode 100644 index 000000000..e7ee6b009 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections" +--- diff --git a/docs/api-reference/endpoints/app-connections/options.mdx b/docs/api-reference/endpoints/app-connections/options.mdx new file mode 100644 index 000000000..7cc03aca3 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/options.mdx @@ -0,0 +1,4 @@ +--- +title: "Options" +openapi: "GET /api/v1/app-connections/options" +--- diff --git a/docs/api-reference/endpoints/jwt-auth/attach.mdx b/docs/api-reference/endpoints/jwt-auth/attach.mdx new file mode 100644 index 000000000..f2905f1a0 --- /dev/null +++ b/docs/api-reference/endpoints/jwt-auth/attach.mdx @@ -0,0 +1,4 @@ +--- +title: "Attach" +openapi: "POST /api/v1/auth/jwt-auth/identities/{identityId}" +--- diff --git a/docs/api-reference/endpoints/jwt-auth/login.mdx b/docs/api-reference/endpoints/jwt-auth/login.mdx new file mode 100644 index 000000000..c037fbf7f --- /dev/null +++ b/docs/api-reference/endpoints/jwt-auth/login.mdx @@ -0,0 +1,4 @@ +--- +title: "Login" +openapi: "POST /api/v1/auth/jwt-auth/login" +--- diff --git a/docs/api-reference/endpoints/jwt-auth/retrieve.mdx b/docs/api-reference/endpoints/jwt-auth/retrieve.mdx new file mode 100644 index 000000000..8100ef843 --- /dev/null +++ b/docs/api-reference/endpoints/jwt-auth/retrieve.mdx @@ -0,0 +1,4 @@ +--- +title: "Retrieve" +openapi: "GET /api/v1/auth/jwt-auth/identities/{identityId}" +--- diff --git a/docs/api-reference/endpoints/jwt-auth/revoke.mdx b/docs/api-reference/endpoints/jwt-auth/revoke.mdx new file mode 100644 index 000000000..13a61475a --- /dev/null +++ b/docs/api-reference/endpoints/jwt-auth/revoke.mdx @@ -0,0 +1,4 @@ +--- +title: "Revoke" +openapi: "DELETE /api/v1/auth/jwt-auth/identities/{identityId}" +--- diff --git a/docs/api-reference/endpoints/jwt-auth/update.mdx b/docs/api-reference/endpoints/jwt-auth/update.mdx new file mode 100644 index 000000000..8a53907ab --- /dev/null +++ b/docs/api-reference/endpoints/jwt-auth/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/auth/jwt-auth/identities/{identityId}" +--- diff --git a/docs/api-reference/endpoints/ssh/ca/create.mdx b/docs/api-reference/endpoints/ssh/ca/create.mdx new file mode 100644 index 000000000..b053d0133 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/ca/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/ssh/ca" +--- diff --git a/docs/api-reference/endpoints/ssh/ca/delete.mdx b/docs/api-reference/endpoints/ssh/ca/delete.mdx new file mode 100644 index 000000000..989fd1c4b --- /dev/null +++ b/docs/api-reference/endpoints/ssh/ca/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/ssh/ca/{sshCaId}" +--- diff --git a/docs/api-reference/endpoints/ssh/ca/list-certificate-templates.mdx b/docs/api-reference/endpoints/ssh/ca/list-certificate-templates.mdx new file mode 100644 index 000000000..632a9f8c0 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/ca/list-certificate-templates.mdx @@ -0,0 +1,4 @@ +--- +title: "List templates" +openapi: "GET /api/v1/ssh/ca/{sshCaId}/certificate-templates" +--- diff --git a/docs/api-reference/endpoints/ssh/ca/list.mdx b/docs/api-reference/endpoints/ssh/ca/list.mdx new file mode 100644 index 000000000..c31dd4099 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/ca/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/workspace/{projectId}/ssh-cas" +--- diff --git a/docs/api-reference/endpoints/ssh/ca/public-key.mdx b/docs/api-reference/endpoints/ssh/ca/public-key.mdx new file mode 100644 index 000000000..1f9b570d1 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/ca/public-key.mdx @@ -0,0 +1,4 @@ +--- +title: "Retrieve public key" +openapi: "GET /api/v1/ssh/ca/{sshCaId}/public-key" +--- diff --git a/docs/api-reference/endpoints/ssh/ca/read.mdx b/docs/api-reference/endpoints/ssh/ca/read.mdx new file mode 100644 index 000000000..9f5eda90a --- /dev/null +++ b/docs/api-reference/endpoints/ssh/ca/read.mdx @@ -0,0 +1,4 @@ +--- +title: "Retrieve" +openapi: "GET /api/v1/ssh/ca/{sshCaId}" +--- diff --git a/docs/api-reference/endpoints/ssh/ca/update.mdx b/docs/api-reference/endpoints/ssh/ca/update.mdx new file mode 100644 index 000000000..8ec2dc7ad --- /dev/null +++ b/docs/api-reference/endpoints/ssh/ca/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/ssh/ca/{sshCaId}" +--- diff --git a/docs/api-reference/endpoints/ssh/certificate-templates/create.mdx b/docs/api-reference/endpoints/ssh/certificate-templates/create.mdx new file mode 100644 index 000000000..6e3beef1a --- /dev/null +++ b/docs/api-reference/endpoints/ssh/certificate-templates/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/ssh/certificate-templates" +--- diff --git a/docs/api-reference/endpoints/ssh/certificate-templates/delete.mdx b/docs/api-reference/endpoints/ssh/certificate-templates/delete.mdx new file mode 100644 index 000000000..1fa776276 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/certificate-templates/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/ssh/certificate-templates/{certificateTemplateId}" +--- diff --git a/docs/api-reference/endpoints/ssh/certificate-templates/list.mdx b/docs/api-reference/endpoints/ssh/certificate-templates/list.mdx new file mode 100644 index 000000000..4331db1de --- /dev/null +++ b/docs/api-reference/endpoints/ssh/certificate-templates/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/workspace/{projectId}/ssh-certificate-templates" +--- diff --git a/docs/api-reference/endpoints/ssh/certificate-templates/read.mdx b/docs/api-reference/endpoints/ssh/certificate-templates/read.mdx new file mode 100644 index 000000000..13a356688 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/certificate-templates/read.mdx @@ -0,0 +1,4 @@ +--- +title: "Retrieve" +openapi: "GET /api/v1/ssh/certificate-templates/{certificateTemplateId}" +--- diff --git a/docs/api-reference/endpoints/ssh/certificate-templates/update.mdx b/docs/api-reference/endpoints/ssh/certificate-templates/update.mdx new file mode 100644 index 000000000..f566d7535 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/certificate-templates/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/ssh/certificate-templates/{certificateTemplateId}" +--- diff --git a/docs/api-reference/endpoints/ssh/certificates/issue-credentials.mdx b/docs/api-reference/endpoints/ssh/certificates/issue-credentials.mdx new file mode 100644 index 000000000..4a6da70b3 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/certificates/issue-credentials.mdx @@ -0,0 +1,4 @@ +--- +title: "Issue SSH Credentials" +openapi: "POST /api/v1/ssh/certificates/issue" +--- diff --git a/docs/api-reference/endpoints/ssh/certificates/sign-key.mdx b/docs/api-reference/endpoints/ssh/certificates/sign-key.mdx new file mode 100644 index 000000000..0843b34a2 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/certificates/sign-key.mdx @@ -0,0 +1,4 @@ +--- +title: "Sign SSH Public Key" +openapi: "POST /api/v1/ssh/certificates/sign" +--- diff --git a/docs/cli/commands/ssh.mdx b/docs/cli/commands/ssh.mdx new file mode 100644 index 000000000..78712ba6f --- /dev/null +++ b/docs/cli/commands/ssh.mdx @@ -0,0 +1,116 @@ +--- +title: "infisical ssh" +description: "Generate SSH credentials with the CLI" +--- + +## Description + +[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. + +### Sub-commands + + + This command is used to issue SSH credentials (SSH certificate, public key, and private key) against a certificate template. + + We recommend using the `--addToAgent` flag to automatically load issued SSH credentials to the SSH agent. + + ```bash + $ infisical ssh issue-credentials --certificateTemplateId= --principals= --addToAgent + ``` + + ### Flags + + The ID of the SSH certificate template to issue SSH credentials for. + + + A comma-separated list of principals (i.e. usernames like `ec2-user` or hostnames) to issue SSH credentials for. + + + 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. + + + + + This command is used to sign an existing SSH public key against a certificate template; the command outputs the corresponding signed SSH certificate. + + ```bash + $ infisical ssh sign-key --certificateTemplateId= --publicKey= --principals= --outFilePath= + ``` + + The ID of the SSH certificate template to issue the SSH certificate for. + + + The public key to sign. + + Note that either the `--publicKey` or `--publicKeyFilePath` flag must be set for the sub-command to execute successfully. + + + The path to the public key file to sign. + + Note that either the `--publicKey` or `--publicKeyFilePath` flag must be set for the sub-command to execute successfully. + + + A comma-separated list of principals (i.e. usernames like `ec2-user` or hostnames) to issue SSH credentials for. + + + The path to write the SSH certificate to such as `~/.ssh/id_rsa-cert.pub`; the specified file must have the `.pub` extension. If not provided, the credentials will be saved to the directory of the specified `--publicKeyFilePath` or the current working directory where the command is run. + + + 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/documentation/platform/identities/jwt-auth.mdx b/docs/documentation/platform/identities/jwt-auth.mdx new file mode 100644 index 000000000..3dcf12b29 --- /dev/null +++ b/docs/documentation/platform/identities/jwt-auth.mdx @@ -0,0 +1,169 @@ +--- +title: JWT Auth +description: "Learn how to authenticate with Infisical using JWT-based authentication." +--- + +**JWT Auth** is a platform-agnostic authentication method that validates JSON Web Tokens (JWTs) issued by your JWT issuer or authentication system, allowing secure authentication from any platform or environment that can obtain valid JWTs. + +## Diagram + +The following sequence diagram illustrates the JWT Auth workflow for authenticating with Infisical. + +```mermaid +sequenceDiagram + participant Client as Client Application + participant Issuer as JWT Issuer + participant Infis as Infisical + + Client->>Issuer: Step 1: Request JWT token + Issuer-->>Client: Return signed JWT with claims + + Note over Client,Infis: Step 2: Login Operation + Client->>Infis: Send signed JWT to /api/v1/auth/jwt-auth/login + + Note over Infis: Step 3: JWT Validation + Infis->>Infis: Validate JWT signature using configured public keys or JWKS + Infis->>Infis: Verify required claims (aud, sub, iss) + + Note over Infis: Step 4: Token Generation + Infis->>Client: Return short-lived access token + + Note over Client,Infis: Step 5: Access Infisical API with Token + Client->>Infis: Make authenticated requests using the short-lived access token +``` + +## Concept + +At a high-level, Infisical authenticates a client by verifying the JWT and checking that it meets specific requirements (e.g. it is signed by a trusted key) at the `/api/v1/auth/jwt-auth/login` endpoint. If successful, then Infisical returns a short-lived access token that can be used to make authenticated requests to the Infisical API. + +To be more specific: + +1. The client requests a JWT from their JWT issuer. +2. The fetched JWT is sent to Infisical at the `/api/v1/auth/jwt-auth/login` endpoint. +3. Infisical validates the JWT signature using either: + - Pre-configured public keys (Static configuration) + - Public keys fetched from a JWKS endpoint (JWKS configuration) +4. Infisical verifies that the configured claims match in the token. This includes standard claims like subject, audience, and issuer, as well as any additional custom claims specified in the configuration. +5. If all is well, Infisical returns a short-lived access token that the client can use to make authenticated requests to the Infisical API. + + + For JWKS configuration, Infisical needs network-level access to the configured + JWKS endpoint. + + +## Guide + +In the following steps, we explore how to create and use identities to access the Infisical API using the JWT authentication method. + + + + To create an identity, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**. + + ![identities organization](/images/platform/identities/identities-org.png) + + When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + + ![identities organization create](/images/platform/identities/identities-org-create.png) + + Now input a few details for your new identity. Here's some guidance for each field: + + - Name (required): A friendly name for the identity. + - Role (required): A role from the **Organization Roles** tab for the identity to assume. The organization role assigned will determine what organization level resources this identity can have access to. + + Once you've created an identity, you'll be redirected to a page where you can manage the identity. + + ![identities page](/images/platform/identities/identities-page.png) + + Since the identity has been configured with Universal Auth by default, you should re-configure it to use JWT Auth instead. To do this, press to edit the **Authentication** section, + remove the existing Universal Auth configuration, and add a new JWT Auth configuration onto the identity. + + ![identities page remove default auth](/images/platform/identities/identities-page-remove-default-auth.png) + + ![identities create jwt auth method](/images/platform/identities/identities-org-create-jwt-auth-method-jwks.png) + ![identities create jwt auth method](/images/platform/identities/identities-org-create-jwt-auth-method-static.png) + + Restrict access by properly configuring the JWT validation settings. + + Here's some more guidance for each field: + + **Static configuration**: + - Public Keys: One or more PEM-encoded public keys (RSA or ECDSA) used to verify JWT signatures. Each key must include the proper BEGIN/END markers. + + **JWKS configuration**: + - JWKS URL: The endpoint URL that serves your JSON Web Key Sets (JWKS). This endpoint must provide the public keys used for JWT signature verification. + - JWKS CA Certificate: Optional PEM-encoded CA certificate used for validating the TLS connection to the JWKS endpoint. + + **Common fields for both configurations**: + - Issuer: The unique identifier of the JWT provider. This value is used to verify the iss (issuer) claim in the JWT. + - Audiences: A list of intended recipients. This value is checked against the aud (audience) claim in the token. + - Subject: The expected principal that is the subject of the JWT. This value is checked against the sub (subject) claim in the token. + - Claims: Additional claims that must be present in the JWT for it to be valid. You can specify required claim names and their expected values. + - Access Token TTL (default is `2592000` equivalent to 30 days): The lifetime for an access token in seconds. This value will be referenced at renewal time. + - Access Token Max TTL (default is `2592000` equivalent to 30 days): The maximum lifetime for an access token in seconds. This value will be referenced at renewal time. + - Access Token Max Number of Uses (default is `0`): The maximum number of times that an access token can be used; a value of `0` implies infinite number of uses. + - Access Token Trusted IPs: The IPs or CIDR ranges that access tokens can be used from. By default, each token is given the `0.0.0.0/0`, allowing usage from any network address. + + The `subject`, `audiences`, and `claims` fields support glob pattern matching; however, we highly recommend using hardcoded values whenever possible. + + + + To enable the identity to access project-level resources such as secrets within a specific project, you should add it to that project. + + To do this, head over to the project you want to add the identity to and go to Project Settings > Access Control > Machine Identities and press **Add identity**. + + Next, select the identity you want to add to the project and the project level role you want to allow it to assume. The project role assigned will determine what project level resources this identity can have access to. + + ![identities project](/images/platform/identities/identities-project.png) + + ![identities project create](/images/platform/identities/identities-project-create.png) + + + + To access the Infisical API as the identity, you will need to obtain a JWT from your JWT issuer that meets the validation requirements configured in step 2. + + Once you have obtained a valid JWT, you can use it to authenticate with Infisical at the `/api/v1/auth/jwt-auth/login` endpoint. + + We provide a code example below of how you might use the JWT to authenticate with Infisical to gain access to the [Infisical API](/api-reference/overview/introduction). + + + The shown example uses Node.js but you can use any other language to authenticate with Infisical using your JWT. + + ```javascript + try { + // Obtain JWT from your issuer + const jwt = ""; + + const infisicalUrl = "https://app.infisical.com"; // or your self-hosted Infisical URL + const identityId = ""; + + const { data } = await axios.post( + `{infisicalUrl}/api/v1/auth/jwt-auth/login`, + { + identityId, + jwt, + } + ); + + console.log("result data: ", data); // access token here + } catch(err) { + console.error(err); + } + ``` + + + + We recommend using one of Infisical's clients like SDKs or the Infisical Agent to authenticate with Infisical using JWT Auth as they handle the authentication process for you. + + + + Each identity access token has a time-to-live (TTL) which you can infer from the response of the login operation; + the default TTL is `2592000` seconds (30 days) which can be adjusted in the configuration. + + If an identity access token exceeds its max TTL or maximum number of uses, it can no longer authenticate with the Infisical API. In this case, + a new access token should be obtained by performing another login operation with a valid JWT. + + + + diff --git a/docs/documentation/platform/ssh.mdx b/docs/documentation/platform/ssh.mdx new file mode 100644 index 000000000..16faf1267 --- /dev/null +++ b/docs/documentation/platform/ssh.mdx @@ -0,0 +1,210 @@ +--- +title: "Infisical SSH" +sidebarTitle: "Infisical SSH" +description: "Learn how to generate SSH credentials to provide secure and centralized SSH access control for your infrastructure." +--- + +## Concept + +Infisical can be used to issue SSH credentials to clients to provide short-lived, secure SSH access to infrastructure; +this improves on many limitations of traditional SSH key-based authentication via mitigation of private key compromise, static key management, +unauthorized access, and SSH key sprawl. + +The following concepts are useful to know when working with Infisical SSH: + +- SSH Certificate Authority (CA): A trusted authority that issues SSH certificates. +- Certificate Template: A set of policies bound to a SSH CA for certificates issued under that template; a CA can possess multiple templates, each with different policies for a different purpose (e.g. for admin versus developer access). +- SSH Certificate: A short-lived, credential issued by the SSH CA granting time-bound access to infrastructure. + +
+ +```mermaid +graph TD + A[SSH CA] + A --> B[Certificate Template A] + A --> C[Certificate Template N] + B --> D[SSH Certificate A] + C --> E[SSH Certificate N] + +``` + +
+ +When using Infisical SSH to provision client access to a remote host, an operator must create a SSH CA in Infisical; a certificate template under it, +specifying policies such as allowed users that can be requested under that template by a client; and configure the host to trust certificates issued by the Infisical SSH CA. + +When a client needs access to a host, they authenticate with Infisical and request a SSH certificate (and optionally key pair) +to be used to access the host for a time-bound session as part of the SSH operation. + +## Client Workflow + +The following sequence diagram illustrates the client workflow for accessing a remote host using an SSH certificate (and optionally key pair) +supplied by Infisical. + +```mermaid +sequenceDiagram + participant Client as Client + participant Infisical as Infisical (SSH CA) + participant Host as Remote Host + + Note over Client,Client: Step 1: Client Authentication with Infisical + Client->>Infisical: Send credential(s) to authenticate with Infisical + + Infisical-->>Client: Return access token + + Note over Client,Infisical: Step 2: SSH Certificate Request + Client->>Infisical: Make authenticated request for SSH certificate via either /api/v1/ssh/issue or /api/v1/ssh/sign + + Infisical-->>Client: Return signed SSH certificate (and optionally key pair) + + Note over Client,Client: Step 3: SSH Operation + Client->>Host: SSH into Host using the SSH certificate + + Host-->>Client: Grant access to the host +``` + +At a high-level, Infisical issues a signed SSH certificate to a client that can be used to access a remote host. + +To be more specific: + +1. The client authenticates with Infisical; this can be done using a machine identity [authentication method](/documentation/platform/identities/machine-identities) or a user [authentication method](/documentation/platform/identities/user-identities). +2. The client makes an authenticated request for an SSH certificate via either the `/api/v1/ssh/issue` or `/api/v1/ssh/sign` endpoints. Note that if the client wishes to use an existing SSH key pair, it can use the `/api/v1/ssh/sign` endpoint; otherwise, it can use the `/api/v1/ssh/issue` endpoint to have Infisical issue a new SSH key pair in conjunction with the certificate. +3. The client uses the issued SSH certificate (and potentially SSH key pair) to temporarily access the host. + + + Note that the workflow above requires an operator to perform additional + configuration on the remote host to trust SSH certificates issued by + Infisical. + + +## Guide to Configuring Infisical SSH + +In the following steps, we explore how to configure Infisical SSH to start issuing SSH certificates to clients as well as a remote host to trust these certificates +as part of the SSH operation. + + + + 1.1. Start by creating a SSH project in the SSH tab of your organization. + + ![ssh project create](/images/platform/ssh/ssh-project.png) + + 1.2. Next, create a CA in the **Certificate Authorities** tab of the + project. + + ![ssh create ca](/images/platform/ssh/ssh-create-ca-1.png) + + ![ssh create ca popup](/images/platform/ssh/ssh-create-ca-2.png) + + Here's some guidance on each field: + + - Friendly Name: A friendly name for the CA; this is only for display. + - Key Algorithm: The type of public key algorithm and size, in bits, of the key pair for the CA. Supported key algorithms are `RSA 2048`, `RSA 4096`, `ECDSA P-256`, and `ECDSA P-384` with the default being `RSA 2048`. + + 1.3. Next, create a certificate template in the **Certificate Templates** section of the newly-created CA. + + A certificate template is a set of policies for certificates issued under that template; each template is bound to a specific CA. + + With certificate templates, you can specify, for example, that certificates issued under a template are only allowed for users with a specific username like `ec2-user` or perhaps that the max TTL requested cannot exceed 1 year. + + ![ssh create template](/images/platform/ssh/ssh-create-template-1.png) + + ![ssh create template popup](/images/platform/ssh/ssh-create-template-2.png) + + Here's some guidance on each field: + + - SSH Template Name: A name for the certificate template; this must be a valid slug. + - Allowed Users: A comma-separated list of valid usernames (e.g. `ec2-user`) on the remote host for which a client can request a certificate for. If you wish to allow a client to request a certificate for any username, set this to `*`; alternatively, if left blank, the template will not allow issuance of certificates under any username. + - Allowed Hosts: A comma-separated list of valid hostnames/domains on the remote host for which a client can request a certificate for. Each item in the list can be either a wildcard hostname (e.g. `*.acme.com`), a specific hostname (e.g. `example.com`), an IPv4 address (e.g. `192.168.1.1`), or an IPv6 address. If left empty, the template will not allow any hostnames; if set to `*`, the template will allow any hostname. + - Default TTL: The default Time-to-Live (TTL) for certificates issued under this template when a client does not explicitly specify a TTL in the certificate request. + - Max TTL: The maximum TTL for certificates issued under this template. + - Allow User Certificates: Whether or not to allow issuance of user certificates. + - Allow Host Certificates: Whether or not to allow issuance of host certificates. + - Allow Custom Key IDs: Whether or not to allow clients to specify a custom key ID to be included on the certificate as part of the certificate request. + + 1.4. Finally, add the user(s) you wish to be able to request a SSH certificate to the SSH project through the **Access Control** tab. + + + + + 2.1. Begin by downloading the CA's public key from the CA's details section. + + ![ssh ca public key](/images/platform/ssh/ssh-ca-public-key.png) + + + The CA's public key can also be retrieved programmatically via API by making a `GET` request to the `/ssh/ca//public-key` endpoint. + + + 2.2. Next, create a file containing this public key in the SSH folder of the remote host; we'll call the file `ca.pub`. + + This would result in the file at the path `/etc/ssh/ca.pub`. + + 2.3. Next, add the following lines to the `/etc/ssh/sshd_config` file on the remote host. + + ```bash + TrustedUserCAKeys /etc/ssh/ca.pub + + PubkeyAcceptedKeyTypes=+ssh-rsa,ssh-rsa-cert-v01@openssh.com + ``` + + 2.4. Finally, reload the SSH daemon on the remote host to apply the changes. + + ```bash + sudo systemctl reload sshd + ``` + + At this point, the remote host is configured to trust SSH certificates issued by the Infisical SSH CA. + + + + +## Guide to Using Infisical SSH to Access a Host + +We show how to obtain a SSH certificate and use it for a client to access a host via CLI: + + + The subsequent guide assumes the following prerequisites: + +- SSH Agent is running: The `ssh-agent` must be actively running on the host machine. +- OpenSSH is installed: The system should have OpenSSH installed; this includes + both the `ssh` client and `ssh-agent`. +- `SSH_AUTH_SOCK` environment variable + is set; the `SSH_AUTH_SOCK` variable should point to the UNIX socket that + `ssh-agent` uses for communication. + + + + + + +```bash +infisical login +``` + + + + Run the `infisical ssh issue-credentials` command, specifying the `--addToAgent` flag to automatically load the SSH certificate into the SSH agent. + ```bash + infisical ssh issue-credentials --certificateTemplateId= --principals= --addToAgent + ``` + + Here's some guidance on each flag: + + - `certificateTemplateId`: The ID of the certificate template to use for issuing the SSH certificate. + - `principals`: The comma-delimited username(s) or hostname(s) to include in the SSH certificate. + + For fuller documentation on commands and flags supported by the Infisical CLI for SSH, refer to the docs [here](/cli/commands/ssh). + + + + Finally, SSH into the desired host; the SSH operation will be performed using the SSH certificate loaded into the SSH agent. + + ```bash + ssh username@hostname + ``` + + + + + Note that the above workflow can be executed via API or other client methods + such as SDK. + \ No newline at end of file diff --git a/docs/images/app-connections/aws/access-key-connection.png b/docs/images/app-connections/aws/access-key-connection.png new file mode 100644 index 000000000..9c70da623 Binary files /dev/null and b/docs/images/app-connections/aws/access-key-connection.png differ diff --git a/docs/images/app-connections/aws/assume-role-connection.png b/docs/images/app-connections/aws/assume-role-connection.png new file mode 100644 index 000000000..c01f2e016 Binary files /dev/null and b/docs/images/app-connections/aws/assume-role-connection.png differ diff --git a/docs/images/app-connections/aws/create-access-key-method.png b/docs/images/app-connections/aws/create-access-key-method.png new file mode 100644 index 000000000..a82cca038 Binary files /dev/null and b/docs/images/app-connections/aws/create-access-key-method.png differ diff --git a/docs/images/app-connections/aws/create-assume-role-method.png b/docs/images/app-connections/aws/create-assume-role-method.png new file mode 100644 index 000000000..4b422222d Binary files /dev/null and b/docs/images/app-connections/aws/create-assume-role-method.png differ diff --git a/docs/images/app-connections/aws/parameter-store-permissions.png b/docs/images/app-connections/aws/parameter-store-permissions.png new file mode 100644 index 000000000..1fb2b8118 Binary files /dev/null and b/docs/images/app-connections/aws/parameter-store-permissions.png differ diff --git a/docs/images/app-connections/aws/secrets-manager-permissions.png b/docs/images/app-connections/aws/secrets-manager-permissions.png new file mode 100644 index 000000000..57d2eb2e2 Binary files /dev/null and b/docs/images/app-connections/aws/secrets-manager-permissions.png differ diff --git a/docs/images/app-connections/aws/select-aws-connection.png b/docs/images/app-connections/aws/select-aws-connection.png new file mode 100644 index 000000000..0cd51bb7f Binary files /dev/null and b/docs/images/app-connections/aws/select-aws-connection.png differ diff --git a/docs/images/app-connections/general/add-connection.png b/docs/images/app-connections/general/add-connection.png new file mode 100644 index 000000000..97718065a Binary files /dev/null and b/docs/images/app-connections/general/add-connection.png differ diff --git a/docs/images/app-connections/github/create-github-app-method.png b/docs/images/app-connections/github/create-github-app-method.png new file mode 100644 index 000000000..640fb0213 Binary files /dev/null and b/docs/images/app-connections/github/create-github-app-method.png differ diff --git a/docs/images/app-connections/github/create-oauth-method.png b/docs/images/app-connections/github/create-oauth-method.png new file mode 100644 index 000000000..4898a0de0 Binary files /dev/null and b/docs/images/app-connections/github/create-oauth-method.png differ diff --git a/docs/images/app-connections/github/github-app-connection.png b/docs/images/app-connections/github/github-app-connection.png new file mode 100644 index 000000000..3d81bc182 Binary files /dev/null and b/docs/images/app-connections/github/github-app-connection.png differ diff --git a/docs/images/app-connections/github/install-github-app.png b/docs/images/app-connections/github/install-github-app.png new file mode 100644 index 000000000..3b09ed485 Binary files /dev/null and b/docs/images/app-connections/github/install-github-app.png differ diff --git a/docs/images/app-connections/github/oauth-connection.png b/docs/images/app-connections/github/oauth-connection.png new file mode 100644 index 000000000..bf907256c Binary files /dev/null and b/docs/images/app-connections/github/oauth-connection.png differ diff --git a/docs/images/app-connections/github/select-github-connection.png b/docs/images/app-connections/github/select-github-connection.png new file mode 100644 index 000000000..2856d0a39 Binary files /dev/null and b/docs/images/app-connections/github/select-github-connection.png differ diff --git a/docs/images/integrations/azure-app-configuration/create-integration-form.png b/docs/images/integrations/azure-app-configuration/create-integration-form.png index 58a935d8f..af2cbb062 100644 Binary files a/docs/images/integrations/azure-app-configuration/create-integration-form.png and b/docs/images/integrations/azure-app-configuration/create-integration-form.png differ diff --git a/docs/images/integrations/azure-key-vault/integrations-azure-key-vault-tenant-select.png b/docs/images/integrations/azure-key-vault/integrations-azure-key-vault-tenant-select.png new file mode 100644 index 000000000..6c63aaea1 Binary files /dev/null and b/docs/images/integrations/azure-key-vault/integrations-azure-key-vault-tenant-select.png differ diff --git a/docs/images/integrations/circleci/integrations-circleci-auth.png b/docs/images/integrations/circleci/integrations-circleci-auth.png index 055ebbf4a..73a5fd686 100644 Binary files a/docs/images/integrations/circleci/integrations-circleci-auth.png and b/docs/images/integrations/circleci/integrations-circleci-auth.png differ diff --git a/docs/images/integrations/circleci/integrations-circleci-create-context.png b/docs/images/integrations/circleci/integrations-circleci-create-context.png new file mode 100644 index 000000000..9d911953e Binary files /dev/null and b/docs/images/integrations/circleci/integrations-circleci-create-context.png differ diff --git a/docs/images/integrations/circleci/integrations-circleci-create-project.png b/docs/images/integrations/circleci/integrations-circleci-create-project.png new file mode 100644 index 000000000..73ab1e75a Binary files /dev/null and b/docs/images/integrations/circleci/integrations-circleci-create-project.png differ diff --git a/docs/images/integrations/circleci/integrations-circleci.png b/docs/images/integrations/circleci/integrations-circleci.png index 5ee9a9df0..cde74678d 100644 Binary files a/docs/images/integrations/circleci/integrations-circleci.png and b/docs/images/integrations/circleci/integrations-circleci.png differ diff --git a/docs/images/platform/identities/identities-org-create-jwt-auth-method-jwks.png b/docs/images/platform/identities/identities-org-create-jwt-auth-method-jwks.png new file mode 100644 index 000000000..1f693b346 Binary files /dev/null and b/docs/images/platform/identities/identities-org-create-jwt-auth-method-jwks.png differ diff --git a/docs/images/platform/identities/identities-org-create-jwt-auth-method-static.png b/docs/images/platform/identities/identities-org-create-jwt-auth-method-static.png new file mode 100644 index 000000000..5d434a6e0 Binary files /dev/null and b/docs/images/platform/identities/identities-org-create-jwt-auth-method-static.png differ diff --git a/docs/images/platform/ssh/ssh-ca-public-key.png b/docs/images/platform/ssh/ssh-ca-public-key.png new file mode 100644 index 000000000..42653df4a Binary files /dev/null and b/docs/images/platform/ssh/ssh-ca-public-key.png differ diff --git a/docs/images/platform/ssh/ssh-create-ca-1.png b/docs/images/platform/ssh/ssh-create-ca-1.png new file mode 100644 index 000000000..e9a5b7f06 Binary files /dev/null and b/docs/images/platform/ssh/ssh-create-ca-1.png differ diff --git a/docs/images/platform/ssh/ssh-create-ca-2.png b/docs/images/platform/ssh/ssh-create-ca-2.png new file mode 100644 index 000000000..63025025f Binary files /dev/null and b/docs/images/platform/ssh/ssh-create-ca-2.png differ diff --git a/docs/images/platform/ssh/ssh-create-template-1.png b/docs/images/platform/ssh/ssh-create-template-1.png new file mode 100644 index 000000000..9d9420948 Binary files /dev/null and b/docs/images/platform/ssh/ssh-create-template-1.png differ diff --git a/docs/images/platform/ssh/ssh-create-template-2.png b/docs/images/platform/ssh/ssh-create-template-2.png new file mode 100644 index 000000000..7b93e6d80 Binary files /dev/null and b/docs/images/platform/ssh/ssh-create-template-2.png differ diff --git a/docs/images/platform/ssh/ssh-project.png b/docs/images/platform/ssh/ssh-project.png new file mode 100644 index 000000000..0b57f9245 Binary files /dev/null and b/docs/images/platform/ssh/ssh-project.png differ diff --git a/docs/integrations/app-connections/aws.mdx b/docs/integrations/app-connections/aws.mdx new file mode 100644 index 000000000..65af1bcdc --- /dev/null +++ b/docs/integrations/app-connections/aws.mdx @@ -0,0 +1,354 @@ +--- +title: "AWS Connection" +description: "Learn how to configure an AWS Connection for Infisical." +--- + +Infisical supports two methods for connecting to AWS. + + + + Infisical will assume the provided role in your AWS account securely, without the need to share any credentials. + + **Prerequisites:** + + - Set up and add envars to [Infisical Cloud](https://app.infisical.com) + + + To connect your self-hosted Infisical instance with AWS, you need to set up an AWS IAM User account that can assume the configured AWS IAM Role. + + If your instance is deployed on AWS, the aws-sdk will automatically retrieve the credentials. Ensure that you assign the provided permission policy to your deployed instance, such as ECS or EC2. + + The following steps are for instances not deployed on AWS: + + + Navigate to [Create IAM User](https://console.aws.amazon.com/iamv2/home#/users/create) in your AWS Console. + + + Attach the following inline permission policy to the IAM User to allow it to assume any IAM Roles: + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowAssumeAnyRole", + "Effect": "Allow", + "Action": "sts:AssumeRole", + "Resource": "arn:aws:iam::*:role/*" + } + ] + } + ``` + + + Obtain the AWS access key ID and secret access key for your IAM User by navigating to **IAM > Users > [Your User] > Security credentials > Access keys**. + + ![Access Key Step 1](/images/integrations/aws/integrations-aws-access-key-1.png) + ![Access Key Step 2](/images/integrations/aws/integrations-aws-access-key-2.png) + ![Access Key Step 3](/images/integrations/aws/integrations-aws-access-key-3.png) + + + 1. Set the access key as **INF_APP_CONNECTION_AWS_CLIENT_ID**. + 2. Set the secret key as **INF_APP_CONNECTION_AWS_CLIENT_SECRET**. + + + + + + + 1. Navigate to the [Create IAM Role](https://console.aws.amazon.com/iamv2/home#/roles/create?step=selectEntities) page in your AWS Console. + ![IAM Role Creation](/images/integrations/aws/integration-aws-iam-assume-role.png) + + 2. Select **AWS Account** as the **Trusted Entity Type**. + 3. Choose **Another AWS Account** and enter **381492033652** (Infisical AWS Account ID). This restricts the role to be assumed only by Infisical. If self-hosting, provide your AWS account number instead. + 4. Optionally, enable **Require external ID** and enter your **Organization ID** to further enhance security. + + + + Depending on your use case, add one or more of the following policies to your IAM Role: + + + + + + Use the following custom policy to grant the minimum permissions required by Infisical to sync secrets to AWS Secrets Manager: + + ![IAM Role Secrets Manager Permissions](/images/app-connections/aws/secrets-manager-permissions.png) + + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowSecretsManagerAccess", + "Effect": "Allow", + "Action": [ + "secretsmanager:GetSecretValue", + "secretsmanager:CreateSecret", + "secretsmanager:UpdateSecret", + "secretsmanager:DescribeSecret", + "secretsmanager:TagResource", + "secretsmanager:UntagResource", + "kms:ListKeys", + "kms:ListAliases", + "kms:Encrypt", + "kms:Decrypt" + ], + "Resource": "*" + } + ] + } + ``` + + + Use the following custom policy to grant the minimum permissions required by Infisical to sync secrets to AWS Parameter Store: + + ![IAM Role Secrets Manager Permissions](/images/app-connections/aws/parameter-store-permissions.png) + + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowSSMAccess", + "Effect": "Allow", + "Action": [ + "ssm:PutParameter", + "ssm:DeleteParameter", + "ssm:GetParameters", + "ssm:GetParametersByPath", + "ssm:DescribeParameters", + "ssm:DeleteParameters", + "ssm:AddTagsToResource", // if you need to add tags to secrets + "kms:ListKeys", // if you need to specify the KMS key + "kms:ListAliases", // if you need to specify the KMS key + "kms:Encrypt", // if you need to specify the KMS key + "kms:Decrypt" // if you need to specify the KMS key + ], + "Resource": "*" + } + ] + } + ``` + + + + + + + + ![Copy IAM Role ARN](/images/integrations/aws/integration-aws-iam-assume-arn.png) + + + + + + 1. Navigate to the App Connections tab on the Organization Settings page. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + 2. Select the **AWS Connection** option. + ![Select AWS Connection](/images/app-connections/aws/select-aws-connection.png) + + 3. Select the **Assume Role** method option and provide the **AWS IAM Role ARN** obtained from the previous step and press **Connect to AWS**. + ![Create AWS Connection](/images/app-connections/aws/create-assume-role-method.png) + + 4. Your **AWS Connection** is now available for use. + ![Assume Role AWS Connection](/images/app-connections/aws/assume-role-connection.png) + + + To create an AWS Connection, make an API request to the [Create AWS + Connection](/api-reference/endpoints/app-connections/aws/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/aws \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-aws-connection", + "method": "assume-role", + "credentials": { + "roleArn": "...", + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-aws-connection", + "version": 123, + "orgId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "app": "aws", + "method": "assume-role", + "credentials": {} + } + } + ``` + + + + + + + + Infisical will use the provided **Access Key ID** and **Secret Key** to connect to your AWS instance. + + **Prerequisites:** + + - Set up and add envars to [Infisical Cloud](https://app.infisical.com) + + + + 1. Navigate to the [Create IAM Role](https://console.aws.amazon.com/iamv2/home#/roles/create?step=selectEntities) page in your AWS Console. + ![IAM Role Creation](/images/integrations/aws/integration-aws-iam-assume-role.png) + + 2. Select **AWS Account** as the **Trusted Entity Type**. + 3. Choose **Another AWS Account** and enter **381492033652** (Infisical AWS Account ID). This restricts the role to be assumed only by Infisical. If self-hosting, provide your AWS account number instead. + 4. Optionally, enable **Require external ID** and enter your **Organization ID** to further enhance security. + + + + Depending on your use case, add one or more of the following policies to your IAM Role: + + + + + + Use the following custom policy to grant the minimum permissions required by Infisical to sync secrets to AWS Secrets Manager: + + ![IAM Role Secrets Manager Permissions](/images/app-connections/aws/secrets-manager-permissions.png) + + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowSecretsManagerAccess", + "Effect": "Allow", + "Action": [ + "secretsmanager:GetSecretValue", + "secretsmanager:CreateSecret", + "secretsmanager:UpdateSecret", + "secretsmanager:DescribeSecret", + "secretsmanager:TagResource", + "secretsmanager:UntagResource", + "kms:ListKeys", + "kms:ListAliases", + "kms:Encrypt", + "kms:Decrypt" + ], + "Resource": "*" + } + ] + } + ``` + + + Use the following custom policy to grant the minimum permissions required by Infisical to sync secrets to AWS Parameter Store: + + ![IAM Role Secrets Manager Permissions](/images/app-connections/aws/parameter-store-permissions.png) + + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowSSMAccess", + "Effect": "Allow", + "Action": [ + "ssm:PutParameter", + "ssm:DeleteParameter", + "ssm:GetParameters", + "ssm:GetParametersByPath", + "ssm:DescribeParameters", + "ssm:DeleteParameters", + "ssm:AddTagsToResource", // if you need to add tags to secrets + "kms:ListKeys", // if you need to specify the KMS key + "kms:ListAliases", // if you need to specify the KMS key + "kms:Encrypt", // if you need to specify the KMS key + "kms:Decrypt" // if you need to specify the KMS key + ], + "Resource": "*" + } + ] + } + ``` + + + + + + + Retrieve an AWS **Access Key ID** and a **Secret Key** for your IAM user in **IAM > Users > User > Security credentials > Access keys**. + + ![access key 1](/images/integrations/aws/integrations-aws-access-key-1.png) + ![access key 2](/images/integrations/aws/integrations-aws-access-key-2.png) + ![access key 3](/images/integrations/aws/integrations-aws-access-key-3.png) + + + + + 1. Navigate to the App Connections tab on the Organization Settings page. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + 2. Select the **AWS Connection** option. + ![Select AWS Connection](/images/app-connections/aws/select-aws-connection.png) + + 3. Select the **Access Key** method option and provide the **Access Key ID** and **Secret Key** obtained from the previous step and press **Connect to AWS**. + ![Create AWS Connection](/images/app-connections/aws/create-access-key-method.png) + + 4. Your **AWS Connection** is now available for use. + ![Assume Role AWS Connection](/images/app-connections/aws/access-key-connection.png) + + + To create an AWS Connection, make an API request to the [Create AWS + Connection](/api-reference/endpoints/app-connections/aws/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/aws \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-aws-connection", + "method": "access-key", + "credentials": { + "accessKeyId": "...", + "secretKey": "..." + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-aws-connection", + "version": 123, + "orgId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "app": "aws", + "method": "access-key", + "credentials": { + "accessKeyId": "..." + } + } + } + ``` + + + + + + + diff --git a/docs/integrations/app-connections/github.mdx b/docs/integrations/app-connections/github.mdx new file mode 100644 index 000000000..18f702bb6 --- /dev/null +++ b/docs/integrations/app-connections/github.mdx @@ -0,0 +1,169 @@ +--- +title: "GitHub Connection" +description: "Learn how to configure a GitHub Connection for Infisical." +--- + +Infisical supports two methods for connecting to GitHub. + + + + Infisical will use a GitHub App with finely grained permissions to connect to GitHub. + + **Prerequisites:** + + - Set up and add envars to [Infisical Cloud](https://app.infisical.com) + + + Using the GitHub integration with app authentication on a self-hosted instance of Infisical requires configuring an application on GitHub + and registering your instance with it. + + + + Navigate to the GitHub app settings [here](https://github.com/settings/apps). Click **New GitHub App**. + + ![integrations github app create](/images/integrations/github/app/self-hosted-github-app-create.png) + + Give the application a name, a homepage URL (your self-hosted domain i.e. `https://your-domain.com`), and a callback URL (i.e. `https://your-domain.com/app-connections/github/oauth/callback`). + + ![integrations github app basic details](/images/integrations/github/app/self-hosted-github-app-basic-details.png) + + Enable request user authorization during app installation. + ![integrations github app enable auth](/images/integrations/github/app/self-hosted-github-app-enable-oauth.png) + + Disable webhook by unchecking the Active checkbox. + ![integrations github app webhook](/images/integrations/github/app/self-hosted-github-app-webhook.png) + + Set the repository permissions as follows: Metadata: Read-only, Secrets: Read and write, Environments: Read and write, Actions: Read. + ![integrations github app repository](/images/integrations/github/app/self-hosted-github-app-repository.png) + + Similarly, set the organization permissions as follows: Secrets: Read and write. + ![integrations github app organization](/images/integrations/github/app/self-hosted-github-app-organization.png) + + Create the Github application. + ![integrations github app create confirm](/images/integrations/github/app/self-hosted-github-app-create-confirm.png) + + + If you have a GitHub organization, you can create an application under it + in your organization Settings > Developer settings > GitHub Apps > New GitHub App. + + + + Generate a new **Client Secret** for your GitHub application. + ![integrations github app create secret](/images/integrations/github/app/self-hosted-github-app-secret.png) + + Generate a new **Private Key** for your Github application. + ![integrations github app create private key](/images/integrations/github/app/self-hosted-github-app-private-key.png) + + Obtain the necessary Github application credentials. This would be the application slug, client ID, app ID, client secret, and private key. + ![integrations github app credentials](/images/integrations/github/app/self-hosted-github-app-credentials.png) + + Back in your Infisical instance, add the five new environment variables for the credentials of your GitHub application: + + - `INF_APP_CONNECTION_GITHUB_APP_CLIENT_ID`: The **Client ID** of your GitHub application. + - `INF_APP_CONNECTION_GITHUB_APP_CLIENT_SECRET`: The **Client Secret** of your GitHub application. + - `INF_APP_CONNECTION_GITHUB_APP_CLIENT_SLUG`: The **Slug** of your GitHub application. This is the one found in the URL. + - `INF_APP_CONNECTION_GITHUB_APP_CLIENT_APP_ID`: The **App ID** of your GitHub application. + - `INF_APP_CONNECTION_GITHUB_APP_CLIENT_PRIVATE_KEY`: The **Private Key** of your GitHub application. + + Once added, restart your Infisical instance and use the GitHub integration via app authentication. + + + + + ## Setup GitHub Connection in Infisical + + + + Navigate to the **App Connections** tab on the **Organization Settings** page. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Select the **GitHub Connection** option from the connection options modal. + ![Select GitHub Connection](/images/app-connections/github/select-github-connection.png) + + + Select the **GitHub App** method and click **Connect to GitHub**. + ![Connect via GitHub App](/images/app-connections/github/create-github-app-method.png) + + + You will then be redirected to the GitHub app installation page. + + Install and authorize the GitHub application. This will redirect you back to Infisical's App Connections page. + ![Install GitHub App](/images/app-connections/github/install-github-app.png) + + + Your **GitHub Connection** is now available for use. + ![Assume Role AWS Connection](/images/app-connections/github/github-app-connection.png) + + + + + Infisical will use an OAuth App to connect to GitHub. + + **Prerequisites:** + + - Set up and add envars to [Infisical Cloud](https://app.infisical.com) + + + Using the GitHub integration on a self-hosted instance of Infisical requires configuring an OAuth application in GitHub + and registering your instance with it. + + + Navigate to your user Settings > Developer settings > OAuth Apps to create a new GitHub OAuth application. + + ![integrations github config](../../images/integrations/github/integrations-github-config-settings.png) + ![integrations github config](../../images/integrations/github/integrations-github-config-dev-settings.png) + ![integrations github config](../../images/integrations/github/integrations-github-config-new-app.png) + + Create the OAuth application. As part of the form, set the **Homepage URL** to your self-hosted domain `https://your-domain.com` + and the **Authorization callback URL** to `https://your-domain.com/app-connections/github/oauth/callback`. + + ![integrations github config](../../images/integrations/github/integrations-github-config-new-app-form.png) + + + If you have a GitHub organization, you can create an OAuth application under it + in your organization Settings > Developer settings > OAuth Apps > New Org OAuth App. + + + + Obtain the **Client ID** and generate a new **Client Secret** for your GitHub OAuth application. + + ![integrations github config](../../images/integrations/github/integrations-github-config-credentials.png) + + Back in your Infisical instance, add two new environment variables for the credentials of your GitHub OAuth application: + + - `INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID`: The **Client ID** of your GitHub OAuth application. + - `INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_SECRET`: The **Client Secret** of your GitHub OAuth application. + + Once added, restart your Infisical instance and use the GitHub integration. + + + + + ## Setup GitHub Connection in Infisical + + + + Navigate to the **App Connections** tab on the **Organization Settings** page. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Select the **GitHub Connection** option from the connection options modal. + ![Select GitHub Connection](/images/app-connections/github/select-github-connection.png) + + + Select the **OAuth** method and click **Connect to GitHub**. + ![Connect via GitHub App](/images/app-connections/github/create-oauth-method.png) + + + You will then be redirected to the GitHub to grant Infisical access to your GitHub account (organization and repo privileges). + Once granted, you will redirect you back to Infisical's App Connections page. + ![GitHub Authorization](/images/integrations/github/integrations-github-auth.png) + + + Your **GitHub Connection** is now available for use. + ![Assume Role AWS Connection](/images/app-connections/github/oauth-connection.png) + + + + diff --git a/docs/integrations/app-connections/overview.mdx b/docs/integrations/app-connections/overview.mdx new file mode 100644 index 000000000..64f3616de --- /dev/null +++ b/docs/integrations/app-connections/overview.mdx @@ -0,0 +1,77 @@ +--- +sidebarTitle: "Overview" +description: "Learn how to manage and configure third-party app connections with Infisical." +--- + +App Connections enable your organization to integrate Infisical with third-party services in a secure and versatile way. + +## Concept + +App Connections are an organization-level resource used to establish connections with third-party applications +that can be used across Infisical projects. Example use cases include syncing secrets, generating dynamic secrets, and more. + +
+ +
+ + ```mermaid + %%{init: {'flowchart': {'curve': 'linear'} } }%% + graph TD + A[AWS] + B[AWS Connection] + C[Project 1 Secret Sync] + D[Project 2 Secret Sync] + E[Project 3 Generate Dynamic Secret] + + B --> A + C --> B + D --> B + E --> B + + classDef default fill:#ffffff,stroke:#666,stroke-width:2px,rx:10px,color:black + classDef aws fill:#FFF2B2,stroke:#E6C34A,stroke-width:2px,color:black,rx:15px + classDef project fill:#E6F4FF,stroke:#0096D6,stroke-width:2px,color:black,rx:15px + classDef connection fill:#F4FFE6,stroke:#96D600,stroke-width:2px,color:black,rx:15px + + class A aws + class B connection + class C,D,E project + ``` + +
+ +## Workflow + +App Connections require initial setup in both your third-party application and Infisical. Follow these steps to establish a secure connection: + + + For step-by-step guides specific to each application, refer to the App Connections section in the Navigation Bar. + + +1. Create Access Entity: If necessary, create an entity such as a service account or role within the third-party application you want to connect to. Be sure +to limit the access of this entity to the minimal permission set required to perform the operations you need. For example: + - For secret syncing: Read/write permissions to specific secret stores + - For dynamic secrets: Permissions to create temporary credentials + + + Whenever possible, Infisical encourages creating a designated service account for your App Connection to limit the scope of permissions based on your use-case. + + +2. Generate Authentication Credentials: Obtain the required credentials from your third-party application. These can vary between applications and might be: + - an API key or access token + - A client ID and secret pair + - other credentials, etc. + +3. Create App Connection: Configure the connection in Infisical using your generated credentials through either the UI or API. + + + Some App Connections can only be created via the UI such as connections using OAuth. + + +4. Utilize the Connection: Use your App Connection for various features across Infisical such as our Secrets Sync by selecting it via the dropdown menu +in the UI or by passing the associated `connectionId` when generating resources via the API. + + + Infisical is continuously expanding its third-party application support. If your desired application isn't listed, + you can still use previous methods of connecting to it such as our Native Integrations. + \ No newline at end of file diff --git a/docs/integrations/cicd/circleci.mdx b/docs/integrations/cicd/circleci.mdx index 0753f40f7..5bf04822d 100644 --- a/docs/integrations/cicd/circleci.mdx +++ b/docs/integrations/cicd/circleci.mdx @@ -11,21 +11,30 @@ Prerequisites: Obtain an API token in User Settings > Personal API Tokens - ![integrations circleci token](../../images/integrations/circleci/integrations-circleci-token.png) + ![integrations circleci token](/images/integrations/circleci/integrations-circleci-token.png) Navigate to your project's integrations tab in Infisical. - ![integrations](../../images/integrations.png) + ![integrations](/images/integrations.png) Press on the CircleCI tile and input your CircleCI API token to grant Infisical access to your CircleCI account. - ![integrations circleci authorization](../../images/integrations/circleci/integrations-circleci-auth.png) + ![integrations circleci authorization](/images/integrations/circleci/integrations-circleci-auth.png) - Select which Infisical environment secrets you want to sync to which CircleCI project and press create integration to start syncing secrets to CircleCI. + Select which Infisical environment secrets you want to sync to which CircleCI project or context. + + + ![integrations circle ci project](/images/integrations/circleci/integrations-circleci-create-project.png) + + + ![integrations circle ci project](/images/integrations/circleci/integrations-circleci-create-context.png) + + + + Finally, press create integration to start syncing secrets to CircleCI. + ![integrations circleci](/images/integrations/circleci/integrations-circleci.png) - ![create integration circleci](../../images/integrations/circleci/integrations-circleci-create.png) - ![integrations circleci](../../images/integrations/circleci/integrations-circleci.png) - \ No newline at end of file + diff --git a/docs/integrations/cloud/azure-app-configuration.mdx b/docs/integrations/cloud/azure-app-configuration.mdx index 249325704..69ef4a97d 100644 --- a/docs/integrations/cloud/azure-app-configuration.mdx +++ b/docs/integrations/cloud/azure-app-configuration.mdx @@ -29,6 +29,36 @@ description: "How to sync secrets from Infisical to Azure App Configuration" ![integrations](../../images/integrations/azure-app-configuration/create-integration-form.png) Press create integration to start syncing secrets to Azure App Configuration. + + + The Azure App Configuration integration requires the following permissions to be set on the user / service principal + for Infisical to sync secrets to Azure App Configuration: `Read Key-Value`, `Write Key-Value`, `Delete Key-Value`. + + Any role with these permissions would work such as the **App Configuration Data Owner** role. Alternatively, you can use the + **App Configuration Data Reader** role for read-only access or **App Configuration Data Contributor** role for read/write access. + + + + + + #### Azure references + When adding secrets in Infisical that reference Azure Key Vault secrets, Infisical will automatically sets the content type to `application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8` in Azure App Configuration. + The following reference formats are automatically detected when added on Infisical's side: + - `{ "uri": "https://my-key-vault.vault.azure.net/secrets/my-secret" }` + - `https://my-key-vault.vault.azure.net/secrets/my-secret` + + #### Azure Labels + You can sync secrets from Infisical to Azure with custom labels by enabling the `Use Labels` option during setup: + + **When enabled**: Secrets will be pushed to Azure with your specified label + + **When disabled**: Secrets will be pushed with an empty (null) label + + + If you have set the initial sync to `import` have behavior, the label selection affects which secrets are imported from Azure: + - With `Use Labels` disabled: Only secrets with empty labels are imported on initial sync + - With `Use Labels` enabled: Only secrets matching your specified label are imported on initial sync + diff --git a/docs/integrations/cloud/azure-devops.mdx b/docs/integrations/cloud/azure-devops.mdx index 6d1ba6b17..4eaaf0cc1 100644 --- a/docs/integrations/cloud/azure-devops.mdx +++ b/docs/integrations/cloud/azure-devops.mdx @@ -21,7 +21,7 @@ You'll need to create a new personal access token (PAT) in order to authenticate ![integrations](../../images/integrations/azure-devops/create-new-token.png) - Please make sure that the token has access to the following scopes: Variable Groups _(read/write)_, Release _(read/write)_, Project and Team _(read)_, Service Connections _(read & query)_ + Please make sure that the token has access to the following scopes: Variable Groups _(read, create, & manage)_, Release _(read/write)_, Project and Team _(read)_, Service Connections _(read & query)_ diff --git a/docs/integrations/cloud/azure-key-vault.mdx b/docs/integrations/cloud/azure-key-vault.mdx index d04d90b4f..ae04fda88 100644 --- a/docs/integrations/cloud/azure-key-vault.mdx +++ b/docs/integrations/cloud/azure-key-vault.mdx @@ -17,6 +17,10 @@ description: "How to sync secrets from Infisical to Azure Key Vault" ![integrations](../../images/integrations.png) Press on the Azure Key Vault tile and grant Infisical access to Azure Key Vault. + You can optionally authenticate against a specific tenant by providing the Azure tenant or directory ID. + + ![integrations](/images/integrations/azure-key-vault/integrations-azure-key-vault-tenant-select.png) + Obtain the Vault URI of your key vault in the Overview tab. diff --git a/docs/integrations/platforms/kubernetes.mdx b/docs/integrations/platforms/kubernetes.mdx index 2c699c197..4f835c4f7 100644 --- a/docs/integrations/platforms/kubernetes.mdx +++ b/docs/integrations/platforms/kubernetes.mdx @@ -41,10 +41,54 @@ The operator can be install via [Helm](https://helm.sh) or [kubectl](https://git helm install --generate-name infisical-helm-charts/secrets-operator --version=0.1.4 --set controllerManager.manager.image.tag=v0.2.0 ``` + **Namespace-scoped Installation** + + The operator can be configured to watch and manage secrets in a specific namespace instead of having cluster-wide access. This is useful for: + + - **Enhanced Security**: Limit the operator's permissions to only specific namespaces instead of cluster-wide access + - **Multi-tenant Clusters**: Run separate operator instances for different teams or applications + - **Resource Isolation**: Ensure operators in different namespaces don't interfere with each other + - **Development & Testing**: Run development and production operators side by side in isolated namespaces + + **Note**: For multiple namespace-scoped installations, only the first installation should install CRDs. Subsequent installations should set `installCRDs: false` to avoid conflicts. + + ```bash + # First namespace installation (with CRDs) + helm install operator-namespace1 infisical-helm-charts/secrets-operator \ + --namespace first-namespace \ + --set scopedNamespace=first-namespace \ + --set scopedRBAC=true + + # Subsequent namespace installations + helm install operator-namespace2 infisical-helm-charts/secrets-operator \ + --namespace another-namespace \ + --set scopedNamespace=another-namespace \ + --set scopedRBAC=true \ + --set installCRDs=false + ``` + + When scoped to a namespace, the operator will: + + - Only watch InfisicalSecrets in the specified namespace + - Only create/update Kubernetes secrets in that namespace + - Only access deployments in that namespace + + The default configuration gives cluster-wide access: + + ```yaml + installCRDs: true # Install CRDs (set to false for additional namespace installations) + scopedNamespace: "" # Empty for cluster-wide access + scopedRBAC: false # Cluster-wide permissions + ``` + + If you want to install operators in multiple namespaces simultaneously: + - Make sure to set `installCRDs: false` for all but one of the installations to avoid conflicts, as CRDs are cluster-wide resources. + - Use unique release names for each installation (e.g., operator-namespace1, operator-namespace2). + - - For production deployments, it is highly recommended to set the version of the Kubernetes operator manually instead of pointing to the latest version. - Doing so will help you avoid accidental updates to the newest release which may introduce unintended breaking changes. View all application versions [here](https://hub.docker.com/r/infisical/kubernetes-operator/tags). + + For production deployments, it is highly recommended to set the version of the Kubernetes operator manually instead of pointing to the latest version. + Doing so will help you avoid accidental updates to the newest release which may introduce unintended breaking changes. View all application versions [here](https://hub.docker.com/r/infisical/kubernetes-operator/tags). The command below will install the most recent version of the Kubernetes operator. However, to set the version manually, download the manifest and set the image tag version of `infisical/kubernetes-operator` according to your desired version. @@ -700,6 +744,7 @@ Define secret keys and their corresponding templates. Each data value uses a Golang template with access to all secrets retrieved from the specified scope. Secrets are structured as follows: + ```golang type TemplateSecret struct { Value string `json:"value"` @@ -708,6 +753,7 @@ type TemplateSecret struct { ``` #### Example template configuration: + ```golang managedSecretReference: secretName: managed-secret @@ -719,19 +765,23 @@ type TemplateSecret struct { ``` When you run the following command: + ```bash kubectl get secret managed-secret -o jsonpath='{.data}' ``` You'll receive Kubernetes secrets output that includes the NEW_KEY: + ```bash {... "KEY":"d29ybGQ=","NEW_KEY":"LyBoZWxsbw=="} ``` When you set `includeAllSecrets` as `false` the Kubernetes secrets outputs will be: + ```bash {"NEW_KEY":"LyBoZWxsbw=="} ``` + Creation polices allow you to control whether or not owner references should be added to the managed Kubernetes secret that is generated by the Infisical operator. diff --git a/docs/mint.json b/docs/mint.json index 9c28137fa..69ad84f11 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -32,10 +32,7 @@ "thumbsRating": true }, "api": { - "baseUrl": [ - "https://app.infisical.com", - "http://localhost:8080" - ] + "baseUrl": ["https://app.infisical.com", "http://localhost:8080"] }, "topbarLinks": [ { @@ -76,9 +73,7 @@ "documentation/getting-started/introduction", { "group": "Quickstart", - "pages": [ - "documentation/guides/local-development" - ] + "pages": ["documentation/guides/local-development"] }, { "group": "Guides", @@ -119,6 +114,7 @@ "documentation/platform/pki/alerting" ] }, + "documentation/platform/ssh", { "group": "Key Management (KMS)", "pages": [ @@ -229,6 +225,7 @@ "documentation/platform/identities/gcp-auth", "documentation/platform/identities/azure-auth", "documentation/platform/identities/aws-auth", + "documentation/platform/identities/jwt-auth", { "group": "OIDC Auth", "pages": [ @@ -323,6 +320,7 @@ "cli/commands/run", "cli/commands/secrets", "cli/commands/dynamic-secrets", + "cli/commands/ssh", "cli/commands/export", "cli/commands/token", "cli/commands/service-token", @@ -467,15 +465,11 @@ }, { "group": "Build Tool Integrations", - "pages": [ - "integrations/build-tools/gradle" - ] + "pages": ["integrations/build-tools/gradle"] }, { "group": "", - "pages": [ - "sdks/overview" - ] + "pages": ["sdks/overview"] }, { "group": "SDK's", @@ -495,9 +489,7 @@ "api-reference/overview/authentication", { "group": "Examples", - "pages": [ - "api-reference/overview/examples/integration" - ] + "pages": ["api-reference/overview/examples/integration"] } ] }, @@ -593,6 +585,16 @@ "api-reference/endpoints/oidc-auth/revoke" ] }, + { + "group": "JWT Auth", + "pages": [ + "api-reference/endpoints/jwt-auth/login", + "api-reference/endpoints/jwt-auth/attach", + "api-reference/endpoints/jwt-auth/retrieve", + "api-reference/endpoints/jwt-auth/update", + "api-reference/endpoints/jwt-auth/revoke" + ] + }, { "group": "Groups", "pages": [ @@ -772,15 +774,11 @@ }, { "group": "Service Tokens", - "pages": [ - "api-reference/endpoints/service-tokens/get" - ] + "pages": ["api-reference/endpoints/service-tokens/get"] }, { "group": "Audit Logs", - "pages": [ - "api-reference/endpoints/audit-logs/export-audit-log" - ] + "pages": ["api-reference/endpoints/audit-logs/export-audit-log"] } ] }, @@ -850,6 +848,40 @@ } ] }, + { + "group": "Infisical SSH", + "pages": [ + { + "group": "Certificates", + "pages": [ + "api-reference/endpoints/ssh/certificates/issue-credentials", + "api-reference/endpoints/ssh/certificates/sign-key" + ] + }, + { + "group": "Certificate Authorities", + "pages": [ + "api-reference/endpoints/ssh/ca/list", + "api-reference/endpoints/ssh/ca/create", + "api-reference/endpoints/ssh/ca/read", + "api-reference/endpoints/ssh/ca/update", + "api-reference/endpoints/ssh/ca/delete", + "api-reference/endpoints/ssh/ca/public-key", + "api-reference/endpoints/ssh/ca/list-certificate-templates" + ] + }, + { + "group": "Certificate Templates", + "pages": [ + "api-reference/endpoints/ssh/certificate-templates/list", + "api-reference/endpoints/ssh/certificate-templates/create", + "api-reference/endpoints/ssh/certificate-templates/read", + "api-reference/endpoints/ssh/certificate-templates/update", + "api-reference/endpoints/ssh/certificate-templates/delete" + ] + } + ] + }, { "group": "Infisical KMS", "pages": [ @@ -879,9 +911,7 @@ }, { "group": "", - "pages": [ - "changelog/overview" - ] + "pages": ["changelog/overview"] }, { "group": "Contributing", @@ -905,16 +935,11 @@ }, { "group": "Contributing to SDK", - "pages": [ - "contributing/sdk/developing" - ] + "pages": ["contributing/sdk/developing"] } ] } ], - "integrations": { - "intercom": "hsg644ru" - }, "analytics": { "koala": { "publicApiKey": "pk_b50d7184e0e39ddd5cdb43cf6abeadd9b97d" @@ -1090,4 +1115,4 @@ } ] } -} \ No newline at end of file +} diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index 2e21410a8..8f902c506 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -418,7 +418,53 @@ When set, all visits to the Infisical login page will automatically redirect use information. -## Native secret integrations +## App Connections + +You can configure third-party app connections for re-use across Infisical Projects. + + + + The AWS IAM User access key ID for assuming roles + + + + The AWS IAM User secret key for assuming roles + + + + + + The ID of the GitHub App + + + + The slug of the GitHub App + + + + The client ID for the GitHub App + + + + The client secret for the GitHub App + + + + The private key for the GitHub App + + + + + + The OAuth2 client ID for GitHub OAuth Connection + + + + The OAuth2 client secret for GitHub OAuth Connection + + + +## Native Secret Integrations To help you sync secrets from Infisical to services such as Github and Gitlab, Infisical provides native integrations out of the box. @@ -492,7 +538,7 @@ To help you sync secrets from Infisical to services such as Github and Gitlab, I - + The AWS IAM User access key for assuming roles. diff --git a/frontend/public/images/integrations/Amazon Web Services.png b/frontend/public/images/integrations/Amazon Web Services.png index 65b4a6ee8..d4025224e 100644 Binary files a/frontend/public/images/integrations/Amazon Web Services.png and b/frontend/public/images/integrations/Amazon Web Services.png differ diff --git a/frontend/public/images/integrations/Circle CI.png b/frontend/public/images/integrations/CircleCI.png similarity index 100% rename from frontend/public/images/integrations/Circle CI.png rename to frontend/public/images/integrations/CircleCI.png diff --git a/frontend/public/lotties/note.json b/frontend/public/lotties/note.json new file mode 100644 index 000000000..783cab705 --- /dev/null +++ b/frontend/public/lotties/note.json @@ -0,0 +1,808 @@ +{ + "v": "5.12.2", + "fr": 29.9700012207031, + "ip": 0, + "op": 45.0000018328876, + "w": 48, + "h": 48, + "nm": "note", + "ddd": 1, + "assets": [], + "layers": [ + { + "ddd": 1, + "ind": 1, + "ty": 4, + "nm": "note-outline-bot_s1g1_s2g2_s3g1_s4g1 Outlines", + "parent": 2, + "sr": 1, + "ks": { + "o": { "a": 0, "k": 100, "ix": 11 }, + "rx": { "a": 0, "k": 0, "ix": 8 }, + "ry": { "a": 0, "k": 0, "ix": 9 }, + "rz": { + "a": 1, + "k": [ + { "i": { "x": [0], "y": [1] }, "o": { "x": [0.333], "y": [0] }, "t": 0, "s": [0] }, + { "i": { "x": [0], "y": [1] }, "o": { "x": [0.333], "y": [0] }, "t": 22, "s": [5] }, + { "t": 44.0000017921567, "s": [0] } + ], + "ix": 10 + }, + "or": { "a": 0, "k": [0, 0, 0], "ix": 7 }, + "p": { "a": 0, "k": [19.448, 27.122, 0], "ix": 2 }, + "a": { "a": 0, "k": [13.405, 11.539, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100, 100], "ix": 6 } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 1, + "k": [ + { + "i": { "x": 0.022, "y": 1 }, + "o": { "x": 0.333, "y": 0 }, + "t": 0, + "s": [ + { + "i": [ + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [0, 0] + ], + "v": [ + [5, 18.078], + [21.809, 18.078] + ], + "c": false + } + ] + }, + { + "i": { "x": 0.022, "y": 1 }, + "o": { "x": 0.333, "y": 0 }, + "t": 22, + "s": [ + { + "i": [ + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [0, 0] + ], + "v": [ + [4.311, 17.73], + [21.654, 16.837] + ], + "c": false + } + ] + }, + { + "t": 44.0000017921567, + "s": [ + { + "i": [ + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [0, 0] + ], + "v": [ + [5, 18.078], + [21.809, 18.078] + ], + "c": false + } + ] + } + ], + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ind": 1, + "ty": "sh", + "ix": 2, + "ks": { + "a": 1, + "k": [ + { + "i": { "x": 0.022, "y": 1 }, + "o": { "x": 0.333, "y": 0 }, + "t": 0, + "s": [ + { + "i": [ + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [0, 0] + ], + "v": [ + [5, 11.281], + [21.809, 11.281] + ], + "c": false + } + ] + }, + { + "i": { "x": 0.022, "y": 1 }, + "o": { "x": 0.333, "y": 0 }, + "t": 22, + "s": [ + { + "i": [ + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [0, 0] + ], + "v": [ + [5, 11.281], + [21.735, 9.907] + ], + "c": false + } + ] + }, + { + "t": 44.0000017921567, + "s": [ + { + "i": [ + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [0, 0] + ], + "v": [ + [5, 11.281], + [21.809, 11.281] + ], + "c": false + } + ] + } + ], + "ix": 2 + }, + "nm": "Path 2", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ind": 2, + "ty": "sh", + "ix": 3, + "ks": { + "a": 1, + "k": [ + { + "i": { "x": 0.022, "y": 1 }, + "o": { "x": 0.333, "y": 0 }, + "t": 0, + "s": [ + { + "i": [ + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [0, 0] + ], + "v": [ + [5, 5], + [13.756, 5] + ], + "c": false + } + ] + }, + { + "i": { "x": 0.022, "y": 1 }, + "o": { "x": 0.333, "y": 0 }, + "t": 22, + "s": [ + { + "i": [ + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [0, 0] + ], + "v": [ + [5.497, 4.939], + [14.633, 4.166] + ], + "c": false + } + ] + }, + { + "t": 44.0000017921567, + "s": [ + { + "i": [ + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [0, 0] + ], + "v": [ + [5, 5], + [13.756, 5] + ], + "c": false + } + ] + } + ], + "ix": 2 + }, + "nm": "Path 3", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "mm", + "mm": 1, + "nm": "Merge Paths 1", + "mn": "ADBE Vector Filter - Merge", + "hd": false + }, + { + "ty": "st", + "c": { "a": 0, "k": [1, 1, 1], "ix": 3 }, + "o": { "a": 0, "k": 100, "ix": 4 }, + "w": { "a": 0, "k": 2.5, "ix": 5 }, + "lc": 2, + "lj": 2, + "bm": 0, + "nm": "Stroke 1", + "mn": "ADBE Vector Graphic - Stroke", + "hd": false + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100], "ix": 3 }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "o": { "a": 0, "k": 100, "ix": 7 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "nm": "Transform" + } + ], + "nm": "Group 1", + "np": 5, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 1 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 3 }, + "m": 1, + "ix": 2, + "nm": "Trim Paths 1", + "mn": "ADBE Vector Filter - Trim", + "hd": false + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 1 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 3 }, + "m": 1, + "ix": 3, + "nm": "Trim Paths 2", + "mn": "ADBE Vector Filter - Trim", + "hd": false + }, + { + "ty": "gr", + "it": [ + { + "ty": "tm", + "s": { + "a": 1, + "k": [ + { + "i": { "x": [0.616], "y": [1] }, + "o": { "x": [0.41], "y": [0] }, + "t": 0, + "s": [0] + }, + { + "i": { "x": [0.005], "y": [1] }, + "o": { "x": [0.369], "y": [0] }, + "t": 22, + "s": [100] + }, + { "t": 44.0000017921567, "s": [0] } + ], + "ix": 1 + }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { + "a": 1, + "k": [ + { + "i": { "x": [0], "y": [1] }, + "o": { "x": [0.333], "y": [0] }, + "t": 22, + "s": [0] + }, + { "t": 44.0000017921567, "s": [360] } + ], + "ix": 3 + }, + "m": 1, + "ix": 1, + "nm": "Trim Paths 1", + "mn": "ADBE Vector Filter - Trim", + "hd": false + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100], "ix": 3 }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "o": { "a": 0, "k": 100, "ix": 7 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "nm": "Transform" + } + ], + "nm": "Group 2", + "np": 1, + "cix": 2, + "bm": 0, + "ix": 4, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 45.0000018328876, + "st": 0, + "ct": 1, + "bm": 0 + }, + { + "ddd": 1, + "ind": 2, + "ty": 4, + "nm": "note-outline-bot_s1g1_s2g1_s3g1_s4g1_background Outlines", + "sr": 1, + "ks": { + "o": { "a": 0, "k": 100, "ix": 11 }, + "rx": { "a": 0, "k": 0, "ix": 8 }, + "ry": { "a": 0, "k": 0, "ix": 9 }, + "rz": { "a": 0, "k": 0, "ix": 10 }, + "or": { "a": 0, "k": [0, 0, 0], "ix": 7 }, + "p": { "a": 0, "k": [24.778, 23.858, 0], "ix": 2 }, + "a": { "a": 0, "k": [19.825, 23.313, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100, 100], "ix": 6 } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 1, + "k": [ + { + "i": { "x": 0, "y": 1 }, + "o": { "x": 0.333, "y": 0 }, + "t": 0, + "s": [ + { + "i": [ + [1.423, 1.657], + [0, 0], + [0, 0], + [0, -2.354], + [0, 0], + [-1.324, 0], + [0, 0] + ], + "o": [ + [0, 0], + [0, 0], + [-1.482, -1.727], + [0, 0], + [0, 1.429], + [0, 0], + [2.093, 0] + ], + "v": [ + [4.581, 2.516], + [1.876, -0.634], + [-1.99, -5.137], + [-6.005, -3.212], + [-6.005, 4.277], + [-3.606, 6.865], + [2.822, 6.865] + ], + "c": true + } + ] + }, + { + "i": { "x": 0, "y": 1 }, + "o": { "x": 0.333, "y": 0 }, + "t": 22, + "s": [ + { + "i": [ + [0.56, 0.711], + [0.835, 1.361], + [0, 0], + [-0.031, 0.137], + [0, 0], + [-0.118, -0.836], + [0, 0] + ], + "o": [ + [0, 0], + [-0.835, -1.361], + [-0.299, -0.286], + [0, 0], + [1.062, 0.814], + [0, 0], + [-0.101, 0.125] + ], + "v": [ + [6.281, -2.819], + [5.393, -4.55], + [3.663, -5.916], + [3.337, -6.047], + [4.119, -5.661], + [6.298, -2.792], + [6.5, -1.722] + ], + "c": true + } + ] + }, + { + "t": 44.0000017921567, + "s": [ + { + "i": [ + [1.423, 1.657], + [0, 0], + [0, 0], + [0, -2.354], + [0, 0], + [-1.324, 0], + [0, 0] + ], + "o": [ + [0, 0], + [0, 0], + [-1.482, -1.727], + [0, 0], + [0, 1.429], + [0, 0], + [2.093, 0] + ], + "v": [ + [4.581, 2.516], + [1.876, -0.634], + [-1.99, -5.137], + [-6.005, -3.212], + [-6.005, 4.277], + [-3.606, 6.865], + [2.822, 6.865] + ], + "c": true + } + ] + } + ], + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "st", + "c": { "a": 0, "k": [1, 1, 1], "ix": 3 }, + "o": { "a": 0, "k": 100, "ix": 4 }, + "w": { "a": 0, "k": 2.5, "ix": 5 }, + "lc": 2, + "lj": 2, + "bm": 0, + "nm": "Stroke 1", + "mn": "ADBE Vector Graphic - Stroke", + "hd": false + }, + { + "ty": "fl", + "c": { "a": 0, "k": [1, 1, 1], "ix": 4 }, + "o": { "a": 0, "k": 0, "ix": 5 }, + "r": 1, + "bm": 0, + "nm": "Fill 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": { "a": 0, "k": [28.646, 11.865], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100], "ix": 3 }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "o": { "a": 0, "k": 100, "ix": 7 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "nm": "Transform" + } + ], + "nm": "Group 1", + "np": 3, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + }, + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 1, + "k": [ + { + "i": { "x": 0, "y": 1 }, + "o": { "x": 0.333, "y": 0 }, + "t": 0, + "s": [ + { + "i": [ + [0, 0], + [0, -0.781], + [0, 0], + [2.763, 0], + [0, 0], + [0, 2.828], + [0, 0], + [0, 0], + [-2.763, 0], + [0, 0], + [-0.625, -0.733] + ], + "o": [ + [0.507, 0.595], + [0, 0], + [0, 2.828], + [0, 0], + [-2.763, 0], + [0, 0], + [0, 0], + [0, -2.828], + [0, 0], + [0.963, 0], + [0, 0] + ], + "v": [ + [14.012, -8.411], + [14.797, -6.279], + [14.797, 13.05], + [9.794, 18.171], + [-9.794, 18.171], + [-14.796, 13.05], + [-14.796, 0.99], + [-14.796, -13.051], + [-9.794, -18.171], + [4.174, -18.171], + [6.676, -17.016] + ], + "c": true + } + ] + }, + { + "i": { "x": 0, "y": 1 }, + "o": { "x": 0.333, "y": 0 }, + "t": 22, + "s": [ + { + "i": [ + [0, 0], + [0.171, -6.028], + [0, 0], + [2.763, 0], + [0, 0], + [0, 2.828], + [-1.453, 6.385], + [0, 0], + [-2.763, 0], + [0, 0], + [-1.739, -0.297] + ], + "o": [ + [0.051, 2.426], + [-0.171, 6.028], + [-1.046, 2.979], + [0, 0], + [-2.763, 0], + [0, 0], + [1.266, -5.562], + [0, -2.828], + [0, 0], + [0.963, 0], + [0, 0] + ], + "v": [ + [15.322, -13.817], + [14.796, -0.028], + [12.547, 13.021], + [6.669, 18.201], + [-12.044, 18.141], + [-17.046, 13.021], + [-14.421, 1.025], + [-12.923, -12.566], + [-7.92, -17.686], + [3.673, -17.672], + [12.612, -17.453] + ], + "c": true + } + ] + }, + { + "t": 44.0000017921567, + "s": [ + { + "i": [ + [0, 0], + [0, -0.781], + [0, 0], + [2.763, 0], + [0, 0], + [0, 2.828], + [0, 0], + [0, 0], + [-2.763, 0], + [0, 0], + [-0.625, -0.733] + ], + "o": [ + [0.507, 0.595], + [0, 0], + [0, 2.828], + [0, 0], + [-2.763, 0], + [0, 0], + [0, 0], + [0, -2.828], + [0, 0], + [0.963, 0], + [0, 0] + ], + "v": [ + [14.012, -8.411], + [14.797, -6.279], + [14.797, 13.05], + [9.794, 18.171], + [-9.794, 18.171], + [-14.796, 13.05], + [-14.796, 0.99], + [-14.796, -13.051], + [-9.794, -18.171], + [4.174, -18.171], + [6.676, -17.016] + ], + "c": true + } + ] + } + ], + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "st", + "c": { "a": 0, "k": [1, 1, 1], "ix": 3 }, + "o": { "a": 0, "k": 100, "ix": 4 }, + "w": { "a": 0, "k": 2.5, "ix": 5 }, + "lc": 2, + "lj": 2, + "bm": 0, + "nm": "Stroke 1", + "mn": "ADBE Vector Graphic - Stroke", + "hd": false + }, + { + "ty": "fl", + "c": { "a": 0, "k": [1, 1, 1], "ix": 4 }, + "o": { "a": 0, "k": 0, "ix": 5 }, + "r": 1, + "bm": 0, + "nm": "Fill 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": { "a": 0, "k": [19.797, 23.455], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100], "ix": 3 }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "o": { "a": 0, "k": 100, "ix": 7 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "nm": "Transform" + } + ], + "nm": "Group 2", + "np": 3, + "cix": 2, + "bm": 0, + "ix": 2, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 45.0000018328876, + "st": 0, + "ct": 1, + "bm": 0 + } + ], + "markers": [], + "props": {} +} diff --git a/frontend/public/lotties/system-regular-126-verified-hover-verified.json b/frontend/public/lotties/system-regular-126-verified-hover-verified.json new file mode 100644 index 000000000..ce8777c0a --- /dev/null +++ b/frontend/public/lotties/system-regular-126-verified-hover-verified.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":60,"w":500,"h":500,"nm":"system-regular-126-verified","ddd":0,"assets":[{"id":"comp_1","nm":"hover-verified","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250.004,250.003,0],"ix":2,"l":2},"a":{"a":0,"k":[250.004,250.003,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[6.109,-6.116],[-6.115,-6.108],[0,0],[-4.002,0],[-3.055,3.051],[0,0],[6.107,6.115],[6.115,-6.107],[0,0]],"o":[[-6.115,-6.107],[-6.107,6.116],[0,0],[3.056,3.052],[4.002,0],[0,0],[6.115,-6.108],[-6.109,-6.116],[0,0],[0,0]],"v":[[-69.803,-8.539],[-91.936,-8.526],[-91.922,13.607],[-39.704,65.762],[-28.644,70.339],[-17.584,65.762],[91.922,-43.616],[91.936,-65.749],[69.803,-65.762],[-28.644,32.57]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-126-verified').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[263.158,242.187],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[73.521,-47.165],[0,0],[0,0],[11.506,86.895],[0,0],[-39.84,20.453],[-33.146,-7.159]],"o":[[-11.51,86.919],[0,0],[0,0],[-73.503,-47.153],[0,0],[33.146,-7.159],[39.84,20.453],[0,0]],"v":[[149.277,-67.68],[15.49,143.298],[0,153.266],[-15.509,143.287],[-149.274,-67.655],[-154.479,-107.449],[0,-153.936],[154.479,-107.449]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[8.073,1.615],[32.173,20.107],[5.074,-3.172],[35.445,-7.092],[-1.068,-8.164],[0,0],[-81.252,-52.124],[0,0],[-2.944,0],[-2.579,1.66],[0,0],[-12.724,96.093],[0,0]],"o":[[-35.445,-7.092],[-5.074,-3.172],[-32.173,20.107],[-8.073,1.615],[0,0],[12.72,96.068],[0,0],[2.579,1.66],[2.944,0],[0,0],[81.27,-52.136],[0,0],[1.068,-8.164]],"v":[[174.946,-135.138],[8.294,-185.147],[-8.294,-185.147],[-174.946,-135.138],[-187.394,-117.763],[-180.307,-63.572],[-32.428,169.62],[-8.469,185.036],[0,187.526],[8.469,185.036],[32.409,169.632],[180.31,-63.596],[187.394,-117.763]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-126-verified').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[250.004,250.003],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":60,"op":300,"st":1,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250.004,250.003,0],"ix":2,"l":2},"a":{"a":0,"k":[250.004,250.003,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[6.109,-6.116],[-6.115,-6.108],[0,0],[-4.002,0],[-3.055,3.051],[0,0],[6.107,6.115],[6.115,-6.107],[0,0]],"o":[[-6.115,-6.107],[-6.107,6.116],[0,0],[3.056,3.052],[4.002,0],[0,0],[6.115,-6.108],[-6.109,-6.116],[0,0],[0,0]],"v":[[-69.803,-8.539],[-91.936,-8.526],[-91.922,13.607],[-39.704,65.762],[-28.644,70.339],[-17.584,65.762],[91.922,-43.616],[91.936,-65.749],[69.803,-65.762],[-28.644,32.57]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-126-verified').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[263.158,242.187],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[73.521,-47.165],[0,0],[0,0],[11.506,86.895],[0,0],[-39.84,20.453],[-33.146,-7.159]],"o":[[-11.51,86.919],[0,0],[0,0],[-73.503,-47.153],[0,0],[33.146,-7.159],[39.84,20.453],[0,0]],"v":[[149.277,-67.68],[15.49,143.298],[0,153.266],[-15.509,143.287],[-149.274,-67.655],[-154.479,-107.449],[0,-153.936],[154.479,-107.449]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[8.073,1.615],[32.173,20.107],[5.074,-3.172],[35.445,-7.092],[-1.068,-8.164],[0,0],[-81.252,-52.124],[0,0],[-2.944,0],[-2.579,1.66],[0,0],[-12.724,96.093],[0,0]],"o":[[-35.445,-7.092],[-5.074,-3.172],[-32.173,20.107],[-8.073,1.615],[0,0],[12.72,96.068],[0,0],[2.579,1.66],[2.944,0],[0,0],[81.27,-52.136],[0,0],[1.068,-8.164]],"v":[[174.946,-135.138],[8.294,-185.147],[-8.294,-185.147],[-174.946,-135.138],[-187.394,-117.763],[-180.307,-63.572],[-32.428,169.62],[-8.469,185.036],[0,187.526],[8.469,185.036],[32.409,169.632],[180.31,-63.596],[187.394,-117.763]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-126-verified').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[250.004,250.003],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.131],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":1,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.628],"y":[0]},"t":30,"s":[27]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":46,"s":[-11]},{"t":60,"s":[0]}],"ix":10},"p":{"a":0,"k":[249.998,250.004,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.131,"y":1},"o":{"x":0.333,"y":0},"t":1,"s":[{"i":[[0,0],[0,0],[77.333,-49.716],[0,0],[0,0],[0,0],[11.993,91.145],[0,0],[-41.666,26.039]],"o":[[0,0],[-11.99,91.149],[0,0],[0,0],[0,0],[-77.33,-49.714],[0,0],[0,0],[41.667,26.039]],"v":[[171.875,-119.795],[164.746,-65.6],[23.905,156.51],[0.007,171.873],[0,171.875],[-23.904,156.508],[-164.743,-65.592],[-171.875,-119.795],[0,-171.875]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.628,"y":0},"t":21,"s":[{"i":[[0,0],[0,0],[99.436,-63.925],[0,0],[0,0],[0,0],[15.421,117.196],[0,0],[-53.575,33.481]],"o":[[0,0],[-15.417,117.201],[0,0],[0,0],[0,0],[-99.432,-63.922],[0,0],[0,0],[53.576,33.481]],"v":[[191.412,-122.573],[182.245,-52.889],[1.15,232.704],[-29.579,252.458],[-29.588,252.46],[-60.324,232.701],[-241.417,-52.879],[-250.588,-122.573],[-29.588,-189.539]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":41.801,"s":[{"i":[[0,0],[0,0],[56.69,-36.445],[0,0],[0,0],[0,0],[8.792,66.816],[0,0],[-30.544,19.088]],"o":[[0,0],[-8.79,66.819],[0,0],[0,0],[0,0],[-56.688,-36.443],[0,0],[0,0],[30.545,19.088]],"v":[[125.996,-87.818],[120.77,-48.09],[17.524,114.733],[0.005,125.995],[0,125.996],[-17.523,114.731],[-120.768,-48.083],[-125.996,-87.818],[0,-125.996]],"c":true}]},{"t":60,"s":[{"i":[[0,0],[0,0],[77.333,-49.716],[0,0],[0,0],[0,0],[11.993,91.145],[0,0],[-41.666,26.039]],"o":[[0,0],[-11.99,91.149],[0,0],[0,0],[0,0],[-77.33,-49.714],[0,0],[0,0],[41.667,26.039]],"v":[[171.875,-119.795],[164.746,-65.6],[23.905,156.51],[0.007,171.873],[0,171.875],[-23.904,156.508],[-164.743,-65.592],[-171.875,-119.795],[0,-171.875]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-126-verified').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":31.3,"ix":5},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":60,"st":-239,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":".primary.design","cl":"primary design","parent":3,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0.006,-0.001,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.157,"y":1},"o":{"x":0.333,"y":0},"t":1,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[153.363,-119.689],[-14.644,46.689],[-66.863,-5.466]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.6,"y":0},"t":21,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[167.916,-123.188],[-50.252,92.864],[-118.061,25.138]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":42,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[114.144,-89.081],[-10.899,34.749],[-49.764,-4.068]],"c":false}]},{"t":60,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[153.363,-119.689],[-14.644,46.689],[-66.863,-5.466]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-126-verified').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":31.3,"ix":5},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":1,"s":[100]},{"t":20,"s":[100],"h":1},{"i":{"x":[0.1],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":28,"s":[0]},{"t":60,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.333],"y":[0]},"t":1,"s":[26.2]},{"t":20,"s":[100],"h":1},{"i":{"x":[0.1],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":28,"s":[0]},{"t":60,"s":[26.2]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":1,"op":60,"st":0,"ct":1,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":3,"nm":"control","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ef":[{"ty":5,"nm":"primary","np":3,"mn":"ADBE Color Control","ix":1,"en":1,"ef":[{"ty":2,"nm":"Color","mn":"ADBE Color Control-0001","ix":1,"v":{"a":0,"k":[1,1,1],"ix":1}}]}],"ip":0,"op":131,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":0,"nm":"hover-verified","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[250,250,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":500,"h":500,"ip":0,"op":70,"st":0,"bm":0}],"markers":[{"tm":0,"cm":"default:hover-verified","dr":60}],"props":{}} \ No newline at end of file diff --git a/frontend/public/lotties/unlock.json b/frontend/public/lotties/unlock.json new file mode 100644 index 000000000..4356593e9 --- /dev/null +++ b/frontend/public/lotties/unlock.json @@ -0,0 +1,470 @@ +{ + "v": "5.12.2", + "fr": 29.9700012207031, + "ip": 0, + "op": 45.0000018328876, + "w": 48, + "h": 48, + "nm": "unlock", + "ddd": 0, + "assets": [], + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "unlock-outline-top_s1g1_s2g2_s3g1_s4g1_background Outlines", + "parent": 2, + "sr": 1, + "ks": { + "o": { "a": 0, "k": 100, "ix": 11 }, + "r": { + "a": 1, + "k": [ + { "i": { "x": [0.667], "y": [1] }, "o": { "x": [0.333], "y": [0] }, "t": 12, "s": [0] }, + { + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.333], "y": [0] }, + "t": 28, + "s": [-16] + }, + { "t": 40.0000016292334, "s": [0] } + ], + "ix": 10 + }, + "p": { "a": 0, "k": [19, 19.473, 0], "ix": 2, "l": 2 }, + "a": { "a": 0, "k": [8.5, 11.125, 0], "ix": 1, "l": 2 }, + "s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [0, 1.292], + [1.933, 0], + [0, -1.933], + [-1.042, -0.607], + [0, 0], + [-0.966, 0], + [0, 0.966], + [0, 0] + ], + "o": [ + [0, -1.933], + [-1.933, 0], + [0, 1.292], + [0, 0], + [0, 0.966], + [0.966, 0], + [0, 0], + [1.042, -0.607] + ], + "v": [ + [3.5, -2.625], + [0, -6.125], + [-3.5, -2.625], + [-1.75, 0.39], + [-1.75, 4.375], + [0, 6.125], + [1.75, 4.375], + [1.75, 0.39] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "st", + "c": { "a": 0, "k": [1, 1, 1], "ix": 3 }, + "o": { "a": 0, "k": 100, "ix": 4 }, + "w": { "a": 0, "k": 2.5, "ix": 5 }, + "lc": 2, + "lj": 2, + "bm": 0, + "nm": "Stroke 1", + "mn": "ADBE Vector Graphic - Stroke", + "hd": false + }, + { + "ty": "fl", + "c": { "a": 0, "k": [1, 1, 1], "ix": 4 }, + "o": { "a": 0, "k": 0, "ix": 5 }, + "r": 1, + "bm": 0, + "nm": "Fill 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": { "a": 0, "k": [8.5, 11.125], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100], "ix": 3 }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "o": { "a": 0, "k": 100, "ix": 7 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "nm": "Transform" + } + ], + "nm": "Group 1", + "np": 3, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 45.0000018328876, + "st": 0, + "ct": 1, + "bm": 0 + }, + { + "ddd": 0, + "ind": 2, + "ty": 4, + "nm": "unlock-outline-bot_s1g1_s2g1_s3g1_s4g1_background Outlines", + "sr": 1, + "ks": { + "o": { "a": 0, "k": 100, "ix": 11 }, + "r": { "a": 0, "k": 0, "ix": 10 }, + "p": { + "a": 1, + "k": [ + { + "i": { "x": 0.667, "y": 1 }, + "o": { "x": 0.333, "y": 0 }, + "t": 0, + "s": [24, 29.826, 0], + "to": [0, 0.313, 0], + "ti": [0, 0, 0] + }, + { + "i": { "x": 0.667, "y": 1 }, + "o": { "x": 0.333, "y": 0 }, + "t": 12, + "s": [24, 31.701, 0], + "to": [0, 0, 0], + "ti": [0, 0.313, 0] + }, + { "t": 28.0000011404634, "s": [24, 29.826, 0] } + ], + "ix": 2, + "l": 2 + }, + "a": { "a": 0, "k": [19, 19.523, 0], "ix": 1, "l": 2 }, + "s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [-1.43, 0.576], + [0, 0], + [-3.266, -1.28], + [0, 0], + [0, -1.555], + [0, 0], + [7.732, 0], + [0, 8.353], + [0, 0] + ], + "o": [ + [0, 0], + [3.253, -1.31], + [0, 0], + [1.448, 0.567], + [0, 0], + [0, 8.353], + [-7.732, 0], + [0, 0], + [0, -1.542] + ], + "v": [ + [-11.633, -10.628], + [-5.258, -13.195], + [4.892, -13.243], + [11.6, -10.615], + [14, -7.097], + [14, -0.602], + [0, 14.523], + [-14, -0.602], + [-14, -7.123] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "st", + "c": { "a": 0, "k": [1, 1, 1], "ix": 3 }, + "o": { "a": 0, "k": 100, "ix": 4 }, + "w": { "a": 0, "k": 2.5, "ix": 5 }, + "lc": 2, + "lj": 2, + "bm": 0, + "nm": "Stroke 1", + "mn": "ADBE Vector Graphic - Stroke", + "hd": false + }, + { + "ty": "fl", + "c": { "a": 0, "k": [1, 1, 1], "ix": 4 }, + "o": { "a": 0, "k": 0, "ix": 5 }, + "r": 1, + "bm": 0, + "nm": "Fill 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": { "a": 0, "k": [19, 19.523], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100], "ix": 3 }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "o": { "a": 0, "k": 100, "ix": 7 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "nm": "Transform" + } + ], + "nm": "Group 1", + "np": 3, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 45.0000018328876, + "st": 0, + "ct": 1, + "bm": 0 + }, + { + "ddd": 0, + "ind": 3, + "ty": 4, + "nm": "unlockoutline-bot_s1g1_s2g1_s3g1_s4g2 Outlines", + "parent": 2, + "sr": 1, + "ks": { + "o": { "a": 0, "k": 100, "ix": 11 }, + "r": { + "a": 1, + "k": [ + { "i": { "x": [0.667], "y": [1] }, "o": { "x": [0.333], "y": [0] }, "t": 0, "s": [0] }, + { "i": { "x": [0.667], "y": [1] }, "o": { "x": [0.333], "y": [0] }, "t": 12, "s": [9] }, + { "i": { "x": [0.667], "y": [1] }, "o": { "x": [0.333], "y": [0] }, "t": 28, "s": [9] }, + { "t": 40.0000016292334, "s": [0] } + ], + "ix": 10 + }, + "p": { "a": 0, "k": [17.628, 0.002, 0], "ix": 2, "l": 2 }, + "a": { "a": 0, "k": [15.029, 13.585, 0], "ix": 1, "l": 2 }, + "s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 1, + "k": [ + { + "i": { "x": 0.667, "y": 1 }, + "o": { "x": 0.333, "y": 0 }, + "t": 0, + "s": [ + { + "i": [ + [0, 0], + [5.305, -0.847], + [-0.847, -5.305], + [0, 0] + ], + "o": [ + [-0.847, -5.305], + [-5.305, 0.846], + [0, 0], + [0, 0] + ], + "v": [ + [10.029, 0.334], + [-1.11, -7.738], + [-9.182, 3.401], + [-8.355, 8.585] + ], + "c": false + } + ] + }, + { + "i": { "x": 0.667, "y": 1 }, + "o": { "x": 0.333, "y": 0 }, + "t": 12, + "s": [ + { + "i": [ + [0, 0], + [5.341, -0.581], + [-0.582, -5.341], + [0, 0] + ], + "o": [ + [-0.582, -5.341], + [-5.341, 0.581], + [0, 0], + [0, 0] + ], + "v": [ + [11.833, 5.021], + [1.11, -3.596], + [-7.507, 7.127], + [-7.094, 9.966] + ], + "c": false + } + ] + }, + { + "i": { "x": 0.667, "y": 1 }, + "o": { "x": 0.333, "y": 0 }, + "t": 28, + "s": [ + { + "i": [ + [0, 0], + [5.341, -0.581], + [-0.582, -5.341], + [0, 0] + ], + "o": [ + [-0.582, -5.341], + [-5.341, 0.581], + [0, 0], + [0, 0] + ], + "v": [ + [11.833, 5.021], + [1.11, -3.596], + [-7.507, 7.127], + [-7.094, 9.966] + ], + "c": false + } + ] + }, + { + "t": 40.0000016292334, + "s": [ + { + "i": [ + [0, 0], + [5.305, -0.847], + [-0.847, -5.305], + [0, 0] + ], + "o": [ + [-0.847, -5.305], + [-5.305, 0.846], + [0, 0], + [0, 0] + ], + "v": [ + [10.029, 0.334], + [-1.11, -7.738], + [-9.182, 3.401], + [-8.355, 8.585] + ], + "c": false + } + ] + } + ], + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "st", + "c": { "a": 0, "k": [1, 1, 1], "ix": 3 }, + "o": { "a": 0, "k": 100, "ix": 4 }, + "w": { "a": 0, "k": 2.5, "ix": 5 }, + "lc": 2, + "lj": 2, + "bm": 0, + "nm": "Stroke 1", + "mn": "ADBE Vector Graphic - Stroke", + "hd": false + }, + { + "ty": "tr", + "p": { "a": 0, "k": [15.029, 13.585], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100], "ix": 3 }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "o": { "a": 0, "k": 100, "ix": 7 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "nm": "Transform" + } + ], + "nm": "Group 1", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 45.0000018328876, + "st": 0, + "ct": 1, + "bm": 0 + } + ], + "markers": [], + "props": {} +} diff --git a/frontend/src/components/dashboard/AddTagsMenu.tsx b/frontend/src/components/dashboard/AddTagsMenu.tsx index 3a54f77ab..d2e295c14 100644 --- a/frontend/src/components/dashboard/AddTagsMenu.tsx +++ b/frontend/src/components/dashboard/AddTagsMenu.tsx @@ -5,6 +5,8 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Menu, Transition } from "@headlessui/react"; import { Tag } from "public/data/frequentInterfaces"; +import { ProjectType } from "@app/hooks/api/workspace/types"; + /** * This is the menu that is used to add more tags to a secret * @param {object} obj @@ -75,7 +77,7 @@ const AddTagsMenu = ({ diff --git a/frontend/src/components/navigation/NavHeader.tsx b/frontend/src/components/navigation/NavHeader.tsx index 973feeb73..76a60d082 100644 --- a/frontend/src/components/navigation/NavHeader.tsx +++ b/frontend/src/components/navigation/NavHeader.tsx @@ -9,6 +9,7 @@ import { twMerge } from "tailwind-merge"; import { useOrganization, useWorkspace } from "@app/context"; import { useToggle } from "@app/hooks"; +import { ProjectType } from "@app/hooks/api/workspace/types"; import { createNotification } from "../notifications"; import { IconButton, Select, SelectItem, Tooltip } from "../v2"; @@ -69,7 +70,11 @@ export default function NavHeader({
{currentOrg?.name?.charAt(0)}
- + {currentOrg?.name} @@ -93,7 +98,10 @@ export default function NavHeader({ {pageName} @@ -130,7 +138,7 @@ export default function NavHeader({ passHref legacyBehavior href={{ - pathname: "/project/[id]/secrets/[env]", + pathname: `/${ProjectType.SecretManager}/[id]/secrets/[env]`, query: { id: router.query.id, env: router.query.env } }} > @@ -199,7 +207,10 @@ export default function NavHeader({ { }; const shouldDisplay = - window.location.origin.includes("https://app.infisical.com") || - window.location.origin.includes("https://us.infisical.com") || - window.location.origin.includes("https://eu.infisical.com") || - window.location.origin.includes("http://localhost:8080"); + isInfisicalCloud() || window.location.origin.includes("http://localhost:8080"); // only display region select for cloud if (!shouldDisplay) return null; diff --git a/frontend/src/components/signup/TeamInviteStep.tsx b/frontend/src/components/signup/TeamInviteStep.tsx index 60276d217..63588ca5c 100644 --- a/frontend/src/components/signup/TeamInviteStep.tsx +++ b/frontend/src/components/signup/TeamInviteStep.tsx @@ -4,6 +4,7 @@ import { useRouter } from "next/router"; import { useAddUsersToOrg } from "@app/hooks/api"; import { useFetchServerStatus } from "@app/hooks/api/serverDetails"; +import { ProjectType } from "@app/hooks/api/workspace/types"; import { usePopUp } from "@app/hooks/usePopUp"; import { Button, EmailServiceSetupModal } from "../v2"; @@ -22,7 +23,7 @@ export default function TeamInviteStep(): JSX.Element { // Redirect user to the getting started page const redirectToHome = async () => { - router.push(`/org/${localStorage.getItem("orgData.id")}/overview`); + router.push(`/org/${localStorage.getItem("orgData.id")}/${ProjectType.SecretManager}/overview`); }; const inviteUsers = async ({ emails: inviteEmails }: { emails: string }) => { diff --git a/frontend/src/components/tags/CreateTagModal/CreateTagModal.tsx b/frontend/src/components/tags/CreateTagModal/CreateTagModal.tsx index a2fde465d..23389cbca 100644 --- a/frontend/src/components/tags/CreateTagModal/CreateTagModal.tsx +++ b/frontend/src/components/tags/CreateTagModal/CreateTagModal.tsx @@ -3,7 +3,6 @@ import { Controller, useForm } from "react-hook-form"; import { faCheck } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; @@ -18,6 +17,7 @@ import { } from "@app/components/v2"; import { useWorkspace } from "@app/context"; import { useCreateWsTag } from "@app/hooks/api"; +import { slugSchema } from "@app/lib/schemas"; export const secretTagsColors = [ { @@ -88,13 +88,7 @@ type Props = { }; const createTagSchema = z.object({ - slug: z - .string() - .trim() - .toLowerCase() - .refine((v) => slugify(v) === v, { - message: "Invalid slug. Slug can only contain alphanumeric characters and hyphens." - }), + slug: slugSchema({ min: 1, field: "Tag Slug" }), color: z.string().trim() }); diff --git a/frontend/src/components/v2/FormControl/FormControl.tsx b/frontend/src/components/v2/FormControl/FormControl.tsx index 8651422a1..4d5446519 100644 --- a/frontend/src/components/v2/FormControl/FormControl.tsx +++ b/frontend/src/components/v2/FormControl/FormControl.tsx @@ -44,7 +44,7 @@ export const FormLabel = ({ )} {tooltipText && ( - + )} diff --git a/frontend/src/components/v2/Modal/Modal.tsx b/frontend/src/components/v2/Modal/Modal.tsx index a741d6d02..2e216ba08 100644 --- a/frontend/src/components/v2/Modal/Modal.tsx +++ b/frontend/src/components/v2/Modal/Modal.tsx @@ -72,7 +72,7 @@ ModalContent.displayName = "ModalContent"; export type ModalProps = Omit & { isOpen?: boolean }; export const Modal = ({ isOpen, ...props }: ModalProps) => ( - + ); export const ModalTrigger = DialogPrimitive.Trigger; diff --git a/frontend/src/components/v2/projects/NewProjectModal.tsx b/frontend/src/components/v2/projects/NewProjectModal.tsx index 8f2cf79e8..3bb283617 100644 --- a/frontend/src/components/v2/projects/NewProjectModal.tsx +++ b/frontend/src/components/v2/projects/NewProjectModal.tsx @@ -32,14 +32,17 @@ import { useSubscription, useUser } from "@app/context"; +import { getProjectHomePage } from "@app/helpers/project"; import { fetchOrgUsers, useAddUserToWsNonE2EE, useCreateWorkspace, - useGetExternalKmsList + useGetExternalKmsList, + useGetUserWorkspaces } from "@app/hooks/api"; import { INTERNAL_KMS_KEY_ID } from "@app/hooks/api/kms/types"; import { InfisicalProjectTemplate, useListProjectTemplates } from "@app/hooks/api/projectTemplates"; +import { ProjectType } from "@app/hooks/api/workspace/types"; const formSchema = z.object({ name: z.string().trim().min(1, "Required").max(64, "Too long, maximum length is 64 characters"), @@ -58,16 +61,18 @@ type TAddProjectFormData = z.infer; interface NewProjectModalProps { isOpen: boolean; onOpenChange: (isOpen: boolean) => void; + projectType: ProjectType; } -type NewProjectFormProps = Pick; +type NewProjectFormProps = Pick; -const NewProjectForm = ({ onOpenChange }: NewProjectFormProps) => { +const NewProjectForm = ({ onOpenChange, projectType }: NewProjectFormProps) => { const router = useRouter(); const { currentOrg } = useOrganization(); const { permission } = useOrgPermission(); const { user } = useUser(); const createWs = useCreateWorkspace(); + const { refetch: refetchWorkspaces } = useGetUserWorkspaces(); const addUsersToProject = useAddUserToWsNonE2EE(); const { subscription } = useSubscription(); @@ -115,15 +120,15 @@ const NewProjectForm = ({ onOpenChange }: NewProjectFormProps) => { if (!user) return; try { const { - data: { - project: { id: newProjectId } - } + data: { project } } = await createWs.mutateAsync({ projectName: name, projectDescription: description, kmsKeyId: kmsKeyId !== INTERNAL_KMS_KEY_ID ? kmsKeyId : undefined, - template + template, + type: projectType }); + const { id: newProjectId } = project; if (addMembers) { const orgUsers = await fetchOrgUsers(currentOrg.id); @@ -137,13 +142,13 @@ const NewProjectForm = ({ onOpenChange }: NewProjectFormProps) => { orgId: currentOrg.id }); } - // eslint-disable-next-line no-promise-executor-return -- We do this because the function returns too fast, which sometimes causes an error when the user is redirected. - await new Promise((resolve) => setTimeout(resolve, 2_000)); + + await refetchWorkspaces(); createNotification({ text: "Project created", type: "success" }); reset(); onOpenChange(false); - router.push(`/project/${newProjectId}/secrets/overview`); + router.push(getProjectHomePage(project)); } catch (err) { console.error(err); createNotification({ text: "Failed to create project", type: "error" }); @@ -314,14 +319,18 @@ const NewProjectForm = ({ onOpenChange }: NewProjectFormProps) => { ); }; -export const NewProjectModal: FC = ({ isOpen, onOpenChange }) => { +export const NewProjectModal: FC = ({ + isOpen, + onOpenChange, + projectType +}) => { return ( - + ); diff --git a/frontend/src/context/OrgPermissionContext/types.ts b/frontend/src/context/OrgPermissionContext/types.ts index 41a2e7e3c..4480bbad8 100644 --- a/frontend/src/context/OrgPermissionContext/types.ts +++ b/frontend/src/context/OrgPermissionContext/types.ts @@ -23,7 +23,8 @@ export enum OrgPermissionSubjects { Kms = "kms", AdminConsole = "organization-admin-console", AuditLogs = "audit-logs", - ProjectTemplates = "project-templates" + ProjectTemplates = "project-templates", + AppConnections = "app-connections" } export enum OrgPermissionAdminConsoleAction { @@ -47,6 +48,7 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.Kms] | [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole] | [OrgPermissionActions, OrgPermissionSubjects.AuditLogs] - | [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates]; + | [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates] + | [OrgPermissionActions, OrgPermissionSubjects.AppConnections]; export type TOrgPermission = MongoAbility; diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index 673b2b41a..b0c3aa463 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -85,6 +85,9 @@ export enum ProjectPermissionSub { CertificateAuthorities = "certificate-authorities", Certificates = "certificates", CertificateTemplates = "certificate-templates", + SshCertificateAuthorities = "ssh-certificate-authorities", + SshCertificateTemplates = "ssh-certificate-templates", + SshCertificates = "ssh-certificates", PkiAlerts = "pki-alerts", PkiCollections = "pki-collections", Kms = "kms", @@ -165,6 +168,9 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities] | [ProjectPermissionActions, ProjectPermissionSub.Certificates] | [ProjectPermissionActions, ProjectPermissionSub.CertificateTemplates] + | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateAuthorities] + | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateTemplates] + | [ProjectPermissionActions, ProjectPermissionSub.SshCertificates] | [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts] | [ProjectPermissionActions, ProjectPermissionSub.PkiCollections] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Project] diff --git a/frontend/src/context/WorkspaceContext/WorkspaceContext.tsx b/frontend/src/context/WorkspaceContext/WorkspaceContext.tsx index 29ecacaa5..e4d7612ec 100644 --- a/frontend/src/context/WorkspaceContext/WorkspaceContext.tsx +++ b/frontend/src/context/WorkspaceContext/WorkspaceContext.tsx @@ -3,7 +3,7 @@ import { useRouter } from "next/router"; import { createNotification } from "@app/components/notifications"; import { useGetUserWorkspaces } from "@app/hooks/api"; -import { Workspace } from "@app/hooks/api/workspace/types"; +import { ProjectType, Workspace } from "@app/hooks/api/workspace/types"; type TWorkspaceContext = { workspaces: Workspace[]; @@ -35,7 +35,7 @@ export const WorkspaceProvider = ({ children }: Props): JSX.Element => { const shouldTriggerNoProjectAccess = !value.isLoading && !value.currentWorkspace && - router.pathname.startsWith("/project") && + Object.values(ProjectType).some((el) => router.pathname.startsWith(`/${el}`)) && workspaceId; // handle redirects for project-specific routes diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts new file mode 100644 index 000000000..9d52fb14e --- /dev/null +++ b/frontend/src/helpers/appConnections.ts @@ -0,0 +1,29 @@ +import { faGithub } from "@fortawesome/free-brands-svg-icons"; +import { faKey, faPassport, faUser } from "@fortawesome/free-solid-svg-icons"; + +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { + AwsConnectionMethod, + GitHubConnectionMethod, + TAppConnection +} from "@app/hooks/api/appConnections/types"; + +export const APP_CONNECTION_MAP: Record = { + [AppConnection.AWS]: { name: "AWS", image: "Amazon Web Services.png" }, + [AppConnection.GitHub]: { name: "GitHub", image: "GitHub.png" } +}; + +export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) => { + switch (method) { + case GitHubConnectionMethod.App: + return { name: "GitHub App", icon: faGithub }; + case GitHubConnectionMethod.OAuth: + return { name: "OAuth", icon: faPassport }; + case AwsConnectionMethod.AccessKey: + return { name: "Access Key", icon: faKey }; + case AwsConnectionMethod.AssumeRole: + return { name: "Assume Role", icon: faUser }; + default: + throw new Error(`Unhandled App Connection Method: ${method}`); + } +}; diff --git a/frontend/src/helpers/parseEnvVar.ts b/frontend/src/helpers/parseEnvVar.ts index 27640b515..8bde05084 100644 --- a/frontend/src/helpers/parseEnvVar.ts +++ b/frontend/src/helpers/parseEnvVar.ts @@ -1,14 +1,31 @@ /** Extracts the key and value from a passed in env string based on the provided delimiters. */ export const getKeyValue = (pastedContent: string, delimiters: string[]) => { - const foundDelimiter = delimiters.find((delimiter) => pastedContent.includes(delimiter)); + if (!pastedContent) { + return { key: "", value: "" }; + } - if (!foundDelimiter) { + let firstDelimiterIndex = -1; + let foundDelimiter = ""; + + delimiters.forEach((delimiter) => { + const index = pastedContent.indexOf(delimiter); + if (index !== -1 && (firstDelimiterIndex === -1 || index < firstDelimiterIndex)) { + firstDelimiterIndex = index; + foundDelimiter = delimiter; + } + }); + + const hasValueAfterDelimiter = pastedContent.length > firstDelimiterIndex + foundDelimiter.length; + + if (firstDelimiterIndex === -1 || !hasValueAfterDelimiter) { return { key: pastedContent.trim(), value: "" }; } - const [key, value] = pastedContent.split(foundDelimiter); + const key = pastedContent.substring(0, firstDelimiterIndex); + const value = pastedContent.substring(firstDelimiterIndex + foundDelimiter.length); + return { key: key.trim(), - value: (value ?? "").trim() + value: value.trim() }; }; diff --git a/frontend/src/helpers/platform.ts b/frontend/src/helpers/platform.ts new file mode 100644 index 000000000..821febcb8 --- /dev/null +++ b/frontend/src/helpers/platform.ts @@ -0,0 +1,4 @@ +export const isInfisicalCloud = () => + window.location.origin.includes("https://app.infisical.com") || + window.location.origin.includes("https://us.infisical.com") || + window.location.origin.includes("https://eu.infisical.com"); diff --git a/frontend/src/helpers/project.ts b/frontend/src/helpers/project.ts index b6338ce35..0734335e7 100644 --- a/frontend/src/helpers/project.ts +++ b/frontend/src/helpers/project.ts @@ -1,5 +1,6 @@ import { apiRequest } from "@app/config/request"; import { createWorkspace } from "@app/hooks/api/workspace/queries"; +import { ProjectType, Workspace } from "@app/hooks/api/workspace/types"; const secretsToBeAdded = [ { @@ -36,12 +37,13 @@ const secretsToBeAdded = [ * Create and initialize a new project in organization with id [organizationId] * Note: current user should be a member of the organization */ -const initProjectHelper = async ({ projectName }: { projectName: string }) => { +export const initProjectHelper = async ({ projectName }: { projectName: string }) => { // create new project const { data: { project } } = await createWorkspace({ - projectName + projectName, + type: ProjectType.SecretManager }); try { @@ -59,4 +61,27 @@ const initProjectHelper = async ({ projectName }: { projectName: string }) => { return project; }; -export { initProjectHelper }; +export const getProjectHomePage = (workspace: Workspace) => { + if (workspace.type === ProjectType.SecretManager) { + return `/${workspace.type}/${workspace.id}/secrets/overview`; + } + if (workspace.type === ProjectType.CertificateManager) { + return `/${workspace.type}/${workspace.id}/certificates`; + } + + if (workspace.type === ProjectType.KMS) { + return `/${workspace.type}/${workspace.id}/kms`; + } + + return `/${workspace.type}/${workspace.id}/ssh`; +}; + +export const getProjectTitle = (type: ProjectType) => { + const titleConvert = { + [ProjectType.SecretManager]: "Secret Management", + [ProjectType.KMS]: "Key Management", + [ProjectType.CertificateManager]: "Cert Management", + [ProjectType.SSH]: "SSH" + }; + return titleConvert[type]; +}; diff --git a/frontend/src/hooks/api/accessApproval/types.ts b/frontend/src/hooks/api/accessApproval/types.ts index 6df257590..bd6173d91 100644 --- a/frontend/src/hooks/api/accessApproval/types.ts +++ b/frontend/src/hooks/api/accessApproval/types.ts @@ -18,15 +18,15 @@ export type TAccessApprovalPolicy = { approvers?: Approver[]; }; -export enum ApproverType{ +export enum ApproverType { User = "user", Group = "group" } -export type Approver ={ +export type Approver = { id: string; type: ApproverType; -} +}; export type TAccessApprovalRequest = { id: string; @@ -70,6 +70,7 @@ export type TAccessApprovalRequest = { secretPath?: string | null; envId: string; enforcementLevel: EnforcementLevel; + deletedAt: Date | null; }; reviewers: { diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts new file mode 100644 index 000000000..3c1a409a4 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -0,0 +1,4 @@ +export enum AppConnection { + AWS = "aws", + GitHub = "github" +} diff --git a/frontend/src/hooks/api/appConnections/index.ts b/frontend/src/hooks/api/appConnections/index.ts new file mode 100644 index 000000000..177955438 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/index.ts @@ -0,0 +1,3 @@ +export * from "./mutations"; +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/mutations.tsx b/frontend/src/hooks/api/appConnections/mutations.tsx new file mode 100644 index 000000000..d9e2912d7 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/mutations.tsx @@ -0,0 +1,58 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; +import { appConnectionKeys } from "@app/hooks/api/appConnections/queries"; +import { + TAppConnectionResponse, + TCreateAppConnectionDTO, + TDeleteAppConnectionDTO, + TUpdateAppConnectionDTO +} from "@app/hooks/api/appConnections/types"; + +export const useCreateAppConnection = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ app, ...params }: TCreateAppConnectionDTO) => { + const { data } = await apiRequest.post( + `/api/v1/app-connections/${app}`, + params + ); + + return data.appConnection; + }, + onSuccess: () => queryClient.invalidateQueries(appConnectionKeys.list()) + }); +}; + +export const useUpdateAppConnection = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ connectionId, app, ...params }: TUpdateAppConnectionDTO) => { + const { data } = await apiRequest.patch( + `/api/v1/app-connections/${app}/${connectionId}`, + params + ); + + return data.appConnection; + }, + onSuccess: (_, { connectionId, app }) => { + queryClient.invalidateQueries(appConnectionKeys.list()); + queryClient.invalidateQueries(appConnectionKeys.byId(app, connectionId)); + } + }); +}; + +export const useDeleteAppConnection = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ connectionId, app }: TDeleteAppConnectionDTO) => { + const { data } = await apiRequest.delete(`/api/v1/app-connections/${app}/${connectionId}`); + + return data; + }, + onSuccess: (_, { connectionId, app }) => { + queryClient.invalidateQueries(appConnectionKeys.list()); + queryClient.invalidateQueries(appConnectionKeys.byId(app, connectionId)); + } + }); +}; diff --git a/frontend/src/hooks/api/appConnections/queries.tsx b/frontend/src/hooks/api/appConnections/queries.tsx new file mode 100644 index 000000000..10e2601ed --- /dev/null +++ b/frontend/src/hooks/api/appConnections/queries.tsx @@ -0,0 +1,136 @@ +import { useMemo } from "react"; +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { + TAppConnection, + TAppConnectionMap, + TAppConnectionOptions, + TGetAppConnection, + TListAppConnections +} from "@app/hooks/api/appConnections/types"; +import { + TAppConnectionOption, + TAppConnectionOptionMap +} from "@app/hooks/api/appConnections/types/app-options"; + +export const appConnectionKeys = { + all: ["app-connection"] as const, + options: () => [...appConnectionKeys.all, "options"] as const, + list: () => [...appConnectionKeys.all, "list"] as const, + listByApp: (app: AppConnection) => [...appConnectionKeys.list(), app], + byId: (app: AppConnection, templateId: string) => + [...appConnectionKeys.all, app, "by-id", templateId] as const +}; + +export const useAppConnectionOptions = ( + options?: Omit< + UseQueryOptions< + TAppConnectionOption[], + unknown, + TAppConnectionOption[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: appConnectionKeys.options(), + queryFn: async () => { + const { data } = await apiRequest.get( + "/api/v1/app-connections/options" + ); + + return data.appConnectionOptions; + }, + ...options + }); +}; + +export const useGetAppConnectionOption = (app: T) => { + const { data: options = [], isLoading } = useAppConnectionOptions(); + + return useMemo( + () => ({ + option: (options.find((opt) => opt.app === app) as TAppConnectionOptionMap[T]) ?? {}, + isLoading + }), + [options, app] + ); +}; + +export const useListAppConnections = ( + options?: Omit< + UseQueryOptions< + TAppConnection[], + unknown, + TAppConnection[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: appConnectionKeys.list(), + queryFn: async () => { + const { data } = await apiRequest.get>( + "/api/v1/app-connections" + ); + + return data.appConnections; + }, + ...options + }); +}; + +export const useListAppConnectionsByApp = ( + app: T, + options?: Omit< + UseQueryOptions< + TAppConnectionMap[T][], + unknown, + TAppConnectionMap[T][], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: appConnectionKeys.listByApp(app), + queryFn: async () => { + const { data } = await apiRequest.get>( + `/api/v1/app-connections/${app}` + ); + + return data.appConnections; + }, + ...options + }); +}; + +export const useGetAppConnectionById = ( + app: T, + connectionId: string, + options?: Omit< + UseQueryOptions< + TAppConnectionMap[T], + unknown, + TAppConnectionMap[T], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: appConnectionKeys.byId(app, connectionId), + queryFn: async () => { + const { data } = await apiRequest.get>( + `/api/v1/app-connections/${app}/${connectionId}` + ); + + return data.appConnection; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts new file mode 100644 index 000000000..bfc9e5903 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -0,0 +1,24 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +export type TAppConnectionOptionBase = { + name: string; + methods: string[]; +}; + +export type TAwsConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.AWS; + accessKeyId?: string; +}; + +export type TGitHubConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.GitHub; + oauthClientId?: string; + appClientSlug?: string; +}; + +export type TAppConnectionOption = TAwsConnectionOption | TGitHubConnectionOption; + +export type TAppConnectionOptionMap = { + [AppConnection.AWS]: TAwsConnectionOption; + [AppConnection.GitHub]: TGitHubConnectionOption; +}; diff --git a/frontend/src/hooks/api/appConnections/types/aws-connection.ts b/frontend/src/hooks/api/appConnections/types/aws-connection.ts new file mode 100644 index 000000000..86074ca35 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/aws-connection.ts @@ -0,0 +1,23 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum AwsConnectionMethod { + AssumeRole = "assume-role", + AccessKey = "access-key" +} + +export type TAwsConnection = TRootAppConnection & { app: AppConnection.AWS } & ( + | { + method: AwsConnectionMethod.AccessKey; + credentials: { + accessKeyId: string; + secretAccessKey: string; + }; + } + | { + method: AwsConnectionMethod.AssumeRole; + credentials: { + roleArn: string; + }; + } + ); diff --git a/frontend/src/hooks/api/appConnections/types/github-connection.ts b/frontend/src/hooks/api/appConnections/types/github-connection.ts new file mode 100644 index 000000000..d00936cda --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/github-connection.ts @@ -0,0 +1,23 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum GitHubConnectionMethod { + App = "github-app", + OAuth = "oauth" +} + +export type TGitHubConnection = TRootAppConnection & { app: AppConnection.GitHub } & ( + | { + method: GitHubConnectionMethod.OAuth; + credentials: { + code: string; + }; + } + | { + method: GitHubConnectionMethod.App; + credentials: { + code: string; + installationId: string; + }; + } + ); diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts new file mode 100644 index 000000000..fcec4a1df --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -0,0 +1,36 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TAppConnectionOption } from "@app/hooks/api/appConnections/types/app-options"; +import { TAwsConnection } from "@app/hooks/api/appConnections/types/aws-connection"; +import { TGitHubConnection } from "@app/hooks/api/appConnections/types/github-connection"; + +export * from "./aws-connection"; +export * from "./github-connection"; + +export type TAppConnection = TAwsConnection | TGitHubConnection; + +export type TListAppConnections = { appConnections: T[] }; +export type TGetAppConnection = { appConnection: T }; +export type TAppConnectionOptions = { appConnectionOptions: TAppConnectionOption[] }; +export type TAppConnectionResponse = { appConnection: TAppConnection }; + +export type TCreateAppConnectionDTO = Pick< + TAppConnection, + "name" | "credentials" | "method" | "app" | "description" +>; + +export type TUpdateAppConnectionDTO = Partial< + Pick +> & { + connectionId: string; + app: AppConnection; +}; + +export type TDeleteAppConnectionDTO = { + app: AppConnection; + connectionId: string; +}; + +export type TAppConnectionMap = { + [AppConnection.AWS]: TAwsConnection; + [AppConnection.GitHub]: TGitHubConnection; +}; diff --git a/frontend/src/hooks/api/appConnections/types/root-connection.ts b/frontend/src/hooks/api/appConnections/types/root-connection.ts new file mode 100644 index 000000000..0dc4a616f --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/root-connection.ts @@ -0,0 +1,9 @@ +export type TRootAppConnection = { + id: string; + name: string; + description?: string | null; + version: number; + orgId: string; + createdAt: string; + updatedAt: string; +}; diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index 404592908..a75767108 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -8,6 +8,7 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.DELETE_SECRET]: "Delete secret", [EventType.GET_WORKSPACE_KEY]: "Read project key", [EventType.AUTHORIZE_INTEGRATION]: "Authorize integration", + [EventType.UPDATE_INTEGRATION_AUTH]: "Update integration auth", [EventType.UNAUTHORIZE_INTEGRATION]: "Unauthorize integration", [EventType.CREATE_INTEGRATION]: "Create integration", [EventType.DELETE_INTEGRATION]: "Delete integration", diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index 1db55d739..0b0c44d7b 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -23,6 +23,7 @@ export enum EventType { DELETE_SECRET = "delete-secret", GET_WORKSPACE_KEY = "get-workspace-key", AUTHORIZE_INTEGRATION = "authorize-integration", + UPDATE_INTEGRATION_AUTH = "update-integration-auth", UNAUTHORIZE_INTEGRATION = "unauthorize-integration", CREATE_INTEGRATION = "create-integration", DELETE_INTEGRATION = "delete-integration", diff --git a/frontend/src/hooks/api/auth/queries.tsx b/frontend/src/hooks/api/auth/queries.tsx index de3c60d46..0c3dbe0d9 100644 --- a/frontend/src/hooks/api/auth/queries.tsx +++ b/frontend/src/hooks/api/auth/queries.tsx @@ -77,11 +77,16 @@ export const selectOrganization = async (data: { export const useSelectOrganization = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async (details: { organizationId: string; userAgent?: UserAgentType }) => { + mutationFn: async (details: { + organizationId: string; + userAgent?: UserAgentType; + forceSetCredentials?: boolean; + }) => { const data = await selectOrganization(details); // If a custom user agent is set, then this session is meant for another consuming application, not the web application. - if (!details.userAgent && !data.isMfaEnabled) { + if ((!details.userAgent && !data.isMfaEnabled) || details.forceSetCredentials) { + localStorage.setItem("orgData.id", details.organizationId); SecurityClient.setToken(data.token); SecurityClient.setProviderAuthToken(""); } diff --git a/frontend/src/hooks/api/ca/constants.tsx b/frontend/src/hooks/api/ca/constants.tsx index 9bb7b89d5..bbc75f90d 100644 --- a/frontend/src/hooks/api/ca/constants.tsx +++ b/frontend/src/hooks/api/ca/constants.tsx @@ -1,3 +1,6 @@ +import { SshCaStatus } from "@app/hooks/api/ssh-ca"; +import { SshCertTemplateStatus } from "@app/hooks/api/sshCertificateTemplates"; + import { CaStatus, CaType } from "./enums"; export const caTypeToNameMap: { [K in CaType]: string } = { @@ -11,7 +14,7 @@ export const caStatusToNameMap: { [K in CaStatus]: string } = { [CaStatus.PENDING_CERTIFICATE]: "Pending Certificate" }; -export const getCaStatusBadgeVariant = (status: CaStatus) => { +export const getCaStatusBadgeVariant = (status: CaStatus | SshCaStatus | SshCertTemplateStatus) => { switch (status) { case CaStatus.ACTIVE: return "success"; diff --git a/frontend/src/hooks/api/ca/queries.tsx b/frontend/src/hooks/api/ca/queries.tsx index a1e633776..da010c866 100644 --- a/frontend/src/hooks/api/ca/queries.tsx +++ b/frontend/src/hooks/api/ca/queries.tsx @@ -7,7 +7,7 @@ import { TCertificateAuthority } from "./types"; export const caKeys = { getCaById: (caId: string) => [{ caId }, "ca"], - getCaCerts: (caId: string) => [{ caId }, "ca-cert"], + getCaCerts: (caId: string) => [{ caId }, "ca-certs"], getCaCrls: (caId: string) => [{ caId }, "ca-crls"], getCaCert: (caId: string) => [{ caId }, "ca-cert"], getCaCsr: (caId: string) => [{ caId }, "ca-csr"], diff --git a/frontend/src/hooks/api/groups/index.tsx b/frontend/src/hooks/api/groups/index.tsx index 26b38d3a4..c23a55832 100644 --- a/frontend/src/hooks/api/groups/index.tsx +++ b/frontend/src/hooks/api/groups/index.tsx @@ -1,9 +1,8 @@ export { - useAddUserToGroup, - useCreateGroup, - useDeleteGroup, - useRemoveUserFromGroup, - useUpdateGroup} from "./mutations"; -export { - useListGroupUsers -} from "./queries"; \ No newline at end of file + useAddUserToGroup, + useCreateGroup, + useDeleteGroup, + useRemoveUserFromGroup, + useUpdateGroup +} from "./mutations"; +export { useGetGroupById, useListGroupUsers } from "./queries"; diff --git a/frontend/src/hooks/api/groups/mutations.tsx b/frontend/src/hooks/api/groups/mutations.tsx index 445ae10bc..2f5c5984c 100644 --- a/frontend/src/hooks/api/groups/mutations.tsx +++ b/frontend/src/hooks/api/groups/mutations.tsx @@ -56,8 +56,9 @@ export const useUpdateGroup = () => { return group; }, - onSuccess: ({ orgId }) => { + onSuccess: ({ orgId, id: groupId }) => { queryClient.invalidateQueries(organizationKeys.getOrgGroups(orgId)); + queryClient.invalidateQueries(groupKeys.getGroupById(groupId)); } }); }; @@ -70,8 +71,9 @@ export const useDeleteGroup = () => { return group; }, - onSuccess: ({ orgId }) => { + onSuccess: ({ orgId, id: groupId }) => { queryClient.invalidateQueries(organizationKeys.getOrgGroups(orgId)); + queryClient.invalidateQueries(groupKeys.getGroupById(groupId)); } }); }; diff --git a/frontend/src/hooks/api/groups/queries.tsx b/frontend/src/hooks/api/groups/queries.tsx index b239b0a61..dc3791db7 100644 --- a/frontend/src/hooks/api/groups/queries.tsx +++ b/frontend/src/hooks/api/groups/queries.tsx @@ -2,7 +2,10 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { EFilterReturnedUsers, TGroup, TGroupUser } from "./types"; + export const groupKeys = { + getGroupById: (groupId: string) => [{ groupId }, "group"] as const, allGroupUserMemberships: () => ["group-user-memberships"] as const, forGroupUserMemberships: (slug: string) => [...groupKeys.allGroupUserMemberships(), slug] as const, @@ -10,22 +13,27 @@ export const groupKeys = { slug, offset, limit, - search + search, + filter }: { slug: string; offset: number; limit: number; search: string; - }) => [...groupKeys.forGroupUserMemberships(slug), { offset, limit, search }] as const + filter?: EFilterReturnedUsers; + }) => [...groupKeys.forGroupUserMemberships(slug), { offset, limit, search, filter }] as const }; -type TUser = { - id: string; - email: string; - username: string; - firstName: string; - lastName: string; - isPartOfGroup: boolean; +export const useGetGroupById = (groupId: string) => { + return useQuery({ + enabled: Boolean(groupId), + queryKey: groupKeys.getGroupById(groupId), + queryFn: async () => { + const { data } = await apiRequest.get(`/api/v1/groups/${groupId}`); + + return { group: data }; + } + }); }; export const useListGroupUsers = ({ @@ -33,20 +41,23 @@ export const useListGroupUsers = ({ groupSlug, offset = 0, limit = 10, - search + search, + filter }: { id: string; groupSlug: string; offset: number; limit: number; search: string; + filter?: EFilterReturnedUsers; }) => { return useQuery({ queryKey: groupKeys.specificGroupUserMemberships({ slug: groupSlug, offset, limit, - search + search, + filter }), enabled: Boolean(groupSlug), keepPreviousData: true, @@ -54,10 +65,11 @@ export const useListGroupUsers = ({ const params = new URLSearchParams({ offset: String(offset), limit: String(limit), - search + search, + ...(filter && { filter }) }); - const { data } = await apiRequest.get<{ users: TUser[]; totalCount: number }>( + const { data } = await apiRequest.get<{ users: TGroupUser[]; totalCount: number }>( `/api/v1/groups/${id}/users`, { params diff --git a/frontend/src/hooks/api/groups/types.ts b/frontend/src/hooks/api/groups/types.ts index 3f69b9a0e..6bc82b39e 100644 --- a/frontend/src/hooks/api/groups/types.ts +++ b/frontend/src/hooks/api/groups/types.ts @@ -11,7 +11,7 @@ export type TGroup = { name: string; slug: string; orgId: string; - createAt: string; + createdAt: string; updatedAt: string; role: string; }; @@ -41,3 +41,18 @@ export type TGroupWithProjectMemberships = { slug: string; orgId: string; }; + +export type TGroupUser = { + id: string; + email: string; + username: string; + firstName: string; + lastName: string; + isPartOfGroup: boolean; + joinedGroupAt: Date; +}; + +export enum EFilterReturnedUsers { + EXISTING_MEMBERS = "existingMembers", + NON_MEMBERS = "nonMembers" +} diff --git a/frontend/src/hooks/api/identities/constants.tsx b/frontend/src/hooks/api/identities/constants.tsx index 0c57ee82c..c11d7dc11 100644 --- a/frontend/src/hooks/api/identities/constants.tsx +++ b/frontend/src/hooks/api/identities/constants.tsx @@ -7,5 +7,6 @@ export const identityAuthToNameMap: { [I in IdentityAuthMethod]: string } = { [IdentityAuthMethod.GCP_AUTH]: "GCP Auth", [IdentityAuthMethod.AWS_AUTH]: "AWS Auth", [IdentityAuthMethod.AZURE_AUTH]: "Azure Auth", - [IdentityAuthMethod.OIDC_AUTH]: "OIDC Auth" + [IdentityAuthMethod.OIDC_AUTH]: "OIDC Auth", + [IdentityAuthMethod.JWT_AUTH]: "JWT Auth" }; diff --git a/frontend/src/hooks/api/identities/enums.tsx b/frontend/src/hooks/api/identities/enums.tsx index 5e445521a..415492e00 100644 --- a/frontend/src/hooks/api/identities/enums.tsx +++ b/frontend/src/hooks/api/identities/enums.tsx @@ -5,5 +5,11 @@ export enum IdentityAuthMethod { GCP_AUTH = "gcp-auth", AWS_AUTH = "aws-auth", AZURE_AUTH = "azure-auth", - OIDC_AUTH = "oidc-auth" + OIDC_AUTH = "oidc-auth", + JWT_AUTH = "jwt-auth" +} + +export enum IdentityJwtConfigurationType { + JWKS = "jwks", + STATIC = "static" } diff --git a/frontend/src/hooks/api/identities/index.tsx b/frontend/src/hooks/api/identities/index.tsx index 5c7bcc3e7..261556752 100644 --- a/frontend/src/hooks/api/identities/index.tsx +++ b/frontend/src/hooks/api/identities/index.tsx @@ -4,6 +4,7 @@ export { useAddIdentityAwsAuth, useAddIdentityAzureAuth, useAddIdentityGcpAuth, + useAddIdentityJwtAuth, useAddIdentityKubernetesAuth, useAddIdentityOidcAuth, useAddIdentityTokenAuth, @@ -15,6 +16,7 @@ export { useDeleteIdentityAwsAuth, useDeleteIdentityAzureAuth, useDeleteIdentityGcpAuth, + useDeleteIdentityJwtAuth, useDeleteIdentityKubernetesAuth, useDeleteIdentityOidcAuth, useDeleteIdentityTokenAuth, @@ -25,20 +27,24 @@ export { useUpdateIdentityAwsAuth, useUpdateIdentityAzureAuth, useUpdateIdentityGcpAuth, + useUpdateIdentityJwtAuth, useUpdateIdentityKubernetesAuth, useUpdateIdentityOidcAuth, useUpdateIdentityTokenAuth, useUpdateIdentityTokenAuthToken, - useUpdateIdentityUniversalAuth} from "./mutations"; + useUpdateIdentityUniversalAuth +} from "./mutations"; export { useGetIdentityAwsAuth, useGetIdentityAzureAuth, useGetIdentityById, useGetIdentityGcpAuth, + useGetIdentityJwtAuth, useGetIdentityKubernetesAuth, useGetIdentityOidcAuth, useGetIdentityProjectMemberships, useGetIdentityTokenAuth, useGetIdentityTokensTokenAuth, useGetIdentityUniversalAuth, - useGetIdentityUniversalAuthClientSecrets} from "./queries"; + useGetIdentityUniversalAuthClientSecrets +} from "./queries"; diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx index 21c4c560e..8daaae236 100644 --- a/frontend/src/hooks/api/identities/mutations.tsx +++ b/frontend/src/hooks/api/identities/mutations.tsx @@ -8,6 +8,7 @@ import { AddIdentityAwsAuthDTO, AddIdentityAzureAuthDTO, AddIdentityGcpAuthDTO, + AddIdentityJwtAuthDTO, AddIdentityKubernetesAuthDTO, AddIdentityOidcAuthDTO, AddIdentityTokenAuthDTO, @@ -22,6 +23,7 @@ import { DeleteIdentityAzureAuthDTO, DeleteIdentityDTO, DeleteIdentityGcpAuthDTO, + DeleteIdentityJwtAuthDTO, DeleteIdentityKubernetesAuthDTO, DeleteIdentityOidcAuthDTO, DeleteIdentityTokenAuthDTO, @@ -32,6 +34,7 @@ import { IdentityAwsAuth, IdentityAzureAuth, IdentityGcpAuth, + IdentityJwtAuth, IdentityKubernetesAuth, IdentityOidcAuth, IdentityTokenAuth, @@ -42,6 +45,7 @@ import { UpdateIdentityAzureAuthDTO, UpdateIdentityDTO, UpdateIdentityGcpAuthDTO, + UpdateIdentityJwtAuthDTO, UpdateIdentityKubernetesAuthDTO, UpdateIdentityOidcAuthDTO, UpdateIdentityTokenAuthDTO, @@ -518,6 +522,118 @@ export const useDeleteIdentityOidcAuth = () => { } }); }; +export const useUpdateIdentityJwtAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + configurationType, + jwksUrl, + jwksCaCert, + publicKeys, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + boundIssuer, + boundAudiences, + boundClaims, + boundSubject + }) => { + const { + data: { identityJwtAuth } + } = await apiRequest.patch<{ identityJwtAuth: IdentityJwtAuth }>( + `/api/v1/auth/jwt-auth/identities/${identityId}`, + { + configurationType, + jwksUrl, + jwksCaCert, + publicKeys, + boundIssuer, + boundAudiences, + boundClaims, + boundSubject, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + } + ); + + return identityJwtAuth; + }, + onSuccess: (_, { identityId, organizationId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + queryClient.invalidateQueries(identitiesKeys.getIdentityById(identityId)); + queryClient.invalidateQueries(identitiesKeys.getIdentityJwtAuth(identityId)); + } + }); +}; + +export const useAddIdentityJwtAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + configurationType, + jwksUrl, + jwksCaCert, + publicKeys, + boundIssuer, + boundAudiences, + boundClaims, + boundSubject, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }) => { + const { + data: { identityJwtAuth } + } = await apiRequest.post<{ identityJwtAuth: IdentityJwtAuth }>( + `/api/v1/auth/jwt-auth/identities/${identityId}`, + { + configurationType, + jwksUrl, + jwksCaCert, + publicKeys, + boundIssuer, + boundAudiences, + boundClaims, + boundSubject, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + } + ); + + return identityJwtAuth; + }, + onSuccess: (_, { identityId, organizationId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + queryClient.invalidateQueries(identitiesKeys.getIdentityById(identityId)); + queryClient.invalidateQueries(identitiesKeys.getIdentityJwtAuth(identityId)); + } + }); +}; + +export const useDeleteIdentityJwtAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ identityId }) => { + const { + data: { identityJwtAuth } + } = await apiRequest.delete(`/api/v1/auth/jwt-auth/identities/${identityId}`); + return identityJwtAuth; + }, + onSuccess: (_, { organizationId, identityId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + queryClient.invalidateQueries(identitiesKeys.getIdentityById(identityId)); + queryClient.invalidateQueries(identitiesKeys.getIdentityJwtAuth(identityId)); + } + }); +}; export const useAddIdentityAzureAuth = () => { const queryClient = useQueryClient(); diff --git a/frontend/src/hooks/api/identities/queries.tsx b/frontend/src/hooks/api/identities/queries.tsx index c5c442407..49136614e 100644 --- a/frontend/src/hooks/api/identities/queries.tsx +++ b/frontend/src/hooks/api/identities/queries.tsx @@ -8,6 +8,7 @@ import { IdentityAwsAuth, IdentityAzureAuth, IdentityGcpAuth, + IdentityJwtAuth, IdentityKubernetesAuth, IdentityMembership, IdentityMembershipOrg, @@ -29,6 +30,7 @@ export const identitiesKeys = { getIdentityAwsAuth: (identityId: string) => [{ identityId }, "identity-aws-auth"] as const, getIdentityAzureAuth: (identityId: string) => [{ identityId }, "identity-azure-auth"] as const, getIdentityTokenAuth: (identityId: string) => [{ identityId }, "identity-token-auth"] as const, + getIdentityJwtAuth: (identityId: string) => [{ identityId }, "identity-jwt-auth"] as const, getIdentityTokensTokenAuth: (identityId: string) => [{ identityId }, "identity-tokens-token-auth"] as const, getIdentityProjectMemberships: (identityId: string) => @@ -276,3 +278,30 @@ export const useGetIdentityOidcAuth = ( enabled: Boolean(identityId) && (options?.enabled ?? true) }); }; + +export const useGetIdentityJwtAuth = ( + identityId: string, + options?: UseQueryOptions< + IdentityJwtAuth, + unknown, + IdentityJwtAuth, + ReturnType + > +) => { + return useQuery({ + queryKey: identitiesKeys.getIdentityJwtAuth(identityId), + queryFn: async () => { + const { + data: { identityJwtAuth } + } = await apiRequest.get<{ identityJwtAuth: IdentityJwtAuth }>( + `/api/v1/auth/jwt-auth/identities/${identityId}` + ); + + return identityJwtAuth; + }, + staleTime: 0, + cacheTime: 0, + ...options, + enabled: Boolean(identityId) && (options?.enabled ?? true) + }); +}; diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index 559a01974..8d344e7f7 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -1,6 +1,6 @@ import { TOrgRole } from "../roles/types"; import { ProjectUserMembershipTemporaryMode, Workspace } from "../workspace/types"; -import { IdentityAuthMethod } from "./enums"; +import { IdentityAuthMethod, IdentityJwtConfigurationType } from "./enums"; export type IdentityTrustedIp = { id: string; @@ -47,7 +47,7 @@ export type IdentityMembershipOrg = { export type IdentityMembership = { id: string; identity: Identity; - project: Pick; + project: Pick; roles: Array< { id: string; @@ -446,6 +446,65 @@ export type DeleteIdentityTokenAuthDTO = { identityId: string; }; +export type IdentityJwtAuth = { + identityId: string; + configurationType: IdentityJwtConfigurationType; + jwksUrl: string; + jwksCaCert: string; + publicKeys: string[]; + boundIssuer: string; + boundAudiences: string; + boundClaims: Record; + boundSubject: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: IdentityTrustedIp[]; +}; + +export type AddIdentityJwtAuthDTO = { + organizationId: string; + identityId: string; + configurationType: string; + jwksUrl?: string; + jwksCaCert: string; + publicKeys?: string[]; + boundIssuer: string; + boundAudiences: string; + boundClaims: Record; + boundSubject: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { + ipAddress: string; + }[]; +}; + +export type UpdateIdentityJwtAuthDTO = { + organizationId: string; + identityId: string; + configurationType?: string; + jwksUrl?: string; + jwksCaCert?: string; + publicKeys?: string[]; + boundIssuer?: string; + boundAudiences?: string; + boundClaims?: Record; + boundSubject?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { + ipAddress: string; + }[]; +}; + +export type DeleteIdentityJwtAuthDTO = { + organizationId: string; + identityId: string; +}; + export type CreateTokenIdentityTokenAuthDTO = { identityId: string; name: string; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 551822f09..19a6fcc06 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -38,6 +38,8 @@ export * from "./secretSharing"; export * from "./secretSnapshots"; export * from "./serverDetails"; export * from "./serviceTokens"; +export * from "./ssh-ca"; +export * from "./sshCertificateTemplates"; export * from "./ssoConfig"; export * from "./subscriptions"; export * from "./tags"; diff --git a/frontend/src/hooks/api/integrationAuth/index.tsx b/frontend/src/hooks/api/integrationAuth/index.tsx index 0ae3511de..e7ee5928a 100644 --- a/frontend/src/hooks/api/integrationAuth/index.tsx +++ b/frontend/src/hooks/api/integrationAuth/index.tsx @@ -7,6 +7,7 @@ export { useGetIntegrationAuthBitBucketWorkspaces, useGetIntegrationAuthById, useGetIntegrationAuthChecklyGroups, + useGetIntegrationAuthCircleCIOrganizations, useGetIntegrationAuthGithubEnvs, useGetIntegrationAuthGithubOrgs, useGetIntegrationAuthNorthflankSecretGroups, diff --git a/frontend/src/hooks/api/integrationAuth/queries.tsx b/frontend/src/hooks/api/integrationAuth/queries.tsx index e5f928158..84a50ae1f 100644 --- a/frontend/src/hooks/api/integrationAuth/queries.tsx +++ b/frontend/src/hooks/api/integrationAuth/queries.tsx @@ -8,6 +8,7 @@ import { BitBucketEnvironment, BitBucketWorkspace, ChecklyGroup, + CircleCIOrganization, Environment, HerokuPipelineCoupling, IntegrationAuth, @@ -128,7 +129,9 @@ const integrationAuthKeys = { integrationAuthId, ...params }: TGetIntegrationAuthOctopusDeployScopeValuesDTO) => - [{ integrationAuthId }, "getIntegrationAuthOctopusDeployScopeValues", params] as const + [{ integrationAuthId }, "getIntegrationAuthOctopusDeployScopeValues", params] as const, + getIntegrationAuthCircleCIOrganizations: (integrationAuthId: string) => + [{ integrationAuthId }, "getIntegrationAuthCircleCIOrganizations"] as const }; const fetchIntegrationAuthById = async (integrationAuthId: string) => { @@ -510,6 +513,15 @@ const fetchIntegrationAuthOctopusDeployScopeValues = async ({ return data; }; +const fetchIntegrationAuthCircleCIOrganizations = async (integrationAuthId: string) => { + const { + data: { organizations } + } = await apiRequest.get<{ + organizations: CircleCIOrganization[]; + }>(`/api/v1/integration-auth/${integrationAuthId}/circleci/organizations`); + return organizations; +}; + export const useGetIntegrationAuthById = (integrationAuthId: string) => { return useQuery({ queryKey: integrationAuthKeys.getIntegrationAuthById(integrationAuthId), @@ -884,6 +896,13 @@ export const useGetIntegrationAuthTeamCityBuildConfigs = ({ }); }; +export const useGetIntegrationAuthCircleCIOrganizations = (integrationAuthId: string) => { + return useQuery({ + queryKey: integrationAuthKeys.getIntegrationAuthCircleCIOrganizations(integrationAuthId), + queryFn: () => fetchIntegrationAuthCircleCIOrganizations(integrationAuthId) + }); +}; + export const useAuthorizeIntegration = () => { const queryClient = useQueryClient(); diff --git a/frontend/src/hooks/api/integrationAuth/types.ts b/frontend/src/hooks/api/integrationAuth/types.ts index 58a643dff..e2dee6067 100644 --- a/frontend/src/hooks/api/integrationAuth/types.ts +++ b/frontend/src/hooks/api/integrationAuth/types.ts @@ -105,6 +105,19 @@ export enum OctopusDeployScope { // tenant, variable set } +export type CircleCIOrganization = { + name: string; + slug: string; + projects: { + name: string; + id: string; + }[]; + contexts: { + name: string; + id: string; + }[]; +}; + export type TGetIntegrationAuthOctopusDeployScopeValuesDTO = { integrationAuthId: string; spaceId: string; @@ -125,3 +138,8 @@ export type TOctopusDeployVariableSetScopeValues = { Name: string; }[]; }; + +export enum CircleCiScope { + Context = "context", + Project = "project" +} diff --git a/frontend/src/hooks/api/integrations/queries.tsx b/frontend/src/hooks/api/integrations/queries.tsx index 11d42631a..5c059ae98 100644 --- a/frontend/src/hooks/api/integrations/queries.tsx +++ b/frontend/src/hooks/api/integrations/queries.tsx @@ -80,6 +80,7 @@ export const useCreateIntegration = () => { key: string; value: string; }[]; + azureLabel?: string; githubVisibility?: string; githubVisibilityRepoIds?: string[]; kmsKeyId?: string; diff --git a/frontend/src/hooks/api/integrations/types.ts b/frontend/src/hooks/api/integrations/types.ts index 0346b065a..7054befc7 100644 --- a/frontend/src/hooks/api/integrations/types.ts +++ b/frontend/src/hooks/api/integrations/types.ts @@ -41,6 +41,7 @@ export type TIntegration = { key: string; value: string; }[]; + azureLabel?: string; kmsKeyId?: string; secretSuffix?: string; diff --git a/frontend/src/hooks/api/kms/types.ts b/frontend/src/hooks/api/kms/types.ts index 3e4b69880..73b821b1a 100644 --- a/frontend/src/hooks/api/kms/types.ts +++ b/frontend/src/hooks/api/kms/types.ts @@ -1,6 +1,8 @@ import slugify from "@sindresorhus/slugify"; import { z } from "zod"; +import { slugSchema } from "@app/lib/schemas"; + export type Kms = { id: string; description: string; @@ -119,13 +121,7 @@ export const ExternalKmsInputSchema = z.discriminatedUnion("type", [ ]); export const AddExternalKmsSchema = z.object({ - name: z - .string() - .trim() - .min(1) - .refine((v) => slugify(v) === v, { - message: "Alias must be a valid slug" - }), + name: slugSchema({ min: 1, field: "Alias" }), description: z.string().trim().optional(), provider: ExternalKmsInputSchema }); diff --git a/frontend/src/hooks/api/migration/mutations.tsx b/frontend/src/hooks/api/migration/mutations.tsx index 41d17b0bd..feee7778d 100644 --- a/frontend/src/hooks/api/migration/mutations.tsx +++ b/frontend/src/hooks/api/migration/mutations.tsx @@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { workspaceKeys } from "../workspace"; +import { ProjectType } from "../workspace/types"; export const useImportEnvKey = () => { const queryClient = useQueryClient(); @@ -31,7 +32,7 @@ export const useImportEnvKey = () => { } }, onSuccess: () => { - queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace); + queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace(ProjectType.SecretManager)); } }); }; diff --git a/frontend/src/hooks/api/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx index 4923177ba..82894d988 100644 --- a/frontend/src/hooks/api/organization/queries.tsx +++ b/frontend/src/hooks/api/organization/queries.tsx @@ -1,5 +1,6 @@ import { useMutation, useQuery, useQueryClient, UseQueryOptions } from "@tanstack/react-query"; +import SecurityClient from "@app/components/utilities/SecurityClient"; import { apiRequest } from "@app/config/request"; import { OrderByDirection } from "@app/hooks/api/generic/types"; @@ -67,7 +68,7 @@ export const useCreateOrg = (options: { invalidate: boolean } = { invalidate: tr mutationFn: async ({ name }: { name: string }) => { const { data: { organization } - } = await apiRequest.post("/api/v2/organizations", { + } = await apiRequest.post<{ organization: { id: string } }>("/api/v2/organizations", { name }); @@ -437,10 +438,13 @@ export const useDeleteOrgById = () => { return useMutation({ mutationFn: async ({ organizationId }: { organizationId: string }) => { const { - data: { organization } - } = await apiRequest.delete<{ organization: Organization }>( + data: { organization, accessToken } + } = await apiRequest.delete<{ organization: Organization; accessToken: string }>( `/api/v2/organizations/${organizationId}` ); + SecurityClient.setToken(accessToken); + localStorage.removeItem("orgData.id"); + return organization; }, onSuccess(_, dto) { diff --git a/frontend/src/hooks/api/roles/types.ts b/frontend/src/hooks/api/roles/types.ts index 0a48c9f97..20c65190c 100644 --- a/frontend/src/hooks/api/roles/types.ts +++ b/frontend/src/hooks/api/roles/types.ts @@ -56,7 +56,7 @@ export type TGetUserProjectPermissionDTO = { export type TCreateOrgRoleDTO = { orgId: string; name: string; - description?: string; + description?: string | null; slug: string; permissions: TPermission[]; }; @@ -74,7 +74,7 @@ export type TDeleteOrgRoleDTO = { export type TCreateProjectRoleDTO = { projectId: string; name: string; - description?: string; + description?: string | null; slug: string; permissions: TProjectPermission[]; }; diff --git a/frontend/src/hooks/api/ssh-ca/constants.tsx b/frontend/src/hooks/api/ssh-ca/constants.tsx new file mode 100644 index 000000000..2742a7bfa --- /dev/null +++ b/frontend/src/hooks/api/ssh-ca/constants.tsx @@ -0,0 +1,14 @@ +export enum SshCaStatus { + ACTIVE = "active", + DISABLED = "disabled" +} + +export enum SshCertType { + USER = "user", + HOST = "host" +} + +export const sshCertTypeToNameMap: { [K in SshCertType]: string } = { + [SshCertType.USER]: "User", + [SshCertType.HOST]: "Host" +}; diff --git a/frontend/src/hooks/api/ssh-ca/index.tsx b/frontend/src/hooks/api/ssh-ca/index.tsx new file mode 100644 index 000000000..8fc57654b --- /dev/null +++ b/frontend/src/hooks/api/ssh-ca/index.tsx @@ -0,0 +1,9 @@ +export { SshCaStatus } from "./constants"; +export { + useCreateSshCa, + useDeleteSshCa, + useIssueSshCreds, + useSignSshKey, + useUpdateSshCa +} from "./mutations"; +export { useGetSshCaById, useGetSshCaCertTemplates } from "./queries"; diff --git a/frontend/src/hooks/api/ssh-ca/mutations.tsx b/frontend/src/hooks/api/ssh-ca/mutations.tsx new file mode 100644 index 000000000..ceddd65b4 --- /dev/null +++ b/frontend/src/hooks/api/ssh-ca/mutations.tsx @@ -0,0 +1,97 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { workspaceKeys } from "../workspace/query-keys"; +import { + TCreateSshCaDTO, + TDeleteSshCaDTO, + TIssueSshCredsDTO, + TIssueSshCredsResponse, + TSignSshKeyDTO, + TSignSshKeyResponse, + TSshCertificateAuthority, + TUpdateSshCaDTO +} from "./types"; + +export const sshCaKeys = { + getSshCaById: (caId: string) => [{ caId }, "ssh-ca"] +}; + +export const useCreateSshCa = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body) => { + const { + data: { ca } + } = await apiRequest.post<{ ca: TSshCertificateAuthority }>("/api/v1/ssh/ca/", body); + return ca; + }, + onSuccess: ({ projectId }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceSshCas(projectId)); + } + }); +}; + +export const useUpdateSshCa = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ caId, ...body }) => { + const { + data: { ca } + } = await apiRequest.patch<{ ca: TSshCertificateAuthority }>(`/api/v1/ssh/ca/${caId}`, body); + return ca; + }, + onSuccess: ({ projectId }, { caId }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceSshCas(projectId)); + queryClient.invalidateQueries(sshCaKeys.getSshCaById(caId)); + } + }); +}; + +export const useDeleteSshCa = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ caId }) => { + const { + data: { ca } + } = await apiRequest.delete<{ ca: TSshCertificateAuthority }>(`/api/v1/ssh/ca/${caId}`); + return ca; + }, + onSuccess: ({ projectId }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceSshCas(projectId)); + } + }); +}; + +export const useSignSshKey = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body) => { + const { data } = await apiRequest.post( + "/api/v1/ssh/certificates/sign", + body + ); + return data; + }, + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries(workspaceKeys.allWorkspaceSshCertificates(projectId)); + } + }); +}; + +export const useIssueSshCreds = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body) => { + const { data } = await apiRequest.post( + "/api/v1/ssh/certificates/issue", + body + ); + return data; + }, + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries(workspaceKeys.allWorkspaceSshCertificates(projectId)); + } + }); +}; diff --git a/frontend/src/hooks/api/ssh-ca/queries.tsx b/frontend/src/hooks/api/ssh-ca/queries.tsx new file mode 100644 index 000000000..b85bbcb35 --- /dev/null +++ b/frontend/src/hooks/api/ssh-ca/queries.tsx @@ -0,0 +1,37 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TSshCertificateTemplate } from "../sshCertificateTemplates/types"; +import { TSshCertificateAuthority } from "./types"; + +export const sshCaKeys = { + getSshCaById: (caId: string) => [{ caId }, "ssh-ca"], + getSshCaCertTemplates: (caId: string) => [{ caId }, "ssh-ca-cert-templates"] +}; + +export const useGetSshCaById = (caId: string) => { + return useQuery({ + queryKey: sshCaKeys.getSshCaById(caId), + queryFn: async () => { + const { + data: { ca } + } = await apiRequest.get<{ ca: TSshCertificateAuthority }>(`/api/v1/ssh/ca/${caId}`); + return ca; + }, + enabled: Boolean(caId) + }); +}; + +export const useGetSshCaCertTemplates = (caId: string) => { + return useQuery({ + queryKey: sshCaKeys.getSshCaCertTemplates(caId), + queryFn: async () => { + const { data } = await apiRequest.get<{ + certificateTemplates: TSshCertificateTemplate[]; + }>(`/api/v1/ssh/ca/${caId}/certificate-templates`); + return data; + }, + enabled: Boolean(caId) + }); +}; diff --git a/frontend/src/hooks/api/ssh-ca/types.ts b/frontend/src/hooks/api/ssh-ca/types.ts new file mode 100644 index 000000000..6e5f02c4d --- /dev/null +++ b/frontend/src/hooks/api/ssh-ca/types.ts @@ -0,0 +1,74 @@ +import { CertKeyAlgorithm } from "../certificates/enums"; +import { SshCaStatus, SshCertType } from "./constants"; + +export type TSshCertificate = { + id: string; + sshCaId: string; + sshCertificateTemplateId: string; + serialNumber: string; + certType: SshCertType; + principals: string[]; + keyId: string; + notBefore: string; + notAfter: string; +}; + +export type TSshCertificateAuthority = { + id: string; + projectId: string; + status: SshCaStatus; + friendlyName: string; + keyAlgorithm: CertKeyAlgorithm; + createdAt: string; + updatedAt: string; + publicKey: string; +}; + +export type TCreateSshCaDTO = { + projectId: string; + friendlyName?: string; + keyAlgorithm: CertKeyAlgorithm; +}; + +export type TUpdateSshCaDTO = { + caId: string; + friendlyName?: string; + status?: SshCaStatus; +}; + +export type TDeleteSshCaDTO = { + caId: string; +}; + +export type TSignSshKeyDTO = { + projectId: string; + certificateTemplateId: string; + publicKey?: string; + certType: SshCertType; + principals: string[]; + ttl?: string; + keyId?: string; +}; + +export type TSignSshKeyResponse = { + serialNumber: string; + signedKey: string; +}; + +export type TIssueSshCredsDTO = { + projectId: string; + certificateTemplateId: string; + keyAlgorithm: CertKeyAlgorithm; + certType: SshCertType; + principals: string[]; + ttl?: string; + keyId?: string; +}; + +export type TIssueSshCredsResponse = { + serialNumber: string; + signedKey: string; + privateKey: string; + publicKey: string; + keyAlgorithm: CertKeyAlgorithm; +}; diff --git a/frontend/src/hooks/api/sshCertificateTemplates/index.tsx b/frontend/src/hooks/api/sshCertificateTemplates/index.tsx new file mode 100644 index 000000000..9efe99c81 --- /dev/null +++ b/frontend/src/hooks/api/sshCertificateTemplates/index.tsx @@ -0,0 +1,7 @@ +export { + useCreateSshCertTemplate, + useDeleteSshCertTemplate, + useUpdateSshCertTemplate +} from "./mutations"; +export { useGetSshCertTemplate } from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/sshCertificateTemplates/mutations.tsx b/frontend/src/hooks/api/sshCertificateTemplates/mutations.tsx new file mode 100644 index 000000000..91fb5eb53 --- /dev/null +++ b/frontend/src/hooks/api/sshCertificateTemplates/mutations.tsx @@ -0,0 +1,59 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { sshCaKeys } from "../ssh-ca/queries"; +import { + TCreateSshCertificateTemplateDTO, + TDeleteSshCertificateTemplateDTO, + TSshCertificateTemplate, + TUpdateSshCertificateTemplateDTO +} from "./types"; + +export const useCreateSshCertTemplate = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (data) => { + const { data: certificateTemplate } = await apiRequest.post( + "/api/v1/ssh/certificate-templates", + data + ); + return certificateTemplate; + }, + onSuccess: ({ sshCaId }) => { + queryClient.invalidateQueries(sshCaKeys.getSshCaCertTemplates(sshCaId)); + } + }); +}; + +export const useUpdateSshCertTemplate = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (data) => { + const { data: certificateTemplate } = await apiRequest.patch( + `/api/v1/ssh/certificate-templates/${data.id}`, + data + ); + + return certificateTemplate; + }, + onSuccess: ({ sshCaId }) => { + queryClient.invalidateQueries(sshCaKeys.getSshCaCertTemplates(sshCaId)); + } + }); +}; + +export const useDeleteSshCertTemplate = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (data) => { + const { data: certificateTemplate } = await apiRequest.delete( + `/api/v1/ssh/certificate-templates/${data.id}` + ); + return certificateTemplate; + }, + onSuccess: ({ sshCaId }) => { + queryClient.invalidateQueries(sshCaKeys.getSshCaCertTemplates(sshCaId)); + } + }); +}; diff --git a/frontend/src/hooks/api/sshCertificateTemplates/queries.tsx b/frontend/src/hooks/api/sshCertificateTemplates/queries.tsx new file mode 100644 index 000000000..b9b535916 --- /dev/null +++ b/frontend/src/hooks/api/sshCertificateTemplates/queries.tsx @@ -0,0 +1,22 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TSshCertificateTemplate } from "./types"; + +export const certTemplateKeys = { + getSshCertTemplateById: (id: string) => [{ id }, "ssh-cert-template"] +}; + +export const useGetSshCertTemplate = (id: string) => { + return useQuery({ + queryKey: certTemplateKeys.getSshCertTemplateById(id), + queryFn: async () => { + const { data: certificateTemplate } = await apiRequest.get( + `/api/v1/ssh/certificate-templates/${id}` + ); + return certificateTemplate; + }, + enabled: Boolean(id) + }); +}; diff --git a/frontend/src/hooks/api/sshCertificateTemplates/types.ts b/frontend/src/hooks/api/sshCertificateTemplates/types.ts new file mode 100644 index 000000000..4099b4b8a --- /dev/null +++ b/frontend/src/hooks/api/sshCertificateTemplates/types.ts @@ -0,0 +1,47 @@ +export enum SshCertTemplateStatus { + ACTIVE = "active", + DISABLED = "disabled" +} + +export type TSshCertificateTemplate = { + id: string; + sshCaId: string; + status: SshCertTemplateStatus; + name: string; + ttl: string; + maxTTL: string; + allowedUsers: string[]; + allowedHosts: string[]; + allowUserCertificates: boolean; + allowHostCertificates: boolean; + allowCustomKeyIds: boolean; +}; + +export type TCreateSshCertificateTemplateDTO = { + sshCaId: string; + name: string; + ttl: string; + maxTTL: string; + allowedUsers: string[]; + allowedHosts: string[]; + allowUserCertificates: boolean; + allowHostCertificates: boolean; + allowCustomKeyIds: boolean; +}; + +export type TUpdateSshCertificateTemplateDTO = { + id: string; + status?: SshCertTemplateStatus; + name?: string; + ttl?: string; + maxTTL?: string; + allowedUsers?: string[]; + allowedHosts?: string[]; + allowUserCertificates?: boolean; + allowHostCertificates?: boolean; + allowCustomKeyIds?: boolean; +}; + +export type TDeleteSshCertificateTemplateDTO = { + id: string; +}; diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index b1c4e224d..44b0a34fb 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -45,4 +45,5 @@ export type SubscriptionPlan = { pkiEst: boolean; enforceMfa: boolean; projectTemplates: boolean; + appConnections: boolean; // TODO: remove once released }; diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index 594287c9c..319fb723d 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -1,6 +1,6 @@ import { MfaMethod } from "../auth/types"; import { UserWsKeyPair } from "../keys/types"; -import { ProjectUserMembershipTemporaryMode } from "../workspace/types"; +import { ProjectType, ProjectUserMembershipTemporaryMode } from "../workspace/types"; export enum AuthMethod { EMAIL = "email", @@ -95,6 +95,7 @@ export type TWorkspaceUser = { project: { id: string; name: string; + type: ProjectType; }; inviteEmail: string; organization: string; diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index cd652970a..665462e7d 100644 --- a/frontend/src/hooks/api/workspace/index.tsx +++ b/frontend/src/hooks/api/workspace/index.tsx @@ -32,6 +32,9 @@ export { useListWorkspaceGroups, useListWorkspacePkiAlerts, useListWorkspacePkiCollections, + useListWorkspaceSshCas, + useListWorkspaceSshCertificates, + useListWorkspaceSshCertificateTemplates, useNameWorkspaceSecrets, useToggleAutoCapitalization, useUpdateIdentityWorkspaceRole, diff --git a/frontend/src/hooks/api/workspace/mutations.tsx b/frontend/src/hooks/api/workspace/mutations.tsx index ae8829591..c4b8211bb 100644 --- a/frontend/src/hooks/api/workspace/mutations.tsx +++ b/frontend/src/hooks/api/workspace/mutations.tsx @@ -4,7 +4,7 @@ import { apiRequest } from "@app/config/request"; import { userKeys } from "../users/query-keys"; import { workspaceKeys } from "./query-keys"; -import { TUpdateWorkspaceGroupRoleDTO } from "./types"; +import { ProjectType, TUpdateWorkspaceGroupRoleDTO } from "./types"; export const useAddGroupToWorkspace = () => { const queryClient = useQueryClient(); @@ -83,7 +83,7 @@ export const useLeaveProject = () => { return apiRequest.delete(`/api/v1/workspace/${workspaceId}/leave`); }, onSuccess: () => { - queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace); + queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace()); } }); }; @@ -95,7 +95,7 @@ export const useMigrateProjectToV3 = () => { return apiRequest.post(`/api/v1/workspace/${workspaceId}/migrate-v3`); }, onSuccess: () => { - queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace); + queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace(ProjectType.SecretManager)); } }); }; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index ec887a401..45524bdb3 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -15,6 +15,8 @@ import { TIntegration } from "../integrations/types"; import { TPkiAlert } from "../pkiAlerts/types"; import { TPkiCollection } from "../pkiCollections/types"; import { EncryptedSecret } from "../secrets/types"; +import { TSshCertificate, TSshCertificateAuthority } from "../ssh-ca/types"; +import { TSshCertificateTemplate } from "../sshCertificateTemplates/types"; import { userKeys } from "../users/query-keys"; import { TWorkspaceUser } from "../users/types"; import { ProjectSlackConfig } from "../workflowIntegrations/types"; @@ -26,6 +28,7 @@ import { DeleteWorkspaceDTO, NameWorkspaceSecretsDTO, ProjectIdentityOrderBy, + ProjectType, TGetUpgradeProjectStatusDTO, TListProjectIdentitiesDTO, ToggleAutoCapitalizationDTO, @@ -82,7 +85,7 @@ export const useUpgradeProject = () => { }); }, onSuccess: () => { - queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace); + queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace(ProjectType.SecretManager)); } }); }; @@ -102,10 +105,11 @@ export const useGetUpgradeProjectStatus = ({ }); }; -const fetchUserWorkspaces = async (includeRoles?: boolean) => { +const fetchUserWorkspaces = async (includeRoles?: boolean, type?: ProjectType | "all") => { const { data } = await apiRequest.get<{ workspaces: Workspace[] }>("/api/v1/workspace", { params: { - includeRoles + includeRoles, + type } }); return data.workspaces; @@ -139,8 +143,16 @@ export const useGetWorkspaceById = ( }); }; -export const useGetUserWorkspaces = (includeRoles?: boolean) => - useQuery(workspaceKeys.getAllUserWorkspace, () => fetchUserWorkspaces(includeRoles)); +export const useGetUserWorkspaces = ({ + includeRoles, + type = "all" +}: { + includeRoles?: boolean; + type?: ProjectType | "all"; +} = {}) => + useQuery(workspaceKeys.getAllUserWorkspace(type || ""), () => + fetchUserWorkspaces(includeRoles, type) + ); const fetchUserWorkspaceMemberships = async (orgId: string) => { const { data } = await apiRequest.get>( @@ -206,33 +218,26 @@ export const useGetWorkspaceIntegrations = (workspaceId: string) => refetchInterval: 4000 }); -export const createWorkspace = ({ - projectName, - projectDescription, - kmsKeyId, - template -}: CreateWorkspaceDTO): Promise<{ data: { project: Workspace } }> => { - return apiRequest.post("/api/v2/workspace", { - projectName, - projectDescription, - kmsKeyId, - template - }); +export const createWorkspace = ( + dto: CreateWorkspaceDTO +): Promise<{ data: { project: Workspace } }> => { + return apiRequest.post("/api/v2/workspace", dto); }; export const useCreateWorkspace = () => { const queryClient = useQueryClient(); return useMutation<{ data: { project: Workspace } }, {}, CreateWorkspaceDTO>({ - mutationFn: async ({ projectName, projectDescription, kmsKeyId, template }) => + mutationFn: async ({ projectName, projectDescription, kmsKeyId, template, type }) => createWorkspace({ projectName, projectDescription, kmsKeyId, - template + template, + type }), - onSuccess: () => { - queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace); + onSuccess: (dto) => { + queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace(dto.data.project.type)); } }); }; @@ -240,15 +245,19 @@ export const useCreateWorkspace = () => { export const useUpdateProject = () => { const queryClient = useQueryClient(); - return useMutation<{}, {}, UpdateProjectDTO>({ - mutationFn: ({ projectID, newProjectName, newProjectDescription }) => { - return apiRequest.patch(`/api/v1/workspace/${projectID}`, { - name: newProjectName, - description: newProjectDescription - }); + return useMutation({ + mutationFn: async ({ projectID, newProjectName, newProjectDescription }) => { + const { data } = await apiRequest.patch<{ workspace: Workspace }>( + `/api/v1/workspace/${projectID}`, + { + name: newProjectName, + description: newProjectDescription + } + ); + return data.workspace; }, - onSuccess: () => { - queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace); + onSuccess: (dto) => { + queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace(dto.type)); } }); }; @@ -256,13 +265,18 @@ export const useUpdateProject = () => { export const useToggleAutoCapitalization = () => { const queryClient = useQueryClient(); - return useMutation<{}, {}, ToggleAutoCapitalizationDTO>({ - mutationFn: ({ workspaceID, state }) => - apiRequest.post(`/api/v1/workspace/${workspaceID}/auto-capitalization`, { - autoCapitalization: state - }), - onSuccess: () => { - queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace); + return useMutation({ + mutationFn: async ({ workspaceID, state }) => { + const { data } = await apiRequest.post<{ workspace: Workspace }>( + `/api/v1/workspace/${workspaceID}/auto-capitalization`, + { + autoCapitalization: state + } + ); + return data.workspace; + }, + onSuccess: (dto) => { + queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace(dto.type)); } }); }; @@ -270,14 +284,15 @@ export const useToggleAutoCapitalization = () => { export const useUpdateWorkspaceVersionLimit = () => { const queryClient = useQueryClient(); - return useMutation<{}, {}, UpdatePitVersionLimitDTO>({ - mutationFn: ({ projectSlug, pitVersionLimit }) => { - return apiRequest.put(`/api/v1/workspace/${projectSlug}/version-limit`, { + return useMutation({ + mutationFn: async ({ projectSlug, pitVersionLimit }) => { + const { data } = await apiRequest.put(`/api/v1/workspace/${projectSlug}/version-limit`, { pitVersionLimit }); + return data.workspace; }, - onSuccess: () => { - queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace); + onSuccess: (dto) => { + queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace(dto.type)); } }); }; @@ -285,14 +300,18 @@ export const useUpdateWorkspaceVersionLimit = () => { export const useUpdateWorkspaceAuditLogsRetention = () => { const queryClient = useQueryClient(); - return useMutation<{}, {}, UpdateAuditLogsRetentionDTO>({ - mutationFn: ({ projectSlug, auditLogsRetentionDays }) => { - return apiRequest.put(`/api/v1/workspace/${projectSlug}/audit-logs-retention`, { - auditLogsRetentionDays - }); + return useMutation({ + mutationFn: async ({ projectSlug, auditLogsRetentionDays }) => { + const { data } = await apiRequest.put( + `/api/v1/workspace/${projectSlug}/audit-logs-retention`, + { + auditLogsRetentionDays + } + ); + return data.workspace; }, - onSuccess: () => { - queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace); + onSuccess: (dto) => { + queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace(dto.type)); } }); }; @@ -300,12 +319,13 @@ export const useUpdateWorkspaceAuditLogsRetention = () => { export const useDeleteWorkspace = () => { const queryClient = useQueryClient(); - return useMutation<{}, {}, DeleteWorkspaceDTO>({ - mutationFn: ({ workspaceID }) => { - return apiRequest.delete(`/api/v1/workspace/${workspaceID}`); + return useMutation({ + mutationFn: async ({ workspaceID }) => { + const { data } = await apiRequest.delete(`/api/v1/workspace/${workspaceID}`); + return data.workspace; }, - onSuccess: () => { - queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace); + onSuccess: (dto) => { + queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace(dto.type)); queryClient.invalidateQueries(["org-admin-projects"]); } }); @@ -322,7 +342,7 @@ export const useCreateWsEnvironment = () => { }); }, onSuccess: () => { - queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace); + queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace(ProjectType.SecretManager)); } }); }; @@ -339,7 +359,7 @@ export const useUpdateWsEnvironment = () => { }); }, onSuccess: () => { - queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace); + queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace(ProjectType.SecretManager)); } }); }; @@ -352,7 +372,7 @@ export const useDeleteWsEnvironment = () => { return apiRequest.delete(`/api/v1/workspace/${workspaceId}/environments/${id}`); }, onSuccess: () => { - queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace); + queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace(ProjectType.SecretManager)); } }); }; @@ -713,6 +733,67 @@ export const useListWorkspaceCertificateTemplates = ({ workspaceId }: { workspac }); }; +export const useListWorkspaceSshCertificates = ({ + offset, + limit, + projectId +}: { + offset: number; + limit: number; + projectId: string; +}) => { + return useQuery({ + queryKey: workspaceKeys.specificWorkspaceSshCertificates({ + offset, + limit, + projectId + }), + queryFn: async () => { + const params = new URLSearchParams({ + offset: String(offset), + limit: String(limit) + }); + + const { data } = await apiRequest.get<{ + certificates: TSshCertificate[]; + totalCount: number; + }>(`/api/v2/workspace/${projectId}/ssh-certificates`, { + params + }); + return data; + }, + enabled: Boolean(projectId) + }); +}; + +export const useListWorkspaceSshCas = (projectId: string) => { + return useQuery({ + queryKey: workspaceKeys.getWorkspaceSshCas(projectId), + queryFn: async () => { + const { + data: { cas } + } = await apiRequest.get<{ cas: Omit[] }>( + `/api/v2/workspace/${projectId}/ssh-cas` + ); + return cas; + }, + enabled: Boolean(projectId) + }); +}; + +export const useListWorkspaceSshCertificateTemplates = (projectId: string) => { + return useQuery({ + queryKey: workspaceKeys.getWorkspaceSshCertificateTemplates(projectId), + queryFn: async () => { + const { data } = await apiRequest.get<{ certificateTemplates: TSshCertificateTemplate[] }>( + `/api/v2/workspace/${projectId}/ssh-certificate-templates` + ); + return data; + }, + enabled: Boolean(projectId) + }); +}; + export const useGetWorkspaceSlackConfig = ({ workspaceId }: { workspaceId: string }) => { return useQuery({ queryKey: workspaceKeys.getWorkspaceSlackConfig(workspaceId), diff --git a/frontend/src/hooks/api/workspace/query-keys.tsx b/frontend/src/hooks/api/workspace/query-keys.tsx index f5a02ec2b..1005fe8bc 100644 --- a/frontend/src/hooks/api/workspace/query-keys.tsx +++ b/frontend/src/hooks/api/workspace/query-keys.tsx @@ -11,7 +11,8 @@ export const workspaceKeys = { getWorkspaceMemberships: (orgId: string) => [{ orgId }, "workspace-memberships"], getWorkspaceAuthorization: (workspaceId: string) => [{ workspaceId }, "workspace-authorizations"], getWorkspaceIntegrations: (workspaceId: string) => [{ workspaceId }, "workspace-integrations"], - getAllUserWorkspace: ["workspaces"] as const, + getAllUserWorkspace: (type?: string) => + type ? ["workspaces", { type }] : (["workspace"] as const), getWorkspaceAuditLogs: (workspaceId: string) => [{ workspaceId }, "workspace-audit-logs"] as const, getWorkspaceUsers: (workspaceId: string) => [{ workspaceId }, "workspace-users"] as const, @@ -52,5 +53,19 @@ export const workspaceKeys = { getWorkspaceCertificateTemplates: (workspaceId: string) => [{ workspaceId }, "workspace-certificate-templates"] as const, getWorkspaceSlackConfig: (workspaceId: string) => - [{ workspaceId }, "workspace-slack-config"] as const + [{ workspaceId }, "workspace-slack-config"] as const, + getWorkspaceSshCas: (projectId: string) => [{ projectId }, "workspace-ssh-cas"] as const, + allWorkspaceSshCertificates: (projectId: string) => + [{ projectId }, "workspace-ssh-certificates"] as const, + specificWorkspaceSshCertificates: ({ + offset, + limit, + projectId + }: { + offset: number; + limit: number; + projectId: string; + }) => [...workspaceKeys.allWorkspaceSshCertificates(projectId), { offset, limit }] as const, + getWorkspaceSshCertificateTemplates: (projectId: string) => + [{ projectId }, "workspace-ssh-certificate-templates"] as const }; diff --git a/frontend/src/hooks/api/workspace/types.ts b/frontend/src/hooks/api/workspace/types.ts index 91cf3a9d8..0510bdbe7 100644 --- a/frontend/src/hooks/api/workspace/types.ts +++ b/frontend/src/hooks/api/workspace/types.ts @@ -8,6 +8,13 @@ export enum ProjectVersion { V3 = 3 } +export enum ProjectType { + SecretManager = "secret-manager", + CertificateManager = "cert-manager", + KMS = "kms", + SSH = "ssh" +} + export enum ProjectUserMembershipTemporaryMode { Relative = "relative" } @@ -16,6 +23,7 @@ export type Workspace = { __v: number; id: string; name: string; + type: ProjectType; description?: string; orgId: string; version: ProjectVersion; @@ -59,6 +67,7 @@ export type CreateWorkspaceDTO = { projectDescription?: string; kmsKeyId?: string; template?: string; + type: ProjectType; }; export type UpdateProjectDTO = { diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 8c4f8e1c8..693f6e342 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -5,7 +5,7 @@ /* eslint-disable no-var */ /* eslint-disable func-names */ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import Link from "next/link"; import { useRouter } from "next/router"; @@ -38,21 +38,17 @@ import { } from "@app/components/v2"; import { useOrganization, useSubscription, useUser, useWorkspace } from "@app/context"; import { usePopUp, useToggle } from "@app/hooks"; -import { - useGetAccessRequestsCount, - useGetOrgTrialUrl, - useGetSecretApprovalRequestCount, - useLogoutUser, - useSelectOrganization -} from "@app/hooks/api"; +import { useGetOrgTrialUrl, useLogoutUser, useSelectOrganization } from "@app/hooks/api"; import { MfaMethod } from "@app/hooks/api/auth/types"; import { AuthMethod } from "@app/hooks/api/users/types"; +import { ProjectType } from "@app/hooks/api/workspace/types"; import { InsecureConnectionBanner } from "@app/layouts/AppLayout/components/InsecureConnectionBanner"; import { ProjectSelect } from "@app/layouts/AppLayout/components/ProjectSelect"; import { navigateUserToOrg } from "@app/views/Login/Login.utils"; import { Mfa } from "@app/views/Login/Mfa"; import { CreateOrgModal } from "@app/views/Org/components"; +import { ProjectSidebarItem } from "./components/ProjectSidebarItems"; import { WishForm } from "./components/WishForm/WishForm"; interface LayoutProps { @@ -87,7 +83,7 @@ export const AppLayout = ({ children }: LayoutProps) => { const { mutateAsync } = useGetOrgTrialUrl(); - const { workspaces, currentWorkspace } = useWorkspace(); + const { currentWorkspace } = useWorkspace(); const { orgs, currentOrg } = useOrganization(); const [shouldShowMfa, toggleShowMfa] = useToggle(false); @@ -96,15 +92,6 @@ export const AppLayout = ({ children }: LayoutProps) => { const { user } = useUser(); const { subscription } = useSubscription(); - const workspaceId = currentWorkspace?.id || ""; - const projectSlug = currentWorkspace?.slug || ""; - - const { data: secretApprovalReqCount } = useGetSecretApprovalRequestCount({ workspaceId }); - const { data: accessApprovalRequestCount } = useGetAccessRequestsCount({ projectSlug }); - - const pendingRequestsCount = useMemo(() => { - return (secretApprovalReqCount?.open || 0) + (accessApprovalRequestCount?.pendingCount || 0); - }, [secretApprovalReqCount, accessApprovalRequestCount]); const infisicalPlatformVersion = process.env.NEXT_PUBLIC_INFISICAL_PLATFORM_VERSION; @@ -152,41 +139,6 @@ export const AppLayout = ({ children }: LayoutProps) => { if (tempLocalStorage("orgData.id") === "" && orgs?.[0]?.id) { localStorage.setItem("orgData.id", orgs?.[0]?.id); } - - if ( - currentOrg && - ((workspaces?.length === 0 && router.asPath.includes("project")) || - router.asPath.includes("/project/undefined") || - (!orgs?.map((org) => org.id)?.includes(router.query.id as string) && - !router.asPath.includes("project") && - !router.asPath.includes("personal") && - !router.asPath.includes("secret-scanning") && - !router.asPath.includes("integration"))) - ) { - router.push(`/org/${currentOrg?.id}/overview`); - } - // else if (!router.asPath.includes("org") && !router.asPath.includes("project") && !router.asPath.includes("integrations") && !router.asPath.includes("personal-settings")) { - - // const pathSegments = router.asPath.split("/").filter((segment) => segment.length > 0); - - // let intendedWorkspaceId; - // if (pathSegments.length >= 2 && pathSegments[0] === "dashboard") { - // [, intendedWorkspaceId] = pathSegments; - // } else if (pathSegments.length >= 3 && pathSegments[0] === "settings") { - // [, , intendedWorkspaceId] = pathSegments; - // } else { - // const lastPathSegments = router.asPath.split("/").pop(); - // if (lastPathSegments !== undefined) { - // [intendedWorkspaceId] = lastPathSegments.split("?"); - // } - // } - - // if (!intendedWorkspaceId) return; - - // if (!["callback", "create", "authorize"].includes(intendedWorkspaceId)) { - // localStorage.setItem("projectData.id", intendedWorkspaceId); - // } - // } }; putUserInOrg(); }, [router.query.id]); @@ -214,9 +166,8 @@ export const AppLayout = ({ children }: LayoutProps) => {
{!router.asPath.includes("personal") && (
- {(router.asPath.includes("project") || - router.asPath.includes("integrations")) && ( - + {(currentWorkspace || router.asPath.includes("integrations")) && ( +
@@ -376,10 +327,11 @@ export const AppLayout = ({ children }: LayoutProps) => {
)} {!router.asPath.includes("org") && + !router.asPath.includes("app-connections") && (!router.asPath.includes("personal") && currentWorkspace ? ( ) : ( - +
Back to organization @@ -387,119 +339,59 @@ export const AppLayout = ({ children }: LayoutProps) => { ))}
- {(router.asPath.includes("project") || router.asPath.includes("integrations")) && - currentWorkspace ? ( - - + + {(router.pathname.startsWith("/org") || + router.pathname.startsWith("/app-connections")) && ( + + - {t("nav.menu.secrets")} + Secret Management - + - Internal PKI + Cert Management - + Key Management - + - Access Control - - - - - - - {t("nav.menu.integrations")} - - - - - - - Secret Rotation - - - - - - - Approvals - {Boolean( - secretApprovalReqCount?.open || - accessApprovalRequestCount?.pendingCount - ) && ( - - {pendingRequestsCount} - - )} - - - - - - - {t("nav.menu.project-settings")} - - - - - ) : ( - - - - - Overview + SSH diff --git a/frontend/src/layouts/AppLayout/ErrorBoundary.tsx b/frontend/src/layouts/AppLayout/ErrorBoundary.tsx index 55acf3433..982dce8d2 100644 --- a/frontend/src/layouts/AppLayout/ErrorBoundary.tsx +++ b/frontend/src/layouts/AppLayout/ErrorBoundary.tsx @@ -5,6 +5,7 @@ import { faBugs, faHome } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Button } from "@app/components/v2"; +import { ProjectType } from "@app/hooks/api/workspace/types"; interface ErrorBoundaryProps { children: ReactNode; @@ -62,7 +63,7 @@ const ErrorPage = ({ error }: { error: Error | null }) => { size="xs" onClick={() => // we need to go to /org/${orgId}/overview, but we need to do a full page reload to ensure that the error the user is facing is properly reset. - window.location.assign(`/org/${orgId}/overview`) + window.location.assign(`/org/${orgId}/${ProjectType.SecretManager}/overview`) } > diff --git a/frontend/src/layouts/AppLayout/components/NavBar/NavBar.tsx b/frontend/src/layouts/AppLayout/components/NavBar/NavBar.tsx deleted file mode 100644 index 979758ba5..000000000 --- a/frontend/src/layouts/AppLayout/components/NavBar/NavBar.tsx +++ /dev/null @@ -1,343 +0,0 @@ -/* eslint-disable jsx-a11y/anchor-is-valid */ -/* eslint-disable react/jsx-key */ -import { Fragment, useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import Image from "next/image"; -import { useRouter } from "next/router"; -import { faGithub, faSlack } from "@fortawesome/free-brands-svg-icons"; -import { faCircleQuestion } from "@fortawesome/free-regular-svg-icons"; -import { - faAngleDown, - faBook, - faCoins, - faEnvelope, - faGear, - faPlus, - faRightFromBracket -} from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Menu, Transition } from "@headlessui/react"; -import { TFunction } from "i18next"; - -import guidGenerator from "@app/components/utilities/randomId"; -import { useOrganization, useSubscription, useUser } from "@app/context"; -import { useGetOrgTrialUrl, useLogoutUser } from "@app/hooks/api"; - -const supportOptions = (t: TFunction) => [ - [ - , - t("nav.support.slack"), - "https://infisical.com/slack" - ], - [ - , - t("nav.support.docs"), - "https://infisical.com/docs/documentation/getting-started/introduction" - ], - [ - , - t("nav.support.issue"), - "https://github.com/Infisical/infisical-cli/issues" - ], - [ - , - t("nav.support.email"), - "mailto:support@infisical.com" - ] -]; - -export interface ICurrentOrg { - name: string; -} - -export interface IUser { - firstName: string; - lastName: string; - email: string; -} - -/** - * This is the navigation bar in the main app. - * It has two main components: support options and user menu (inlcudes billing, logout, org/user settings) - * @returns NavBar - */ -export const Navbar = () => { - const router = useRouter(); - const { subscription } = useSubscription(); - - const { currentOrg, orgs } = useOrganization(); - const { mutateAsync } = useGetOrgTrialUrl(); - const { user } = useUser(); - - const logout = useLogoutUser(); - - const { t } = useTranslation(); - - // remove this memo - const supportOptionsList = useMemo(() => supportOptions(t), [t]); - - const closeApp = async () => { - try { - console.log("Logging out..."); - await logout.mutateAsync(); - localStorage.removeItem("protectedKey"); - localStorage.removeItem("protectedKeyIV"); - localStorage.removeItem("protectedKeyTag"); - localStorage.removeItem("publicKey"); - localStorage.removeItem("encryptedPrivateKey"); - localStorage.removeItem("iv"); - localStorage.removeItem("tag"); - localStorage.removeItem("PRIVATE_KEY"); - localStorage.removeItem("orgData.id"); - localStorage.removeItem("projectData.id"); - router.push("/login"); - } catch (error) { - console.error(error); - } - }; - - return ( -
-
-
-
- logo -
- - Infisical - -
-
- - - Docs - - -
- - - -
- - - {supportOptionsList.map(([icon, text, url]) => ( - -
- {icon} -
{text}
-
-
- ))} -
-
-
- -
- - {user?.firstName} {user?.lastName} - - -
- - -
-
- {t("nav.user.signed-in-as")} -
-
null} - role="button" - tabIndex={0} - onClick={() => router.push("/personal-settings")} - className="mx-1 my-1 flex cursor-pointer flex-row items-center rounded-md px-1 hover:bg-white/5" - > -
- {user?.firstName?.charAt(0)} -
-
-
-

- {" "} - {user?.firstName} {user?.lastName} -

-

{user?.email}

-
- -
-
-
-
-
- {t("nav.user.current-organization")} -
-
null} - role="button" - tabIndex={0} - onClick={() => router.push(`/settings/org/${router.query.id}`)} - className="mt-2 flex cursor-pointer flex-row items-center rounded-md px-2 py-1 hover:bg-white/5" - > -
- {currentOrg?.name?.charAt(0)} -
-
-

{currentOrg?.name}

- -
-
- {subscription && subscription.slug !== null && ( - - )} - -
- {orgs && orgs?.length > 1 && ( -
-
- {t("nav.user.other-organizations")} -
-
- {orgs - ?.filter((org: { id: string }) => org.id !== currentOrg?.id) - .map((org: { id: string; name: string }) => ( -
null} - role="button" - tabIndex={0} - key={guidGenerator()} - onClick={() => { - localStorage.setItem("orgData.id", org.id); - router.reload(); - }} - className="flex w-full cursor-pointer flex-row items-center justify-start rounded-md p-1.5 hover:bg-white/5" - > -
- {org.name.charAt(0)} -
-
-

{org.name}

-
-
- ))} -
-
- )} -
- - {({ active }) => ( - - )} - -
-
-
-
-
-
- {subscription && subscription.slug === "starter" && !subscription.has_used_trial && ( -
- -
- )} -
- ); -}; diff --git a/frontend/src/layouts/AppLayout/components/ProjectSelect/ProjectSelect.tsx b/frontend/src/layouts/AppLayout/components/ProjectSelect/ProjectSelect.tsx index 777d65b71..b351d8ae5 100644 --- a/frontend/src/layouts/AppLayout/components/ProjectSelect/ProjectSelect.tsx +++ b/frontend/src/layouts/AppLayout/components/ProjectSelect/ProjectSelect.tsx @@ -16,10 +16,11 @@ import { useSubscription, useWorkspace } from "@app/context"; +import { getProjectHomePage, getProjectTitle } from "@app/helpers/project"; import { usePopUp } from "@app/hooks"; import { useUpdateUserProjectFavorites } from "@app/hooks/api/users/mutation"; import { useGetUserProjectFavorites } from "@app/hooks/api/users/queries"; -import { Workspace } from "@app/hooks/api/workspace/types"; +import { ProjectType, Workspace } from "@app/hooks/api/workspace/types"; type TWorkspaceWithFaveProp = Workspace & { isFavorite: boolean }; @@ -138,6 +139,7 @@ export const ProjectSelect = () => { const { options, value } = useMemo(() => { const projectOptions = workspaces + .filter((el) => el.type === currentWorkspace?.type) .map((w): Workspace & { isFavorite: boolean } => ({ ...w, isFavorite: Boolean(projectFavorites?.includes(w.id)) @@ -164,7 +166,9 @@ export const ProjectSelect = () => { return (
-

Project

+

+ {currentWorkspace?.type ? getProjectTitle(currentWorkspace?.type) : "Project"} +

{ // todo(akhi): this is not using react query because react query in overview is throwing error when envs are not exact same count // to reproduce change this back to router.push and switch between two projects with different env count // look into this on dashboard revamp - window.location.assign(`/project/${project.id}/secrets/overview`); + window.location.assign(getProjectHomePage(project)); }} options={options} components={{ @@ -206,6 +210,7 @@ export const ProjectSelect = () => { handlePopUpToggle("addNewWs", isOpen)} + projectType={currentWorkspace?.type || ProjectType.SecretManager} />
); diff --git a/frontend/src/layouts/AppLayout/components/ProjectSidebarItems/ProjectSidebarItems.tsx b/frontend/src/layouts/AppLayout/components/ProjectSidebarItems/ProjectSidebarItems.tsx new file mode 100644 index 000000000..a3f3a6169 --- /dev/null +++ b/frontend/src/layouts/AppLayout/components/ProjectSidebarItems/ProjectSidebarItems.tsx @@ -0,0 +1,172 @@ +import { useTranslation } from "react-i18next"; +import Link from "next/link"; +import { useRouter } from "next/router"; + +import { Menu, MenuItem } from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useGetAccessRequestsCount, useGetSecretApprovalRequestCount } from "@app/hooks/api"; +import { ProjectType } from "@app/hooks/api/workspace/types"; + +export const ProjectSidebarItem = () => { + const { currentWorkspace } = useWorkspace(); + const router = useRouter(); + const { t } = useTranslation(); + + const workspaceId = currentWorkspace?.id || ""; + const projectSlug = currentWorkspace?.slug || ""; + + const { data: secretApprovalReqCount } = useGetSecretApprovalRequestCount({ workspaceId }); + const { data: accessApprovalRequestCount } = useGetAccessRequestsCount({ projectSlug }); + + const pendingRequestsCount = + (secretApprovalReqCount?.open || 0) + (accessApprovalRequestCount?.pendingCount || 0); + + if ( + !currentWorkspace || + router.asPath.startsWith("personal") || + router.asPath.startsWith("integrations") || + router.asPath.startsWith("/app-connections") + ) { + return
; + } + + const isSecretManager = currentWorkspace?.type === ProjectType.SecretManager; + const isCertManager = currentWorkspace?.type === ProjectType.CertificateManager; + const isCmek = currentWorkspace?.type === ProjectType.KMS; + const isSsh = currentWorkspace?.type === ProjectType.SSH; + + return ( + + {isSecretManager && ( + + + + {t("nav.menu.secrets")} + + + + )} + {isCertManager && ( + + + + Overview + + + + )} + {isCmek && ( + + + + Overview + + + + )} + {isSsh && ( + + + + Overview + + + + )} + + + + Access Control + + + + {isSecretManager && ( + + + + {t("nav.menu.integrations")} + + + + )} + {isSecretManager && ( + + + + Secret Rotation + + + + )} + {isSecretManager && ( + + + + Approvals + {Boolean( + secretApprovalReqCount?.open || accessApprovalRequestCount?.pendingCount + ) && ( + + {pendingRequestsCount} + + )} + + + + )} + + + + {t("nav.menu.project-settings")} + + + + + ); +}; diff --git a/frontend/src/layouts/AppLayout/components/ProjectSidebarItems/index.tsx b/frontend/src/layouts/AppLayout/components/ProjectSidebarItems/index.tsx new file mode 100644 index 000000000..70b5840b3 --- /dev/null +++ b/frontend/src/layouts/AppLayout/components/ProjectSidebarItems/index.tsx @@ -0,0 +1 @@ +export { ProjectSidebarItem } from "./ProjectSidebarItems"; diff --git a/frontend/src/layouts/AppLayout/components/WishForm/WishForm.tsx b/frontend/src/layouts/AppLayout/components/WishForm/WishForm.tsx index bf87d0672..91907ee8c 100644 --- a/frontend/src/layouts/AppLayout/components/WishForm/WishForm.tsx +++ b/frontend/src/layouts/AppLayout/components/WishForm/WishForm.tsx @@ -65,7 +65,7 @@ export const WishForm = () => {
- Make a wish + Request a feature
val.toLowerCase() === val, "Must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Invalid slug format" - }); +interface SlugSchemaInputs { + min?: number; + max?: number; + field?: string; +} + +export const slugSchema = ({ min = 1, max = 32, field = "Slug" }: SlugSchemaInputs = {}) => { + return z + .string() + .trim() + .min(min, { + message: `${field} field must be at least ${min} lowercase character${min === 1 ? "" : "s"}` + }) + .max(max, { + message: `${field} field must be at most ${max} lowercase character${max === 1 ? "" : "s"}` + }) + .refine((v) => slugify(v, { lowercase: true }) === v, { + message: `${field} field can only contain lowercase letters, numbers, and hyphens` + }); +}; diff --git a/frontend/src/lib/types/index.ts b/frontend/src/lib/types/index.ts new file mode 100644 index 000000000..ce14b6acd --- /dev/null +++ b/frontend/src/lib/types/index.ts @@ -0,0 +1 @@ +export type DiscriminativePick = T extends unknown ? Pick : never; diff --git a/frontend/src/pages/app-connections/github/oauth/callback.tsx b/frontend/src/pages/app-connections/github/oauth/callback.tsx new file mode 100644 index 000000000..79827acaa --- /dev/null +++ b/frontend/src/pages/app-connections/github/oauth/callback.tsx @@ -0,0 +1,134 @@ +import { useEffect } from "react"; +import { useRouter } from "next/router"; +import queryString from "query-string"; + +import { createNotification } from "@app/components/notifications"; +import { ContentLoader } from "@app/components/v2"; +import { + GitHubConnectionMethod, + TAppConnection, + TGitHubConnection, + useCreateAppConnection, + useUpdateAppConnection +} from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +type FormData = Pick & { + returnUrl?: string; + connectionId?: string; +}; + +export default function GitHubOAuthCallbackPage() { + const router = useRouter(); + const updateAppConnection = useUpdateAppConnection(); + const createAppConnection = useCreateAppConnection(); + + // eslint-disable-next-line @typescript-eslint/naming-convention + const { + code, + state, + installation_id: installationId + } = queryString.parse(router.asPath.split("?")[1]); + + useEffect(() => { + (async () => { + let formData: FormData; + + try { + formData = JSON.parse(localStorage.getItem("githubConnectionFormData") ?? "{}") as FormData; + } catch (e) { + createNotification({ + type: "error", + text: "Invalid form state, redirecting..." + }); + router.push(window.location.origin); + return; + } + + // validate state + if (state !== localStorage.getItem("latestCSRFToken")) { + createNotification({ + type: "error", + text: "Invalid state, redirecting..." + }); + router.push(window.location.origin); + return; + } + + localStorage.removeItem("githubConnectionFormData"); + localStorage.removeItem("latestCSRFToken"); + + const { connectionId, name, description, returnUrl } = formData; + + let appConnection: TAppConnection; + + try { + if (connectionId) { + appConnection = await updateAppConnection.mutateAsync({ + app: AppConnection.GitHub, + ...(installationId + ? { + connectionId, + credentials: { + code: code as string, + installationId: installationId as string + } + } + : { + connectionId, + credentials: { + code: code as string + } + }) + }); + } else { + appConnection = await createAppConnection.mutateAsync({ + app: AppConnection.GitHub, + name, + description, + ...(installationId + ? { + method: GitHubConnectionMethod.App, + credentials: { + code: code as string, + installationId: installationId as string + } + } + : { + method: GitHubConnectionMethod.OAuth, + credentials: { + code: code as string + } + }) + }); + } + } catch (e: any) { + createNotification({ + title: `Failed to ${connectionId ? "update" : "add"} GitHub Connection`, + text: e.message, + type: "error" + }); + router.push( + returnUrl ?? + `/org/${localStorage.getItem("orgData.id")}/settings?selectedTab=app-connections` + ); + return; + } + + createNotification({ + text: `Successfully ${connectionId ? "updated" : "added"} GitHub Connection`, + type: "success" + }); + + router.push(returnUrl ?? `/org/${appConnection.orgId}/settings?selectedTab=app-connections`); + })(); + }, []); + + return ( +
+ +
+ ); +} + +GitHubOAuthCallbackPage.requireAuth = true; diff --git a/frontend/src/pages/project/[id]/allowlist/index.tsx b/frontend/src/pages/cert-manager/[id]/allowlist/index.tsx similarity index 100% rename from frontend/src/pages/project/[id]/allowlist/index.tsx rename to frontend/src/pages/cert-manager/[id]/allowlist/index.tsx diff --git a/frontend/src/pages/project/[id]/ca/[caId]/index.tsx b/frontend/src/pages/cert-manager/[id]/ca/[caId]/index.tsx similarity index 100% rename from frontend/src/pages/project/[id]/ca/[caId]/index.tsx rename to frontend/src/pages/cert-manager/[id]/ca/[caId]/index.tsx diff --git a/frontend/src/pages/project/[id]/certificates/index.tsx b/frontend/src/pages/cert-manager/[id]/certificates/index.tsx similarity index 100% rename from frontend/src/pages/project/[id]/certificates/index.tsx rename to frontend/src/pages/cert-manager/[id]/certificates/index.tsx diff --git a/frontend/src/pages/project/[id]/identities/[identityId]/index.tsx b/frontend/src/pages/cert-manager/[id]/identities/[identityId]/index.tsx similarity index 100% rename from frontend/src/pages/project/[id]/identities/[identityId]/index.tsx rename to frontend/src/pages/cert-manager/[id]/identities/[identityId]/index.tsx diff --git a/frontend/src/pages/project/[id]/members/[membershipId]/index.tsx b/frontend/src/pages/cert-manager/[id]/members/[membershipId]/index.tsx similarity index 100% rename from frontend/src/pages/project/[id]/members/[membershipId]/index.tsx rename to frontend/src/pages/cert-manager/[id]/members/[membershipId]/index.tsx diff --git a/frontend/src/pages/project/[id]/members/index.tsx b/frontend/src/pages/cert-manager/[id]/members/index.tsx similarity index 100% rename from frontend/src/pages/project/[id]/members/index.tsx rename to frontend/src/pages/cert-manager/[id]/members/index.tsx diff --git a/frontend/src/pages/project/[id]/pki-collections/[collectionId]/index.tsx b/frontend/src/pages/cert-manager/[id]/pki-collections/[collectionId]/index.tsx similarity index 100% rename from frontend/src/pages/project/[id]/pki-collections/[collectionId]/index.tsx rename to frontend/src/pages/cert-manager/[id]/pki-collections/[collectionId]/index.tsx diff --git a/frontend/src/pages/project/[id]/roles/[roleSlug]/index.tsx b/frontend/src/pages/cert-manager/[id]/roles/[roleSlug]/index.tsx similarity index 100% rename from frontend/src/pages/project/[id]/roles/[roleSlug]/index.tsx rename to frontend/src/pages/cert-manager/[id]/roles/[roleSlug]/index.tsx diff --git a/frontend/src/pages/project/[id]/settings/index.tsx b/frontend/src/pages/cert-manager/[id]/settings/index.tsx similarity index 100% rename from frontend/src/pages/project/[id]/settings/index.tsx rename to frontend/src/pages/cert-manager/[id]/settings/index.tsx diff --git a/frontend/src/pages/integrations/azure-app-configuration/create.tsx b/frontend/src/pages/integrations/azure-app-configuration/create.tsx index c9fe4d1db..9b647c2a2 100644 --- a/frontend/src/pages/integrations/azure-app-configuration/create.tsx +++ b/frontend/src/pages/integrations/azure-app-configuration/create.tsx @@ -10,6 +10,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import queryString from "query-string"; import { z } from "zod"; +import { createNotification } from "@app/components/notifications"; import { SecretPathInput } from "@app/components/v2/SecretPathInput"; import { useCreateIntegration } from "@app/hooks/api"; import { IntegrationSyncBehavior } from "@app/hooks/api/integrations/types"; @@ -19,9 +20,11 @@ import { Card, CardTitle, FormControl, + FormLabel, Input, Select, - SelectItem + SelectItem, + Switch } from "../../../components/v2"; import { useGetIntegrationAuthById } from "../../../hooks/api/integrationAuth"; import { useGetWorkspaceById } from "../../../hooks/api/workspace"; @@ -39,7 +42,9 @@ const schema = z.object({ secretPath: z.string().trim().min(1, { message: "Secret path is required" }), sourceEnvironment: z.string().trim().min(1, { message: "Source environment is required" }), initialSyncBehavior: z.nativeEnum(IntegrationSyncBehavior), - secretPrefix: z.string().default("") + secretPrefix: z.string().default(""), + useLabels: z.boolean().default(false), + azureLabel: z.string().min(1).optional() }); type TFormSchema = z.infer; @@ -60,6 +65,7 @@ export default function AzureAppConfigurationCreateIntegration() { const router = useRouter(); const { control, + watch, setValue, handleSubmit, formState: { isSubmitting } @@ -85,16 +91,28 @@ export default function AzureAppConfigurationCreateIntegration() { } }, [workspace]); + const shouldUseLabels = watch("useLabels"); + const handleIntegrationSubmit = async ({ secretPath, + useLabels, sourceEnvironment, baseUrl, initialSyncBehavior, - secretPrefix + secretPrefix, + azureLabel }: TFormSchema) => { try { if (!integrationAuth?.id) return; + if (useLabels && !azureLabel) { + createNotification({ + type: "error", + text: "Label must be provided when 'Use Labels' is enabled" + }); + return; + } + await mutateAsync({ integrationAuthId: integrationAuth?.id, isActive: true, @@ -103,7 +121,8 @@ export default function AzureAppConfigurationCreateIntegration() { secretPath, metadata: { initialSyncBehavior, - secretPrefix + secretPrefix, + ...(useLabels && { azureLabel }) } }); @@ -155,35 +174,70 @@ export default function AzureAppConfigurationCreateIntegration() {
- ( - - + + )} + /> + +
+ ( + onChange(isChecked)} + isChecked={value} + > + + + )} + /> + + {shouldUseLabels && ( + ( + - {sourceEnvironment.name} - - ))} - - - )} - /> + + + )} + /> + )} +
+
; + +export default function AzureKeyVaultAuthorizeIntegrationPage() { + const router = useRouter(); + const { state, clientId } = router.query; + const { control, handleSubmit } = useForm({ + resolver: zodResolver(schema) + }); + + const onFormSubmit = async ({ tenantId }: FormData) => { + const link = `https://login.microsoftonline.com/${ + tenantId ?? "common" + }/oauth2/v2.0/authorize?client_id=${clientId}&response_type=code&redirect_uri=${ + window.location.origin + }/integrations/azure-key-vault/oauth2/callback&response_mode=query&scope=https://vault.azure.net/.default openid offline_access&state=${state}`; + + window.location.assign(link); + }; + + return ( +
+ + Authorize Azure Key Vault Integration + + + + +
+
+ Azure logo +
+ Azure Key Vault Integration + + +
+ + Docs + +
+
+ +
+
+
+ ( + + + + )} + /> + + +
+
+ ); +} + +AzureKeyVaultAuthorizeIntegrationPage.requireAuth = true; diff --git a/frontend/src/pages/integrations/circleci/authorize.tsx b/frontend/src/pages/integrations/circleci/authorize.tsx index fc57c19ce..fe2c40ebd 100644 --- a/frontend/src/pages/integrations/circleci/authorize.tsx +++ b/frontend/src/pages/integrations/circleci/authorize.tsx @@ -56,7 +56,7 @@ export default function CircleCICreateIntegrationPage() {
CircleCI logo; export default function CircleCICreateIntegrationPage() { const router = useRouter(); - const { mutateAsync } = useCreateIntegration(); + const { mutateAsync, isLoading: isCreatingIntegration } = useCreateIntegration(); + const { currentWorkspace, isLoading: isProjectLoading } = useWorkspace(); - const { integrationAuthId } = queryString.parse(router.asPath.split("?")[1]); + const integrationAuthId = router.query.integrationAuthId as string; - const { data: workspace } = useGetWorkspaceById(localStorage.getItem("projectData.id") ?? ""); - const { data: integrationAuth, isLoading: isintegrationAuthLoading } = useGetIntegrationAuthById( - (integrationAuthId as string) ?? "" - ); - const { data: integrationAuthApps, isLoading: isIntegrationAuthAppsLoading } = - useGetIntegrationAuthApps({ - integrationAuthId: (integrationAuthId as string) ?? "" - }); - - const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(""); - const [targetOrganization, setTargetOrganization] = useState(""); - const [secretPath, setSecretPath] = useState("/"); - - const [targetProjectId, setTargetProjectId] = useState(""); - - const [isLoading, setIsLoading] = useState(false); - - useEffect(() => { - if (workspace) { - setSelectedSourceEnvironment(workspace.environments[0].slug); + const { control, watch, handleSubmit, setValue } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + secretPath: "/", + sourceEnvironment: currentWorkspace?.environments[0], + scope: CircleCiScope.Project } - }, [workspace]); + }); - const handleButtonClick = async () => { + const selectedScope = watch("scope"); + const selectedOrg = watch("targetOrg"); + + const { data: circleCIOrganizations, isLoading: isCircleCIOrganizationsLoading } = + useGetIntegrationAuthCircleCIOrganizations(integrationAuthId); + + const selectedOrganizationEntry = selectedOrg + ? circleCIOrganizations?.find((org) => org.slug === selectedOrg.slug) + : undefined; + + const onSubmit = async (data: TFormData) => { try { - if (!integrationAuth?.id) return; - - if (!targetProjectId || targetOrganization === "none") { - createNotification({ - type: "error", - text: "Please select a project" + if (data.scope === CircleCiScope.Context) { + await mutateAsync({ + scope: data.scope, + integrationAuthId, + isActive: true, + sourceEnvironment: data.sourceEnvironment.slug, + app: data.targetContext.name, + appId: data.targetContext.id, + owner: data.targetOrg.name, + secretPath: data.secretPath + }); + } else { + await mutateAsync({ + scope: data.scope, + integrationAuthId, + isActive: true, + app: data.targetProject.name, // project name + owner: data.targetOrg.name, // organization name + appId: data.targetProject.id, // project id (used for syncing) + sourceEnvironment: data.sourceEnvironment.slug, + secretPath: data.secretPath }); - setIsLoading(false); - return; } - setIsLoading(true); - - const selectedApp = integrationAuthApps?.find( - (integrationAuthApp) => integrationAuthApp.appId === targetProjectId - ); - - if (!selectedApp) { - createNotification({ - type: "error", - text: "Invalid project selected" - }); - setIsLoading(false); - return; - } - - await mutateAsync({ - integrationAuthId: integrationAuth?.id, - isActive: true, - app: selectedApp.name, // project name - owner: selectedApp.owner, // organization name - appId: selectedApp.appId, // project id (used for syncing) - sourceEnvironment: selectedSourceEnvironment, - secretPath + createNotification({ + type: "success", + text: "Successfully created integration" }); - - setIsLoading(false); - - router.push(`/integrations/${localStorage.getItem("projectData.id")}`); + router.push(`/integrations/${currentWorkspace?.id}`); } catch (err) { + createNotification({ + type: "error", + text: "Failed to create integration" + }); console.error(err); } }; - const filteredProjects = useMemo(() => { - if (!integrationAuthApps) return []; + if (isProjectLoading || isCircleCIOrganizationsLoading) + return ( +
+ +
+ ); - return integrationAuthApps.filter((integrationAuthApp) => { - return integrationAuthApp.owner === targetOrganization; - }); - }, [integrationAuthApps, targetOrganization]); - - const filteredOrganizations = useMemo(() => { - const organizations = new Set(); - - if (integrationAuthApps) { - integrationAuthApps.forEach((integrationAuthApp) => { - if (!integrationAuthApp.owner) return; - organizations.add(integrationAuthApp.owner); - }); - } - - return Array.from(organizations); - }, [integrationAuthApps]); - - return integrationAuth && workspace && selectedSourceEnvironment && integrationAuthApps ? ( -
- - Set Up CircleCI Integration - - - + return ( +
+ -
-
+
+
CircleCI logo + + CircleCI Context Integration
- CircleCI Integration - - -
- - Docs - -
-
+ + +
+ + Docs + +
- - - - - - setSecretPath(evt.target.value)} - placeholder="Provide a path, default is /" - /> - - - - - - - {targetOrganization && ( - - - + option.slug} + value={value} + getOptionLabel={(option) => option.name} + onChange={onChange} + options={currentWorkspace?.environments} + placeholder="Select a project environment" + isDisabled={!currentWorkspace?.environments.length} + /> + + )} + /> + ( + + + + )} + /> + ( + + option.slug} + value={value} + getOptionLabel={(option) => option.name} + onChange={(e) => { + setValue("targetProject", { + name: "", + id: "" + }); + setValue("targetContext", { + name: "", + id: "" + }); + + onChange(e); + }} + options={circleCIOrganizations} + placeholder={ + circleCIOrganizations?.length + ? "Select an organization..." + : "No organizations found..." + } + isDisabled={!circleCIOrganizations?.length} + /> + + )} + /> + ( + + + + )} + /> + {selectedScope === CircleCiScope.Context && selectedOrganizationEntry && ( + ( + + option.id!} + getOptionLabel={(option) => option.name} + onChange={onChange} + options={selectedOrganizationEntry?.contexts} + placeholder={ + selectedOrganizationEntry.contexts?.length + ? "Select a context..." + : "No contexts found..." + } + isDisabled={!selectedOrganizationEntry.contexts?.length} + /> + + )} + /> + )} + {selectedScope === CircleCiScope.Project && selectedOrganizationEntry && ( + ( + + option.id!} + getOptionLabel={(option) => option.name} + onChange={onChange} + options={selectedOrganizationEntry?.projects} + placeholder={ + selectedOrganizationEntry.projects?.length + ? "Select a project..." + : "No projects found..." + } + isDisabled={!selectedOrganizationEntry.projects?.length} + /> + + )} + /> )} -
-
-
- {" "} - Pro Tip -
- - After creating an integration, your secrets will start syncing immediately. This might - cause an unexpected override of current secrets in CircleCI with secrets from Infisical. - -
-
- ) : ( -
- - Set Up CircleCI Integration - - - {isIntegrationAuthAppsLoading || isintegrationAuthLoading ? ( - infisical loading indicator - ) : ( -
- -

- Something went wrong. Please contact{" "} - - support@infisical.com - {" "} - if the issue persists. -

-
- )} -
+ ); } diff --git a/frontend/src/pages/integrations/vercel/create.tsx b/frontend/src/pages/integrations/vercel/create.tsx index 813ac1e73..7dc525f90 100644 --- a/frontend/src/pages/integrations/vercel/create.tsx +++ b/frontend/src/pages/integrations/vercel/create.tsx @@ -13,6 +13,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import queryString from "query-string"; import { useCreateIntegration } from "@app/hooks/api"; +import { IntegrationSyncBehavior } from "@app/hooks/api/integrations/types"; import { Button, @@ -36,12 +37,26 @@ const vercelEnvironments = [ { name: "Production", slug: "production" } ]; +const initialSyncBehaviors = [ + { + label: "No Import - Overwrite all values in Vercel", + value: IntegrationSyncBehavior.OVERWRITE_TARGET + }, + { + label: "Import - Prefer values from Infisical", + value: IntegrationSyncBehavior.PREFER_SOURCE + } +]; + export default function VercelCreateIntegrationPage() { const router = useRouter(); const { mutateAsync } = useCreateIntegration(); const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(""); const [secretPath, setSecretPath] = useState("/"); + const [initialSyncBehavior, setInitialSyncBehavior] = useState( + IntegrationSyncBehavior.PREFER_SOURCE + ); const [targetAppId, setTargetAppId] = useState(""); const [targetEnvironment, setTargetEnvironment] = useState(""); const [targetBranch, setTargetBranch] = useState(""); @@ -104,7 +119,10 @@ export default function VercelCreateIntegrationPage() { sourceEnvironment: selectedSourceEnvironment, targetEnvironment, path, - secretPath + secretPath, + metadata: { + initialSyncBehavior + } }); setIsLoading(false); @@ -231,6 +249,21 @@ export default function VercelCreateIntegrationPage() { )} + + + + + -
- - ); -}; - -const TabsObject = () => { - const [downloadCodeCopied, setDownloadCodeCopied] = useState(false); - const [downloadCode2Copied, setDownloadCode2Copied] = useState(false); - const [loginCodeCopied, setLoginCodeCopied] = useState(false); - const [initCodeCopied, setInitCodeCopied] = useState(false); - const [runCodeCopied, setRunCodeCopied] = useState(false); - - return ( - - - - MacOS - - - Windows - - {/* - Arch Linux - */} - - Other Platforms - - - - - - - -

- You can find example of start commands for different frameworks{" "} - - here - - .{" "} -

-
- - -
- - -
- - - -

- You can find example of start commands for different frameworks{" "} - - here - - .{" "} -

-
-
- ); -}; - -const LearningItem = ({ - text, - subText, - complete, - icon, - time, - userAction, - link -}: ItemProps): JSX.Element => { - const registerUserAction = useRegisterUserAction(); - if (link) { - return ( - -
-
null} - role="button" - tabIndex={0} - onClick={async () => { - if (userAction && userAction !== "first_time_secrets_pushed") { - await registerUserAction.mutateAsync(userAction); - } - }} - className={`group relative flex h-[5.5rem] w-full items-center justify-between overflow-hidden rounded-md border ${ - complete - ? "cursor-default border-mineshaft-900 bg-gradient-to-r from-[#0e1f01] to-mineshaft-700" - : "cursor-pointer border-mineshaft-600 bg-mineshaft-800 shadow-xl hover:bg-mineshaft-700" - } text-mineshaft-100 duration-200`} - > -
- - {complete && ( -
- -
- )} -
-
{text}
-
{subText}
-
-
-
- {complete ? "Complete!" : `About ${time}`} -
- {/* {complete &&
} */} -
-
-
- ); - } - return ( -
null} - role="button" - tabIndex={0} - onClick={async () => { - if (userAction) { - await registerUserAction.mutateAsync(userAction); - } - }} - className="relative my-1.5 flex h-[5.5rem] w-full cursor-pointer items-center justify-between overflow-hidden rounded-md border border-dashed border-bunker-400 bg-bunker-700 py-2 pl-2 pr-6 shadow-xl duration-200 hover:bg-bunker-500" - > -
- - {complete && ( -
- -
- )} -
-
{text}
-
{subText}
-
-
-
- {complete ? "Complete!" : `About ${time}`} -
- {complete &&
} -
- ); -}; - -const LearningItemSquare = ({ - text, - subText, - complete, - icon, - time, - userAction, - link -}: ItemProps): JSX.Element => { - const registerUserAction = useRegisterUserAction(); - return ( - -
-
null} - role="button" - tabIndex={0} - onClick={async () => { - if (userAction && userAction !== "first_time_secrets_pushed") { - await registerUserAction.mutateAsync(userAction); - } - }} - className={`group relative flex w-full items-center justify-between overflow-hidden rounded-md border ${ - complete - ? "cursor-default border-mineshaft-900 bg-gradient-to-r from-[#0e1f01] to-mineshaft-700" - : "cursor-pointer border-mineshaft-600 bg-mineshaft-800 shadow-xl hover:bg-mineshaft-700" - } text-mineshaft-100 duration-200`} - > -
-
- - {complete && ( -
- -
- )} -
- {complete ? "Complete!" : `About ${time}`} -
-
-
-
{text}
-
{subText}
-
-
-
-
-
- ); -}; +import { useOrganization } from "@app/context"; +import { ProjectType } from "@app/hooks/api/workspace/types"; // #TODO: Update all the workspaceIds const OrganizationPage = () => { - const { t } = useTranslation(); - const router = useRouter(); - - const { workspaces, isLoading: isWorkspaceLoading } = useWorkspace(); const { currentOrg } = useOrganization(); - const routerOrgId = String(router.query.id); - const orgWorkspaces = workspaces?.filter((workspace) => workspace.orgId === routerOrgId) || []; - const { data: projectFavorites, isLoading: isProjectFavoritesLoading } = - useGetUserProjectFavorites(currentOrg?.id!); - const { mutateAsync: updateUserProjectFavorites } = useUpdateUserProjectFavorites(); - - const isProjectViewLoading = isWorkspaceLoading || isProjectFavoritesLoading; - - const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ - "addNewWs", - "upgradePlan" - ] as const); - - const [hasUserClickedSlack, setHasUserClickedSlack] = useState(false); - const [hasUserClickedIntro, setHasUserClickedIntro] = useState(false); - const [hasUserPushedSecrets, setHasUserPushedSecrets] = useState(false); - const [usersInOrg, setUsersInOrg] = useState(false); - const [searchFilter, setSearchFilter] = useState(""); - const { user } = useUser(); - const { data: serverDetails } = useFetchServerStatus(); - const [projectsViewMode, setProjectsViewMode] = useState( - (localStorage.getItem("projectsViewMode") as ProjectsViewMode) || ProjectsViewMode.GRID - ); - - const { subscription } = useSubscription(); - - const isAddingProjectsAllowed = subscription?.workspaceLimit - ? subscription.workspacesUsed < subscription.workspaceLimit - : true; - useEffect(() => { - onboardingCheck({ - orgId: routerOrgId, - setHasUserClickedIntro, - setHasUserClickedSlack, - setHasUserPushedSecrets, - setUsersInOrg - }); - }, []); - - const isWorkspaceEmpty = !isProjectViewLoading && orgWorkspaces?.length === 0; - - const { - setPage, - perPage, - setPerPage, - page, - offset, - limit, - toggleOrderDirection, - orderDirection - } = usePagination(ProjectOrderBy.Name, { initPerPage: 24 }); - - const filteredWorkspaces = useMemo( - () => - orgWorkspaces - .filter((ws) => ws?.name?.toLowerCase().includes(searchFilter.toLowerCase())) - .sort((a, b) => - orderDirection === OrderByDirection.ASC - ? a.name.toLowerCase().localeCompare(b.name.toLowerCase()) - : b.name.toLowerCase().localeCompare(a.name.toLowerCase()) - ), - [searchFilter, page, perPage, orderDirection, offset, limit] - ); - - useResetPageHelper({ - setPage, - offset, - totalCount: filteredWorkspaces.length - }); - - const { workspacesWithFaveProp } = useMemo(() => { - const workspacesWithFav = filteredWorkspaces - .map((w): Workspace & { isFavorite: boolean } => ({ - ...w, - isFavorite: Boolean(projectFavorites?.includes(w.id)) - })) - .sort((a, b) => Number(b.isFavorite) - Number(a.isFavorite)) - .slice(offset, limit * page); - - return { - workspacesWithFaveProp: workspacesWithFav - }; - }, [filteredWorkspaces, projectFavorites]); - - const addProjectToFavorites = async (projectId: string) => { - try { - if (currentOrg?.id) { - await updateUserProjectFavorites({ - orgId: currentOrg?.id, - projectFavorites: [...(projectFavorites || []), projectId] - }); - } - } catch (err) { - createNotification({ - text: "Failed to add project to favorites.", - type: "error" - }); + if (router.isReady && currentOrg?.id) { + router.push(`/org/${currentOrg?.id}/${ProjectType.SecretManager}/overview`); } - }; + }, [router.isReady, currentOrg?.id]); - const removeProjectFromFavorites = async (projectId: string) => { - try { - if (currentOrg?.id) { - await updateUserProjectFavorites({ - orgId: currentOrg?.id, - projectFavorites: [...(projectFavorites || []).filter((entry) => entry !== projectId)] - }); - } - } catch (err) { - createNotification({ - text: "Failed to remove project from favorites.", - type: "error" - }); - } - }; - - const renderProjectGridItem = (workspace: Workspace, isFavorite: boolean) => ( - // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -
{ - router.push(`/project/${workspace.id}/secrets/overview`); - localStorage.setItem("projectData.id", workspace.id); - }} - key={workspace.id} - className="min-w-72 flex h-40 cursor-pointer flex-col rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4" - > -
-
{workspace.name}
- {isFavorite ? ( - { - e.stopPropagation(); - removeProjectFromFavorites(workspace.id); - }} - /> - ) : ( - { - e.stopPropagation(); - addProjectToFavorites(workspace.id); - }} - /> - )} -
- -
- {workspace.description} -
- -
-
- {workspace.environments?.length || 0} environments -
- -
-
- ); - - const renderProjectListItem = (workspace: Workspace, isFavorite: boolean, index: number) => ( - // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -
{ - router.push(`/project/${workspace.id}/secrets/overview`); - localStorage.setItem("projectData.id", workspace.id); - }} - key={workspace.id} - className={`min-w-72 group grid h-14 cursor-pointer grid-cols-6 border-t border-l border-r border-mineshaft-600 bg-mineshaft-800 px-6 hover:bg-mineshaft-700 ${ - index === 0 && "rounded-t-md" - }`} - > -
-
{workspace.name}
-
-
-
- {workspace.environments?.length || 0} environments -
- {isFavorite ? ( - { - e.stopPropagation(); - removeProjectFromFavorites(workspace.id); - }} - /> - ) : ( - { - e.stopPropagation(); - addProjectToFavorites(workspace.id); - }} - /> - )} -
-
- ); - - let projectsComponents: ReactNode; - - if (filteredWorkspaces.length || isProjectViewLoading) { - switch (projectsViewMode) { - case ProjectsViewMode.GRID: - projectsComponents = ( -
- {isProjectViewLoading && - Array.apply(0, Array(3)).map((_x, i) => ( -
-
- -
-
- -
-
- -
-
- ))} - {!isProjectViewLoading && ( - <> - {workspacesWithFaveProp.map((workspace) => - renderProjectGridItem(workspace, workspace.isFavorite) - )} - - )} -
- ); - - break; - case ProjectsViewMode.LIST: - default: - projectsComponents = ( -
- {isProjectViewLoading && - Array.apply(0, Array(3)).map((_x, i) => ( -
- -
- ))} - {!isProjectViewLoading && - workspacesWithFaveProp.map((workspace, ind) => - renderProjectListItem(workspace, workspace.isFavorite, ind) - )} -
- ); - break; - } - } else if (orgWorkspaces.length) { - projectsComponents = ( -
- -
No projects match search...
-
- ); - } - - return ( -
- - {t("common.head-title", { title: t("settings.members.title") })} - - - {!serverDetails?.redisConfigured && ( -
-

Announcements

-
- - Attention: Updated versions of Infisical now require Redis for full functionality. Learn - how to configure it - - - here - - - . -
-
- )} -
-
-

Projects

-
-
- setSearchFilter(e.target.value)} - leftIcon={} - /> -
- - - - - -
-
- { - localStorage.setItem("projectsViewMode", ProjectsViewMode.GRID); - setProjectsViewMode(ProjectsViewMode.GRID); - }} - ariaLabel="grid" - size="xs" - className={`${ - projectsViewMode === ProjectsViewMode.GRID ? "bg-mineshaft-500" : "bg-transparent" - } min-w-[2.4rem] border-none hover:bg-mineshaft-600`} - > - - - { - localStorage.setItem("projectsViewMode", ProjectsViewMode.LIST); - setProjectsViewMode(ProjectsViewMode.LIST); - }} - ariaLabel="list" - size="xs" - className={`${ - projectsViewMode === ProjectsViewMode.LIST ? "bg-mineshaft-500" : "bg-transparent" - } min-w-[2.4rem] border-none hover:bg-mineshaft-600`} - > - - -
- - {(isAllowed) => ( - - )} - -
- {projectsComponents} - {!isProjectViewLoading && Boolean(filteredWorkspaces.length) && ( - - )} - {isWorkspaceEmpty && ( -
- -
- You are not part of any projects in this organization yet. When you are, they will - appear here. -
-
- Create a new project, or ask other organization members to give you necessary - permissions. -
-
- )} -
-
-

Explore Infisical

-
- {features.map((feature) => ( -
-
{feature.name}
-
- {feature.description} -
-
-

- Setup time: 20 min -

- - Learn more{" "} - - -
-
- ))} -
-
- {!(new Date().getTime() - new Date(user?.createdAt).getTime() < 30 * 24 * 60 * 60 * 1000) && ( -
-

Onboarding Guide

-
- - {orgWorkspaces.length !== 0 && ( - <> - - - - )} -
- -
-
- {orgWorkspaces.length !== 0 && ( -
-
-
- - {false && ( -
- -
- )} -
-
Inject secrets locally
-
- Replace .env files with a more secure and efficient alternative. -
-
-
-
- About 2 min -
-
- - {false &&
} -
- )} - {orgWorkspaces.length !== 0 && ( - - )} -
- )} - handlePopUpToggle("addNewWs", isOpen)} - /> - handlePopUpToggle("upgradePlan", isOpen)} - text="You have exceeded the number of projects allowed on the free plan." - /> - {/* */} -
- ); + return
; }; Object.assign(OrganizationPage, { requireAuth: true }); diff --git a/frontend/src/pages/org/[id]/secret-manager/overview.tsx b/frontend/src/pages/org/[id]/secret-manager/overview.tsx new file mode 100644 index 000000000..313f7d738 --- /dev/null +++ b/frontend/src/pages/org/[id]/secret-manager/overview.tsx @@ -0,0 +1,530 @@ +// REFACTOR(akhilmhdh): This file needs to be split into multiple components too complex + +import { ReactNode, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import Head from "next/head"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { faFolderOpen, faStar } from "@fortawesome/free-regular-svg-icons"; +import { + faArrowDownAZ, + faArrowRight, + faArrowUpZA, + faBorderAll, + faExclamationCircle, + faList, + faMagnifyingGlass, + faPlus, + faSearch, + faStar as faSolidStar +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Button, + IconButton, + Input, + Pagination, + Skeleton, + Tooltip, + UpgradePlanModal +} from "@app/components/v2"; +import { NewProjectModal } from "@app/components/v2/projects"; +import { + OrgPermissionActions, + OrgPermissionSubjects, + useOrganization, + useSubscription +} from "@app/context"; +import { getProjectHomePage } from "@app/helpers/project"; +import { usePagination, useResetPageHelper } from "@app/hooks"; +import { useGetUserWorkspaces } from "@app/hooks/api"; +import { OrderByDirection } from "@app/hooks/api/generic/types"; +// import { fetchUserWsKey } from "@app/hooks/api/keys/queries"; +import { useFetchServerStatus } from "@app/hooks/api/serverDetails"; +import { Workspace } from "@app/hooks/api/types"; +import { useUpdateUserProjectFavorites } from "@app/hooks/api/users/mutation"; +import { useGetUserProjectFavorites } from "@app/hooks/api/users/queries"; +import { ProjectType } from "@app/hooks/api/workspace/types"; +import { usePopUp } from "@app/hooks/usePopUp"; + +enum ProjectsViewMode { + GRID = "grid", + LIST = "list" +} + +enum ProjectOrderBy { + Name = "name" +} + +const formatTitle = (type: ProjectType) => { + if (type === ProjectType.SecretManager) return "Secret Management"; + if (type === ProjectType.CertificateManager) return "Cert Management"; + if (type === ProjectType.KMS) return "Key Management"; + return "SSH"; +}; + +const formatDescription = (type: ProjectType) => { + if (type === ProjectType.SecretManager) + return "Securely store, manage, and rotate various application secrets, such as database credentials, API keys, etc."; + if (type === ProjectType.CertificateManager) + return "Manage your PKI infrastructure and issue digital certificates for services, applications, and devices."; + if (type === ProjectType.KMS) + return "Centralize the management of keys for cryptographic operations, such as encryption and decryption."; + return "Generate SSH credentials to provide secure and centralized SSH access control for your infrastructure."; +}; + +type Props = { + type: ProjectType; +}; + +// #TODO: Update all the workspaceIds +export const ProductOverview = ({ type }: Props) => { + const { t } = useTranslation(); + + const router = useRouter(); + + const { data: workspaces, isLoading: isWorkspaceLoading } = useGetUserWorkspaces({ type }); + const { currentOrg } = useOrganization(); + const orgWorkspaces = workspaces || []; + const { data: projectFavorites, isLoading: isProjectFavoritesLoading } = + useGetUserProjectFavorites(currentOrg?.id!); + const { mutateAsync: updateUserProjectFavorites } = useUpdateUserProjectFavorites(); + + const isProjectViewLoading = isWorkspaceLoading || isProjectFavoritesLoading; + + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ + "addNewWs", + "upgradePlan" + ] as const); + + const [searchFilter, setSearchFilter] = useState(""); + const { data: serverDetails } = useFetchServerStatus(); + const [projectsViewMode, setProjectsViewMode] = useState( + (localStorage.getItem("projectsViewMode") as ProjectsViewMode) || ProjectsViewMode.GRID + ); + + const { subscription } = useSubscription(); + + const isAddingProjectsAllowed = subscription?.workspaceLimit + ? subscription.workspacesUsed < subscription.workspaceLimit + : true; + + const isWorkspaceEmpty = !isProjectViewLoading && orgWorkspaces?.length === 0; + + const { + setPage, + perPage, + setPerPage, + page, + offset, + limit, + toggleOrderDirection, + orderDirection + } = usePagination(ProjectOrderBy.Name, { initPerPage: 24 }); + + const filteredWorkspaces = useMemo( + () => + orgWorkspaces + .filter((ws) => ws?.name?.toLowerCase().includes(searchFilter.toLowerCase())) + .sort((a, b) => + orderDirection === OrderByDirection.ASC + ? a.name.toLowerCase().localeCompare(b.name.toLowerCase()) + : b.name.toLowerCase().localeCompare(a.name.toLowerCase()) + ), + [searchFilter, orderDirection, orgWorkspaces] + ); + + useResetPageHelper({ + setPage, + offset, + totalCount: filteredWorkspaces.length + }); + + const { workspacesWithFaveProp } = useMemo(() => { + const workspacesWithFav = filteredWorkspaces + .map((w): Workspace & { isFavorite: boolean } => ({ + ...w, + isFavorite: Boolean(projectFavorites?.includes(w.id)) + })) + .sort((a, b) => Number(b.isFavorite) - Number(a.isFavorite)) + .slice(offset, limit * page); + + return { + workspacesWithFaveProp: workspacesWithFav + }; + }, [filteredWorkspaces, projectFavorites]); + + const addProjectToFavorites = async (projectId: string) => { + try { + if (currentOrg?.id) { + await updateUserProjectFavorites({ + orgId: currentOrg?.id, + projectFavorites: [...(projectFavorites || []), projectId] + }); + } + } catch (err) { + createNotification({ + text: "Failed to add project to favorites.", + type: "error" + }); + } + }; + + const removeProjectFromFavorites = async (projectId: string) => { + try { + if (currentOrg?.id) { + await updateUserProjectFavorites({ + orgId: currentOrg?.id, + projectFavorites: [...(projectFavorites || []).filter((entry) => entry !== projectId)] + }); + } + } catch (err) { + createNotification({ + text: "Failed to remove project from favorites.", + type: "error" + }); + } + }; + + const renderProjectGridItem = (workspace: Workspace, isFavorite: boolean) => ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events +
{ + router.push(getProjectHomePage(workspace)); + localStorage.setItem("projectData.id", workspace.id); + }} + key={workspace.id} + className="min-w-72 flex h-40 cursor-pointer flex-col rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4" + > +
+
{workspace.name}
+ {isFavorite ? ( + { + e.stopPropagation(); + removeProjectFromFavorites(workspace.id); + }} + /> + ) : ( + { + e.stopPropagation(); + addProjectToFavorites(workspace.id); + }} + /> + )} +
+ +
+ {workspace.description} +
+ +
+ {type === ProjectType.SecretManager && ( +
+ {workspace.environments?.length || 0} environments +
+ )} + +
+
+ ); + + const renderProjectListItem = (workspace: Workspace, isFavorite: boolean, index: number) => ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events +
{ + router.push(getProjectHomePage(workspace)); + localStorage.setItem("projectData.id", workspace.id); + }} + key={workspace.id} + className={`min-w-72 group grid h-14 cursor-pointer grid-cols-6 border-t border-l border-r border-mineshaft-600 bg-mineshaft-800 px-6 hover:bg-mineshaft-700 ${ + index === 0 && "rounded-t-md" + }`} + > +
+
{workspace.name}
+
+
+
+ {workspace.environments?.length || 0} environments +
+ {isFavorite ? ( + { + e.stopPropagation(); + removeProjectFromFavorites(workspace.id); + }} + /> + ) : ( + { + e.stopPropagation(); + addProjectToFavorites(workspace.id); + }} + /> + )} +
+
+ ); + + let projectsComponents: ReactNode; + + if (filteredWorkspaces.length || isProjectViewLoading) { + switch (projectsViewMode) { + case ProjectsViewMode.GRID: + projectsComponents = ( +
+ {isProjectViewLoading && + Array.apply(0, Array(3)).map((_x, i) => ( +
+
+ +
+
+ +
+
+ +
+
+ ))} + {!isProjectViewLoading && ( + <> + {workspacesWithFaveProp.map((workspace) => + renderProjectGridItem(workspace, workspace.isFavorite) + )} + + )} +
+ ); + + break; + case ProjectsViewMode.LIST: + default: + projectsComponents = ( +
+ {isProjectViewLoading && + Array.apply(0, Array(3)).map((_x, i) => ( +
+ +
+ ))} + {!isProjectViewLoading && + workspacesWithFaveProp.map((workspace, ind) => + renderProjectListItem(workspace, workspace.isFavorite, ind) + )} +
+ ); + break; + } + } else if (orgWorkspaces.length && searchFilter) { + projectsComponents = ( +
+ +
No projects match search...
+
+ ); + } + + return ( +
+ + {t("common.head-title", { title: t("settings.members.title") })} + + + {!serverDetails?.redisConfigured && ( +
+

Announcements

+
+ + Attention: Updated versions of Infisical now require Redis for full functionality. Learn + how to configure it + + + here + + + . +
+
+ )} +
+
+

{formatTitle(type)}

+
+
+

{formatDescription(type)}

+
+
+ setSearchFilter(e.target.value)} + leftIcon={} + /> +
+ + + + + +
+
+ { + localStorage.setItem("projectsViewMode", ProjectsViewMode.GRID); + setProjectsViewMode(ProjectsViewMode.GRID); + }} + ariaLabel="grid" + size="xs" + className={`${ + projectsViewMode === ProjectsViewMode.GRID ? "bg-mineshaft-500" : "bg-transparent" + } min-w-[2.4rem] border-none hover:bg-mineshaft-600`} + > + + + { + localStorage.setItem("projectsViewMode", ProjectsViewMode.LIST); + setProjectsViewMode(ProjectsViewMode.LIST); + }} + ariaLabel="list" + size="xs" + className={`${ + projectsViewMode === ProjectsViewMode.LIST ? "bg-mineshaft-500" : "bg-transparent" + } min-w-[2.4rem] border-none hover:bg-mineshaft-600`} + > + + +
+ + {(isAllowed) => ( + + )} + +
+ {projectsComponents} + {!isProjectViewLoading && Boolean(filteredWorkspaces.length) && ( + + )} + {isWorkspaceEmpty && ( +
+ +
+ You are not part of any projects in this organization yet. When you are, they will + appear here. +
+
+ Create a new project, or ask other organization members to give you necessary + permissions. +
+
+ )} +
+ handlePopUpToggle("addNewWs", isOpen)} + projectType={type} + /> + handlePopUpToggle("upgradePlan", isOpen)} + text="You have exceeded the number of projects allowed on the free plan." + /> +
+ ); +}; + +const SecretManagerOverviewPage = () => ; + +Object.assign(SecretManagerOverviewPage, { requireAuth: true }); + +export default SecretManagerOverviewPage; diff --git a/frontend/src/pages/org/[id]/ssh/overview.tsx b/frontend/src/pages/org/[id]/ssh/overview.tsx new file mode 100644 index 000000000..2ab3bb6cc --- /dev/null +++ b/frontend/src/pages/org/[id]/ssh/overview.tsx @@ -0,0 +1,9 @@ +import { ProjectType } from "@app/hooks/api/workspace/types"; + +import { ProductOverview } from "../secret-manager/overview"; + +const SshManagerOverviewPage = () => ; + +Object.assign(SshManagerOverviewPage, { requireAuth: true }); + +export default SshManagerOverviewPage; diff --git a/frontend/src/pages/secret-manager/[id]/allowlist/index.tsx b/frontend/src/pages/secret-manager/[id]/allowlist/index.tsx new file mode 100644 index 000000000..73c25d802 --- /dev/null +++ b/frontend/src/pages/secret-manager/[id]/allowlist/index.tsx @@ -0,0 +1,21 @@ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; + +import { IPAllowlistPage } from "@app/views/Project/IPAllowListPage"; + +const ProjectAllowlist = () => { + const { t } = useTranslation(); + return ( + <> + + {t("common.head-title", { title: t("settings.project.title") })} + + + + + ); +}; + +export default ProjectAllowlist; + +ProjectAllowlist.requireAuth = true; diff --git a/frontend/src/pages/project/[id]/approval/index.tsx b/frontend/src/pages/secret-manager/[id]/approval/index.tsx similarity index 100% rename from frontend/src/pages/project/[id]/approval/index.tsx rename to frontend/src/pages/secret-manager/[id]/approval/index.tsx diff --git a/frontend/src/pages/secret-manager/[id]/identities/[identityId]/index.tsx b/frontend/src/pages/secret-manager/[id]/identities/[identityId]/index.tsx new file mode 100644 index 000000000..ae8b2716a --- /dev/null +++ b/frontend/src/pages/secret-manager/[id]/identities/[identityId]/index.tsx @@ -0,0 +1,20 @@ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; + +import { IdentityDetailsPage } from "@app/views/Project/IdentityDetailsPage"; + +export default function ProjectIdentityDetailsPage() { + const { t } = useTranslation(); + + return ( + <> + + {t("common.head-title", { title: t("settings.members.title") })} + + + + + ); +} + +ProjectIdentityDetailsPage.requireAuth = true; diff --git a/frontend/src/pages/secret-manager/[id]/members/[membershipId]/index.tsx b/frontend/src/pages/secret-manager/[id]/members/[membershipId]/index.tsx new file mode 100644 index 000000000..033aa2b15 --- /dev/null +++ b/frontend/src/pages/secret-manager/[id]/members/[membershipId]/index.tsx @@ -0,0 +1,20 @@ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; + +import { MemberDetailsPage } from "@app/views/Project/MemberDetailsPage"; + +export default function Page() { + const { t } = useTranslation(); + + return ( + <> + + {t("common.head-title", { title: t("settings.members.title") })} + + + + + ); +} + +Page.requireAuth = true; diff --git a/frontend/src/pages/secret-manager/[id]/members/index.tsx b/frontend/src/pages/secret-manager/[id]/members/index.tsx new file mode 100644 index 000000000..4bcbb833e --- /dev/null +++ b/frontend/src/pages/secret-manager/[id]/members/index.tsx @@ -0,0 +1,21 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; + +import { MembersPage } from "@app/views/Project/MembersPage"; + +export default function WorkspaceMemberSettings() { + const { t } = useTranslation(); + + return ( + <> + + {t("common.head-title", { title: t("settings.members.title") })} + + + + + ); +} + +WorkspaceMemberSettings.requireAuth = true; diff --git a/frontend/src/pages/secret-manager/[id]/roles/[roleSlug]/index.tsx b/frontend/src/pages/secret-manager/[id]/roles/[roleSlug]/index.tsx new file mode 100644 index 000000000..17c854cee --- /dev/null +++ b/frontend/src/pages/secret-manager/[id]/roles/[roleSlug]/index.tsx @@ -0,0 +1,20 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; + +import { RolePage } from "@app/views/Project/RolePage"; + +export default function Role() { + const { t } = useTranslation(); + return ( + <> + + {t("common.head-title", { title: "Project Settings" })} + + + + + ); +} + +Role.requireAuth = true; diff --git a/frontend/src/pages/project/[id]/secret-rotation/index.tsx b/frontend/src/pages/secret-manager/[id]/secret-rotation/index.tsx similarity index 100% rename from frontend/src/pages/project/[id]/secret-rotation/index.tsx rename to frontend/src/pages/secret-manager/[id]/secret-rotation/index.tsx diff --git a/frontend/src/pages/project/[id]/secrets/[env].tsx b/frontend/src/pages/secret-manager/[id]/secrets/[env].tsx similarity index 100% rename from frontend/src/pages/project/[id]/secrets/[env].tsx rename to frontend/src/pages/secret-manager/[id]/secrets/[env].tsx diff --git a/frontend/src/pages/project/[id]/secrets/overview.tsx b/frontend/src/pages/secret-manager/[id]/secrets/overview.tsx similarity index 100% rename from frontend/src/pages/project/[id]/secrets/overview.tsx rename to frontend/src/pages/secret-manager/[id]/secrets/overview.tsx diff --git a/frontend/src/pages/secret-manager/[id]/settings/index.tsx b/frontend/src/pages/secret-manager/[id]/settings/index.tsx new file mode 100644 index 000000000..331ba8cc5 --- /dev/null +++ b/frontend/src/pages/secret-manager/[id]/settings/index.tsx @@ -0,0 +1,22 @@ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; + +import { ProjectSettingsPage } from "@app/views/Settings/ProjectSettingsPage"; + +const ProjectSettings = () => { + const { t } = useTranslation(); + + return ( + <> + + {t("common.head-title", { title: t("settings.project.title") })} + + + + + ); +}; + +export default ProjectSettings; + +ProjectSettings.requireAuth = true; diff --git a/frontend/src/pages/signup/index.tsx b/frontend/src/pages/signup/index.tsx index 0719111d6..fa26437f4 100644 --- a/frontend/src/pages/signup/index.tsx +++ b/frontend/src/pages/signup/index.tsx @@ -16,6 +16,7 @@ import { useServerConfig } from "@app/context"; import { useVerifySignupEmailVerificationCode } from "@app/hooks/api"; import { fetchOrganizations } from "@app/hooks/api/organization/queries"; import { useFetchServerStatus } from "@app/hooks/api/serverDetails"; +import { ProjectType } from "@app/hooks/api/workspace/types"; /** * @returns the signup page @@ -47,7 +48,7 @@ export default function SignUp() { const tryAuth = async () => { try { const userOrgs = await fetchOrganizations(); - router.push(`/org/${userOrgs[0].id}/overview`); + router.push(`/org/${userOrgs[0].id}/${ProjectType.SecretManager}/overview`); } catch (error) { console.log("Error - Not logged in yet"); } @@ -90,7 +91,7 @@ export default function SignUp() { if (!serverDetails?.emailConfigured && step === 5) { const userOrgs = await fetchOrganizations(); - router.push(`/org/${userOrgs[0].id}/overview`); + router.push(`/org/${userOrgs[0].id}/${ProjectType.SecretManager}/overview`); } })(); }, [step]); diff --git a/frontend/src/pages/signupinvite.tsx b/frontend/src/pages/signupinvite.tsx index e64f4d7c0..8a0d712e3 100644 --- a/frontend/src/pages/signupinvite.tsx +++ b/frontend/src/pages/signupinvite.tsx @@ -31,6 +31,7 @@ import { } from "@app/hooks/api/auth/queries"; import { MfaMethod } from "@app/hooks/api/auth/types"; import { fetchOrganizations } from "@app/hooks/api/organization/queries"; +import { ProjectType } from "@app/hooks/api/workspace/types"; import { navigateUserToOrg } from "@app/views/Login/Login.utils"; import { Mfa } from "@app/views/Login/Mfa"; @@ -386,7 +387,7 @@ export default function SignupInvite() { setBackupKeyError, setBackupKeyIssued }); - router.push(`/org/${organizationId}/overview`); + router.push(`/org/${organizationId}/${ProjectType.SecretManager}/overview`); }} size="lg" /> diff --git a/frontend/src/pages/ssh/[id]/allowlist/index.tsx b/frontend/src/pages/ssh/[id]/allowlist/index.tsx new file mode 100644 index 000000000..73c25d802 --- /dev/null +++ b/frontend/src/pages/ssh/[id]/allowlist/index.tsx @@ -0,0 +1,21 @@ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; + +import { IPAllowlistPage } from "@app/views/Project/IPAllowListPage"; + +const ProjectAllowlist = () => { + const { t } = useTranslation(); + return ( + <> + + {t("common.head-title", { title: t("settings.project.title") })} + + + + + ); +}; + +export default ProjectAllowlist; + +ProjectAllowlist.requireAuth = true; diff --git a/frontend/src/pages/ssh/[id]/ca/[caId]/index.tsx b/frontend/src/pages/ssh/[id]/ca/[caId]/index.tsx new file mode 100644 index 000000000..cdaa4d0b2 --- /dev/null +++ b/frontend/src/pages/ssh/[id]/ca/[caId]/index.tsx @@ -0,0 +1,18 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import Head from "next/head"; + +import { SshCaPage } from "@app/views/Project/SshCaPage"; + +export default function SshCa() { + return ( + <> + + SSH Certificate Authority + + + + + ); +} + +SshCa.requireAuth = true; diff --git a/frontend/src/pages/ssh/[id]/identities/[identityId]/index.tsx b/frontend/src/pages/ssh/[id]/identities/[identityId]/index.tsx new file mode 100644 index 000000000..ae8b2716a --- /dev/null +++ b/frontend/src/pages/ssh/[id]/identities/[identityId]/index.tsx @@ -0,0 +1,20 @@ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; + +import { IdentityDetailsPage } from "@app/views/Project/IdentityDetailsPage"; + +export default function ProjectIdentityDetailsPage() { + const { t } = useTranslation(); + + return ( + <> + + {t("common.head-title", { title: t("settings.members.title") })} + + + + + ); +} + +ProjectIdentityDetailsPage.requireAuth = true; diff --git a/frontend/src/pages/ssh/[id]/members/[membershipId]/index.tsx b/frontend/src/pages/ssh/[id]/members/[membershipId]/index.tsx new file mode 100644 index 000000000..033aa2b15 --- /dev/null +++ b/frontend/src/pages/ssh/[id]/members/[membershipId]/index.tsx @@ -0,0 +1,20 @@ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; + +import { MemberDetailsPage } from "@app/views/Project/MemberDetailsPage"; + +export default function Page() { + const { t } = useTranslation(); + + return ( + <> + + {t("common.head-title", { title: t("settings.members.title") })} + + + + + ); +} + +Page.requireAuth = true; diff --git a/frontend/src/pages/ssh/[id]/members/index.tsx b/frontend/src/pages/ssh/[id]/members/index.tsx new file mode 100644 index 000000000..4bcbb833e --- /dev/null +++ b/frontend/src/pages/ssh/[id]/members/index.tsx @@ -0,0 +1,21 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; + +import { MembersPage } from "@app/views/Project/MembersPage"; + +export default function WorkspaceMemberSettings() { + const { t } = useTranslation(); + + return ( + <> + + {t("common.head-title", { title: t("settings.members.title") })} + + + + + ); +} + +WorkspaceMemberSettings.requireAuth = true; diff --git a/frontend/src/pages/ssh/[id]/roles/[roleSlug]/index.tsx b/frontend/src/pages/ssh/[id]/roles/[roleSlug]/index.tsx new file mode 100644 index 000000000..17c854cee --- /dev/null +++ b/frontend/src/pages/ssh/[id]/roles/[roleSlug]/index.tsx @@ -0,0 +1,20 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; + +import { RolePage } from "@app/views/Project/RolePage"; + +export default function Role() { + const { t } = useTranslation(); + return ( + <> + + {t("common.head-title", { title: "Project Settings" })} + + + + + ); +} + +Role.requireAuth = true; diff --git a/frontend/src/pages/ssh/[id]/settings/index.tsx b/frontend/src/pages/ssh/[id]/settings/index.tsx new file mode 100644 index 000000000..331ba8cc5 --- /dev/null +++ b/frontend/src/pages/ssh/[id]/settings/index.tsx @@ -0,0 +1,22 @@ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; + +import { ProjectSettingsPage } from "@app/views/Settings/ProjectSettingsPage"; + +const ProjectSettings = () => { + const { t } = useTranslation(); + + return ( + <> + + {t("common.head-title", { title: t("settings.project.title") })} + + + + + ); +}; + +export default ProjectSettings; + +ProjectSettings.requireAuth = true; diff --git a/frontend/src/pages/ssh/[id]/ssh/index.tsx b/frontend/src/pages/ssh/[id]/ssh/index.tsx new file mode 100644 index 000000000..f574566c1 --- /dev/null +++ b/frontend/src/pages/ssh/[id]/ssh/index.tsx @@ -0,0 +1,23 @@ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; + +import { SshPage } from "@app/views/Project/SshPage"; + +const Ssh = () => { + const { t } = useTranslation(); + + return ( +
+ + {t("common.head-title", { title: "Certificates" })} + + + + +
+ ); +}; + +export default Ssh; + +Ssh.requireAuth = true; diff --git a/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationConnectionSection.tsx b/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationConnectionSection.tsx index 778cdd00a..465760996 100644 --- a/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationConnectionSection.tsx +++ b/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationConnectionSection.tsx @@ -1,6 +1,7 @@ import { integrationSlugNameMapping } from "public/data/frequentConstants"; import { FormLabel } from "@app/components/v2"; +import { CircleCiScope } from "@app/hooks/api/integrationAuth/types"; import { IntegrationMappingBehavior, TIntegrationWithEnv } from "@app/hooks/api/integrations/types"; type Props = { @@ -46,6 +47,11 @@ export const IntegrationConnectionSection = ({ integration }: Props) => { case "qovery": return integration.scope; case "circleci": + if (integration.scope === CircleCiScope.Context) { + return "Context"; + } + + return "Project"; case "terraform-cloud": return "Project"; case "aws-secret-manager": @@ -77,7 +83,6 @@ export const IntegrationConnectionSection = ({ integration }: Props) => { return `${integration.owner}`; } return `${integration.owner}/${integration.app}`; - case "aws-parameter-store": case "rundeck": return `${integration.path}`; diff --git a/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationSettingsSection.tsx b/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationSettingsSection.tsx index f537baec6..0b8174c18 100644 --- a/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationSettingsSection.tsx +++ b/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationSettingsSection.tsx @@ -14,6 +14,7 @@ const metadataMappings: Record { Object.entries(integration.metadata).map(([key, value]) => (

- {metadataMappings[key as keyof typeof metadataMappings]} + {!!value && metadataMappings[key as keyof typeof metadataMappings]}

{renderValue(key as MetadataKey, value)}

diff --git a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx index e1a1ff6fb..93083e72a 100644 --- a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx +++ b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx @@ -69,7 +69,7 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => createIntegrationMissingEnvVarsNotification(integrationOption.slug); return; } - link = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${window.location.origin}/integrations/azure-key-vault/oauth2/callback&response_mode=query&scope=https://vault.azure.net/.default openid offline_access&state=${state}`; + link = `${window.location.origin}/integrations/azure-key-vault/authorize?clientId=${integrationOption.clientId}&state=${state}`; break; case "azure-app-configuration": if (!integrationOption.clientId) { diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationDetails.tsx b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationDetails.tsx index f785ca745..4e31cdedb 100644 --- a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationDetails.tsx +++ b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationDetails.tsx @@ -1,4 +1,5 @@ import { FormLabel } from "@app/components/v2"; +import { CircleCiScope } from "@app/hooks/api/integrationAuth/types"; import { IntegrationMappingBehavior, TIntegration } from "@app/hooks/api/integrations/types"; type Props = { @@ -52,7 +53,8 @@ export const IntegrationDetails = ({ integration }: Props) => { { - const userOrgs = await fetchOrganizations(); + const userOrgs = await fetchOrganizations().catch(() => []); const nonAuthEnforcedOrgs = userOrgs.filter((org) => !org.authEnforced); if (organizationId) { localStorage.setItem("orgData.id", organizationId); - router.push(`/org/${organizationId}/overview`); + router.push(`/org/${organizationId}/${ProjectType.SecretManager}/overview`); return; } @@ -20,7 +21,7 @@ export const navigateUserToOrg = async (router: NextRouter, organizationId?: str // user is part of at least 1 non-auth enforced org const userOrg = nonAuthEnforcedOrgs[0] && nonAuthEnforcedOrgs[0].id; localStorage.setItem("orgData.id", userOrg); - router.push(`/org/${userOrg}/overview`); + router.push(`/org/${userOrg}/${ProjectType.SecretManager}/overview`); } else { // user is not part of any non-auth enforced orgs localStorage.removeItem("orgData.id"); diff --git a/frontend/src/views/Org/GroupPage/GroupPage.tsx b/frontend/src/views/Org/GroupPage/GroupPage.tsx new file mode 100644 index 000000000..acde15760 --- /dev/null +++ b/frontend/src/views/Org/GroupPage/GroupPage.tsx @@ -0,0 +1,175 @@ +import { useRouter } from "next/router"; +import { faChevronLeft, faEllipsis } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Button, + DeleteActionModal, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Spinner, + Tooltip, + UpgradePlanModal +} from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; +import { withPermission } from "@app/hoc"; +import { useDeleteGroup } from "@app/hooks/api"; +import { useGetGroupById } from "@app/hooks/api/groups/queries"; +import { usePopUp } from "@app/hooks/usePopUp"; +import { TabSections } from "@app/views/Org/Types"; + +import { GroupCreateUpdateModal } from "./components/GroupCreateUpdateModal"; +import { GroupMembersSection } from "./components/GroupMembersSection"; +import { GroupDetailsSection } from "./components"; + +export const GroupPage = withPermission( + () => { + const router = useRouter(); + const groupId = router.query.groupId as string; + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + + const { data, isLoading } = useGetGroupById(groupId); + + const { mutateAsync: deleteMutateAsync } = useDeleteGroup(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "groupCreateUpdate", + "deleteGroup", + "upgradePlan" + ] as const); + + const onDeleteGroupSubmit = async ({ name, id }: { name: string; id: string }) => { + try { + await deleteMutateAsync({ + id + }); + createNotification({ + text: `Successfully deleted the ${name} group`, + type: "success" + }); + router.push(`/org/${orgId}/members?selectedTab=${TabSections.Groups}`); + } catch (err) { + console.error(err); + createNotification({ + text: `Failed to delete the ${name} group`, + type: "error" + }); + } + + handlePopUpClose("deleteGroup"); + }; + + if (isLoading) return ; + + return ( +
+ {data && ( +
+ +
+

{data.group.name}

+ + +
+ + + +
+
+ + + {(isAllowed) => ( + { + handlePopUpOpen("groupCreateUpdate", { + groupId, + name: data.group.name, + slug: data.group.slug, + role: data.group.role + }); + }} + disabled={!isAllowed} + > + Edit Group + + )} + + + {(isAllowed) => ( + { + handlePopUpOpen("deleteGroup", { + id: groupId, + name: data.group.name + }); + }} + disabled={!isAllowed} + > + Delete Group + + )} + + +
+
+
+
+ +
+ +
+
+ )} + + handlePopUpToggle("deleteGroup", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onDeleteGroupSubmit(popUp?.deleteGroup?.data as { name: string; id: string }) + } + /> + handlePopUpToggle("upgradePlan", isOpen)} + text={(popUp.upgradePlan?.data as { description: string })?.description} + /> +
+ ); + }, + { action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Groups } +); diff --git a/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupMembersModal.tsx b/frontend/src/views/Org/GroupPage/components/AddGroupMemberModal.tsx similarity index 71% rename from frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupMembersModal.tsx rename to frontend/src/views/Org/GroupPage/components/AddGroupMemberModal.tsx index e7f38318a..ab81aa445 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupMembersModal.tsx +++ b/frontend/src/views/Org/GroupPage/components/AddGroupMemberModal.tsx @@ -22,21 +22,22 @@ import { } from "@app/components/v2"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; import { useDebounce, useResetPageHelper } from "@app/hooks"; -import { useAddUserToGroup, useListGroupUsers, useRemoveUserFromGroup } from "@app/hooks/api"; +import { useAddUserToGroup, useListGroupUsers } from "@app/hooks/api"; +import { EFilterReturnedUsers } from "@app/hooks/api/groups/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; type Props = { - popUp: UsePopUpState<["groupMembers"]>; - handlePopUpToggle: (popUpName: keyof UsePopUpState<["groupMembers"]>, state?: boolean) => void; + popUp: UsePopUpState<["addGroupMembers"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["addGroupMembers"]>, state?: boolean) => void; }; -export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { +export const AddGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(10); const [searchMemberFilter, setSearchMemberFilter] = useState(""); const [debouncedSearch] = useDebounce(searchMemberFilter); - const popUpData = popUp?.groupMembers?.data as { + const popUpData = popUp?.addGroupMembers?.data as { groupId: string; slug: string; }; @@ -47,7 +48,8 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { groupSlug: popUpData?.slug, offset, limit: perPage, - search: debouncedSearch + search: debouncedSearch, + filter: EFilterReturnedUsers.NON_MEMBERS }); const { totalCount = 0 } = data ?? {}; @@ -58,36 +60,31 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { setPage }); - const { mutateAsync: assignMutateAsync } = useAddUserToGroup(); - const { mutateAsync: unassignMutateAsync } = useRemoveUserFromGroup(); + const { mutateAsync: addUserToGroupMutateAsync } = useAddUserToGroup(); - const handleAssignment = async (username: string, assign: boolean) => { + const handleAddMember = async (username: string) => { try { - if (!popUpData?.slug) return; - - if (assign) { - await assignMutateAsync({ - groupId: popUpData.groupId, - username, - slug: popUpData.slug - }); - } else { - await unassignMutateAsync({ - groupId: popUpData.groupId, - username, - slug: popUpData.slug + if (!popUpData?.slug) { + createNotification({ + text: "Some data is missing, please refresh the page and try again", + type: "error" }); + return; } + await addUserToGroupMutateAsync({ + groupId: popUpData.groupId, + username, + slug: popUpData.slug + }); + createNotification({ - text: `Successfully ${assign ? "assigned" : "removed"} user ${ - assign ? "to" : "from" - } group`, + text: "Successfully assigned user to the group", type: "success" }); } catch (err) { createNotification({ - text: `Failed to ${assign ? "assign" : "remove"} user ${assign ? "to" : "from"} group`, + text: "Failed to assign user to the group", type: "error" }); } @@ -95,12 +92,12 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { return ( { - handlePopUpToggle("groupMembers", isOpen); + handlePopUpToggle("addGroupMembers", isOpen); }} > - + setSearchMemberFilter(e.target.value)} @@ -118,7 +115,7 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { {isLoading && } {!isLoading && - data?.users?.map(({ id, firstName, lastName, username, isPartOfGroup }) => { + data?.users?.map(({ id, firstName, lastName, username }) => { return ( @@ -138,9 +135,9 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { colorSchema="primary" variant="outline_bg" type="submit" - onClick={() => handleAssignment(username, !isPartOfGroup)} + onClick={() => handleAddMember(username)} > - {isPartOfGroup ? "Unassign" : "Assign"} + Assign ); }} @@ -162,7 +159,9 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { )} {!isLoading && !data?.users?.length && ( )} diff --git a/frontend/src/views/Org/GroupPage/components/GroupCreateUpdateModal.tsx b/frontend/src/views/Org/GroupPage/components/GroupCreateUpdateModal.tsx new file mode 100644 index 000000000..39187f3cb --- /dev/null +++ b/frontend/src/views/Org/GroupPage/components/GroupCreateUpdateModal.tsx @@ -0,0 +1,192 @@ +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 { + Button, + FilterableSelect, + FormControl, + Input, + Modal, + ModalContent +} from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { findOrgMembershipRole } from "@app/helpers/roles"; +import { useCreateGroup, useGetOrgRoles, useUpdateGroup } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const GroupFormSchema = z.object({ + name: z.string().min(1, "Name cannot be empty").max(50, "Name must be 50 characters or fewer"), + slug: z + .string() + .min(5, "Slug must be at least 5 characters long") + .max(36, "Slug must be 36 characters or fewer"), + role: z.object({ name: z.string(), slug: z.string() }) +}); + +export type TGroupFormData = z.infer; + +type Props = { + popUp: UsePopUpState<["groupCreateUpdate"]>; + handlePopUpClose: (popUpName: keyof UsePopUpState<["groupCreateUpdate"]>) => void; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["groupCreateUpdate"]>, + state?: boolean + ) => void; +}; + +export const GroupCreateUpdateModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) => { + const { currentOrg } = useOrganization(); + const { data: roles } = useGetOrgRoles(currentOrg?.id || ""); + const { mutateAsync: createMutateAsync, isLoading: createIsLoading } = useCreateGroup(); + const { mutateAsync: updateMutateAsync, isLoading: updateIsLoading } = useUpdateGroup(); + + const { control, handleSubmit, reset } = useForm({ + resolver: zodResolver(GroupFormSchema) + }); + + useEffect(() => { + const group = popUp?.groupCreateUpdate?.data as { + groupId: string; + name: string; + slug: string; + role: string; + customRole: { + name: string; + slug: string; + }; + }; + + if (!roles?.length) return; + + if (group) { + reset({ + name: group.name, + slug: group.slug, + role: group?.customRole ?? findOrgMembershipRole(roles, group.role) + }); + } else { + reset({ + name: "", + slug: "", + role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole) + }); + } + }, [popUp?.groupCreateUpdate?.data, roles]); + + const onGroupModalSubmit = async ({ name, slug, role }: TGroupFormData) => { + try { + if (!currentOrg?.id) return; + + const group = popUp?.groupCreateUpdate?.data as { + groupId: string; + name: string; + slug: string; + }; + + if (group) { + await updateMutateAsync({ + id: group.groupId, + name, + slug, + role: role.slug || undefined + }); + } else { + await createMutateAsync({ + name, + slug, + organizationId: currentOrg.id, + role: role.slug || undefined + }); + } + handlePopUpToggle("groupCreateUpdate", false); + reset(); + + createNotification({ + text: `Successfully ${popUp?.groupCreateUpdate?.data ? "updated" : "created"} group`, + type: "success" + }); + } catch (err) { + createNotification({ + text: `Failed to ${popUp?.groupCreateUpdate?.data ? "updated" : "created"} group`, + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("groupCreateUpdate", isOpen); + reset(); + }} + > + +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + option.slug} + getOptionLabel={(option) => option.name} + /> + + )} + /> +
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/views/Org/GroupPage/components/GroupDetailsSection.tsx b/frontend/src/views/Org/GroupPage/components/GroupDetailsSection.tsx new file mode 100644 index 000000000..624cc7241 --- /dev/null +++ b/frontend/src/views/Org/GroupPage/components/GroupDetailsSection.tsx @@ -0,0 +1,88 @@ +import { faPencil } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { OrgPermissionCan } from "@app/components/permissions"; +import { IconButton, Spinner, Tooltip } from "@app/components/v2"; +import { CopyButton } from "@app/components/v2/CopyButton"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { useGetGroupById } from "@app/hooks/api/"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + groupId: string; + handlePopUpOpen: (popUpName: keyof UsePopUpState<["groupCreateUpdate"]>, data?: {}) => void; +}; + +export const GroupDetailsSection = ({ groupId, handlePopUpOpen }: Props) => { + const { data, isLoading } = useGetGroupById(groupId); + + if (isLoading) return ; + + return data ? ( +
+
+

Group Details

+ + {(isAllowed) => { + return ( + + { + handlePopUpOpen("groupCreateUpdate", { + groupId, + name: data.group.name, + slug: data.group.slug, + role: data.group.role + }); + }} + > + + + + ); + }} + +
+
+
+

Group ID

+
+

{data.group.id}

+ +
+
+
+

Name

+

{data.group.name}

+
+
+

Slug

+
+

{data.group.slug}

+ +
+
+
+

Organization Role

+

{data.group.role}

+
+
+

Created At

+

+ {new Date(data.group.createdAt).toLocaleString()} +

+
+
+
+ ) : ( +
+
+

Group data not found

+
+
+ ); +}; diff --git a/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembersSection.tsx b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembersSection.tsx new file mode 100644 index 000000000..08de6c724 --- /dev/null +++ b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembersSection.tsx @@ -0,0 +1,90 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { DeleteActionModal, IconButton } from "@app/components/v2"; +import { useRemoveUserFromGroup } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { AddGroupMembersModal } from "../AddGroupMemberModal"; +import { GroupMembersTable } from "./GroupMembersTable"; + +type Props = { + groupId: string; + groupSlug: string; +}; + +export const GroupMembersSection = ({ groupId, groupSlug }: Props) => { + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ + "addGroupMembers", + "removeMemberFromGroup" + ] as const); + + const { mutateAsync: removeUserFromGroupMutateAsync } = useRemoveUserFromGroup(); + const handleRemoveUserFromGroup = async (username: string) => { + try { + await removeUserFromGroupMutateAsync({ + groupId, + username, + slug: groupSlug + }); + + createNotification({ + text: `Successfully removed user ${username} from the group`, + type: "success" + }); + + handlePopUpToggle("removeMemberFromGroup", false); + } catch (err) { + createNotification({ + text: `Failed to remove user ${username} from the group`, + type: "error" + }); + } + }; + + return ( +
+
+

Group Members

+ { + handlePopUpOpen("addGroupMembers", { + groupId, + slug: groupSlug + }); + }} + > + + +
+
+ +
+ + handlePopUpToggle("removeMemberFromGroup", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => { + const userData = popUp?.removeMemberFromGroup?.data as { + username: string; + id: string; + }; + + return handleRemoveUserFromGroup(userData.username); + }} + /> +
+ ); +}; diff --git a/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembersTable.tsx b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembersTable.tsx new file mode 100644 index 000000000..2423fd6d5 --- /dev/null +++ b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembersTable.tsx @@ -0,0 +1,195 @@ +import { useMemo } from "react"; +import { + faArrowDown, + faArrowUp, + faFolder, + faMagnifyingGlass, + faSearch +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Button, + EmptyState, + IconButton, + Input, + Pagination, + Table, + TableContainer, + TableSkeleton, + TBody, + Th, + THead, + Tr +} from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { usePagination, useResetPageHelper } from "@app/hooks"; +import { useListGroupUsers } from "@app/hooks/api"; +import { OrderByDirection } from "@app/hooks/api/generic/types"; +import { EFilterReturnedUsers } from "@app/hooks/api/groups/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { GroupMembershipRow } from "./GroupMembershipRow"; + +type Props = { + groupId: string; + groupSlug: string; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["removeMemberFromGroup", "addGroupMembers"]>, + data?: {} + ) => void; +}; + +enum GroupMembersOrderBy { + Name = "name" +} + +export const GroupMembersTable = ({ groupId, groupSlug, handlePopUpOpen }: Props) => { + const { + search, + setSearch, + setPage, + page, + perPage, + setPerPage, + offset, + orderDirection, + toggleOrderDirection + } = usePagination(GroupMembersOrderBy.Name, { initPerPage: 10 }); + + const { data: groupMemberships, isLoading } = useListGroupUsers({ + id: groupId, + groupSlug, + offset, + limit: perPage, + search, + filter: EFilterReturnedUsers.EXISTING_MEMBERS + }); + + const filteredGroupMemberships = useMemo(() => { + return groupMemberships && groupMemberships?.users + ? groupMemberships?.users + ?.filter((membership) => { + const userSearchString = `${membership.firstName && membership.firstName} ${ + membership.lastName && membership.lastName + } ${membership.email && membership.email} ${ + membership.username && membership.username + }`; + return userSearchString.toLowerCase().includes(search.trim().toLowerCase()); + }) + .sort((a, b) => { + const [membershipOne, membershipTwo] = + orderDirection === OrderByDirection.ASC ? [a, b] : [b, a]; + + const membershipOneComparisonString = membershipOne.firstName + ? membershipOne.firstName + : membershipOne.email; + + const membershipTwoComparisonString = membershipTwo.firstName + ? membershipTwo.firstName + : membershipTwo.email; + + const comparison = membershipOneComparisonString + .toLowerCase() + .localeCompare(membershipTwoComparisonString.toLowerCase()); + + return comparison; + }) + : []; + }, [groupMemberships, orderDirection, search]); + + useResetPageHelper({ + totalCount: filteredGroupMemberships?.length, + offset, + setPage + }); + + return ( +
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search users..." + /> + + + + + + + + + + + {isLoading && } + {!isLoading && + filteredGroupMemberships.slice(offset, perPage * page).map((userGroupMembership) => { + return ( + + ); + })} + +
+
+ Name + + + +
+
EmailAdded On +
+ {Boolean(filteredGroupMemberships.length) && ( + + )} + {!isLoading && !filteredGroupMemberships?.length && ( + + )} + {!groupMemberships?.users.length && ( + + {(isAllowed) => ( +
+ +
+ )} +
+ )} +
+
+ ); +}; diff --git a/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembershipRow.tsx b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembershipRow.tsx new file mode 100644 index 000000000..943a6574e --- /dev/null +++ b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembershipRow.tsx @@ -0,0 +1,53 @@ +import { faUserMinus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { OrgPermissionCan } from "@app/components/permissions"; +import { IconButton, Td, Tooltip, Tr } from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { TGroupUser } from "@app/hooks/api/groups/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + user: TGroupUser; + handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeMemberFromGroup"]>, data?: {}) => void; +}; + +export const GroupMembershipRow = ({ + user: { firstName, lastName, username, joinedGroupAt, email, id }, + handlePopUpOpen +}: Props) => { + return ( + + +

{`${firstName ?? "-"} ${lastName ?? ""}`}

+ + +

{email}

+ + + +

{new Date(joinedGroupAt).toLocaleDateString()}

+
+ + + + {(isAllowed) => { + return ( + + handlePopUpOpen("removeMemberFromGroup", { username })} + variant="plain" + colorSchema="danger" + > + + + + ); + }} + + + + ); +}; diff --git a/frontend/src/views/Org/GroupPage/components/GroupMembersSection/index.tsx b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/index.tsx new file mode 100644 index 000000000..70c696609 --- /dev/null +++ b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/index.tsx @@ -0,0 +1 @@ +export { GroupMembersSection } from "./GroupMembersSection"; diff --git a/frontend/src/views/Org/GroupPage/components/index.tsx b/frontend/src/views/Org/GroupPage/components/index.tsx new file mode 100644 index 000000000..003c47910 --- /dev/null +++ b/frontend/src/views/Org/GroupPage/components/index.tsx @@ -0,0 +1 @@ +export { GroupDetailsSection } from "./GroupDetailsSection"; diff --git a/frontend/src/views/Org/GroupPage/index.tsx b/frontend/src/views/Org/GroupPage/index.tsx new file mode 100644 index 000000000..3dec23a1c --- /dev/null +++ b/frontend/src/views/Org/GroupPage/index.tsx @@ -0,0 +1 @@ +export { GroupPage } from "./GroupPage"; diff --git a/frontend/src/views/Org/IdentityPage/components/IdentityProjectsSection/IdentityProjectRow.tsx b/frontend/src/views/Org/IdentityPage/components/IdentityProjectsSection/IdentityProjectRow.tsx index 4cbd1362d..a6342dbaf 100644 --- a/frontend/src/views/Org/IdentityPage/components/IdentityProjectsSection/IdentityProjectRow.tsx +++ b/frontend/src/views/Org/IdentityPage/components/IdentityProjectsSection/IdentityProjectRow.tsx @@ -5,7 +5,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { format } from "date-fns"; import { createNotification } from "@app/components/notifications"; -import { IconButton, Td, Tooltip, Tr } from "@app/components/v2"; +import { IconButton, Tag, Td, Tooltip, Tr } from "@app/components/v2"; import { useWorkspace } from "@app/context"; import { IdentityMembership } from "@app/hooks/api/identities/types"; import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; @@ -52,7 +52,9 @@ export const IdentityProjectRow = ({ key={`identity-project-membership-${id}`} onClick={() => { if (isAccessible) { - router.push(`/project/${project.id}/members?selectedTab=${TabSections.Identities}`); + router.push( + `/${project?.type}/${project.id}/members?selectedTab=${TabSections.Identities}` + ); return; } @@ -63,6 +65,9 @@ export const IdentityProjectRow = ({ }} > {project.name} + + {project.type} + {`${formatRoleName(roles[0].role, roles[0].customRoleName)}${ roles.length > 1 ? ` (+${roles.length - 1})` : "" }`} diff --git a/frontend/src/views/Org/IdentityPage/components/IdentityProjectsSection/IdentityProjectsTable.tsx b/frontend/src/views/Org/IdentityPage/components/IdentityProjectsSection/IdentityProjectsTable.tsx index 195f74111..cb9f1c640 100644 --- a/frontend/src/views/Org/IdentityPage/components/IdentityProjectsSection/IdentityProjectsTable.tsx +++ b/frontend/src/views/Org/IdentityPage/components/IdentityProjectsSection/IdentityProjectsTable.tsx @@ -106,6 +106,7 @@ export const IdentityProjectsTable = ({ identityId, handlePopUpOpen }: Props) =>
+ Type Role Added On diff --git a/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsSection.tsx b/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsSection.tsx index f72adf61f..9c3949150 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsSection.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsSection.tsx @@ -8,7 +8,6 @@ import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@a import { useDeleteGroup } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; -import { OrgGroupMembersModal } from "./OrgGroupMembersModal"; import { OrgGroupModal } from "./OrgGroupModal"; import { OrgGroupsTable } from "./OrgGroupsTable"; @@ -78,7 +77,6 @@ export const OrgGroupsSection = () => { handlePopUpClose={handlePopUpClose} handlePopUpToggle={handlePopUpToggle} /> - { + const router = useRouter(); const { currentOrg } = useOrganization(); const orgId = currentOrg?.id || ""; const { isLoading, data: groups = [] } = useGetOrganizationGroups(orgId); @@ -223,7 +225,11 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => { .slice(offset, perPage * page) .map(({ id, name, slug, role, customRole }) => { return ( - + router.push(`/org/${orgId}/groups/${id}`)} + className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700" + key={`org-group-${id}`} + > {name} {slug} @@ -277,30 +283,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => { - {(isAllowed) => ( - { - e.stopPropagation(); - handlePopUpOpen("groupMembers", { - groupId: id, - slug - }); - }} - disabled={!isAllowed} - > - Manage Users - - )} - - {(isAllowed) => ( { )} + + {(isAllowed) => ( + router.push(`/org/${orgId}/groups/${id}`)} + disabled={!isAllowed} + > + Manage Members + + )} + ({ resolver: yupResolver(schema), @@ -216,6 +226,17 @@ export const IdentityAuthMethodModalContent = ({ handlePopUpToggle={handlePopUpToggle} /> ) + }, + + [IdentityAuthMethod.JWT_AUTH]: { + revokeMethod: revokeJwtAuth, + render: () => ( + + ) } }; diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityJwtAuthForm.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityJwtAuthForm.tsx new file mode 100644 index 000000000..d31bd43bd --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityJwtAuthForm.tsx @@ -0,0 +1,688 @@ +import { useEffect } from "react"; +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { faQuestionCircle } from "@fortawesome/free-regular-svg-icons"; +import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + IconButton, + Input, + Select, + SelectItem, + TextArea, + Tooltip +} from "@app/components/v2"; +import { useOrganization, useSubscription } from "@app/context"; +import { useAddIdentityJwtAuth, useUpdateIdentityJwtAuth } from "@app/hooks/api"; +import { IdentityAuthMethod } from "@app/hooks/api/identities"; +import { IdentityJwtConfigurationType } from "@app/hooks/api/identities/enums"; +import { useGetIdentityJwtAuth } from "@app/hooks/api/identities/queries"; +import { IdentityTrustedIp } from "@app/hooks/api/identities/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const commonSchema = z.object({ + accessTokenTrustedIps: z + .array( + z.object({ + ipAddress: z.string().max(50) + }) + ) + .min(1), + accessTokenTTL: z.string().refine((val) => Number(val) <= 315360000, { + message: "Access Token TTL cannot be greater than 315360000" + }), + accessTokenMaxTTL: z.string().refine((val) => Number(val) <= 315360000, { + message: "Access Token Max TTL cannot be greater than 315360000" + }), + accessTokenNumUsesLimit: z.string(), + boundIssuer: z.string().trim().default(""), + boundAudiences: z.string().optional().default(""), + boundClaims: z.array( + z.object({ + key: z.string(), + value: z.string() + }) + ), + boundSubject: z.string().optional().default("") +}); + +const schema = z.discriminatedUnion("configurationType", [ + z + .object({ + configurationType: z.literal(IdentityJwtConfigurationType.JWKS), + jwksUrl: z.string().trim().url(), + jwksCaCert: z.string().trim().default(""), + publicKeys: z + .object({ + value: z.string() + }) + .array() + .optional() + }) + .merge(commonSchema), + z + .object({ + configurationType: z.literal(IdentityJwtConfigurationType.STATIC), + jwksUrl: z.string().trim().optional(), + jwksCaCert: z.string().trim().optional().default(""), + publicKeys: z + .object({ + value: z.string().min(1) + }) + .array() + .min(1) + }) + .merge(commonSchema) +]); + +export type FormData = z.infer; + +type Props = { + handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["identityAuthMethod", "revokeAuthMethod"]>, + state?: boolean + ) => void; + identityAuthMethodData: { + identityId: string; + name: string; + configuredAuthMethods?: IdentityAuthMethod[]; + authMethod?: IdentityAuthMethod; + }; +}; + +export const IdentityJwtAuthForm = ({ + handlePopUpOpen, + handlePopUpToggle, + identityAuthMethodData +}: Props) => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + const { subscription } = useSubscription(); + + const { mutateAsync: addMutateAsync } = useAddIdentityJwtAuth(); + const { mutateAsync: updateMutateAsync } = useUpdateIdentityJwtAuth(); + + const isUpdate = identityAuthMethodData?.configuredAuthMethods?.includes( + identityAuthMethodData.authMethod! || "" + ); + const { data } = useGetIdentityJwtAuth(identityAuthMethodData?.identityId ?? "", { + enabled: isUpdate + }); + + const { + watch, + control, + handleSubmit, + reset, + setValue, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + accessTokenTTL: "2592000", + accessTokenMaxTTL: "2592000", + accessTokenNumUsesLimit: "0", + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }], + configurationType: IdentityJwtConfigurationType.JWKS + } + }); + + const selectedConfigurationType = watch("configurationType") as IdentityJwtConfigurationType; + + const { + fields: publicKeyFields, + append: appendPublicKeyFields, + remove: removePublicKeyFields + } = useFieldArray({ + control, + name: "publicKeys" + }); + + const { + fields: boundClaimsFields, + append: appendBoundClaimField, + remove: removeBoundClaimField + } = useFieldArray({ + control, + name: "boundClaims" + }); + + const { + fields: accessTokenTrustedIpsFields, + append: appendAccessTokenTrustedIp, + remove: removeAccessTokenTrustedIp + } = useFieldArray({ control, name: "accessTokenTrustedIps" }); + + useEffect(() => { + if (data) { + reset({ + configurationType: data.configurationType, + jwksUrl: data.jwksUrl, + jwksCaCert: data.jwksCaCert, + publicKeys: data.publicKeys.map((pk) => ({ + value: pk + })), + boundIssuer: data.boundIssuer, + boundAudiences: data.boundAudiences, + boundClaims: Object.entries(data.boundClaims).map(([key, value]) => ({ + key, + value + })), + boundSubject: data.boundSubject, + accessTokenTTL: String(data.accessTokenTTL), + accessTokenMaxTTL: String(data.accessTokenMaxTTL), + accessTokenNumUsesLimit: String(data.accessTokenNumUsesLimit), + accessTokenTrustedIps: data.accessTokenTrustedIps.map( + ({ ipAddress, prefix }: IdentityTrustedIp) => { + return { + ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}` + }; + } + ) + }); + } else { + reset({ + configurationType: IdentityJwtConfigurationType.JWKS, + jwksUrl: "", + jwksCaCert: "", + boundIssuer: "", + boundAudiences: "", + boundClaims: [], + boundSubject: "", + accessTokenTTL: "2592000", + accessTokenMaxTTL: "2592000", + accessTokenNumUsesLimit: "0", + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + }); + } + }, [data]); + + const onFormSubmit = async ({ + accessTokenTrustedIps, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + configurationType, + jwksUrl, + jwksCaCert, + publicKeys, + boundIssuer, + boundAudiences, + boundClaims, + boundSubject + }: FormData) => { + try { + if (!identityAuthMethodData) { + return; + } + + if (data) { + await updateMutateAsync({ + identityId: identityAuthMethodData.identityId, + organizationId: orgId, + configurationType, + jwksUrl, + jwksCaCert, + publicKeys: publicKeys?.map((field) => field.value).filter(Boolean), + boundIssuer, + boundAudiences, + boundClaims: Object.fromEntries(boundClaims.map((entry) => [entry.key, entry.value])), + boundSubject, + accessTokenTTL: Number(accessTokenTTL), + accessTokenMaxTTL: Number(accessTokenMaxTTL), + accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), + accessTokenTrustedIps + }); + } else { + await addMutateAsync({ + identityId: identityAuthMethodData.identityId, + configurationType, + jwksUrl, + jwksCaCert, + publicKeys: publicKeys?.map((field) => field.value).filter(Boolean), + boundIssuer, + boundAudiences, + boundClaims: Object.fromEntries(boundClaims.map((entry) => [entry.key, entry.value])), + boundSubject, + organizationId: orgId, + accessTokenTTL: Number(accessTokenTTL), + accessTokenMaxTTL: Number(accessTokenMaxTTL), + accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), + accessTokenTrustedIps + }); + } + + handlePopUpToggle("identityAuthMethod", false); + + createNotification({ + text: `Successfully ${isUpdate ? "updated" : "configured"} auth method`, + type: "success" + }); + + reset(); + } catch (err) { + createNotification({ + text: `Failed to ${isUpdate ? "update" : "configure"} identity`, + type: "error" + }); + } + }; + + return ( +
+ ( + + + + )} + /> + {selectedConfigurationType === IdentityJwtConfigurationType.JWKS && ( + <> + ( + + + + )} + /> + ( + +