diff --git a/backend/e2e-test/routes/v1/secret-folder.spec.ts b/backend/e2e-test/routes/v1/secret-folder.spec.ts index 4d4bd7ab4..e954179a7 100644 --- a/backend/e2e-test/routes/v1/secret-folder.spec.ts +++ b/backend/e2e-test/routes/v1/secret-folder.spec.ts @@ -40,7 +40,7 @@ describe("Secret Folder Router", async () => { { name: "folder1", path: "/" }, // one in root { name: "folder1", path: "/level1/level2" }, // then create a deep one creating intermediate ones { name: "folder2", path: "/" }, - { name: "folder1", path: "/level1/level2" } // this should not create folder return same thing + { name: "folder3", path: "/level1/level2" } ])("Create folder $name in $path", async ({ name, path }) => { const createdFolder = await createFolder({ path, name }); // check for default environments @@ -57,7 +57,7 @@ describe("Secret Folder Router", async () => { { path: "/", expected: { - folders: [{ name: "folder1" }, { name: "level1" }, { name: "folder2" }], + folders: [{ name: "folder4" }, { name: "level2" }, { name: "folder5" }], length: 3 } }, @@ -162,4 +162,25 @@ describe("Secret Folder Router", async () => { expect(updatedFolderList).toHaveProperty("folders"); expect(updatedFolderList.folders.length).toEqual(0); }); + test("Creating a duplicate folder should return a 400 error", async () => { + const newFolder = await createFolder({ name: "folder-duplicate", path: "/level1/level2" }); + + const res = await testServer.inject({ + method: "POST", + url: `/api/v1/folders`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + name: "folder-duplicate", + path: "/level1/level2" + } + }); + expect(res.statusCode).toBe(400); + const payload = JSON.parse(res.payload); + expect(payload).toHaveProperty("error"); + await deleteFolder({ path: "/level1/level2", id: newFolder.id }); + }); }); diff --git a/backend/e2e-test/routes/v2/secret-folder.spec.ts b/backend/e2e-test/routes/v2/secret-folder.spec.ts index a2bed759a..92bcc92e5 100644 --- a/backend/e2e-test/routes/v2/secret-folder.spec.ts +++ b/backend/e2e-test/routes/v2/secret-folder.spec.ts @@ -18,7 +18,7 @@ const createFolder = async (dto: { path: string; name: string }) => { return res.json().folder; }; -const deleteFolder = async (dto: { path: string; id: string }) => { +const deleteFolder = async (dto: { path: string; id: string; forceDelete?: boolean }) => { const res = await testServer.inject({ method: "DELETE", url: `/api/v2/folders/${dto.id}`, @@ -28,7 +28,8 @@ const deleteFolder = async (dto: { path: string; id: string }) => { body: { projectId: seedData1.project.id, environment: seedData1.environment.slug, - path: dto.path + path: dto.path, + forceDelete: dto.forceDelete ?? false } }); expect(res.statusCode).toBe(200); @@ -40,7 +41,7 @@ describe("Secret Folder Router", async () => { { name: "folder1", path: "/" }, // one in root { name: "folder1", path: "/level1/level2" }, // then create a deep one creating intermediate ones { name: "folder2", path: "/" }, - { name: "folder1", path: "/level1/level2" } // this should not create folder return same thing + { name: "folder3", path: "/level1/level2" } ])("Create folder $name in $path", async ({ name, path }) => { const createdFolder = await createFolder({ path, name }); // check for default environments @@ -57,7 +58,7 @@ describe("Secret Folder Router", async () => { { path: "/", expected: { - folders: [{ name: "folder1" }, { name: "level1" }, { name: "folder2" }], + folders: [{ name: "folder4" }, { name: "level2" }, { name: "folder5" }], length: 3 } }, @@ -86,7 +87,7 @@ describe("Secret Folder Router", async () => { folders: expect.arrayContaining(expected.folders.map((el) => expect.objectContaining(el))) }); - await Promise.all(newFolders.map(({ id }) => deleteFolder({ path, id }))); + await Promise.all(newFolders.map(({ id }) => deleteFolder({ path, id, forceDelete: true }))); }); test("Update a deep folder", async () => { @@ -162,4 +163,26 @@ describe("Secret Folder Router", async () => { expect(updatedFolderList).toHaveProperty("folders"); expect(updatedFolderList.folders.length).toEqual(0); }); + + test("Creating a duplicate folder should return a 400 error", async () => { + const newFolder = await createFolder({ name: "folder-duplicate", path: "/level1/level2" }); + + const res = await testServer.inject({ + method: "POST", + url: `/api/v2/folders`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: { + projectId: seedData1.project.id, + environment: seedData1.environment.slug, + name: "folder-duplicate", + path: "/level1/level2" + } + }); + expect(res.statusCode).toBe(400); + const payload = JSON.parse(res.payload); + expect(payload).toHaveProperty("error"); + await deleteFolder({ path: "/level1/level2", id: newFolder.id }); + }); }); diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index b12d99d43..bbc27ebc1 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -530,6 +530,9 @@ import { TUsers, TUsersInsert, TUsersUpdate, + TVaultExternalMigrationConfigs, + TVaultExternalMigrationConfigsInsert, + TVaultExternalMigrationConfigsUpdate, TWebhooks, TWebhooksInsert, TWebhooksUpdate, @@ -1377,5 +1380,10 @@ declare module "knex/types/tables" { TAdditionalPrivilegesInsert, TAdditionalPrivilegesUpdate >; + [TableName.VaultExternalMigrationConfig]: KnexOriginal.CompositeTableType< + TVaultExternalMigrationConfigs, + TVaultExternalMigrationConfigsInsert, + TVaultExternalMigrationConfigsUpdate + >; } } diff --git a/backend/src/db/migrations/20251013104547_add-in-platform-external-migration.ts b/backend/src/db/migrations/20251013104547_add-in-platform-external-migration.ts new file mode 100644 index 000000000..e5f74a0e0 --- /dev/null +++ b/backend/src/db/migrations/20251013104547_add-in-platform-external-migration.ts @@ -0,0 +1,29 @@ +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.VaultExternalMigrationConfig))) { + await knex.schema.createTable(TableName.VaultExternalMigrationConfig, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("orgId").notNullable(); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + + t.string("namespace").notNullable(); + + t.uuid("connectionId"); + t.foreign("connectionId").references("id").inTable(TableName.AppConnection); + + t.timestamps(true, true, true); + t.unique(["orgId", "namespace"]); + }); + + await createOnUpdateTrigger(knex, TableName.VaultExternalMigrationConfig); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.VaultExternalMigrationConfig); + await dropOnUpdateTrigger(knex, TableName.VaultExternalMigrationConfig); +} diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index e40a275a4..4f0f221ff 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -180,5 +180,6 @@ export * from "./user-aliases"; export * from "./user-encryption-keys"; export * from "./user-group-membership"; export * from "./users"; +export * from "./vault-external-migration-configs"; export * from "./webhooks"; export * from "./workflow-integrations"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index cfd29311b..28e9471ad 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -207,7 +207,9 @@ export enum TableName { PamFolder = "pam_folders", PamResource = "pam_resources", PamAccount = "pam_accounts", - PamSession = "pam_sessions" + PamSession = "pam_sessions", + + VaultExternalMigrationConfig = "vault_external_migration_configs" } export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt" | "commitId"; diff --git a/backend/src/db/schemas/vault-external-migration-configs.ts b/backend/src/db/schemas/vault-external-migration-configs.ts new file mode 100644 index 000000000..310166669 --- /dev/null +++ b/backend/src/db/schemas/vault-external-migration-configs.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 VaultExternalMigrationConfigsSchema = z.object({ + id: z.string().uuid(), + orgId: z.string().uuid(), + namespace: z.string(), + connectionId: z.string().uuid().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TVaultExternalMigrationConfigs = z.infer; +export type TVaultExternalMigrationConfigsInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TVaultExternalMigrationConfigsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/ee/routes/v1/kmip-spec-router.ts b/backend/src/ee/routes/v1/kmip-spec-router.ts index c7db12de9..6fcf05d99 100644 --- a/backend/src/ee/routes/v1/kmip-spec-router.ts +++ b/backend/src/ee/routes/v1/kmip-spec-router.ts @@ -129,7 +129,7 @@ export const registerKmipSpecRouter = async (server: FastifyZodProvider) => { id: z.string(), value: z.string(), algorithm: z.string(), - kmipMetadata: z.record(z.any()).optional() + kmipMetadata: z.record(z.any()).nullish() }) } }, @@ -435,7 +435,7 @@ export const registerKmipSpecRouter = async (server: FastifyZodProvider) => { key: z.string(), name: z.string(), algorithm: z.nativeEnum(SymmetricKeyAlgorithm), - kmipMetadata: z.record(z.any()).optional() + kmipMetadata: z.record(z.any()).nullish() }), response: { 200: z.object({ diff --git a/backend/src/ee/services/kmip/kmip-types.ts b/backend/src/ee/services/kmip/kmip-types.ts index c37d511c6..695f46942 100644 --- a/backend/src/ee/services/kmip/kmip-types.ts +++ b/backend/src/ee/services/kmip/kmip-types.ts @@ -78,7 +78,7 @@ export type TKmipRegisterDTO = { name: string; key: string; algorithm: SymmetricKeyAlgorithm; - kmipMetadata?: Record; + kmipMetadata?: Record | null; } & KmipOperationBaseDTO; export type TSetupOrgKmipDTO = { diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index c2e67908f..fba9d0cca 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -214,6 +214,20 @@ export const licenseServiceFactory = ({ const identityUsed = await licenseDAL.countOrgUsersAndIdentities(orgId); currentPlan.identitiesUsed = identityUsed; + if (currentPlan.identityLimit && currentPlan.identityLimit !== identityUsed) { + try { + await licenseServerCloudApi.request.patch(`/api/license-server/v1/customers/${org.customerId}/cloud-plan`, { + quantity: membersUsed, + quantityIdentities: identityUsed + }); + } catch (error) { + logger.error( + error, + `Update seats used: encountered an error when updating plan for customer [customerId=${org.customerId}]` + ); + } + } + await keyStore.setItemWithExpiry( FEATURE_CACHE_KEY(org.id), LICENSE_SERVER_CLOUD_PLAN_TTL, diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 8b9e6d275..5a336fa11 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -123,16 +123,16 @@ export const IDENTITIES = { hasDeleteProtection: "Prevents deletion of the identity when enabled." }, UPDATE: { - identityId: "The ID of the identity to update.", + identityId: "The ID of the machine identity to update.", name: "The new name of the identity.", role: "The new role of the identity.", hasDeleteProtection: "Prevents deletion of the identity when enabled." }, DELETE: { - identityId: "The ID of the identity to delete." + identityId: "The ID of the machine identity to delete." }, GET_BY_ID: { - identityId: "The ID of the identity to get details.", + identityId: "The ID of the machine identity to get details.", orgId: "The ID of the org of the identity" }, LIST: { @@ -157,7 +157,7 @@ export const UNIVERSAL_AUTH = { clientSecret: "Your Machine Identity Client Secret." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", clientSecretTrustedIps: "A list of IPs or CIDR ranges that the Client Secret can be used from together with the Client ID to get back an access token. You can use 0.0.0.0/0, to allow usage from any network address.", accessTokenTrustedIps: @@ -176,13 +176,13 @@ export const UNIVERSAL_AUTH = { "How long to wait from the most recent failed login until resetting the lockout counter." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the auth method for." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." }, UPDATE: { - identityId: "The ID of the identity to update the auth method for.", + identityId: "The ID of the machine identity to update the auth method for.", clientSecretTrustedIps: "The new list of IPs or CIDR ranges that the Client Secret can be used from.", accessTokenTrustedIps: "The new list of IPs or CIDR ranges that access tokens can be used from.", accessTokenTTL: "The new lifetime for an access token in seconds.", @@ -196,25 +196,25 @@ export const UNIVERSAL_AUTH = { "How long to wait from the most recent failed login until resetting the lockout counter." }, CREATE_CLIENT_SECRET: { - identityId: "The ID of the identity to create a client secret for.", + identityId: "The ID of the machine identity to create a client secret for.", description: "The description of the client secret.", numUsesLimit: "The maximum number of times that the client secret can be used; a value of 0 implies infinite number of uses.", ttl: "The lifetime for the client secret in seconds." }, LIST_CLIENT_SECRETS: { - identityId: "The ID of the identity to list client secrets for." + identityId: "The ID of the machine identity to list client secrets for." }, GET_CLIENT_SECRET: { - identityId: "The ID of the identity to get the client secret from.", + identityId: "The ID of the machine identity to get the client secret from.", clientSecretId: "The ID of the client secret to get details." }, REVOKE_CLIENT_SECRET: { - identityId: "The ID of the identity to revoke the client secret from.", + identityId: "The ID of the machine identity to revoke the client secret from.", clientSecretId: "The ID of the client secret to revoke." }, CLEAR_CLIENT_LOCKOUTS: { - identityId: "The ID of the identity to clear the client lockouts from." + identityId: "The ID of the machine identity to clear the client lockouts from." }, RENEW_ACCESS_TOKEN: { accessToken: "The access token to renew." @@ -226,13 +226,13 @@ export const UNIVERSAL_AUTH = { export const LDAP_AUTH = { LOGIN: { - identityId: "The ID of the identity to login.", + identityId: "The ID of the machine identity to login.", username: "The username of the LDAP user to login.", password: "The password of the LDAP user to login." }, ATTACH: { templateId: "The ID of the identity auth template to attach the configuration onto.", - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", url: "The URL of the LDAP server.", allowedFields: "The comma-separated array of key/value pairs of required fields that the LDAP entry must have in order to authenticate.", @@ -252,7 +252,7 @@ export const LDAP_AUTH = { "How long to wait from the most recent failed login until resetting the lockout counter." }, UPDATE: { - identityId: "The ID of the identity to update the configuration for.", + identityId: "The ID of the machine identity to update the configuration for.", url: "The new URL of the LDAP server.", allowedFields: "The comma-separated list of allowed fields to return from the LDAP user.", searchBase: "The new base DN to search for the LDAP user.", @@ -272,19 +272,19 @@ export const LDAP_AUTH = { "How long to wait from the most recent failed login until resetting the lockout counter." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the configuration for." + identityId: "The ID of the machine identity to retrieve the configuration for." }, REVOKE: { - identityId: "The ID of the identity to revoke the configuration for." + identityId: "The ID of the machine identity to revoke the configuration for." }, CLEAR_CLIENT_LOCKOUTS: { - identityId: "The ID of the identity to clear the client lockouts from." + identityId: "The ID of the machine identity to clear the client lockouts from." } } as const; export const ALICLOUD_AUTH = { LOGIN: { - identityId: "The ID of the identity to login.", + identityId: "The ID of the machine identity to login.", Action: "The Alibaba Cloud API action. For STS GetCallerIdentity, this should be 'GetCallerIdentity'.", Format: "The response format. For STS GetCallerIdentity, this should be 'JSON'.", Version: "The API version. This should be in 'YYYY-MM-DD' format (e.g., '2015-04-01').", @@ -296,7 +296,7 @@ export const ALICLOUD_AUTH = { Signature: "The signature string calculated based on the request parameters and AccessKey Secret." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", allowedArns: "The comma-separated list of trusted ARNs that are allowed to authenticate with Infisical.", accessTokenTTL: "The lifetime for an access token in seconds.", accessTokenMaxTTL: "The maximum lifetime for an access token in seconds.", @@ -304,7 +304,7 @@ export const ALICLOUD_AUTH = { accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from." }, UPDATE: { - identityId: "The ID of the identity to update the auth method for.", + identityId: "The ID of the machine identity to update the auth method for.", allowedArns: "The comma-separated list of trusted ARNs that are allowed to authenticate with Infisical.", accessTokenTTL: "The new lifetime for an access token in seconds.", accessTokenMaxTTL: "The new maximum lifetime for an access token in seconds.", @@ -312,19 +312,19 @@ export const ALICLOUD_AUTH = { accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the auth method for." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." } } as const; export const TLS_CERT_AUTH = { LOGIN: { - identityId: "The ID of the identity to login." + identityId: "The ID of the machine identity to login." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", allowedCommonNames: "The comma-separated list of trusted common names that are allowed to authenticate with Infisical.", caCertificate: "The PEM-encoded CA certificate to validate client certificates.", @@ -334,7 +334,7 @@ export const TLS_CERT_AUTH = { accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from." }, UPDATE: { - identityId: "The ID of the identity to update the auth method for.", + identityId: "The ID of the machine identity to update the auth method for.", allowedCommonNames: "The comma-separated list of trusted common names that are allowed to authenticate with Infisical.", caCertificate: "The PEM-encoded CA certificate to validate client certificates.", @@ -344,16 +344,16 @@ export const TLS_CERT_AUTH = { accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the auth method for." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." } } as const; export const AWS_AUTH = { LOGIN: { - identityId: "The ID of the identity to login.", + identityId: "The ID of the machine identity to login.", iamHttpRequestMethod: "The HTTP request method used in the signed request.", iamRequestUrl: "The base64-encoded HTTP URL used in the signed request. Most likely, the base64-encoding of https://sts.amazonaws.com/.", @@ -362,7 +362,7 @@ export const AWS_AUTH = { iamRequestHeaders: "The base64-encoded headers of the sts:GetCallerIdentity signed request." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", allowedPrincipalArns: "The comma-separated list of trusted IAM principal ARNs that are allowed to authenticate with Infisical.", allowedAccountIds: @@ -374,7 +374,7 @@ export const AWS_AUTH = { accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from." }, UPDATE: { - identityId: "The ID of the identity to update the auth method for.", + identityId: "The ID of the machine identity to update the auth method for.", allowedPrincipalArns: "The new comma-separated list of trusted IAM principal ARNs that are allowed to authenticate with Infisical.", allowedAccountIds: @@ -386,21 +386,21 @@ export const AWS_AUTH = { accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the auth method for." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." } } as const; export const OCI_AUTH = { LOGIN: { - identityId: "The ID of the identity to login.", + identityId: "The ID of the machine identity to login.", userOcid: "The OCID of the user attempting login.", headers: "The headers of the signed request." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", tenancyOcid: "The OCID of your tenancy.", allowedUsernames: "The comma-separated list of trusted OCI account usernames that are allowed to authenticate with Infisical.", @@ -410,7 +410,7 @@ export const OCI_AUTH = { accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from." }, UPDATE: { - identityId: "The ID of the identity to update the auth method for.", + identityId: "The ID of the machine identity to update the auth method for.", tenancyOcid: "The OCID of your tenancy.", allowedUsernames: "The comma-separated list of trusted OCI account usernames that are allowed to authenticate with Infisical.", @@ -420,19 +420,19 @@ export const OCI_AUTH = { accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the auth method for." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." } } as const; export const AZURE_AUTH = { LOGIN: { - identityId: "The ID of the identity to login." + identityId: "The ID of the machine identity to login." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", tenantId: "The tenant ID for the Azure AD organization.", resource: "The resource URL for the application registered in Azure AD.", allowedServicePrincipalIds: @@ -443,7 +443,7 @@ export const AZURE_AUTH = { 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.", + identityId: "The ID of the machine identity to update the auth method for.", tenantId: "The new tenant ID for the Azure AD organization.", resource: "The new resource URL for the application registered in Azure AD.", allowedServicePrincipalIds: @@ -454,19 +454,19 @@ export const AZURE_AUTH = { 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." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." } } as const; export const GCP_AUTH = { LOGIN: { - identityId: "The ID of the identity to login." + identityId: "The ID of the machine identity to login." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", allowedServiceAccounts: "The comma-separated list of trusted service account emails corresponding to the GCE resource(s) allowed to authenticate with Infisical.", allowedProjects: @@ -479,7 +479,7 @@ export const GCP_AUTH = { 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.", + identityId: "The ID of the machine identity to update the auth method for.", allowedServiceAccounts: "The new comma-separated list of trusted service account emails corresponding to the GCE resource(s) allowed to authenticate with Infisical.", allowedProjects: @@ -492,19 +492,19 @@ export const GCP_AUTH = { 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." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." } } as const; export const KUBERNETES_AUTH = { LOGIN: { - identityId: "The ID of the identity to login." + identityId: "The ID of the machine identity to login." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", kubernetesHost: "The host string, host:port pair, or URL to the base of the Kubernetes API server.", caCert: "The PEM-encoded CA cert for the Kubernetes API server.", tokenReviewerJwt: @@ -523,7 +523,7 @@ export const KUBERNETES_AUTH = { 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.", + identityId: "The ID of the machine identity to update the auth method for.", kubernetesHost: "The new host string, host:port pair, or URL to the base of the Kubernetes API server.", caCert: "The new PEM-encoded CA cert for the Kubernetes API server.", tokenReviewerJwt: @@ -542,41 +542,41 @@ export const KUBERNETES_AUTH = { 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." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." } } as const; export const TOKEN_AUTH = { ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", 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.", + identityId: "The ID of the machine identity to update the auth method for.", 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." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." }, GET_TOKENS: { - identityId: "The ID of the identity to list token metadata for.", + identityId: "The ID of the machine identity to list token metadata for.", offset: "The offset to start from. If you enter 10, it will start from the 10th token.", limit: "The number of tokens to return." }, CREATE_TOKEN: { - identityId: "The ID of the identity to create the token for.", + identityId: "The ID of the machine identity to create the token for.", name: "The name of the token to create." }, UPDATE_TOKEN: { @@ -590,10 +590,10 @@ export const TOKEN_AUTH = { export const OIDC_AUTH = { LOGIN: { - identityId: "The ID of the identity to login." + identityId: "The ID of the machine identity to login." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", oidcDiscoveryUrl: "The URL used to retrieve the OpenID Connect configuration from the identity provider.", caCert: "The PEM-encoded CA cert for establishing secure communication with the Identity Provider endpoints.", boundIssuer: "The unique identifier of the identity provider issuing the JWT.", @@ -607,7 +607,7 @@ export const OIDC_AUTH = { 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.", + identityId: "The ID of the machine identity to update the auth method for.", oidcDiscoveryUrl: "The new URL used to retrieve the OpenID Connect configuration from the identity provider.", caCert: "The new PEM-encoded CA cert for establishing secure communication with the Identity Provider endpoints.", boundIssuer: "The new unique identifier of the identity provider issuing the JWT.", @@ -621,19 +621,19 @@ export const OIDC_AUTH = { 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." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." } } as const; export const JWT_AUTH = { LOGIN: { - identityId: "The ID of the identity to login." + identityId: "The ID of the machine identity to login." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine 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.", @@ -650,7 +650,7 @@ export const JWT_AUTH = { 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.", + identityId: "The ID of the machine 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.", @@ -667,10 +667,10 @@ export const JWT_AUTH = { 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." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." } } as const; @@ -854,12 +854,12 @@ export const PROJECT_IDENTITIES = { search: "The text string that identity membership names will be filtered by." }, GET_IDENTITY_MEMBERSHIP_BY_ID: { - identityId: "The ID of the identity to get the membership for.", + identityId: "The ID of the machine identity to get the membership for.", projectId: "The ID of the project to get the identity membership for." }, UPDATE_IDENTITY_MEMBERSHIP: { projectId: "The ID of the project to update the identity membership for.", - identityId: "The ID of the identity to update the membership for.", + identityId: "The ID of the machine identity to update the membership for.", roles: { description: "A list of role slugs to assign to the identity project membership.", role: "The role slug to assign to the newly created identity project membership.", @@ -872,11 +872,11 @@ export const PROJECT_IDENTITIES = { }, DELETE_IDENTITY_MEMBERSHIP: { projectId: "The ID of the project to delete the identity membership from.", - identityId: "The ID of the identity to delete the membership from." + identityId: "The ID of the machine identity to delete the membership from." }, CREATE_IDENTITY_MEMBERSHIP: { projectId: "The ID of the project to create the identity membership from.", - identityId: "The ID of the identity to create the membership from.", + identityId: "The ID of the machine identity to create the membership from.", role: "The role slug to assign to the newly created identity project membership.", roles: { description: "A list of role slugs to assign to the newly created identity project membership.", @@ -950,7 +950,8 @@ export const FOLDERS = { projectId: "The ID of the project to delete the folder from.", environment: "The slug of the environment where the folder is located.", directory: "The directory of the folder to delete. (Deprecated in favor of path)", - path: "The path of the folder to delete." + path: "The path of the folder to delete.", + forceDelete: "Whether to force delete the folder even if it contains resources." } } as const; @@ -1271,7 +1272,7 @@ export const SECRET_TAGS = { export const IDENTITY_ADDITIONAL_PRIVILEGE = { CREATE: { projectSlug: "The slug of the project of the identity in.", - identityId: "The ID of the identity to create.", + identityId: "The ID of the machine identity to create.", slug: "The slug of the privilege to create.", permissions: `@deprecated - use privilegePermission The permission object for the privilege. @@ -1297,7 +1298,7 @@ The permission object for the privilege. }, UPDATE: { projectSlug: "The slug of the project of the identity in.", - identityId: "The ID of the identity to update.", + identityId: "The ID of the machine identity to update.", slug: "The slug of the privilege to update.", newSlug: "The new slug of the privilege to update.", permissions: `@deprecated - use privilegePermission @@ -1323,17 +1324,17 @@ The permission object for the privilege. }, DELETE: { projectSlug: "The slug of the project of the identity in.", - identityId: "The ID of the identity to delete.", + identityId: "The ID of the machine identity to delete.", slug: "The slug of the privilege to delete." }, GET_BY_SLUG: { projectSlug: "The slug of the project of the identity in.", - identityId: "The ID of the identity to list.", + identityId: "The ID of the machine identity to list.", slug: "The slug of the privilege." }, LIST: { projectSlug: "The slug of the project of the identity in.", - identityId: "The ID of the identity to list.", + identityId: "The ID of the machine identity to list.", unpacked: "Whether the system should send the permissions as unpacked." } }; @@ -1375,7 +1376,7 @@ export const PROJECT_USER_ADDITIONAL_PRIVILEGE = { export const IDENTITY_ADDITIONAL_PRIVILEGE_V2 = { CREATE: { - identityId: "The ID of the identity to create the privilege for.", + identityId: "The ID of the machine identity to create the privilege for.", projectId: "The ID of the project of the identity in.", slug: "The slug of the privilege to create.", permission: "The permission for the privilege.", @@ -1386,7 +1387,7 @@ export const IDENTITY_ADDITIONAL_PRIVILEGE_V2 = { }, UPDATE: { id: "The ID of the identity privilege.", - identityId: "The ID of the identity to update.", + identityId: "The ID of the machine identity to update.", slug: "The slug of the privilege to update.", privilegePermission: "The permission for the privilege.", isTemporary: "Whether the privilege is temporary.", @@ -1396,12 +1397,12 @@ export const IDENTITY_ADDITIONAL_PRIVILEGE_V2 = { }, DELETE: { id: "The ID of the identity privilege.", - identityId: "The ID of the identity to delete.", + identityId: "The ID of the machine identity to delete.", slug: "The slug of the privilege to delete." }, GET_BY_SLUG: { projectSlug: "The slug of the project of the identity in.", - identityId: "The ID of the identity to list.", + identityId: "The ID of the machine identity to list.", slug: "The slug of the privilege." }, GET_BY_ID: { @@ -1409,7 +1410,7 @@ export const IDENTITY_ADDITIONAL_PRIVILEGE_V2 = { }, LIST: { projectId: "The ID of the project that the identity is in.", - identityId: "The ID of the identity to list." + identityId: "The ID of the machine identity to list." } }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index e206c0cdc..a805fa1be 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -184,6 +184,7 @@ import { externalGroupOrgRoleMappingDALFactory } from "@app/services/external-gr import { externalGroupOrgRoleMappingServiceFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-service"; import { externalMigrationQueueFactory } from "@app/services/external-migration/external-migration-queue"; import { externalMigrationServiceFactory } from "@app/services/external-migration/external-migration-service"; +import { vaultExternalMigrationConfigDALFactory } from "@app/services/external-migration/vault-external-migration-config-dal"; import { folderCheckpointDALFactory } from "@app/services/folder-checkpoint/folder-checkpoint-dal"; import { folderCheckpointResourcesDALFactory } from "@app/services/folder-checkpoint-resources/folder-checkpoint-resources-dal"; import { folderCommitDALFactory } from "@app/services/folder-commit/folder-commit-dal"; @@ -542,6 +543,8 @@ export const registerRoutes = async ( const membershipRoleDAL = membershipRoleDALFactory(db); const roleDAL = roleDALFactory(db); + const vaultExternalMigrationConfigDAL = vaultExternalMigrationConfigDALFactory(db); + const eventBusService = eventBusFactory(server.redis); const sseService = sseServiceFactory(eventBusService, server.redis); @@ -1371,7 +1374,8 @@ export const registerRoutes = async ( projectDAL, folderCommitService, secretApprovalPolicyService, - secretV2BridgeDAL + secretV2BridgeDAL, + dynamicSecretDAL }); const secretImportService = secretImportServiceFactory({ @@ -1908,13 +1912,6 @@ export const registerRoutes = async ( notificationService }); - const migrationService = externalMigrationServiceFactory({ - externalMigrationQueue, - userDAL, - permissionService, - gatewayService - }); - const externalGroupOrgRoleMappingService = externalGroupOrgRoleMappingServiceFactory({ permissionService, licenseService, @@ -2252,6 +2249,18 @@ export const registerRoutes = async ( kmsService }); + const migrationService = externalMigrationServiceFactory({ + externalMigrationQueue, + userDAL, + permissionService, + gatewayService, + kmsService, + appConnectionService, + vaultExternalMigrationConfigDAL, + secretService, + auditLogService + }); + // setup the communication with license key server await licenseService.init(); diff --git a/backend/src/server/routes/v1/deprecated-secret-folder-router.ts b/backend/src/server/routes/v1/deprecated-secret-folder-router.ts index ecb955025..955ec06ca 100644 --- a/backend/src/server/routes/v1/deprecated-secret-folder-router.ts +++ b/backend/src/server/routes/v1/deprecated-secret-folder-router.ts @@ -318,7 +318,8 @@ export const registerDeprecatedSecretFolderRouter = async (server: FastifyZodPro ...req.body, projectId: req.body.workspaceId, idOrName: req.params.folderIdOrName, - path + path, + forceDelete: true }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, diff --git a/backend/src/server/routes/v1/identity-access-token-router.ts b/backend/src/server/routes/v1/identity-access-token-router.ts index 20ed8d150..0a9cd8dd6 100644 --- a/backend/src/server/routes/v1/identity-access-token-router.ts +++ b/backend/src/server/routes/v1/identity-access-token-router.ts @@ -13,7 +13,7 @@ export const registerIdentityAccessTokenRouter = async (server: FastifyZodProvid schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Renew access token", + description: "Renew machine identity access token", body: z.object({ accessToken: z.string().trim().describe(UNIVERSAL_AUTH.RENEW_ACCESS_TOKEN.accessToken) }), @@ -48,7 +48,7 @@ export const registerIdentityAccessTokenRouter = async (server: FastifyZodProvid schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Revoke access token", + description: "Revoke machine identity access token", body: z.object({ accessToken: z.string().trim().describe(UNIVERSAL_AUTH.REVOKE_ACCESS_TOKEN.accessToken) }), diff --git a/backend/src/server/routes/v1/identity-alicloud-auth-router.ts b/backend/src/server/routes/v1/identity-alicloud-auth-router.ts index 4c444d704..3645a8bb6 100644 --- a/backend/src/server/routes/v1/identity-alicloud-auth-router.ts +++ b/backend/src/server/routes/v1/identity-alicloud-auth-router.ts @@ -21,7 +21,7 @@ export const registerIdentityAliCloudAuthRouter = async (server: FastifyZodProvi schema: { hide: false, tags: [ApiDocsTags.AliCloudAuth], - description: "Login with Alibaba Cloud Auth", + description: "Login with Alibaba Cloud Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(ALICLOUD_AUTH.LOGIN.identityId), Action: z.enum(["GetCallerIdentity"]).describe(ALICLOUD_AUTH.LOGIN.Action), @@ -108,7 +108,7 @@ export const registerIdentityAliCloudAuthRouter = async (server: FastifyZodProvi schema: { hide: false, tags: [ApiDocsTags.AliCloudAuth], - description: "Attach Alibaba Cloud Auth configuration onto identity", + description: "Attach Alibaba Cloud Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -200,7 +200,7 @@ export const registerIdentityAliCloudAuthRouter = async (server: FastifyZodProvi schema: { hide: false, tags: [ApiDocsTags.AliCloudAuth], - description: "Update Alibaba Cloud Auth configuration on identity", + description: "Update Alibaba Cloud Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -292,7 +292,7 @@ export const registerIdentityAliCloudAuthRouter = async (server: FastifyZodProvi schema: { hide: false, tags: [ApiDocsTags.AliCloudAuth], - description: "Retrieve Alibaba Cloud Auth configuration on identity", + description: "Retrieve Alibaba Cloud Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -340,7 +340,7 @@ export const registerIdentityAliCloudAuthRouter = async (server: FastifyZodProvi schema: { hide: false, tags: [ApiDocsTags.AliCloudAuth], - description: "Delete Alibaba Cloud Auth configuration on identity", + description: "Delete Alibaba Cloud Auth configuration on machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts b/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts index fe7cf7d5b..59526899c 100644 --- a/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts +++ b/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts @@ -23,7 +23,7 @@ export const registerIdentityAwsAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.AwsAuth], - description: "Login with AWS Auth", + description: "Login with AWS Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(AWS_AUTH.LOGIN.identityId), iamHttpRequestMethod: z.string().default("POST").describe(AWS_AUTH.LOGIN.iamHttpRequestMethod), @@ -75,7 +75,7 @@ export const registerIdentityAwsAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.AwsAuth], - description: "Attach AWS Auth configuration onto identity", + description: "Attach AWS Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -171,7 +171,7 @@ export const registerIdentityAwsAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.AwsAuth], - description: "Update AWS Auth configuration on identity", + description: "Update AWS Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -255,7 +255,7 @@ export const registerIdentityAwsAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.AwsAuth], - description: "Retrieve AWS Auth configuration on identity", + description: "Retrieve AWS Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -303,7 +303,7 @@ export const registerIdentityAwsAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.AwsAuth], - description: "Delete AWS Auth configuration on identity", + description: "Delete AWS Auth configuration on machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-azure-auth-router.ts b/backend/src/server/routes/v1/identity-azure-auth-router.ts index 9ef733a25..2649655bd 100644 --- a/backend/src/server/routes/v1/identity-azure-auth-router.ts +++ b/backend/src/server/routes/v1/identity-azure-auth-router.ts @@ -20,7 +20,7 @@ export const registerIdentityAzureAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.AzureAuth], - description: "Login with Azure Auth", + description: "Login with Azure Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(AZURE_AUTH.LOGIN.identityId), jwt: z.string() @@ -70,7 +70,7 @@ export const registerIdentityAzureAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.AzureAuth], - description: "Attach Azure Auth configuration onto identity", + description: "Attach Azure Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -165,7 +165,7 @@ export const registerIdentityAzureAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.AzureAuth], - description: "Update Azure Auth configuration on identity", + description: "Update Azure Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -255,7 +255,7 @@ export const registerIdentityAzureAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.AzureAuth], - description: "Retrieve Azure Auth configuration on identity", + description: "Retrieve Azure Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -304,7 +304,7 @@ export const registerIdentityAzureAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.AzureAuth], - description: "Delete Azure Auth configuration on identity", + description: "Delete Azure Auth configuration on machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-gcp-auth-router.ts b/backend/src/server/routes/v1/identity-gcp-auth-router.ts index 91e8038a7..d65c46613 100644 --- a/backend/src/server/routes/v1/identity-gcp-auth-router.ts +++ b/backend/src/server/routes/v1/identity-gcp-auth-router.ts @@ -20,7 +20,7 @@ export const registerIdentityGcpAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.GcpAuth], - description: "Login with GCP Auth", + description: "Login with GCP Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(GCP_AUTH.LOGIN.identityId), jwt: z.string() @@ -70,7 +70,7 @@ export const registerIdentityGcpAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.GcpAuth], - description: "Attach GCP Auth configuration onto identity", + description: "Attach GCP Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -163,7 +163,7 @@ export const registerIdentityGcpAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.GcpAuth], - description: "Update GCP Auth configuration on identity", + description: "Update GCP Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -249,7 +249,7 @@ export const registerIdentityGcpAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.GcpAuth], - description: "Retrieve GCP Auth configuration on identity", + description: "Retrieve GCP Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -298,7 +298,7 @@ export const registerIdentityGcpAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.GcpAuth], - description: "Delete GCP Auth configuration on identity", + description: "Delete GCP Auth configuration on machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-jwt-auth-router.ts b/backend/src/server/routes/v1/identity-jwt-auth-router.ts index ecffaebe1..2a882471d 100644 --- a/backend/src/server/routes/v1/identity-jwt-auth-router.ts +++ b/backend/src/server/routes/v1/identity-jwt-auth-router.ts @@ -96,7 +96,7 @@ export const registerIdentityJwtAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.JwtAuth], - description: "Login with JWT Auth", + description: "Login with JWT Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(JWT_AUTH.LOGIN.identityId), jwt: z.string().trim() @@ -148,7 +148,7 @@ export const registerIdentityJwtAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.JwtAuth], - description: "Attach JWT Auth configuration onto identity", + description: "Attach JWT Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -217,7 +217,7 @@ export const registerIdentityJwtAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.JwtAuth], - description: "Update JWT Auth configuration on identity", + description: "Update JWT Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -283,7 +283,7 @@ export const registerIdentityJwtAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.JwtAuth], - description: "Retrieve JWT Auth configuration on identity", + description: "Retrieve JWT Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -332,7 +332,7 @@ export const registerIdentityJwtAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.JwtAuth], - description: "Delete JWT Auth configuration on identity", + description: "Delete JWT Auth configuration on machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts index 6f0500c29..0794cf00d 100644 --- a/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts +++ b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts @@ -41,7 +41,7 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide schema: { hide: false, tags: [ApiDocsTags.KubernetesAuth], - description: "Login with Kubernetes Auth", + description: "Login with Kubernetes Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(KUBERNETES_AUTH.LOGIN.identityId), jwt: z.string().trim() @@ -93,7 +93,7 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide schema: { hide: false, tags: [ApiDocsTags.KubernetesAuth], - description: "Attach Kubernetes Auth configuration onto identity", + description: "Attach Kubernetes Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -240,7 +240,7 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide schema: { hide: false, tags: [ApiDocsTags.KubernetesAuth], - description: "Update Kubernetes Auth configuration on identity", + description: "Update Kubernetes Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -383,7 +383,7 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide schema: { hide: false, tags: [ApiDocsTags.KubernetesAuth], - description: "Retrieve Kubernetes Auth configuration on identity", + description: "Retrieve Kubernetes Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -432,7 +432,7 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide schema: { hide: false, tags: [ApiDocsTags.KubernetesAuth], - description: "Delete Kubernetes Auth configuration on identity", + description: "Delete Kubernetes Auth configuration on machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-ldap-auth-router.ts b/backend/src/server/routes/v1/identity-ldap-auth-router.ts index ac384d216..caf5708e3 100644 --- a/backend/src/server/routes/v1/identity-ldap-auth-router.ts +++ b/backend/src/server/routes/v1/identity-ldap-auth-router.ts @@ -120,7 +120,7 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.LdapAuth], - description: "Login with LDAP Auth", + description: "Login with LDAP Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(LDAP_AUTH.LOGIN.identityId), username: z.string().describe(LDAP_AUTH.LOGIN.username), @@ -198,7 +198,7 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.LdapAuth], - description: "Attach LDAP Auth configuration onto identity", + description: "Attach LDAP Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -389,7 +389,7 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.LdapAuth], - description: "Update LDAP Auth configuration on identity", + description: "Update LDAP Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -510,7 +510,7 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.LdapAuth], - description: "Retrieve LDAP Auth configuration on identity", + description: "Retrieve LDAP Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -568,7 +568,7 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.LdapAuth], - description: "Delete LDAP Auth configuration on identity", + description: "Delete LDAP Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -621,7 +621,7 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.LdapAuth], - description: "Clear LDAP Auth Lockouts for identity", + description: "Clear LDAP Auth Lockouts for machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-oci-auth-router.ts b/backend/src/server/routes/v1/identity-oci-auth-router.ts index df19492a6..24d414286 100644 --- a/backend/src/server/routes/v1/identity-oci-auth-router.ts +++ b/backend/src/server/routes/v1/identity-oci-auth-router.ts @@ -20,7 +20,7 @@ export const registerIdentityOciAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OciAuth], - description: "Login with OCI Auth", + description: "Login with OCI Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(OCI_AUTH.LOGIN.identityId), userOcid: z.string().trim().describe(OCI_AUTH.LOGIN.userOcid), @@ -87,7 +87,7 @@ export const registerIdentityOciAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OciAuth], - description: "Attach OCI Auth configuration onto identity", + description: "Attach OCI Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -176,7 +176,7 @@ export const registerIdentityOciAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OciAuth], - description: "Update OCI Auth configuration on identity", + description: "Update OCI Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -259,7 +259,7 @@ export const registerIdentityOciAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OciAuth], - description: "Retrieve OCI Auth configuration on identity", + description: "Retrieve OCI Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -307,7 +307,7 @@ export const registerIdentityOciAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OciAuth], - description: "Delete OCI Auth configuration on identity", + description: "Delete OCI Auth configuration on machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-oidc-auth-router.ts b/backend/src/server/routes/v1/identity-oidc-auth-router.ts index 147105aba..48fa64bf4 100644 --- a/backend/src/server/routes/v1/identity-oidc-auth-router.ts +++ b/backend/src/server/routes/v1/identity-oidc-auth-router.ts @@ -44,7 +44,7 @@ export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OidcAuth], - description: "Login with OIDC Auth", + description: "Login with OIDC Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(OIDC_AUTH.LOGIN.identityId), jwt: z.string().trim() @@ -100,7 +100,7 @@ export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OidcAuth], - description: "Attach OIDC Auth configuration onto identity", + description: "Attach OIDC Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -201,7 +201,7 @@ export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OidcAuth], - description: "Update OIDC Auth configuration on identity", + description: "Update OIDC Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -300,7 +300,7 @@ export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OidcAuth], - description: "Retrieve OIDC Auth configuration on identity", + description: "Retrieve OIDC Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -349,7 +349,7 @@ export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OidcAuth], - description: "Delete OIDC Auth configuration on identity", + description: "Delete OIDC Auth configuration on machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-router.ts b/backend/src/server/routes/v1/identity-router.ts index 65b9448c5..d6a42c4a2 100644 --- a/backend/src/server/routes/v1/identity-router.ts +++ b/backend/src/server/routes/v1/identity-router.ts @@ -34,7 +34,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.Identities], - description: "Create identity", + description: "Create machine identity", security: [ { bearerAuth: [] @@ -109,7 +109,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.Identities], - description: "Update identity", + description: "Update machine identity", security: [ { bearerAuth: [] @@ -173,7 +173,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.Identities], - description: "Delete identity", + description: "Delete machine identity", security: [ { bearerAuth: [] @@ -222,7 +222,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.Identities], - description: "Get an identity by id", + description: "Get a machine identity by id", security: [ { bearerAuth: [] @@ -280,7 +280,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.Identities], - description: "List identities", + description: "List machine identities", security: [ { bearerAuth: [] @@ -330,7 +330,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.Identities], - description: "Search identities", + description: "Search machine identities", security: [ { bearerAuth: [] @@ -427,7 +427,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { - description: "List project memberships that identity with id is part of", + description: "List project memberships that machine identity with id is part of", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-tls-cert-auth-router.ts b/backend/src/server/routes/v1/identity-tls-cert-auth-router.ts index 0503ed16f..d549160db 100644 --- a/backend/src/server/routes/v1/identity-tls-cert-auth-router.ts +++ b/backend/src/server/routes/v1/identity-tls-cert-auth-router.ts @@ -44,7 +44,7 @@ export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvid schema: { hide: false, tags: [ApiDocsTags.TlsCertAuth], - description: "Login with TLS Certificate Auth", + description: "Login with TLS Certificate Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(TLS_CERT_AUTH.LOGIN.identityId) }), @@ -102,7 +102,7 @@ export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvid schema: { hide: false, tags: [ApiDocsTags.TlsCertAuth], - description: "Attach TLS Certificate Auth configuration onto identity", + description: "Attach TLS Certificate Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -203,7 +203,7 @@ export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvid schema: { hide: false, tags: [ApiDocsTags.TlsCertAuth], - description: "Update TLS Certificate Auth configuration on identity", + description: "Update TLS Certificate Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -304,7 +304,7 @@ export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvid schema: { hide: false, tags: [ApiDocsTags.TlsCertAuth], - description: "Retrieve TLS Certificate Auth configuration on identity", + description: "Retrieve TLS Certificate Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -354,7 +354,7 @@ export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvid schema: { hide: false, tags: [ApiDocsTags.TlsCertAuth], - description: "Delete TLS Certificate Auth configuration on identity", + description: "Delete TLS Certificate Auth configuration on machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-token-auth-router.ts b/backend/src/server/routes/v1/identity-token-auth-router.ts index 9a33d0651..9040d8909 100644 --- a/backend/src/server/routes/v1/identity-token-auth-router.ts +++ b/backend/src/server/routes/v1/identity-token-auth-router.ts @@ -20,7 +20,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.TokenAuth], - description: "Attach Token Auth configuration onto identity", + description: "Attach Token Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -112,7 +112,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.TokenAuth], - description: "Update Token Auth configuration on identity", + description: "Update Token Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -198,7 +198,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.TokenAuth], - description: "Retrieve Token Auth configuration on identity", + description: "Retrieve Token Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -247,7 +247,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.TokenAuth], - description: "Delete Token Auth configuration on identity", + description: "Delete Token Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -297,7 +297,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.TokenAuth], - description: "Create token for identity with Token Auth", + description: "Create token for machine identity with Token Auth", security: [ { bearerAuth: [] @@ -361,7 +361,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.TokenAuth], - description: "Get tokens for identity with Token Auth", + description: "Get tokens for machine identity with Token Auth", security: [ { bearerAuth: [] @@ -416,7 +416,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.TokenAuth], - description: "Update token for identity with Token Auth", + description: "Update token for machine identity with Token Auth", security: [ { bearerAuth: [] @@ -472,7 +472,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.TokenAuth], - description: "Revoke token for identity with Token Auth", + description: "Revoke token for machine identity with Token Auth", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-universal-auth-router.ts b/backend/src/server/routes/v1/identity-universal-auth-router.ts index 153e1e641..0443d35dd 100644 --- a/backend/src/server/routes/v1/identity-universal-auth-router.ts +++ b/backend/src/server/routes/v1/identity-universal-auth-router.ts @@ -32,7 +32,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Login with Universal Auth", + description: "Login with Universal Auth for machine identity", body: z.object({ clientId: z.string().trim().describe(UNIVERSAL_AUTH.LOGIN.clientId), clientSecret: z.string().trim().describe(UNIVERSAL_AUTH.LOGIN.clientSecret) @@ -90,7 +90,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Attach Universal Auth configuration onto identity", + description: "Attach Universal Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -208,7 +208,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Update Universal Auth configuration on identity", + description: "Update Universal Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -331,7 +331,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Retrieve Universal Auth configuration on identity", + description: "Retrieve Universal Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -380,7 +380,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Delete Universal Auth configuration on identity", + description: "Delete Universal Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -429,7 +429,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Create Universal Auth Client Secret for identity", + description: "Create Universal Auth Client Secret for machine identity", security: [ { bearerAuth: [] @@ -487,7 +487,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "List Universal Auth Client Secrets for identity", + description: "List Universal Auth Client Secrets for machine identity", security: [ { bearerAuth: [] @@ -537,7 +537,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Get Universal Auth Client Secret for identity", + description: "Get Universal Auth Client Secret for machine identity", security: [ { bearerAuth: [] @@ -567,7 +567,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { ...req.auditLogInfo, orgId: clientSecretData.orgId, event: { - type: EventType.REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET, + type: EventType.GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET_BY_ID, metadata: { identityId: clientSecretData.identityId, clientSecretId: clientSecretData.id @@ -589,7 +589,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Revoke Universal Auth Client Secrets for identity", + description: "Revoke Universal Auth Client Secrets for machine identity", security: [ { bearerAuth: [] @@ -641,7 +641,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Clear Universal Auth Lockouts for identity", + description: "Clear Universal Auth Lockouts for machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v2/secret-folder-router.ts b/backend/src/server/routes/v2/secret-folder-router.ts index 0bf062452..251ad8e85 100644 --- a/backend/src/server/routes/v2/secret-folder-router.ts +++ b/backend/src/server/routes/v2/secret-folder-router.ts @@ -263,7 +263,8 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => .default("/") .transform(prefixWithSlash) // Transformations get skipped if path is undefined .transform(removeTrailingSlash) - .describe(FOLDERS.DELETE.path) + .describe(FOLDERS.DELETE.path), + forceDelete: z.boolean().optional().default(false).describe(FOLDERS.DELETE.forceDelete) }), response: { 200: z.object({ @@ -279,7 +280,8 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, ...req.body, - idOrName: req.params.folderIdOrName + idOrName: req.params.folderIdOrName, + forceDelete: req.body.forceDelete }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, diff --git a/backend/src/server/routes/v3/external-migration-router.ts b/backend/src/server/routes/v3/external-migration-router.ts index a3b744485..f0737e01d 100644 --- a/backend/src/server/routes/v3/external-migration-router.ts +++ b/backend/src/server/routes/v3/external-migration-router.ts @@ -7,6 +7,7 @@ import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { ExternalMigrationProviders, + VaultImportStatus, VaultMappingType } from "@app/services/external-migration/external-migration-types"; @@ -113,4 +114,366 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider return { enabled }; } }); + + server.route({ + method: "GET", + url: "/vault/configs", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.object({ + configs: z + .object({ + id: z.string(), + orgId: z.string(), + namespace: z.string(), + connectionId: z.string().nullish(), + createdAt: z.date(), + updatedAt: z.date() + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const configs = await server.services.migration.getVaultExternalMigrationConfigs({ + actor: req.permission + }); + + return { configs }; + } + }); + + server.route({ + method: "POST", + url: "/vault/configs", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + connectionId: z.string(), + namespace: z.string() + }), + response: { + 200: z.object({ + config: z.object({ + id: z.string(), + orgId: z.string(), + namespace: z.string(), + connectionId: z.string().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const config = await server.services.migration.createVaultExternalMigration({ + ...req.body, + actor: req.permission + }); + + return { config }; + } + }); + + server.route({ + method: "PUT", + url: "/vault/configs/:id", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + id: z.string() + }), + body: z.object({ + connectionId: z.string(), + namespace: z.string() + }), + response: { + 200: z.object({ + config: z.object({ + id: z.string(), + orgId: z.string(), + namespace: z.string(), + connectionId: z.string().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const config = await server.services.migration.updateVaultExternalMigration({ + id: req.params.id, + ...req.body, + actor: req.permission + }); + + return { config }; + } + }); + + server.route({ + method: "DELETE", + url: "/vault/configs/:id", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + id: z.string() + }), + response: { + 200: z.object({ + config: z.object({ + id: z.string(), + orgId: z.string(), + namespace: z.string(), + connectionId: z.string().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const config = await server.services.migration.deleteVaultExternalMigration({ + id: req.params.id, + actor: req.permission + }); + + return { config }; + } + }); + + server.route({ + method: "GET", + url: "/vault/namespaces", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.object({ + namespaces: z.array(z.object({ id: z.string(), name: z.string() })) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const namespaces = await server.services.migration.getVaultNamespaces({ + actor: req.permission + }); + + return { namespaces }; + } + }); + + server.route({ + method: "GET", + url: "/vault/policies", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + namespace: z.string() + }), + response: { + 200: z.object({ + policies: z.array(z.object({ name: z.string(), rules: z.string() })) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const policies = await server.services.migration.getVaultPolicies({ + actor: req.permission, + namespace: req.query.namespace + }); + + return { policies }; + } + }); + + server.route({ + method: "GET", + url: "/vault/mounts", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + namespace: z.string() + }), + response: { + 200: z.object({ + mounts: z.array(z.object({ path: z.string(), type: z.string(), version: z.string().nullish() })) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const mounts = await server.services.migration.getVaultMounts({ + actor: req.permission, + namespace: req.query.namespace + }); + + return { mounts }; + } + }); + + server.route({ + method: "GET", + url: "/vault/auth-mounts", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + namespace: z.string(), + authType: z.string().optional() + }), + response: { + 200: z.object({ + mounts: z.array(z.object({ path: z.string(), type: z.string() })) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const mounts = await server.services.migration.getVaultAuthMounts({ + actor: req.permission, + namespace: req.query.namespace, + authType: req.query.authType + }); + + return { mounts }; + } + }); + + server.route({ + method: "POST", + url: "/vault/import-secrets", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + projectId: z.string(), + environment: z.string(), + secretPath: z.string(), + vaultNamespace: z.string(), + vaultSecretPath: z.string() + }), + response: { + 200: z.object({ + status: z.nativeEnum(VaultImportStatus) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const result = await server.services.migration.importVaultSecrets({ + actor: req.permission, + auditLogInfo: req.auditLogInfo, + ...req.body + }); + + return result; + } + }); + + server.route({ + method: "GET", + url: "/vault/secret-paths", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + namespace: z.string(), + mountPath: z.string() + }), + response: { + 200: z.object({ + secretPaths: z.string().array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const secretPaths = await server.services.migration.getVaultSecretPaths({ + actor: req.permission, + namespace: req.query.namespace, + mountPath: req.query.mountPath + }); + + return { secretPaths }; + } + }); + + server.route({ + method: "GET", + url: "/vault/auth-roles/kubernetes", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + namespace: z.string(), + mountPath: z.string() + }), + + response: { + 200: z.object({ + roles: z.array( + z.object({ + name: z.string(), + mountPath: z.string(), + bound_service_account_names: z.array(z.string()), + bound_service_account_namespaces: z.array(z.string()), + token_ttl: z.number().optional(), + token_max_ttl: z.number().optional(), + token_policies: z.array(z.string()).optional(), + token_bound_cidrs: z.array(z.string()).optional(), + token_explicit_max_ttl: z.number().optional(), + token_no_default_policy: z.boolean().optional(), + token_num_uses: z.number().optional(), + token_period: z.number().optional(), + token_type: z.string().optional(), + audience: z.string().optional(), + alias_name_source: z.string().optional(), + config: z.object({ + kubernetes_host: z.string(), + kubernetes_ca_cert: z.string().optional(), + issuer: z.string().optional(), + disable_iss_validation: z.boolean().optional(), + disable_local_ca_jwt: z.boolean().optional() + }) + }) + ) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const roles = await server.services.migration.getVaultKubernetesAuthRoles({ + actor: req.permission, + namespace: req.query.namespace, + mountPath: req.query.mountPath + }); + + return { roles }; + } + }); }; diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-enums.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-enums.ts index a1e2c8f09..ce0e00f4a 100644 --- a/backend/src/services/app-connection/hc-vault/hc-vault-connection-enums.ts +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-enums.ts @@ -2,3 +2,7 @@ export enum HCVaultConnectionMethod { AccessToken = "access-token", AppRole = "app-role" } + +export enum HCVaultAuthType { + Kubernetes = "kubernetes" +} diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts index 3a79e2f8e..38f97700c 100644 --- a/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts @@ -12,8 +12,56 @@ import { logger } from "@app/lib/logger"; import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; -import { HCVaultConnectionMethod } from "./hc-vault-connection-enums"; -import { THCVaultConnection, THCVaultConnectionConfig, THCVaultMountResponse } from "./hc-vault-connection-types"; +import { HCVaultAuthType, HCVaultConnectionMethod } from "./hc-vault-connection-enums"; +import { + THCVaultAuthMount, + THCVaultAuthMountResponse, + THCVaultConnection, + THCVaultConnectionConfig, + THCVaultKubernetesAuthConfig, + THCVaultKubernetesAuthRole, + THCVaultKubernetesAuthRoleWithConfig, + THCVaultMount, + THCVaultMountResponse +} from "./hc-vault-connection-types"; + +// Concurrency limit for HC Vault API requests to avoid rate limiting +const HC_VAULT_CONCURRENCY_LIMIT = 20; + +/** + * Creates a concurrency limiter that restricts the number of concurrent async operations + * @param limit - Maximum number of concurrent operations + * @returns A function that takes an async function and executes it with concurrency control + */ +const createConcurrencyLimiter = (limit: number) => { + let activeCount = 0; + const queue: Array<() => void> = []; + + const next = () => { + activeCount -= 1; + if (queue.length > 0) { + const resolve = queue.shift(); + resolve?.(); + } + }; + + return async (fn: () => Promise): Promise => { + // If we're at the limit, wait in queue + if (activeCount >= limit) { + await new Promise((resolve) => { + queue.push(resolve); + }); + } + + activeCount += 1; + + try { + return await fn(); + } finally { + next(); + } + }; +}; export const getHCVaultInstanceUrl = async (config: THCVaultConnectionConfig) => { const instanceUrl = removeTrailingSlash(config.credentials.instanceUrl); @@ -181,30 +229,573 @@ export const validateHCVaultConnectionCredentials = async ( } }; -export const listHCVaultMounts = async ( +export const listHCVaultPolicies = async ( + namespace: string, connection: THCVaultConnection, gatewayService: Pick ) => { const instanceUrl = await getHCVaultInstanceUrl(connection); const accessToken = await getHCVaultAccessToken(connection, gatewayService); + try { + const { data: listData } = await requestWithHCVaultGateway<{ + data: { + policies: string[]; + }; + }>(connection, gatewayService, { + url: `${instanceUrl}/v1/sys/policy`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + "X-Vault-Namespace": namespace + } + }); + + const policyNames = listData.data.policies || []; + + const limiter = createConcurrencyLimiter(HC_VAULT_CONCURRENCY_LIMIT); + + const policies = await Promise.all( + policyNames.map((policyName) => + limiter(async () => { + try { + const { data: policyData } = await requestWithHCVaultGateway<{ + data: { + name: string; + rules: string; + }; + }>(connection, gatewayService, { + url: `${instanceUrl}/v1/sys/policy/${policyName}`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + "X-Vault-Namespace": namespace + } + }); + + return { + name: policyData.data.name, + rules: policyData.data.rules + }; + } catch (error: unknown) { + logger.error(error, `Unable to fetch policy details for ${policyName}`); + return { + name: policyName, + rules: "" + }; + } + }) + ) + ); + + return policies; + } catch (error: unknown) { + logger.error(error, "Unable to list HC Vault policies"); + + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to list policies: ${error.message || "Unknown error"}` + }); + } + + throw new BadRequestError({ + message: "Unable to list policies from HashiCorp Vault" + }); + } +}; + +export const listHCVaultNamespaces = async ( + connection: THCVaultConnection, + gatewayService: Pick +) => { + const instanceUrl = await getHCVaultInstanceUrl(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); + + const currentNamespace = connection.credentials.namespace || "/"; + + // Helper function to fetch namespaces at a specific path + const fetchNamespacesAtPath = async (namespacePath: string): Promise => { + try { + const { data } = await requestWithHCVaultGateway<{ + data: { + keys: string[]; + key_info?: { + [key: string]: { + id: string; + path: string; + custom_metadata?: Record; + }; + }; + }; + }>(connection, gatewayService, { + url: `${instanceUrl}/v1/sys/namespaces?list=true`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + "X-Vault-Namespace": namespacePath + } + }); + + return data.data.keys || []; + } catch (error: unknown) { + if (error instanceof AxiosError && error.response?.status === 404) { + // No child namespaces at this path + return null; + } + throw error; + } + }; + + // Recursive function to get all namespaces at all depths with controlled parallelization + const recursivelyGetAllNamespaces = async ( + parentPath: string, + limiter: ReturnType + ): Promise => { + const childKeys = await fetchNamespacesAtPath(parentPath); + + if (childKeys === null || childKeys.length === 0) { + return []; + } + + // Process namespaces in parallel with concurrency control + const namespacesArrays = await Promise.all( + childKeys.map((namespaceKey) => + limiter(async () => { + // Remove trailing slash from the key + const cleanNamespaceKey = namespaceKey.replace(/\/$/, ""); + + // Build the full path + let fullNamespacePath: string; + if (parentPath === "/") { + fullNamespacePath = cleanNamespaceKey; + } else { + fullNamespacePath = `${parentPath}/${cleanNamespaceKey}`; + } + + // Recursively fetch child namespaces + const childNamespaces = await recursivelyGetAllNamespaces(fullNamespacePath, limiter); + + // Return this namespace and all its children + return [fullNamespacePath, ...childNamespaces]; + }) + ) + ); + + // Flatten the arrays into a single array + return namespacesArrays.flat(); + }; + + try { + // Create concurrency limiter to avoid overwhelming the Vault instance + const limiter = createConcurrencyLimiter(HC_VAULT_CONCURRENCY_LIMIT); + + // Get all namespaces starting from currentNamespace + const childNamespaces = await recursivelyGetAllNamespaces(currentNamespace, limiter); + + // Build the result array with full paths + const namespaces = childNamespaces.map((path) => ({ + id: path, + name: path + })); + + // Always include the current/root namespace + namespaces.unshift({ + id: currentNamespace, + name: currentNamespace + }); + + return namespaces; + } catch (error: unknown) { + logger.error(error, "Unable to list HC Vault namespaces"); + + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to list namespaces: ${error.message || "Unknown error"}` + }); + } + + throw new BadRequestError({ + message: "Unable to list namespaces from HashiCorp Vault" + }); + } +}; + +export const listHCVaultMounts = async ( + connection: THCVaultConnection, + gatewayService: Pick, + namespace?: string +) => { + const instanceUrl = await getHCVaultInstanceUrl(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); + + const targetNamespace = namespace || connection.credentials.namespace; + const { data } = await requestWithHCVaultGateway(connection, gatewayService, { url: `${instanceUrl}/v1/sys/mounts`, method: "GET", headers: { "X-Vault-Token": accessToken, - ...(connection.credentials.namespace ? { "X-Vault-Namespace": connection.credentials.namespace } : {}) + ...(targetNamespace ? { "X-Vault-Namespace": targetNamespace } : {}) } }); - const mounts: string[] = []; + const mounts: THCVaultMount[] = []; - // Filter for "kv" version 2 type only Object.entries(data.data).forEach(([path, mount]) => { - if (mount.type === "kv" && mount.options?.version === "2") { - mounts.push(path); - } + mounts.push({ + path, + type: mount.type, + version: mount.options?.version + }); }); return mounts; }; + +export const listHCVaultSecretPaths = async ( + namespace: string, + connection: THCVaultConnection, + gatewayService: Pick, + filterMountPath?: string +) => { + const instanceUrl = await getHCVaultInstanceUrl(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); + + const getPaths = async (mountPath: string, secretPath: string, kvVersion: "1" | "2"): Promise => { + try { + let path: string; + if (kvVersion === "2") { + // For KV v2: /v1/{mount}/metadata/{path}?list=true + path = secretPath ? `${mountPath}/metadata/${secretPath}` : `${mountPath}/metadata`; + } else { + // For KV v1: /v1/{mount}/{path}?list=true + path = secretPath ? `${mountPath}/${secretPath}` : mountPath; + } + + const { data } = await requestWithHCVaultGateway<{ + data: { + keys: string[]; + }; + }>(connection, gatewayService, { + url: `${instanceUrl}/v1/${path}?list=true`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + "X-Vault-Namespace": namespace + } + }); + + return data.data.keys; + } catch (error) { + if (error instanceof AxiosError && error.response?.status === 404) { + return null; + } + + throw error; + } + }; + + // Recursive function to get all secret paths in a mount with controlled parallelization + const recursivelyGetAllPaths = async ( + mountPath: string, + kvVersion: "1" | "2", + limiter: ReturnType, + currentPath: string = "" + ): Promise => { + const paths = await getPaths(mountPath, currentPath, kvVersion); + + if (paths === null || paths.length === 0) { + return []; + } + + // Process paths in parallel with concurrency control + const secretPathsArrays = await Promise.all( + paths.map((path) => + limiter(async () => { + const cleanPath = path.endsWith("/") ? path.slice(0, -1) : path; + const fullItemPath = currentPath ? `${currentPath}/${cleanPath}` : cleanPath; + + if (path.endsWith("/")) { + // it's a folder so we recurse into it + return recursivelyGetAllPaths(mountPath, kvVersion, limiter, fullItemPath); + } + // it's a secret so we return it + return [`${mountPath}/${fullItemPath}`]; + }) + ) + ); + + // Flatten the arrays into a single array + return secretPathsArrays.flat(); + }; + + // Get all mounts + const mounts = await listHCVaultMounts(connection, gatewayService, namespace); + + // Filter for KV mounts (kv, kv-v1, kv-v2) + let kvMounts = mounts.filter((mount) => mount.type === "kv" || mount.type.startsWith("kv")); + + // If filterMountPath is provided, filter to only that mount + if (filterMountPath) { + const normalizedFilterPath = filterMountPath.replace(/\/$/, ""); // Remove trailing slash + kvMounts = kvMounts.filter((mount) => mount.path.replace(/\/$/, "") === normalizedFilterPath); + } + + // Create concurrency limiter to avoid overwhelming the Vault instance + const limiter = createConcurrencyLimiter(HC_VAULT_CONCURRENCY_LIMIT); + + // Collect all secret paths from all KV mounts in parallel + const allSecretPathsArrays = await Promise.all( + kvMounts.map(async (mount) => { + const kvVersion = mount.version === "2" ? "2" : "1"; + const cleanMountPath = mount.path.replace(/\/$/, ""); // Remove trailing slash + return recursivelyGetAllPaths(cleanMountPath, kvVersion, limiter); + }) + ); + + // Flatten the arrays into a single array + const allSecretPaths = allSecretPathsArrays.flat(); + + return allSecretPaths; +}; + +export const getHCVaultSecretsForPath = async ( + namespace: string, + secretPath: string, + connection: THCVaultConnection, + gatewayService: Pick +) => { + const instanceUrl = await getHCVaultInstanceUrl(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); + + try { + // Extract mount and path from the secretPath + // secretPath format: {mount}/{path} + const pathParts = secretPath.split("/"); + const mountPath = pathParts[0]; + const actualPath = pathParts.slice(1).join("/"); + + if (!mountPath || !actualPath) { + throw new BadRequestError({ + message: "Invalid secret path format. Expected format: {mount}/{path}" + }); + } + + // Get mounts to determine KV version + const mounts = await listHCVaultMounts(connection, gatewayService, namespace); + const mount = mounts.find((m) => m.path.replace(/\/$/, "") === mountPath); + + if (!mount) { + throw new BadRequestError({ + message: `Mount '${mountPath}' not found in HashiCorp Vault` + }); + } + + const kvVersion = mount.version === "2" ? "2" : "1"; + + // Fetch secrets based on KV version + if (kvVersion === "2") { + // For KV v2: /v1/{mount}/data/{path} + const { data } = await requestWithHCVaultGateway<{ + data: { + data: Record; // KV v2 has nested data structure + metadata: { + created_time: string; + deletion_time: string; + destroyed: boolean; + version: number; + }; + }; + }>(connection, gatewayService, { + url: `${instanceUrl}/v1/${mountPath}/data/${actualPath}`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + "X-Vault-Namespace": namespace + } + }); + + return data.data.data; + } + + // For KV v1: /v1/{mount}/{path} + const { data } = await requestWithHCVaultGateway<{ + data: Record; // KV v1 has flat data structure + lease_duration: number; + lease_id: string; + renewable: boolean; + }>(connection, gatewayService, { + url: `${instanceUrl}/v1/${mountPath}/${actualPath}`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + "X-Vault-Namespace": namespace + } + }); + + return data.data; + } catch (error: unknown) { + logger.error(error, "Unable to fetch secrets from HC Vault path"); + + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to fetch secrets: ${error.message || "Unknown error"}` + }); + } + + if (error instanceof BadRequestError) { + throw error; + } + + throw new BadRequestError({ + message: "Unable to fetch secrets from HashiCorp Vault" + }); + } +}; + +export const getHCVaultAuthMounts = async ( + namespace: string, + authType: HCVaultAuthType | undefined, + connection: THCVaultConnection, + gatewayService: Pick +): Promise => { + const instanceUrl = await getHCVaultInstanceUrl(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); + + try { + const { data } = await requestWithHCVaultGateway(connection, gatewayService, { + url: `${instanceUrl}/v1/sys/auth`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + "X-Vault-Namespace": namespace + } + }); + + const authMounts: THCVaultAuthMount[] = []; + + Object.entries(data.data).forEach(([path, authMethod]) => { + // If authType is specified, filter by it; otherwise, include all + if (!authType || authMethod.type === authType) { + authMounts.push({ + path, + type: authMethod.type, + description: authMethod.description, + accessor: authMethod.accessor + }); + } + }); + + return authMounts; + } catch (error: unknown) { + const authTypeStr = authType || "all"; + logger.error(error, `Unable to list HC Vault ${authTypeStr} auth mounts`); + + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to list ${authTypeStr} auth mounts: ${error.message || "Unknown error"}` + }); + } + + throw new BadRequestError({ + message: `Unable to list ${authTypeStr} auth mounts from HashiCorp Vault` + }); + } +}; + +export const getHCVaultKubernetesAuthRoles = async ( + namespace: string, + mountPath: string, + connection: THCVaultConnection, + gatewayService: Pick +): Promise => { + const instanceUrl = await getHCVaultInstanceUrl(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); + + // Remove trailing slash from mount path + const cleanMountPath = mountPath.endsWith("/") ? mountPath.slice(0, -1) : mountPath; + + try { + // 1. Get the Kubernetes auth configuration for this mount + const { data: configResponse } = await requestWithHCVaultGateway<{ data: THCVaultKubernetesAuthConfig }>( + connection, + gatewayService, + { + url: `${instanceUrl}/v1/auth/${cleanMountPath}/config`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + "X-Vault-Namespace": namespace + } + } + ); + + const kubernetesConfig = configResponse.data; + + // 2. List all roles in this mount + const { data: roleListResponse } = await requestWithHCVaultGateway<{ data: { keys: string[] } }>( + connection, + gatewayService, + { + url: `${instanceUrl}/v1/auth/${cleanMountPath}/role`, + method: "LIST", + headers: { + "X-Vault-Token": accessToken, + "X-Vault-Namespace": namespace + } + } + ); + + const roleNames = roleListResponse.data.keys; + + if (!roleNames || roleNames.length === 0) { + return []; + } + + // 3. Fetch details for each role with concurrency control + const limiter = createConcurrencyLimiter(HC_VAULT_CONCURRENCY_LIMIT); + + const roleDetailsPromises = roleNames.map((roleName) => + limiter(async () => { + const { data: roleResponse } = await requestWithHCVaultGateway<{ data: THCVaultKubernetesAuthRole }>( + connection, + gatewayService, + { + url: `${instanceUrl}/v1/auth/${cleanMountPath}/role/${roleName}`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + "X-Vault-Namespace": namespace + } + } + ); + + // 4. Merge the role with the config + return { + ...roleResponse.data, + name: roleName, + config: kubernetesConfig, + mountPath: cleanMountPath + } as THCVaultKubernetesAuthRoleWithConfig; + }) + ); + + const roles = await Promise.all(roleDetailsPromises); + + return roles; + } catch (error: unknown) { + logger.error(error, "Unable to list HC Vault Kubernetes auth roles"); + + if (error instanceof AxiosError) { + const errorMessage = + (error.response?.data as { errors?: string[] })?.errors?.[0] || error.message || "Unknown error"; + throw new BadRequestError({ + message: `Failed to list Kubernetes auth roles: ${errorMessage}` + }); + } + + throw new BadRequestError({ + message: "Unable to list Kubernetes auth roles from HashiCorp Vault" + }); + } +}; diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-service.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-service.ts index 589c7c1bd..037964da4 100644 --- a/backend/src/services/app-connection/hc-vault/hc-vault-connection-service.ts +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-service.ts @@ -21,7 +21,8 @@ export const hcVaultConnectionService = ( try { const mounts = await listHCVaultMounts(appConnection, gatewayService); - return mounts; + // Filter for KV version 2 mounts only and extract just the paths + return mounts.filter((mount) => mount.type === "kv" && mount.version === "2").map((mount) => mount.path); } catch (error) { logger.error(error, "Failed to establish connection with Hashicorp Vault"); return []; diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-types.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-types.ts index 6f254eda0..d25dbc9e6 100644 --- a/backend/src/services/app-connection/hc-vault/hc-vault-connection-types.ts +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-types.ts @@ -33,3 +33,65 @@ export type THCVaultMountResponse = { }; }; }; + +export type THCVaultMount = { + path: string; + type: string; + version?: string | null; +}; + +export type THCVaultAuthMountResponse = { + data: { + [key: string]: { + type: string; + description: string; + accessor: string; + config: { + default_lease_ttl: number; + max_lease_ttl: number; + force_no_cache: boolean; + }; + local: boolean; + seal_wrap: boolean; + external_entropy_access: boolean; + options: Record | null; + }; + }; +}; + +export type THCVaultAuthMount = { + path: string; + type: string; + description: string; + accessor: string; +}; + +export type THCVaultKubernetesAuthConfig = { + kubernetes_host: string; + kubernetes_ca_cert?: string; + issuer?: string; + disable_iss_validation?: boolean; + disable_local_ca_jwt?: boolean; +}; + +export type THCVaultKubernetesAuthRole = { + name: string; + bound_service_account_names: string[]; + bound_service_account_namespaces: string[]; + token_ttl?: number; + token_max_ttl?: number; + token_policies?: string[]; + token_bound_cidrs?: string[]; + token_explicit_max_ttl?: number; + token_no_default_policy?: boolean; + token_num_uses?: number; + token_period?: number; + token_type?: string; + audience?: string; + alias_name_source?: string; +}; + +export type THCVaultKubernetesAuthRoleWithConfig = THCVaultKubernetesAuthRole & { + config: THCVaultKubernetesAuthConfig; + mountPath: string; +}; diff --git a/backend/src/services/external-migration/external-migration-service.ts b/backend/src/services/external-migration/external-migration-service.ts index 14885b1d6..4192ffcd5 100644 --- a/backend/src/services/external-migration/external-migration-service.ts +++ b/backend/src/services/external-migration/external-migration-service.ts @@ -1,9 +1,33 @@ import { OrgMembershipRole } from "@app/db/schemas"; +import { + AuditLogInfo, + EventType, + SecretApprovalEvent, + TAuditLogServiceFactory +} from "@app/ee/services/audit-log/audit-log-types"; import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { crypto } from "@app/lib/crypto/cryptography"; -import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors"; +import { DatabaseErrorCode } from "@app/lib/error-codes"; +import { BadRequestError, DatabaseError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { OrgServiceActor } from "@app/lib/types"; +import { AppConnection } from "../app-connection/app-connection-enums"; +import { decryptAppConnectionCredentials } from "../app-connection/app-connection-fns"; +import { TAppConnectionServiceFactory } from "../app-connection/app-connection-service"; +import { + getHCVaultAuthMounts, + getHCVaultKubernetesAuthRoles, + getHCVaultSecretsForPath, + HCVaultAuthType, + listHCVaultMounts, + listHCVaultPolicies, + listHCVaultSecretPaths, + THCVaultConnection +} from "../app-connection/hc-vault"; +import { TKmsServiceFactory } from "../kms/kms-service"; +import { TSecretServiceFactory } from "../secret/secret-service"; +import { SecretProtectionType } from "../secret/secret-types"; import { TUserDALFactory } from "../user/user-dal"; import { decryptEnvKeyDataFn, @@ -15,16 +39,29 @@ import { TExternalMigrationQueueFactory } from "./external-migration-queue"; import { ExternalMigrationProviders, ExternalPlatforms, + TCreateVaultExternalMigrationDTO, + TDeleteVaultExternalMigrationDTO, THasCustomVaultMigrationDTO, TImportEnvKeyDataDTO, - TImportVaultDataDTO + TImportVaultDataDTO, + TUpdateVaultExternalMigrationDTO, + VaultImportStatus } from "./external-migration-types"; +import { TVaultExternalMigrationConfigDALFactory } from "./vault-external-migration-config-dal"; type TExternalMigrationServiceFactoryDep = { permissionService: TPermissionServiceFactory; + secretService: TSecretServiceFactory; + auditLogService: Pick; externalMigrationQueue: TExternalMigrationQueueFactory; + appConnectionService: Pick; + vaultExternalMigrationConfigDAL: Pick< + TVaultExternalMigrationConfigDALFactory, + "create" | "findOne" | "transaction" | "find" | "updateById" | "deleteById" | "findById" + >; userDAL: Pick; gatewayService: Pick; + kmsService: Pick; }; export type TExternalMigrationServiceFactory = ReturnType; @@ -33,7 +70,12 @@ export const externalMigrationServiceFactory = ({ permissionService, externalMigrationQueue, userDAL, - gatewayService + gatewayService, + secretService, + auditLogService, + appConnectionService, + vaultExternalMigrationConfigDAL, + kmsService }: TExternalMigrationServiceFactoryDep) => { const importEnvKeyData = async ({ decryptionKey, @@ -171,9 +213,554 @@ export const externalMigrationServiceFactory = ({ return actorOrgId in vaultMigrationTransformMappings; }; + const validateVaultExternalMigrationConnection = async ({ + connection, + namespace + }: { + connection: THCVaultConnection; + namespace: string; + }) => { + // Allow root namespace access when no namespace is configured on the connection + const isRootAccess = namespace === "root" || namespace === "/"; + const hasNoNamespace = connection.credentials.namespace === undefined; + + if (hasNoNamespace && isRootAccess) { + // Skip validation for root access with no configured namespace + } else if (connection.credentials.namespace !== namespace) { + throw new BadRequestError({ message: "Namespace value does not match the namespace of the connection" }); + } + + try { + await listHCVaultPolicies(namespace, connection, gatewayService); + await getHCVaultAuthMounts(namespace, HCVaultAuthType.Kubernetes, connection, gatewayService); + + const mounts = await listHCVaultMounts(connection, gatewayService); + const sampleKvMount = mounts.find((mount) => mount.type === "kv"); + if (sampleKvMount) { + await listHCVaultSecretPaths(namespace, connection, gatewayService, sampleKvMount.path); + } + } catch (error) { + throw new BadRequestError({ + message: `Failed to establish namespace confiugration. ${error instanceof Error ? error.message : "Unknown error"}` + }); + } + }; + + const createVaultExternalMigration = async ({ namespace, connectionId, actor }: TCreateVaultExternalMigrationDTO) => { + const { hasRole } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can configure vault external migration" }); + } + + const connection = await appConnectionService.connectAppConnectionById( + AppConnection.HCVault, + connectionId, + actor + ); + + await validateVaultExternalMigrationConnection({ + connection, + namespace + }); + + try { + const config = await vaultExternalMigrationConfigDAL.create({ + namespace, + connectionId, + orgId: actor.orgId + }); + + return config; + } catch (error) { + if ( + error instanceof DatabaseError && + (error.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation + ) { + throw new BadRequestError({ + message: `Vault external migration already exists for this namespace` + }); + } + + throw error; + } + }; + + const updateVaultExternalMigration = async ({ + id, + namespace, + connectionId, + actor + }: TUpdateVaultExternalMigrationDTO) => { + const { hasRole } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can update vault external migration" }); + } + + if (connectionId) { + const connection = await appConnectionService.connectAppConnectionById( + AppConnection.HCVault, + connectionId, + actor + ); + + await validateVaultExternalMigrationConnection({ + connection, + namespace + }); + } + + const config = await vaultExternalMigrationConfigDAL.updateById(id, { + namespace, + connectionId + }); + + return config; + }; + + const getVaultExternalMigrationConfigs = async ({ actor }: { actor: OrgServiceActor }) => { + const { hasRole } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can view vault external migration configs" }); + } + + const configs = await vaultExternalMigrationConfigDAL.find({ + orgId: actor.orgId + }); + + return configs; + }; + + const getVaultNamespaces = async ({ actor }: { actor: OrgServiceActor }) => { + const { hasRole } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can view vault namespaces" }); + } + + // Get all configured namespaces for this org + const vaultConfigs = await vaultExternalMigrationConfigDAL.find({ + orgId: actor.orgId + }); + + // Return the configured namespaces as an array of objects with id and name + // where both id and name are the namespace path + const namespaces = vaultConfigs.map((config) => ({ + id: config.namespace, + name: config.namespace + })); + + return namespaces; + }; + + const getVaultPolicies = async ({ actor, namespace }: { actor: OrgServiceActor; namespace: string }) => { + const { hasRole } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can view vault policies" }); + } + + const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({ + orgId: actor.orgId, + namespace + }); + + if (!vaultConfig) { + throw new NotFoundError({ message: "Vault migration config not found for this namespace" }); + } + + if (!vaultConfig.connection) { + throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" }); + } + + const credentials = await decryptAppConnectionCredentials({ + orgId: vaultConfig.orgId, + encryptedCredentials: vaultConfig.connection.encryptedCredentials, + kmsService, + projectId: null + }); + + const connection = { + ...vaultConfig.connection, + credentials + } as THCVaultConnection; + + const policies = await listHCVaultPolicies(namespace, connection, gatewayService); + return policies; + }; + + const getVaultMounts = async ({ actor, namespace }: { actor: OrgServiceActor; namespace: string }) => { + const { hasRole } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can view vault mounts" }); + } + + const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({ + orgId: actor.orgId, + namespace + }); + + if (!vaultConfig) { + throw new NotFoundError({ message: "Vault migration config not found for this namespace" }); + } + + if (!vaultConfig.connection) { + throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" }); + } + + const credentials = await decryptAppConnectionCredentials({ + orgId: vaultConfig.orgId, + encryptedCredentials: vaultConfig.connection.encryptedCredentials, + kmsService, + projectId: null + }); + + const connection = { + ...vaultConfig.connection, + credentials + } as THCVaultConnection; + + const mounts = await listHCVaultMounts(connection, gatewayService, namespace); + return mounts; + }; + + const getVaultSecretPaths = async ({ + actor, + namespace, + mountPath + }: { + actor: OrgServiceActor; + namespace: string; + mountPath: string; + }) => { + const { hasRole } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can view vault secret paths" }); + } + + const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({ + orgId: actor.orgId, + namespace + }); + + if (!vaultConfig) { + throw new NotFoundError({ message: "Vault migration config not found for this namespace" }); + } + + if (!vaultConfig.connection) { + throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" }); + } + + const credentials = await decryptAppConnectionCredentials({ + orgId: vaultConfig.orgId, + encryptedCredentials: vaultConfig.connection.encryptedCredentials, + kmsService, + projectId: null + }); + + const connection = { + ...vaultConfig.connection, + credentials + } as THCVaultConnection; + + const secretPaths = await listHCVaultSecretPaths(namespace, connection, gatewayService, mountPath); + + return secretPaths; + }; + + const importVaultSecrets = async ({ + actor, + projectId, + environment, + secretPath, + vaultNamespace, + vaultSecretPath, + auditLogInfo + }: { + actor: OrgServiceActor; + projectId: string; + environment: string; + secretPath: string; + vaultNamespace: string; + vaultSecretPath: string; + auditLogInfo: AuditLogInfo; + }) => { + const { hasRole } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can import vault secrets" }); + } + + const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({ + orgId: actor.orgId, + namespace: vaultNamespace + }); + + if (!vaultConfig) { + throw new NotFoundError({ message: "Vault migration config not found for this namespace" }); + } + + if (!vaultConfig.connection) { + throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" }); + } + + const credentials = await decryptAppConnectionCredentials({ + orgId: vaultConfig.orgId, + encryptedCredentials: vaultConfig.connection.encryptedCredentials, + kmsService, + projectId: null + }); + + const connection = { + ...vaultConfig.connection, + credentials + } as THCVaultConnection; + + const vaultSecrets = await getHCVaultSecretsForPath(vaultNamespace, vaultSecretPath, connection, gatewayService); + + try { + const secretOperation = await secretService.createManySecretsRaw({ + actorId: actor.id, + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + secretPath, + environment, + projectId, + secrets: Object.entries(vaultSecrets).map(([secretKey, secretValue]) => ({ + secretKey, + secretValue + })) + }); + + if (secretOperation.type === SecretProtectionType.Approval) { + await auditLogService.createAuditLog({ + projectId, + ...auditLogInfo, + event: { + type: EventType.SECRET_APPROVAL_REQUEST, + metadata: { + committedBy: secretOperation.approval.committerUserId, + secretApprovalRequestId: secretOperation.approval.id, + secretApprovalRequestSlug: secretOperation.approval.slug, + secretPath, + environment, + secrets: Object.entries(vaultSecrets).map(([secretKey]) => ({ + secretKey + })), + eventType: SecretApprovalEvent.CreateMany + } + } + }); + + return { status: VaultImportStatus.ApprovalRequired }; + } + + return { status: VaultImportStatus.Imported }; + } catch (error) { + throw new BadRequestError({ + message: `Failed to import Vault secrets. ${error instanceof Error ? error.message : "Unknown error"}` + }); + } + }; + + const deleteVaultExternalMigration = async ({ id, actor }: TDeleteVaultExternalMigrationDTO) => { + const { hasRole } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can delete vault external migration configs" }); + } + + const config = await vaultExternalMigrationConfigDAL.findById(id); + + if (!config) { + throw new NotFoundError({ message: "Vault migration config not found" }); + } + + if (config.orgId !== actor.orgId) { + throw new ForbiddenRequestError({ message: "Config does not belong to this organization" }); + } + + const deletedConfig = await vaultExternalMigrationConfigDAL.deleteById(id); + + return deletedConfig; + }; + + const getVaultAuthMounts = async ({ + actor, + namespace, + authType + }: { + actor: OrgServiceActor; + namespace: string; + authType?: string; + }) => { + const { hasRole } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can view vault auth mounts" }); + } + + const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({ + orgId: actor.orgId, + namespace + }); + + if (!vaultConfig) { + throw new NotFoundError({ message: "Vault migration config not found for this namespace" }); + } + + if (!vaultConfig.connection) { + throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" }); + } + + const credentials = await decryptAppConnectionCredentials({ + orgId: vaultConfig.orgId, + encryptedCredentials: vaultConfig.connection.encryptedCredentials, + kmsService, + projectId: null + }); + + const connection = { + ...vaultConfig.connection, + credentials + } as THCVaultConnection; + + const authMounts = await getHCVaultAuthMounts(namespace, authType as HCVaultAuthType, connection, gatewayService); + + return authMounts; + }; + + const getVaultKubernetesAuthRoles = async ({ + actor, + namespace, + mountPath + }: { + actor: OrgServiceActor; + namespace: string; + mountPath: string; + }) => { + const { hasRole } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can view vault Kubernetes auth roles" }); + } + + const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({ + orgId: actor.orgId, + namespace + }); + + if (!vaultConfig) { + throw new NotFoundError({ message: "Vault migration config not found for this namespace" }); + } + + if (!vaultConfig.connection) { + throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" }); + } + + const credentials = await decryptAppConnectionCredentials({ + orgId: vaultConfig.orgId, + encryptedCredentials: vaultConfig.connection.encryptedCredentials, + kmsService, + projectId: null + }); + + const connection = { + ...vaultConfig.connection, + credentials + } as THCVaultConnection; + + // Get roles for the specified mount path only + const roles = await getHCVaultKubernetesAuthRoles(namespace, mountPath, connection, gatewayService); + + return roles; + }; + return { importEnvKeyData, importVaultData, - hasCustomVaultMigration + hasCustomVaultMigration, + createVaultExternalMigration, + getVaultExternalMigrationConfigs, + updateVaultExternalMigration, + deleteVaultExternalMigration, + getVaultNamespaces, + getVaultPolicies, + getVaultMounts, + getVaultAuthMounts, + getVaultSecretPaths, + importVaultSecrets, + getVaultKubernetesAuthRoles }; }; diff --git a/backend/src/services/external-migration/external-migration-types.ts b/backend/src/services/external-migration/external-migration-types.ts index 804444172..49e5bfa88 100644 --- a/backend/src/services/external-migration/external-migration-types.ts +++ b/backend/src/services/external-migration/external-migration-types.ts @@ -1,4 +1,4 @@ -import { TOrgPermission } from "@app/lib/types"; +import { OrgServiceActor, TOrgPermission } from "@app/lib/types"; import { ActorAuthMethod, ActorType } from "../auth/auth-type"; @@ -121,3 +121,26 @@ export enum ExternalMigrationProviders { Vault = "vault", EnvKey = "env-key" } + +export enum VaultImportStatus { + Imported = "imported", + ApprovalRequired = "approval-required" +} + +export type TCreateVaultExternalMigrationDTO = { + namespace: string; + connectionId: string; + actor: OrgServiceActor; +}; + +export type TUpdateVaultExternalMigrationDTO = { + id: string; + namespace: string; + connectionId: string | null; + actor: OrgServiceActor; +}; + +export type TDeleteVaultExternalMigrationDTO = { + id: string; + actor: OrgServiceActor; +}; diff --git a/backend/src/services/external-migration/vault-external-migration-config-dal.ts b/backend/src/services/external-migration/vault-external-migration-config-dal.ts new file mode 100644 index 000000000..34ff8757a --- /dev/null +++ b/backend/src/services/external-migration/vault-external-migration-config-dal.ts @@ -0,0 +1,67 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { buildFindFilter, ormify, prependTableNameToFindFilter, selectAllTableCols } from "@app/lib/knex"; + +export type TVaultExternalMigrationConfigDALFactory = ReturnType; + +export const vaultExternalMigrationConfigDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.VaultExternalMigrationConfig); + + const findOne = async (filter: { orgId: string; namespace: string }, tx?: Knex) => { + try { + const result = await (tx || db?.replicaNode?.() || db)(TableName.VaultExternalMigrationConfig) + .leftJoin( + TableName.AppConnection, + `${TableName.AppConnection}.id`, + `${TableName.VaultExternalMigrationConfig}.connectionId` + ) + /* eslint-disable @typescript-eslint/no-misused-promises */ + .where(buildFindFilter(prependTableNameToFindFilter(TableName.VaultExternalMigrationConfig, filter))) + .select(selectAllTableCols(TableName.VaultExternalMigrationConfig)) + .select( + db.ref("id").withSchema(TableName.AppConnection).as("appConnectionId"), + db.ref("name").withSchema(TableName.AppConnection).as("appConnectionName"), + db.ref("app").withSchema(TableName.AppConnection).as("appConnectionApp"), + db.ref("encryptedCredentials").withSchema(TableName.AppConnection).as("appConnectionEncryptedCredentials"), + db.ref("orgId").withSchema(TableName.AppConnection).as("appConnectionOrgId"), + db.ref("method").withSchema(TableName.AppConnection).as("appConnectionMethod"), + db.ref("description").withSchema(TableName.AppConnection).as("appConnectionDescription"), + db.ref("version").withSchema(TableName.AppConnection).as("appConnectionVersion"), + db.ref("gatewayId").withSchema(TableName.AppConnection).as("appConnectionGatewayId"), + db.ref("projectId").withSchema(TableName.AppConnection).as("appConnectionProjectId"), + db.ref("createdAt").withSchema(TableName.AppConnection).as("appConnectionCreatedAt"), + db.ref("updatedAt").withSchema(TableName.AppConnection).as("appConnectionUpdatedAt") + ) + .first(); + + if (!result) return undefined; + + return { + ...result, + connection: result.appConnectionId + ? { + id: result.appConnectionId, + name: result.appConnectionName, + app: result.appConnectionApp, + encryptedCredentials: result.appConnectionEncryptedCredentials, + orgId: result.appConnectionOrgId, + method: result.appConnectionMethod, + description: result.appConnectionDescription, + version: result.appConnectionVersion, + gatewayId: result.appConnectionGatewayId, + projectId: result.appConnectionProjectId, + createdAt: result.appConnectionCreatedAt, + updatedAt: result.appConnectionUpdatedAt + } + : undefined + }; + } catch (error) { + throw new DatabaseError({ error, name: "Find one" }); + } + }; + + return { ...orm, findOne }; +}; diff --git a/backend/src/services/kms/kms-types.ts b/backend/src/services/kms/kms-types.ts index eaf0adbde..11badd7a5 100644 --- a/backend/src/services/kms/kms-types.ts +++ b/backend/src/services/kms/kms-types.ts @@ -99,5 +99,5 @@ export type TImportKeyMaterialDTO = { projectId: string; orgId: string; keyUsage: KmsKeyUsage; - kmipMetadata?: Record; + kmipMetadata?: Record | null; }; diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index 8e0892bd1..c216ea3a8 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -5,6 +5,7 @@ import path from "path"; import { v4 as uuidv4, validate as uuidValidate } from "uuid"; import { ActionProjectType, TProjectEnvironments, TSecretFolders, TSecretFoldersInsert } from "@app/db/schemas"; +import { TDynamicSecretDALFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-dal"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service"; @@ -12,6 +13,7 @@ import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/ import { PgSqlLock } from "@app/keystore/keystore"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { OrderByDirection, OrgServiceActor } from "@app/lib/types"; +import { ActorType } from "@app/services/auth/auth-type"; import { buildFolderPath } from "@app/services/secret-folder/secret-folder-fns"; import { @@ -47,7 +49,11 @@ type TSecretFolderServiceFactoryDep = { folderCommitService: Pick; projectDAL: Pick; secretApprovalPolicyService: Pick; - secretV2BridgeDAL: Pick; + secretV2BridgeDAL: Pick< + TSecretV2BridgeDALFactory, + "findByFolderIds" | "invalidateSecretCacheByProjectId" | "findOne" + >; + dynamicSecretDAL: Pick; }; export type TSecretFolderServiceFactory = ReturnType; @@ -61,7 +67,8 @@ export const secretFolderServiceFactory = ({ folderCommitService, projectDAL, secretApprovalPolicyService, - secretV2BridgeDAL + secretV2BridgeDAL, + dynamicSecretDAL }: TSecretFolderServiceFactoryDep) => { const createFolder = async ({ projectId, @@ -111,24 +118,11 @@ export const secretFolderServiceFactory = ({ }); } - // check if the exact folder already exists - const existingFolder = await folderDAL.findOne( - { - envId: env.id, - parentId: parentFolder.id, - name, - isReserved: false - }, - tx - ); - - if (existingFolder) { - return existingFolder; - } - // exact folder case if (parentFolder.path === pathWithFolder) { - return parentFolder; + throw new BadRequestError({ + message: `Folder with name '${name}' already exists in path '${secretPath}'` + }); } let currentParentId = parentFolder.id; @@ -534,13 +528,19 @@ export const secretFolderServiceFactory = ({ projectId, env, parentId, - idOrName + idOrName, + actor }: { projectId: string; env: TProjectEnvironments; parentId: string; idOrName: string; + actor: ActorType; }) => { + if (actor === ActorType.IDENTITY) { + return; + } + let targetFolder = await folderDAL .findOne({ envId: env.id, @@ -638,7 +638,8 @@ export const secretFolderServiceFactory = ({ actorAuthMethod, environment, path: secretPath, - idOrName + idOrName, + forceDelete = false }: TDeleteFolderDTO) => { const { permission } = await permissionService.getProjectPermission({ actor, @@ -664,7 +665,7 @@ export const secretFolderServiceFactory = ({ message: `Folder with path '${secretPath}' in environment with slug '${environment}' not found` }); - await $checkFolderPolicy({ projectId, env, parentId: parentFolder.id, idOrName }); + await $checkFolderPolicy({ projectId, env, parentId: parentFolder.id, idOrName, actor }); let folderToDelete = await folderDAL .findOne({ @@ -690,6 +691,22 @@ export const secretFolderServiceFactory = ({ throw new NotFoundError({ message: `Folder with ID '${idOrName}' not found` }); } + // Check if folder contains resources (secrets, dynamic secrets, subfolders) + if (!forceDelete) { + const error = new BadRequestError({ + message: `Cannot delete folder "${folderToDelete.name}" because it contains resources. Use forceDelete=true to delete it forcefully.`, + name: "deleteFolder" + }); + const secretV2 = await secretV2BridgeDAL.findOne({ folderId: folderToDelete.id }).catch(() => null); + if (secretV2) throw error; + + const dynamicSecret = await dynamicSecretDAL.findOne({ folderId: folderToDelete.id }).catch(() => null); + if (dynamicSecret) throw error; + + const subfolder = await folderDAL.findOne({ parentId: folderToDelete.id }).catch(() => null); + if (subfolder) throw error; + } + const [doc] = await folderDAL.delete( { envId: env.id, @@ -1315,7 +1332,7 @@ export const secretFolderServiceFactory = ({ }); } - await $checkFolderPolicy({ projectId, env, parentId: parentFolder.id, idOrName }); + await $checkFolderPolicy({ projectId, env, parentId: parentFolder.id, idOrName, actor }); let folderToDelete = await folderDAL .findOne({ diff --git a/backend/src/services/secret-folder/secret-folder-types.ts b/backend/src/services/secret-folder/secret-folder-types.ts index eed815da5..da8be52a0 100644 --- a/backend/src/services/secret-folder/secret-folder-types.ts +++ b/backend/src/services/secret-folder/secret-folder-types.ts @@ -37,6 +37,7 @@ export type TDeleteFolderDTO = { environment: string; path: string; idOrName: string; + forceDelete?: boolean; } & TProjectPermission; export type TGetFolderDTO = { diff --git a/docs/docs.json b/docs/docs.json index b2d514aed..7f25805d8 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -421,6 +421,7 @@ "documentation/guides/node", "documentation/guides/python", "documentation/guides/nextjs-vercel", + "documentation/guides/kubernetes-operator", "documentation/guides/microsoft-power-apps" ] } diff --git a/docs/documentation/guides/kubernetes-operator.mdx b/docs/documentation/guides/kubernetes-operator.mdx new file mode 100644 index 000000000..fc42b8d2f --- /dev/null +++ b/docs/documentation/guides/kubernetes-operator.mdx @@ -0,0 +1,239 @@ +--- +title: "Managing Secrets With Kubernetes Operator" +sidebarTitle: "Kubernetes Operator" +description: "How to use the Infisical Kubernetes Operator to Push Secrets, Pull Secrets, and Generate Dynamic Secrets within your clusters." +--- + +Infisical's Kubernetes Operator provides a seamless, secure, and automated way to synchronize secrets between your Infisical instance and your Kubernetes clusters. The Operator's three Custom Resource Definitions (CRDs) make this possible. In this guide, we provide the necessary CRDs and configurations for your kubernetes cluster, but you can customize them to fit your use-case. + +In this guide, we'll walk through how to: + +1. **Install the Infisical Operator on your Kubernetes cluster**. +2. **Configure Authentication using Kubernetes Service Accounts**. +3. **Use Each of the three CRDs**. + - **InfisicalSecret** [Sync secrets from Infisical to Kubernetes] + - **InfisicalPushSecret** [Sync secrets from Kubernetes to Infisical] + - **InfisicalDynamicSecret** [Manage Dynamic Secrets and automatically create time-bound leases] + +## Prerequisites + +Before we begin, make sure your environment is ready +1. Installed tools + - [helm](https://helm.sh/docs/intro/install/), [git](https://git-scm.com/downloads), [kubectl](https://kubernetes.io/docs/tasks/tools/) +2. Kubernetes Cluster + - Ensure you have access to a running cluster and connect with kubectl +3. PostgreSQL Cluster (for InfisicalDynamicSecret) + - Ensure you have a running database that you have access to +4. Clone [infisical-guides-source-code](https://github.com/Infisical/infisical-guides-source-code) repository +5. Access to an Infisical instance (cloud or self-hosted) + +## Step-By-Step Guide + + + + +The [Infisical Operator](https://infisical.com/docs/integrations/platforms/kubernetes/overview) runs inside your cluster and is responsible for handling secret synchronization events. + +```console +helm repo add infisical-helm-charts 'https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/' +helm repo update +helm install infisical-operator infisical-helm-charts/secrets-operator +``` + +Verify the operator pod is running: + +```console +kubectl get pods -n default +``` + + + +The operator uses a [Machine Identity](https://infisical.com/docs/documentation/platform/identities/machine-identities) to authenticate with Infisical through the Kubernetes Auth Method +1. Login to [Infisical](https://app.infisical.com/) +2. Select **Organization Access** from the left navigational pane +3. Create an Identity and give it a name and a role +4. Once the Machine Identity is created, copy the **Identity ID** (will be used later) +5. Select the created Machine Identity, and add a [Kubernetes Authentication Method](https://infisical.com/docs/documentation/platform/identities/kubernetes-auth). Use these configurations: + - **Allowed Service Account Names**: infisical-service-account, default + - **Allowed Namespaces**: default + - **Kubernetes Host URL** can be found by running ```kubectl cluster-info``` + - **Token Reviewer JWT / CA Certificate**: We will be generating these two later and adding them in later, leave it blank for now +6. Once the Machine Identity has been created, navigate back to **Overview** +7. Now select **Add New Project** + - Add a **Project Name**, and select **Secrets Management** as the product type + - Add a description (Optional). +8. Once the Project is created, navigate into the Project to **Add Secrets**. You can add any key-value pair for this example, however if you want to use the InfisicalSecret CRD example provided in this demo, use the following configurations: + - **Key**: SMTP_HOST + - **Value**: smtp@gmail.com + - **Tags**: N/A (Not needed for this demonstration) + - **Environments**: Production +9. Now lets navigate to the **Project Access** tab on the left hand navigation pane. + - Add the machine identity we created, and give it Admin permissions (just for demonstration purposes) + + + + + +Now we will be interacting with the local repository you cloned earlier. Make sure you are in the directory that contains the yaml configurations. Assuming you are in your root user directory: + +```console +cd infisical-guides-source-code/kubernetes-operator-demo +``` + +1. Create the ```infisical-token-reviewer``` service account. This Manifest creates a **service account** that the Infisical Operator uses to authenticate with Kubernetes for token reviews. It allows Infisical to validate Kubernetes tokens securely during the Machine Identity authentication process. +```console +kubectl apply -f infisical-reviewer-service-account.yaml +``` +2. Create the token for the reviewer service account. This Yaml defines a **service account token secret** linked to the reviewer account created above. It generates a JWT token that Infisical uses for the Kubernetes Auth Method in your Machine Identity configuration. +```console +kubectl apply -f service-account-reviewer-token.yaml +``` +3. This file binds the ```infisical-token-reviewer``` service account to the built-in ```system:auth-delegator``` ClusterRole. That role allows the service account to perform **token review** and **authentication delegation** requests on behalf of other service accounts - a key part of Kubernetes-based identity verification. Without this binding, the Infisical Operator wouldn't have permission to validate tokens. +```console +kubectl apply -f cluster-role-binding.yaml +``` +4. Create the service account that will be used by the InfisicalSecret. This file creates a **dedicated service account** ```infisical-service-account``` that the Infisical Operator uses to access and sync secrets within your cluster. It operates as the Operator's working identity in your cluster, separate from the token reviewer. +```console +kubectl apply -f infisical-service-account.yaml +``` +5. Create the token for the Infisical service account. This manifest defines a **token secret** for the ```infisical-service-account```. It allows the Infisical operator to authenticate against Infisical's API when syncing secrets. The token will then be manually patched and associated with the service account to make sure Kubernetes mains it persistently. +```console +kubectl apply -f infisical-service-account-token.yaml +``` +6. Apply the patch to manually associate the token secret +```console +kubectl patch serviceaccount infisical-service-account -p '{"secrets": [{"name": "infisical-service-account-token"}]}' -n default +``` +7. Create the **JWT Token** and **Certificate** and add it to the **Machine Identity** we created under **Kubernetes Auth**. For the generated CA, navigate to the **Advanced** tab to paste the certificate: +- JWT Command +```console +kubectl get secret infisical-token-reviewer-token -n default -o jsonpath='{.data.token}' | base64 -d +``` + +- CA Command +```console +kubectl get secret infisical-token-reviewer-token -n default -o jsonpath='{.data.ca\.crt}' | base64 -d +``` + + + +1. Check to see if the service accounts were created + +```console +kubectl get serviceaccount -n default | grep infisical +``` + +2. Verify the tokens were created and linked + +```console +kubectl get secrets -n default | grep infisical +``` + + + +The [InfisicalSecret](https://infisical.com/docs/integrations/platforms/kubernetes/infisical-secret-crd) CRD tells the operator to sync secrets from Infisical to Kubernetes. By referencing your ```identityID```, ```projectSlug```, and ```envSlug```, this CRD tells the Infisical Operator which Infisical secrets to fetch and how to format them into a Kubernetes Secret. Make sure to edit the provided CRD to match your specific Machine Identity ID, Project ID, and which environment your secrets are being pulled from (default is prod). + - **Project Slug**: Can be found when you select your project and navigate to settings + - **Identity ID**: Can be found when you select your machine identity from your organization's access control + +1. After editing the ```example-infisical-secret-crd.yaml``` to contain your demo-specific values, apply the yaml in your cluster +```console +kubectl apply -f example-infisical-secret-crd.yaml +``` + + + + +1. Check that the ```InfisicalSecret``` was created successfully +```console +kubectl get infisicalsecret -n default +``` +2. Check that the operator created the ```managed-secret``` +```console +kubectl get secret managed-secret -n default +``` +3. View the secret contents (base64 encoded) +```console +kubectl get secret managed-secret -n default -o jsonpath='{.data}' | jq +``` + + + +1. Deploy the nginx demo deployment that will use the managed secret. +```console +kubectl apply -f demo-deployment.yaml +``` +2. Wait 15-20 seconds and then verify the deployments +```console +kubectl get deployments +kubectl get pods -l app=nginx +``` + + + +1. Check that the environment variable is in the running pod. If everything was successful, at this point you should be able to see the secret populate in the kubernetes pod and have a successful **sync** from Infisical to Kubernetes. +```console +kubectl exec -it $(kubectl get pod -l app=nginx -o jsonpath='{.items[0].metadata.name}') -- env | grep SMTP +``` + + + +Now that we have successfully synced secrets from Infisical to Kubernetes, lets explore how we can push **Kubernetes Secrets** to Infisical. + + 1. Either create a **Kubernetes Secret** via yaml, or use the one in the repository. +```console +kubectl apply -f source-secret.yaml +``` + 2. Verify creation of the secret +```console +kubectl get secret push-secret-demo -n default -o yaml +``` + + + +The [InfisicalPushSecret](https://infisical.com/docs/integrations/platforms/kubernetes/infisical-push-secret-crd) CRD tells the operator to sync secrets from Kubernetes to Infisical. Make sure you edit the CRD to include the specific **Project Slug**, and **Identity ID**. The other values present in ```example-push-secret.yaml``` should be configured based on the previously committed yaml configurations. + + 1. Apply the InfisicalPushSecret CRD provided after making the necessary changes +```console +kubectl apply -f example-push-secret-crd.yaml +``` + + 2. Once your CRD has been configured, go back to your project within Infisical and check to see if your secrets have populated there. + +![secrets dashboard](../../images/push-secret.png) + + + + +The [InfisicalDynamicSecret](https://infisical.com/docs/integrations/platforms/kubernetes/infisical-dynamic-secret-crd) CRD allows you to sync dynamic secrets and create leases automatically in Kubernetes as native **Kubernetes Secret** resources Any Pod, Deployment, or other Kubernetes resource can make use of dynamic secrets from Infisical just like any other Kubernetes secret. + + 1. Navigate to your Infisical **Project** and click on the dropdown next to **Add Secret**. From here you will select **Add Dynamic Secret** + - Select **SQL Database** as the service you would like to connect to. + - Select **PostegreSQL** as the database service. Enter in the connection details for your database, specifically the **Host**, **Port**, **User**, **Password**, and **Database Name**. + - For the **Secret Name**, if you want to use the same name as the one in the cloned **InfisicalDynamicSecret** CRD, use the name **dynamic-secret-lease**. Otherwise you will need to change the **dynamicSecret.secretName** config in the InfisicalDynamicSecret CRD to whatever you name the secret here. + - In the CA (SSL) section, make sure to upload the **CA Certificate** for your database. + - Finally, select **Prod** as the environment (we are keeping this configuration as part of the demonstration). + +![secrets dashboard dynamic](../../images/dynamic-secret.png) + + 2. Edit the ```dynamic-secret-crd``` with the proper machine **Identity ID**, **Project Slug**, **dynamicSecret.secretName** (same as the **Secret Name** you gave to the dynamic secret in Infisical), and managedSecretReference.secretName (name of the kubernetes secret that Infisical Operator will create/populate in the cluster). + - If you want to keep the **managedSecretReference.secretName** then you can leave it as **dynamic-secret-test** + + 3. Once the changes have been saved, apply the yaml: + + ```console + kubectl apply -f dynamic-secret-crd.yaml + ``` + + 4. After applying the CRD, you should notice that the dynamic secret lease has been created and synced with your cluster. Verify by running: + +```console +kubectl get secret dynamic-secret-test -n default -o yaml +``` + + 5. Once the dynamic secret lease has been created, you should see that the secret has data that contains the lease credentials. + +![dynamic secrets output](../../images/dynamic-secret-crd.png) + + + +**Congratulations! You successfully managed secrets with Kubernetes.** \ No newline at end of file diff --git a/docs/documentation/platform/external-migrations/overview.mdx b/docs/documentation/platform/external-migrations/overview.mdx index cf6c5e5a1..d8e85fe91 100644 --- a/docs/documentation/platform/external-migrations/overview.mdx +++ b/docs/documentation/platform/external-migrations/overview.mdx @@ -1,16 +1,22 @@ --- title: "External Migrations" sidebarTitle: "Overview" -description: "Learn how to migrate secrets from third-party secrets management platforms to Infisical." +description: "Learn how to migrate resources from third-party secrets management platforms to Infisical." --- ## Overview -Infisical supports migrating secrets from third-party secrets management platforms to Infisical. This is useful if you're looking to easily switch to Infisical and wish to move over your existing secrets from a different platform. +Infisical supports migrating resources from third-party secrets management platforms to Infisical. This is useful if you're looking to easily switch to Infisical and wish to move over your existing resources from a different platform. + +Infisical offers two types of migration approaches: + +- **In-Platform Migration Tooling**: Configure platform connections to enable granular, on-demand imports of secrets, policies, and configurations directly within the Infisical UI. This allows you to migrate resources incrementally as needed. + +- **Bulk Data Import**: Perform one-time organization-level migrations to import all resources from external platforms at once. This is ideal for initial migrations when moving entirely to Infisical. ## Supported Platforms - [EnvKey](./envkey) - [Vault](./vault) -We're always looking to add more migration paths for other providers. If we're missing a platform, please open an issue on our [GitHub repository](https://github.com/infisical/infisical/issues). \ No newline at end of file +We're always looking to add more migration paths for other providers. If we're missing a platform, please open an issue on our [GitHub repository](https://github.com/infisical/infisical/issues). diff --git a/docs/documentation/platform/external-migrations/vault.mdx b/docs/documentation/platform/external-migrations/vault.mdx index 8254e104f..e267cc5e9 100644 --- a/docs/documentation/platform/external-migrations/vault.mdx +++ b/docs/documentation/platform/external-migrations/vault.mdx @@ -1,40 +1,239 @@ --- title: "Migrating from Vault to Infisical" sidebarTitle: "Vault" -description: "Learn how to migrate secrets from Vault to Infisical." +description: "Learn how to migrate resources from Vault to Infisical." --- -## Migrating from Vault +Infisical provides two approaches for migrating from HashiCorp Vault. -Migrating from Vault Self-Hosted or Dedicated Vault is a straight forward process with our inbuilt migration option. In order to migrate from Vault, you'll need to provide Infisical an access token to your Vault instance. +### Which approach should I use? -Currently the Vault migration only supports migrating secrets from the KV V2 and V1 secrets engine. If you're using a different secrets engine, please open an issue on our [GitHub repository](https://github.com/infisical/infisical/issues). +**Choose In-Platform Migration Tooling if you want to:** +- Migrate specific secrets, not everything at once +- Import secrets into existing Infisical projects +- Translate Vault policies to Infisical access controls +- Import Kubernetes authentication configurations +- Have more control over the migration process -### Prerequisites +**Choose Bulk Data Import if you want to:** -- A Vault instance with the KV secret engine enabled. -- An access token to your Vault instance. - - -### Project Mapping - -When migrating from Vault, you'll need to choose how you want to map your Vault resources to Infisical projects. - -There are two options for project mapping: - -- `Namespace`: This will map your selected Vault namespace to a single Infisical project. When you select this option, each KV secret engine within the namespace will be mapped to a single Infisical project. Each KV secret engine will be mapped to a Infisical environment within the project. This means if you have 3 KV secret engines, you'll have 3 environments inside the same project, where the name of the environments correspond to the name of the KV secret engines. -- `Key Vault`: This will map all the KV secret engines within your Vault instance to a Infisical project. Each KV engine will be created as a Infisical project. This means if you have 3 KV secret engines, you'll have 3 Infisical projects. For each of the created projects, a single default environment will be created called `Production`, which will contain all your secrets from the corresponding KV secret engine. +- Migrate all secrets from Vault in one go +- Automatically create new Infisical projects from your Vault structure +- Perform a one-time migration when moving entirely from Vault to Infisical +## In-Platform Migration Tooling +This migration approach lets you set up a connection to your Vault instance once, then import specific resources as needed throughout Infisical. +### Step 1: Set Up Your Vault Connection - In order to migrate from Vault, you'll need to create a Vault policy that allows Infisical to read the secrets and metadata from the KV v2 secrets engines within your Vault instance. + In your Vault instance, create a policy that allows Infisical to read your secrets, policies, and authentication configurations. This policy grants read-only access and doesn't allow Infisical to modify anything in Vault. + + ```hcl + # System endpoints - for listing namespaces, policies, mounts, and auth methods + path "sys/namespaces" { + capabilities = ["list"] + } - ```python + path "sys/policy" { + capabilities = ["read", "list"] + } + + path "sys/policy/*" { + capabilities = ["read"] + } + + path "sys/mounts" { + capabilities = ["read"] + } + + path "sys/auth" { + capabilities = ["read"] + } + + # KV v2 secrets - for listing and reading secrets + # Replace '+' with your actual KV v2 mount paths (e.g., "secret", "kv") + path "+/metadata/*" { + capabilities = ["list", "read"] + } + + path "+/data/*" { + capabilities = ["read"] + } + + # KV v1 secrets - for listing and reading secrets + # Replace '+' with your actual KV v1 mount paths (e.g., "secret", "kv-v1") + # WARNING: This is broad - ideally specify exact mount names + path "+/*" { + capabilities = ["list", "read"] + } + + # Kubernetes auth - for reading auth configuration and roles + path "auth/+/config" { + capabilities = ["read"] + } + + path "auth/+/role" { + capabilities = ["list"] + } + + path "auth/+/role/*" { + capabilities = ["read"] + } + ``` + + + Save this policy in Vault with the name `infisical-in-platform-migration`. + + + + + In Infisical, navigate to **Organization Settings > App Connections** and create a new HashiCorp Vault connection. + + Follow the [HashiCorp Vault App Connection documentation](/integrations/app-connections/hashicorp-vault) for detailed setup instructions. When configuring authentication (Token or AppRole), make sure it uses the `infisical-in-platform-migration` policy you created. + + + + + Navigate to **Organization Settings > External Migrations** in Infisical. + + Under the "In-Platform Migration Tooling" section for HashiCorp Vault, click **"+ Add Namespace"**. + + ![In-Platform Migration Tooling](/images/platform/external-migrations/vault-in-platform/external-migration-overview.png) + + Configure your namespace: + + ![Namespace Configuration](/images/platform/external-migrations/vault-in-platform/namespace-configuration-modal.png) + + - **Namespace**: Enter your Vault namespace path (e.g., `admin/namespace1`). If you intend to use the root namespace, set the namespace value to "root". + - **Connection**: Select the App Connection you created in the previous step. + + + You can add multiple namespaces with different connections if you have multiple Vault instances or namespaces to migrate from. + + + + + +### Step 2: Import Your Resources + +Once your Vault connection is configured, you'll see import options throughout Infisical wherever relevant. Here's what you can import: + +#### Import Secrets into a Project + +You can import secrets from Vault directly into a specific environment and secret path: + +1. Navigate to your project and select a specific environment (e.g., Development, Production) +2. In the secrets view, click the dropdown icon (caret) next to the **"+ Add Secret"** button +3. Select **"Add from HashiCorp Vault"** + + ![Import Vault Secrets](/images/platform/external-migrations/vault-in-platform/import-vault-secrets-modal.png) + +4. Choose your Vault namespace and the secret path you want to import +5. Click **"Import Secrets"** + +The secrets will be imported into your current environment and folder path. + +#### Import Kubernetes Authentication Configurations + +When setting up Kubernetes authentication for a machine identity, you can import the configuration from Vault: + +1. Navigate to **Access Control > Machine Identities** and select an identity +2. Click **"Add Authentication Method"** and choose **Kubernetes Auth** +3. In the configuration modal, click **"Load from Vault"** + + ![Load Kubernetes Auth from Vault](/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-auth-modal.png) + +4. Select your Vault namespace and the Kubernetes role +5. Click **"Load"** + + ![Kubernetes Auth Form Populated](/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-auth-modal-form.png) + +The authentication settings (service accounts, TTL, policies, etc.) will be automatically populated from your Vault configuration. + + + Sensitive values like service account JWTs cannot be retrieved from Vault and + must be manually provided in the form after importing the configuration. + + +#### Import and Translate Access Control Policies + +When configuring project role-based access control, you can import Vault HCL policies and automatically translate them to Infisical permissions. + + + Policy translation is best-effort and provides a starting point based on your + Vault configuration. The translated permissions should be reviewed and + adjusted as needed since Vault and Infisical have different access control + models. Infisical will analyze path patterns and capabilities to suggest + equivalent permissions. + + +**To import and translate a policy:** + +1. Navigate to your project, then go to **Access Control > Roles** and create or edit a role +2. In the policy configuration, click **"Add from HashiCorp Vault"** + + ![Import Vault Policy Button](/images/platform/external-migrations/vault-in-platform/translate-vault-policy-toggle.png) + +3. Select your Vault namespace +4. Either choose an existing policy from the dropdown or paste your own HCL policy + + ![Translate Vault Policy Modal](/images/platform/external-migrations/vault-in-platform/translate-vault-policy-modal.png) + +5. Review the automatically translated Infisical permissions +6. Make any adjustments and save + + + **How policy translation works:** + + - Vault path patterns are analyzed to identify KV secret engines and environments + - Vault capabilities (`read`,`list`, `create`, etc.) are mapped to Infisical permissions + - Wildcards in paths are converted to glob patterns + - Secret paths are preserved for granular access control + + Always review the translated permissions carefully, as Vault's capability-based model may not map 1:1 with Infisical's permission structure. + + +--- + +## Bulk Data Import + +This migration approach imports all secrets from your Vault instance in one operation and automatically creates new Infisical projects based on your Vault structure. + +### Understanding Project Mapping + +Before starting the bulk import, you need to decide how your Vault structure will map to Infisical projects: + + + Each Vault namespace becomes a single Infisical project, with each KV secret engine becoming an environment within that project. + + **Example:** If you have a namespace with 3 KV secret engines (`dev-secrets`, `staging-secrets`, `prod-secrets`): + + - Creates: 1 Infisical project + - Environments: 3 (`dev-secrets`, `staging-secrets`, `prod-secrets`) + + + + Each KV secret engine becomes its own Infisical project with a single `Production` environment. + + **Example:** If you have 3 KV secret engines (`dev-secrets`, `staging-secrets`, `prod-secrets`): + + - Creates: 3 Infisical projects (`dev-secrets`, `staging-secrets`, `prod-secrets`) + - Each project has: 1 environment (`Production`) + + +### How to Perform a Bulk Import + + + + In your Vault instance, create a policy that allows Infisical to read all secrets and metadata. This policy grants read-only access. + + + ```hcl # Allow listing secret engines/mounts path "sys/mounts" { capabilities = ["read", "list"] @@ -63,65 +262,54 @@ There are two options for project mapping: capabilities = ["read", "list"] } ``` + - Save this policy with the name `infisical-migration`. + Save this policy in Vault with the name `infisical-bulk-migration`. - You can use the Vault CLI to easily generate an access token for the new `infisical-migration` policy that you created in the previous step. + Use the Vault CLI to generate an access token: ```bash - vault token create --policy="infisical-migration" + vault token create --policy="infisical-bulk-migration" ``` - After generating the token, you should see the following output: + Copy the `token` value from the output - you'll need it in the next step. - ```t - $ vault token create --policy="infisical-migration" - - Key Value - --- ----- - token - token_accessor p6kJDiBSzYYdabJUIpGCsCBm - token_duration 768h - token_renewable true - token_policies ["default" "infisical-migration"] - identity_policies [] - policies ["default" "infisical-migration"] - ``` - - Copy the `token` field and save it for later, as you'll need this when configuring the migration to Infisical. - - Open the Infisical dashboard and go to Organization Settings > External Migrations. + + In Infisical, navigate to **Organization Settings > External Migrations**. ![Infisical Organization settings](/images/platform/external-migrations/infisical-external-migration-dashboard.png) + + Under the "Bulk Data Import" section, click **"+ Import"**. + - - Select the Vault platform and click on Next. + + Select **HashiCorp Vault** as the migration source and click **Next**. ![Select Vault platform](/images/platform/external-migrations/infisical-import-vault-modal.png) + - - Enter the Vault access token that you generated in the previous step and click Import data. + + Fill in your Vault connection details: ![Configure Vault migration](/images/platform/external-migrations/infisical-import-vault.png) - - `Vault URL`: The URL of your Vault instance. - - `Vault Namespace`: The namespace of your Vault instance. This is optional, and can be left blank if you're not using namespaces for your Vault instance. - - `Vault Access Token`: The access token that you generated in the previous step. + - **Vault URL**: Your Vault instance URL (e.g., `https://vault.example.com`) + - **Vault Namespace**: Optional - only needed if using Vault Enterprise namespaces + - **Vault Access Token**: The token you generated in step 2 + - **Project Mapping**: Choose how to structure your Infisical projects (see [Understanding Project Mapping](#understanding-project-mapping)) - - `Project Mapping`: Choose how you want to map your Vault resources to Infisical projects. You can review the mapping options in the [Project Mapping](#project-mapping) section. + Click **"Import Data"** to start the migration. - Click on Import data to start the migration. + + The import runs in the background and may take several minutes. You'll receive an email when it completes. + - - - It may take several minutes to complete the migration. You will receive an email when the migration is complete, or if there were any errors during the migration process. - \ No newline at end of file diff --git a/docs/images/dynamic-secret-crd.png b/docs/images/dynamic-secret-crd.png new file mode 100644 index 000000000..8b20dbd2e Binary files /dev/null and b/docs/images/dynamic-secret-crd.png differ diff --git a/docs/images/dynamic-secret.png b/docs/images/dynamic-secret.png new file mode 100644 index 000000000..812b2dfc3 Binary files /dev/null and b/docs/images/dynamic-secret.png differ diff --git a/docs/images/platform/external-migrations/vault-in-platform/external-migration-overview.png b/docs/images/platform/external-migrations/vault-in-platform/external-migration-overview.png new file mode 100644 index 000000000..3f2be6217 Binary files /dev/null and b/docs/images/platform/external-migrations/vault-in-platform/external-migration-overview.png differ diff --git a/docs/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-auth-modal-form.png b/docs/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-auth-modal-form.png new file mode 100644 index 000000000..230393be6 Binary files /dev/null and b/docs/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-auth-modal-form.png differ diff --git a/docs/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-auth-modal.png b/docs/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-auth-modal.png new file mode 100644 index 000000000..e3c41d187 Binary files /dev/null and b/docs/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-auth-modal.png differ diff --git a/docs/images/platform/external-migrations/vault-in-platform/import-vault-secrets-modal.png b/docs/images/platform/external-migrations/vault-in-platform/import-vault-secrets-modal.png new file mode 100644 index 000000000..55185db37 Binary files /dev/null and b/docs/images/platform/external-migrations/vault-in-platform/import-vault-secrets-modal.png differ diff --git a/docs/images/platform/external-migrations/vault-in-platform/namespace-configuration-modal.png b/docs/images/platform/external-migrations/vault-in-platform/namespace-configuration-modal.png new file mode 100644 index 000000000..828b82544 Binary files /dev/null and b/docs/images/platform/external-migrations/vault-in-platform/namespace-configuration-modal.png differ diff --git a/docs/images/platform/external-migrations/vault-in-platform/translate-vault-policy-modal.png b/docs/images/platform/external-migrations/vault-in-platform/translate-vault-policy-modal.png new file mode 100644 index 000000000..34955b03f Binary files /dev/null and b/docs/images/platform/external-migrations/vault-in-platform/translate-vault-policy-modal.png differ diff --git a/docs/images/platform/external-migrations/vault-in-platform/translate-vault-policy-toggle.png b/docs/images/platform/external-migrations/vault-in-platform/translate-vault-policy-toggle.png new file mode 100644 index 000000000..a165fff77 Binary files /dev/null and b/docs/images/platform/external-migrations/vault-in-platform/translate-vault-policy-toggle.png differ diff --git a/docs/images/push-secret.png b/docs/images/push-secret.png new file mode 100644 index 000000000..a70228434 Binary files /dev/null and b/docs/images/push-secret.png differ diff --git a/frontend/src/components/features/WishForm.tsx b/frontend/src/components/features/WishForm.tsx index fc38d0731..118900bc9 100644 --- a/frontend/src/components/features/WishForm.tsx +++ b/frontend/src/components/features/WishForm.tsx @@ -63,14 +63,14 @@ export const WishForm = () => { open={isOpen} > -
+
Request a feature
( -
- - +const BreadcrumbContainer = ({ + breadcrumbs, + className +}: { + breadcrumbs: TBreadcrumbFormat[]; + className?: string; +}) => ( +
+ + {(breadcrumbs as TBreadcrumbFormat[]).map((el, index) => { const isNotLastCrumb = index + 1 !== breadcrumbs.length; const BreadcrumbSegment = isNotLastCrumb ? BreadcrumbLink : BreadcrumbPage; @@ -165,8 +171,8 @@ const BreadcrumbContainer = ({ breadcrumbs }: { breadcrumbs: TBreadcrumbFormat[] const Component = el.component; return ( - - + + diff --git a/frontend/src/components/v2/PageHeader/PageHeader.tsx b/frontend/src/components/v2/PageHeader/PageHeader.tsx index 01e710cd3..3c9e743ff 100644 --- a/frontend/src/components/v2/PageHeader/PageHeader.tsx +++ b/frontend/src/components/v2/PageHeader/PageHeader.tsx @@ -5,29 +5,48 @@ import { ReactNode } from "@tanstack/react-router"; import { twMerge } from "tailwind-merge"; import { Badge } from "@app/components/v2"; +import { BadgeProps } from "@app/components/v2/Badge/Badge"; +import { ProjectType } from "@app/hooks/api/projects/types"; type Props = { title: ReactNode; description?: ReactNode; children?: ReactNode; className?: string; - scope: "org" | "project" | "namespace" | "instance"; + scope: "org" | "namespace" | "instance" | ProjectType | null; }; const SCOPE_NAME: Record, { label: string; icon: IconDefinition }> = { org: { label: "Organization", icon: faGlobe }, - project: { label: "Project", icon: faCube }, + [ProjectType.SecretManager]: { label: "Project", icon: faCube }, + [ProjectType.CertificateManager]: { label: "Project", icon: faCube }, + [ProjectType.SSH]: { label: "Project", icon: faCube }, + [ProjectType.KMS]: { label: "Project", icon: faCube }, + [ProjectType.PAM]: { label: "Project", icon: faCube }, + [ProjectType.SecretScanning]: { label: "Project", icon: faCube }, namespace: { label: "Namespace", icon: faCubes }, instance: { label: "Server", icon: faServer } }; +const SCOPE_VARIANT: Record, BadgeProps["variant"]> = { + org: "org", + [ProjectType.SecretManager]: "project", + [ProjectType.CertificateManager]: "project", + [ProjectType.SSH]: "project", + [ProjectType.KMS]: "project", + [ProjectType.PAM]: "project", + [ProjectType.SecretScanning]: "project", + namespace: "namespace", + instance: "instance" +}; + export const PageHeader = ({ title, description, children, className, scope }: Props) => ( -
+

{title}

{scope && ( - + {SCOPE_NAME[scope].label} diff --git a/frontend/src/components/v2/Tabs/Tabs.tsx b/frontend/src/components/v2/Tabs/Tabs.tsx index 4100fd144..ebe8eb2ed 100644 --- a/frontend/src/components/v2/Tabs/Tabs.tsx +++ b/frontend/src/components/v2/Tabs/Tabs.tsx @@ -1,10 +1,19 @@ +import { IconDefinition } from "@fortawesome/free-brands-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import * as TabsPrimitive from "@radix-ui/react-tabs"; import { twMerge } from "tailwind-merge"; export type TabsProps = TabsPrimitive.TabsProps; export const Tabs = ({ className, children, ...props }: TabsProps) => ( - + {children} ); @@ -13,7 +22,11 @@ export type TabListProps = TabsPrimitive.TabsListProps; export const TabList = ({ className, children, ...props }: TabListProps) => ( {children} @@ -26,18 +39,29 @@ export const Tab = ({ className, children, variant = "project", + icon, ...props -}: TabProps & { variant?: "project" | "namespace" | "org" }) => ( +}: TabProps & { + icon?: IconDefinition; + variant?: "project" | "namespace" | "org" | "instance"; +}) => ( + {icon && } {children} ); @@ -46,7 +70,10 @@ export type TabPanelProps = TabsPrimitive.TabsContentProps; export const TabPanel = ({ className, children, ...props }: TabPanelProps) => ( {children} diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index 970e0592d..33bc2107d 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -43,6 +43,8 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET]: "Revoke universal auth client secret", [EventType.CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS]: "Clear universal auth lockouts", [EventType.GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS]: "Get universal auth client secrets", + [EventType.GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET_BY_ID]: + "Get universal auth client secret by id", [EventType.CREATE_ENVIRONMENT]: "Create environment", [EventType.UPDATE_ENVIRONMENT]: "Update environment", [EventType.DELETE_ENVIRONMENT]: "Delete environment", diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index b10fcb60a..ea684a4bb 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -48,6 +48,7 @@ export enum EventType { REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "revoke-identity-universal-auth-client-secret", CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS = "clear-identity-universal-auth-lockouts", GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS = "get-identity-universal-auth-client-secret", + GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET_BY_ID = "get-identity-universal-auth-client-secret-by-id", LOGIN_IDENTITY_LDAP_AUTH = "login-identity-ldap-auth", ADD_IDENTITY_LDAP_AUTH = "add-identity-ldap-auth", diff --git a/frontend/src/hooks/api/auditLogs/types.tsx b/frontend/src/hooks/api/auditLogs/types.tsx index 1bb321fa2..fc15b5064 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -318,6 +318,14 @@ interface GetIdentityUniversalAuthClientSecretsEvent { }; } +interface GetIdentityUniversalAuthClientSecretByIdEvent { + type: EventType.GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET_BY_ID; + metadata: { + identityId: string; + clientSecretId: string; + }; +} + interface RevokeIdentityUniversalAuthClientSecretEvent { type: EventType.REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET; metadata: { @@ -906,6 +914,7 @@ export type Event = | GetIdentityUniversalAuthEvent | CreateIdentityUniversalAuthClientSecretEvent | GetIdentityUniversalAuthClientSecretsEvent + | GetIdentityUniversalAuthClientSecretByIdEvent | RevokeIdentityUniversalAuthClientSecretEvent | ClearIdentityUniversalAuthLockoutsEvent | CreateEnvironmentEvent diff --git a/frontend/src/hooks/api/migration/index.ts b/frontend/src/hooks/api/migration/index.ts index 0c2adeab0..177955438 100644 --- a/frontend/src/hooks/api/migration/index.ts +++ b/frontend/src/hooks/api/migration/index.ts @@ -1,2 +1,3 @@ export * from "./mutations"; export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/migration/mutations.tsx b/frontend/src/hooks/api/migration/mutations.tsx index b2694b458..00d3148ef 100644 --- a/frontend/src/hooks/api/migration/mutations.tsx +++ b/frontend/src/hooks/api/migration/mutations.tsx @@ -1,8 +1,12 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { dashboardKeys } from "@app/hooks/api/dashboard/queries"; +import { secretKeys } from "@app/hooks/api/secrets/queries"; import { projectKeys } from "../projects"; +import { externalMigrationQueryKeys } from "./queries"; +import { TImportVaultSecretsDTO, TVaultExternalMigrationConfig, VaultImportStatus } from "./types"; export const useImportEnvKey = () => { const queryClient = useQueryClient(); @@ -65,3 +69,97 @@ export const useImportVault = () => { } }); }; + +export const useImportVaultSecrets = () => { + const queryClient = useQueryClient(); + + return useMutation<{ status: VaultImportStatus }, object, TImportVaultSecretsDTO>({ + mutationFn: async (dto) => { + const { data } = await apiRequest.post<{ status: VaultImportStatus }>( + "/api/v3/external-migration/vault/import-secrets", + dto + ); + return data; + }, + onSuccess: (_, { projectId, environment, secretPath }) => { + queryClient.invalidateQueries({ queryKey: dashboardKeys.all() }); + queryClient.invalidateQueries({ + queryKey: secretKeys.getProjectSecret({ + projectId, + environment, + secretPath + }) + }); + } + }); +}; + +export const useCreateVaultExternalMigrationConfig = () => { + const queryClient = useQueryClient(); + + return useMutation< + TVaultExternalMigrationConfig, + Error, + { connectionId: string; namespace: string } + >({ + mutationFn: async ({ connectionId, namespace }) => { + const { data } = await apiRequest.post<{ config: TVaultExternalMigrationConfig }>( + "/api/v3/external-migration/vault/configs", + { + connectionId, + namespace + } + ); + return data.config; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: externalMigrationQueryKeys.vaultConfigs() + }); + } + }); +}; + +export const useUpdateVaultExternalMigrationConfig = () => { + const queryClient = useQueryClient(); + + return useMutation< + TVaultExternalMigrationConfig, + Error, + { id: string; connectionId: string; namespace: string } + >({ + mutationFn: async ({ id, connectionId, namespace }) => { + const { data } = await apiRequest.put<{ config: TVaultExternalMigrationConfig }>( + `/api/v3/external-migration/vault/configs/${id}`, + { + connectionId, + namespace + } + ); + return data.config; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: externalMigrationQueryKeys.vaultConfigs() + }); + } + }); +}; + +export const useDeleteVaultExternalMigrationConfig = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ id }) => { + const { data } = await apiRequest.delete<{ config: TVaultExternalMigrationConfig }>( + `/api/v3/external-migration/vault/configs/${id}` + ); + return data.config; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: externalMigrationQueryKeys.vaultConfigs() + }); + } + }); +}; diff --git a/frontend/src/hooks/api/migration/queries.tsx b/frontend/src/hooks/api/migration/queries.tsx index e96533b09..e4ce6824d 100644 --- a/frontend/src/hooks/api/migration/queries.tsx +++ b/frontend/src/hooks/api/migration/queries.tsx @@ -2,12 +2,35 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { ExternalMigrationProviders } from "./types"; +import { + ExternalMigrationProviders, + TVaultExternalMigrationConfig, + VaultKubernetesAuthRole +} from "./types"; -const externalMigrationQueryKeys = { +export const externalMigrationQueryKeys = { customMigrationAvailable: (provider: ExternalMigrationProviders) => [ "custom-migration-available", provider + ], + vaultConfigs: () => ["vault-external-migration-configs"], + vaultNamespaces: () => ["vault-namespaces"], + vaultPolicies: (namespace?: string) => ["vault-policies", namespace], + vaultMounts: (namespace?: string) => ["vault-mounts", namespace], + vaultAuthMounts: (namespace?: string, authType?: string) => [ + "vault-auth-mounts", + namespace, + authType + ], + vaultSecretPaths: (namespace?: string, mountPath?: string) => [ + "vault-secret-paths", + namespace, + mountPath + ], + vaultKubernetesAuthRoles: (namespace?: string, mountPath?: string) => [ + "vault-kubernetes-auth-roles", + namespace, + mountPath ] }; @@ -20,3 +43,132 @@ export const useHasCustomMigrationAvailable = (provider: ExternalMigrationProvid ) }); }; + +export const useGetVaultExternalMigrationConfigs = () => { + return useQuery({ + queryKey: externalMigrationQueryKeys.vaultConfigs(), + queryFn: async () => { + const { data } = await apiRequest.get<{ configs: TVaultExternalMigrationConfig[] }>( + "/api/v3/external-migration/vault/configs" + ); + return data.configs; + } + }); +}; + +export const useGetVaultNamespaces = () => { + return useQuery({ + queryKey: externalMigrationQueryKeys.vaultNamespaces(), + queryFn: async () => { + const { data } = await apiRequest.get<{ + namespaces: Array<{ id: string; name: string }>; + }>("/api/v3/external-migration/vault/namespaces"); + return data.namespaces; + } + }); +}; + +export const useGetVaultPolicies = (enabled = true, namespace?: string) => { + return useQuery({ + queryKey: externalMigrationQueryKeys.vaultPolicies(namespace), + queryFn: async () => { + const { data } = await apiRequest.get<{ + policies: Array<{ name: string; rules: string }>; + }>("/api/v3/external-migration/vault/policies", { + params: { + namespace + } + }); + + return data.policies; + }, + enabled + }); +}; + +export const useGetVaultMounts = (enabled = true, namespace?: string) => { + return useQuery({ + queryKey: externalMigrationQueryKeys.vaultMounts(namespace), + queryFn: async () => { + const { data } = await apiRequest.get<{ + mounts: Array<{ path: string; type: string; version: string | null }>; + }>("/api/v3/external-migration/vault/mounts", { + params: { + namespace + } + }); + + return data.mounts; + }, + enabled + }); +}; + +export const useGetVaultSecretPaths = (enabled = true, namespace?: string, mountPath?: string) => { + return useQuery({ + queryKey: externalMigrationQueryKeys.vaultSecretPaths(namespace, mountPath), + queryFn: async () => { + if (!namespace || !mountPath) { + throw new Error("Both namespace and mountPath are required"); + } + + const { data } = await apiRequest.get<{ + secretPaths: string[]; + }>("/api/v3/external-migration/vault/secret-paths", { + params: { + namespace, + mountPath + } + }); + + return data.secretPaths; + }, + enabled: enabled && !!namespace && !!mountPath + }); +}; + +export const useGetVaultAuthMounts = (enabled = true, namespace?: string, authType?: string) => { + return useQuery({ + queryKey: externalMigrationQueryKeys.vaultAuthMounts(namespace, authType), + queryFn: async () => { + const { data } = await apiRequest.get<{ + mounts: Array<{ path: string; type: string }>; + }>("/api/v3/external-migration/vault/auth-mounts", { + params: { + namespace, + ...(authType && { authType }) + } + }); + + return data.mounts; + }, + enabled + }); +}; + +export const useGetVaultKubernetesAuthRoles = ( + enabled = true, + namespace?: string, + mountPath?: string +) => { + return useQuery({ + queryKey: externalMigrationQueryKeys.vaultKubernetesAuthRoles(namespace, mountPath), + queryFn: async () => { + if (!namespace || !mountPath) { + throw new Error("Both namespace and mountPath are required"); + } + + const { data } = await apiRequest.get<{ + roles: VaultKubernetesAuthRole[]; + }>("/api/v3/external-migration/vault/auth-roles/kubernetes", { + params: { + namespace, + mountPath + } + }); + + return data.roles; + }, + enabled: enabled && !!namespace && !!mountPath + }); +}; diff --git a/frontend/src/hooks/api/migration/types.ts b/frontend/src/hooks/api/migration/types.ts index 945f18d8e..f4303ea26 100644 --- a/frontend/src/hooks/api/migration/types.ts +++ b/frontend/src/hooks/api/migration/types.ts @@ -2,3 +2,50 @@ export enum ExternalMigrationProviders { Vault = "vault", EnvKey = "env-key" } + +export enum VaultImportStatus { + Imported = "imported", + ApprovalRequired = "approval-required" +} + +export type TVaultExternalMigrationConfig = { + id: string; + orgId: string; + namespace: string; + connectionId: string | null; + createdAt: string; + updatedAt: string; +}; + +export type TImportVaultSecretsDTO = { + projectId: string; + environment: string; + secretPath: string; + vaultNamespace: string; + vaultSecretPath: string; +}; + +export type VaultKubernetesAuthRole = { + name: string; + bound_service_account_names: string[]; + bound_service_account_namespaces: string[]; + token_ttl?: number; + token_max_ttl?: number; + token_policies?: string[]; + token_bound_cidrs?: string[]; + token_explicit_max_ttl?: number; + token_no_default_policy?: boolean; + token_num_uses?: number; + token_period?: number; + token_type?: string; + audience?: string; + alias_name_source?: string; + mountPath: string; + config: { + kubernetes_host: string; + kubernetes_ca_cert?: string; + issuer?: string; + disable_iss_validation?: boolean; + disable_local_ca_jwt?: boolean; + }; +}; diff --git a/frontend/src/hooks/api/secretFolders/index.tsx b/frontend/src/hooks/api/secretFolders/index.tsx index 0676f5d14..9d2e44621 100644 --- a/frontend/src/hooks/api/secretFolders/index.tsx +++ b/frontend/src/hooks/api/secretFolders/index.tsx @@ -2,6 +2,7 @@ export { useCreateFolder, useDeleteFolder, useGetFoldersByEnv, + useGetOrCreateFolder, useGetProjectFolders, useUpdateFolder } from "./queries"; diff --git a/frontend/src/hooks/api/secretFolders/queries.tsx b/frontend/src/hooks/api/secretFolders/queries.tsx index 3e752eed3..56f7825c0 100644 --- a/frontend/src/hooks/api/secretFolders/queries.tsx +++ b/frontend/src/hooks/api/secretFolders/queries.tsx @@ -140,6 +140,59 @@ export const useGetFoldersByEnv = ({ return { folders, folderNames, isFolderPresentInEnv, getFolderByNameAndEnv }; }; +export const useGetOrCreateFolder = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (dto) => { + const { data: existingFolder } = await apiRequest.get<{ folders: TSecretFolder[] }>( + "/api/v2/folders", + { + params: { + projectId: dto.projectId, + environment: dto.environment, + path: dto.path || "/" + } + } + ); + + const folder = existingFolder.folders.find((f) => f.name === dto.name); + + if (folder) return folder; + + const { data } = await apiRequest.post("/api/v2/folders", { + ...dto, + projectId: dto.projectId + }); + + return data; + }, + onSuccess: (_, { projectId, environment, path }) => { + queryClient.invalidateQueries({ + queryKey: dashboardKeys.getDashboardSecrets({ + projectId, + secretPath: path ?? "/" + }) + }); + queryClient.invalidateQueries({ + queryKey: folderQueryKeys.getSecretFolders({ projectId, environment, path }) + }); + queryClient.invalidateQueries({ + queryKey: secretSnapshotKeys.list({ projectId, environment, directory: path }) + }); + queryClient.invalidateQueries({ + queryKey: secretSnapshotKeys.count({ projectId, environment, directory: path }) + }); + queryClient.invalidateQueries({ + queryKey: commitKeys.count({ projectId, environment, directory: path }) + }); + queryClient.invalidateQueries({ + queryKey: commitKeys.history({ projectId, environment, directory: path }) + }); + } + }); +}; + export const useCreateFolder = () => { const queryClient = useQueryClient(); @@ -170,6 +223,9 @@ export const useCreateFolder = () => { queryClient.invalidateQueries({ queryKey: commitKeys.count({ projectId, environment, directory: path }) }); + queryClient.invalidateQueries({ + queryKey: commitKeys.history({ projectId, environment, directory: path }) + }); } }); }; @@ -218,12 +274,13 @@ export const useDeleteFolder = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ path = "/", folderId, environment, projectId }) => { + mutationFn: async ({ path = "/", folderId, environment, projectId, forceDelete = true }) => { const { data } = await apiRequest.delete(`/api/v2/folders/${folderId}`, { data: { environment, projectId, - path + path, + forceDelete } }); return data; diff --git a/frontend/src/hooks/api/secretFolders/types.ts b/frontend/src/hooks/api/secretFolders/types.ts index e9ee2ae5d..f3e3cecaf 100644 --- a/frontend/src/hooks/api/secretFolders/types.ts +++ b/frontend/src/hooks/api/secretFolders/types.ts @@ -59,6 +59,7 @@ export type TDeleteFolderDTO = { environment: string; folderId: string; path?: string; + forceDelete?: boolean; }; export type TUpdateFolderBatchDTO = { diff --git a/frontend/src/index.css b/frontend/src/index.css index 360894c2f..7c9b7db9b 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -42,7 +42,7 @@ --font-inter: "Inter", sans-serif; --color-org-v1: #30B3FF; --color-namespace-v1: #96ff59; - + --max-width-8xl: 88rem; /* 1408px */ /* Primary */ --color-primary-50: #fffff5; --color-primary-100: #fcfce8; diff --git a/frontend/src/layouts/AdminLayout/AdminLayout.tsx b/frontend/src/layouts/AdminLayout/AdminLayout.tsx index 859ce9627..89e8a761e 100644 --- a/frontend/src/layouts/AdminLayout/AdminLayout.tsx +++ b/frontend/src/layouts/AdminLayout/AdminLayout.tsx @@ -12,7 +12,7 @@ import { RedisBanner } from "@app/layouts/OrganizationLayout/components/RedisBan import { SmtpBanner } from "@app/layouts/OrganizationLayout/components/SmtpBanner"; import { InsecureConnectionBanner } from "../OrganizationLayout/components/InsecureConnectionBanner"; -import { AdminSidebar } from "./Sidebar"; +import { AdminNavBar } from "./AdminNavBar"; export const AdminLayout = () => { const { t } = useTranslation(); @@ -33,9 +33,9 @@ export const AdminLayout = () => { {!isLoading && !serverDetails?.emailConfigured && } {!isLoading && subscription.auditLogs && } {!window.isSecureContext && } -
- -
+
+ +
diff --git a/frontend/src/layouts/AdminLayout/AdminNavBar.tsx b/frontend/src/layouts/AdminLayout/AdminNavBar.tsx new file mode 100644 index 000000000..49cc1edf9 --- /dev/null +++ b/frontend/src/layouts/AdminLayout/AdminNavBar.tsx @@ -0,0 +1,99 @@ +import { faCheckCircle } from "@fortawesome/free-regular-svg-icons"; +import { + faArrowLeft, + faBuilding, + faCog, + faDatabase, + faKey, + faLock, + faPlug, + faUserTie +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Link, useMatchRoute } from "@tanstack/react-router"; +import { motion } from "framer-motion"; + +import { Tab, TabList, Tabs, Tooltip } from "@app/components/v2"; + +const generalTabs = [ + { + label: "General", + icon: faCog, + link: "/admin/" + }, + { + label: "Resource Overview", + icon: faBuilding, + link: "/admin/resources/overview" + }, + { + label: "Access Control", + icon: faUserTie, + link: "/admin/access-management" + }, + { + label: "Encryption", + icon: faLock, + link: "/admin/encryption" + }, + { + label: "Authentication", + icon: faCheckCircle, + link: "/admin/authentication" + }, + { + label: "Integrations", + icon: faPlug, + link: "/admin/integrations" + }, + { + label: "Caching", + icon: faDatabase, + link: "/admin/caching" + }, + { + label: "Environment Variables", + icon: faKey, + link: "/admin/environment" + } +]; + +export const AdminNavBar = () => { + const matchRoute = useMatchRoute(); + + return ( +
+ + + +
+ ); +}; diff --git a/frontend/src/layouts/AdminLayout/Sidebar.tsx b/frontend/src/layouts/AdminLayout/Sidebar.tsx deleted file mode 100644 index 185ea1dbf..000000000 --- a/frontend/src/layouts/AdminLayout/Sidebar.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { faCheckCircle } from "@fortawesome/free-regular-svg-icons"; -import { - faBuilding, - faChevronLeft, - faCog, - faDatabase, - faKey, - faLock, - faPlug, - faUserTie -} from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Link, useMatchRoute } from "@tanstack/react-router"; - -import { Menu, MenuGroup, MenuItem } from "@app/components/v2"; - -const generalTabs = [ - { - label: "General", - icon: faCog, - link: "/admin/" - }, - { - label: "Encryption", - icon: faLock, - link: "/admin/encryption" - }, - { - label: "Authentication", - icon: faCheckCircle, - link: "/admin/authentication" - }, - { - label: "Integrations", - icon: faPlug, - link: "/admin/integrations" - }, - { - label: "Caching", - icon: faDatabase, - link: "/admin/caching" - }, - { - label: "Environment Variables", - icon: faKey, - link: "/admin/environment" - } -]; - -const othersTabs = [ - { - label: "Access Controls", - icon: faUserTie, - link: "/admin/access-management" - }, - { - label: "Resource Overview", - icon: faBuilding, - link: "/admin/resources/overview" - } -]; - -export const AdminSidebar = () => { - const matchRoute = useMatchRoute(); - - return ( - - ); -}; diff --git a/frontend/src/layouts/KmsLayout/KmsLayout.tsx b/frontend/src/layouts/KmsLayout/KmsLayout.tsx index 4aa1c1406..c4fdf32eb 100644 --- a/frontend/src/layouts/KmsLayout/KmsLayout.tsx +++ b/frontend/src/layouts/KmsLayout/KmsLayout.tsx @@ -1,9 +1,7 @@ -import { faBook, faCog, faCube, faHome, faLock, faUsers } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Link, Outlet } from "@tanstack/react-router"; +import { Link, Outlet, useLocation } from "@tanstack/react-router"; import { motion } from "framer-motion"; -import { Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2"; +import { Tab, TabList, Tabs } from "@app/components/v2"; import { useProject, useProjectPermission } from "@app/context"; import { AssumePrivilegeModeBanner } from "../ProjectLayout/components/AssumePrivilegeModeBanner"; @@ -12,138 +10,81 @@ export const KmsLayout = () => { const { currentProject } = useProject(); const { assumedPrivilegeDetails } = useProjectPermission(); + const location = useLocation(); + return (
-
+
-
+ + {({ isActive }) => KMIP} + + + {({ isActive }) => ( + + Access Control + + )} + + + {({ isActive }) => Audit Logs} + + + {({ isActive }) => Settings} + + + -
- {assumedPrivilegeDetails && } - -
+
+ {assumedPrivilegeDetails && } +
+
); diff --git a/frontend/src/layouts/OrganizationLayout/OrganizationLayout.tsx b/frontend/src/layouts/OrganizationLayout/OrganizationLayout.tsx index f2866259c..c1cc10e41 100644 --- a/frontend/src/layouts/OrganizationLayout/OrganizationLayout.tsx +++ b/frontend/src/layouts/OrganizationLayout/OrganizationLayout.tsx @@ -13,7 +13,7 @@ import { useFetchServerStatus } from "@app/hooks/api"; import { AuditLogBanner } from "./components/AuditLogBanner"; import { InsecureConnectionBanner } from "./components/InsecureConnectionBanner"; import { Navbar } from "./components/NavBar"; -import { OrgSidebar } from "./components/OrgSidebar"; +import { OrgNavBar } from "./components/OrgNavBar"; import { RedisBanner } from "./components/RedisBanner"; import { SmtpBanner } from "./components/SmtpBanner"; @@ -41,16 +41,16 @@ export const OrganizationLayout = () => { className={`dark hidden ${containerHeight} w-full flex-col overflow-x-hidden bg-bunker-800 transition-all md:flex`} > - {!isLoading && !serverDetails?.redisConfigured && } - {!isLoading && !serverDetails?.emailConfigured && } - {!isLoading && subscription.auditLogs && } - {!window.isSecureContext && } -
- +
+ + {!isLoading && !isInsideProject && !serverDetails?.redisConfigured && } + {!isLoading && !isInsideProject && !serverDetails?.emailConfigured && } + {!isLoading && !isInsideProject && subscription.auditLogs && } + {!window.isSecureContext && !isInsideProject && }
diff --git a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx index 596202bf6..f3182ab83 100644 --- a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx @@ -9,6 +9,7 @@ import { faEnvelope, faExclamationTriangle, faGlobe, + faInfinity, faInfo, faInfoCircle, faServer, @@ -43,7 +44,7 @@ import { envConfig } from "@app/config/env"; import { useOrganization, useSubscription, useUser } from "@app/context"; import { isInfisicalCloud } from "@app/helpers/platform"; import { useToggle } from "@app/hooks"; -import { projectKeys, useGetOrganizations, useLogoutUser } from "@app/hooks/api"; +import { projectKeys, useGetOrganizations, useGetOrgTrialUrl, useLogoutUser } from "@app/hooks/api"; import { authKeys, selectOrganization } from "@app/hooks/api/auth/queries"; import { MfaMethod } from "@app/hooks/api/auth/types"; import { getAuthToken } from "@app/hooks/api/reactQuery"; @@ -162,6 +163,8 @@ export const Navbar = () => { await navigateUserToOrg(navigate, orgId); }; + const { mutateAsync } = useGetOrgTrialUrl(); + const logout = useLogoutUser(); const logOutUser = async () => { try { @@ -201,144 +204,192 @@ export const Navbar = () => { const isServerAdminPanel = location.pathname.startsWith("/admin"); - const isOrgScope = breadcrumbs?.length === 1; // TODO: scott/akhil is this adequate? + const isOrgScope = location.pathname.startsWith("/organization"); // TODO: scott/akhil is this adequate? return ( -
-
- - infisical logo - -
-

/

- {isServerAdminPanel ? ( - <> - -
- -
-
Server Console
+
+
+
+ + infisical logo -

/

- {breadcrumbs ? ( - // scott: remove /admin as we show server console above - - ) : null} - - ) : ( - <> -
- - -
- - - {currentOrg?.name} - -
- {getPlan(subscription)} -
- {subscription.cardDeclined && ( - +

/

+ {isServerAdminPanel ? ( + <> + +
+ +
+
Server Console
+ +

/

+ {breadcrumbs ? ( + // scott: remove /admin as we show server console above + + ) : null} + + ) : ( + <> +
+ + +
+ -
- -
- - )} -
- - -
- - - -
-
- -
organizations
- {orgs?.map((org) => { - return ( - - - - ); - })} -
- } onClick={logOutUser}> - Log Out - - - -
-

/

- {breadcrumbs ? ( - - ) : null} - + + )} +
+ + +
+ + + +
+
+ +
+ organizations +
+ {orgs?.map((org) => { + return ( + + + + ); + })} +
+ } + onClick={logOutUser} + > + Log Out + + + +
+ {!isOrgScope && ( + <> +

/

+ {breadcrumbs ? ( + + ) : null} + + )} + + )} +
+ {subscription && subscription.slug === "starter" && !subscription.has_used_trial && ( + + + + )} + {user.superAdmin && !location.pathname.startsWith("/admin") && ( + + + Server Console + )} -
diff --git a/frontend/src/layouts/OrganizationLayout/components/NavBar/Notification.tsx b/frontend/src/layouts/OrganizationLayout/components/NavBar/Notification.tsx index 82f5d0fdd..ade478c69 100644 --- a/frontend/src/layouts/OrganizationLayout/components/NavBar/Notification.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/Notification.tsx @@ -16,15 +16,15 @@ export const Notification = ({ notification, onDelete }: Props) => { return (
-
+
{!notification.isRead && ( - + )} {notification.title}} @@ -45,7 +45,7 @@ export const Notification = ({ notification, onDelete }: Props) => { )}
-
+
{ + const { popUp, handlePopUpToggle } = usePopUp(["createOrg"] as const); + + const { pathname } = useLocation(); + + return ( + <> + {!isHidden && ( +
+ + + +
+ )} + handlePopUpToggle("createOrg", false)} + /> + + ); +}; diff --git a/frontend/src/layouts/OrganizationLayout/components/OrgNavBar/index.tsx b/frontend/src/layouts/OrganizationLayout/components/OrgNavBar/index.tsx new file mode 100644 index 000000000..06509e919 --- /dev/null +++ b/frontend/src/layouts/OrganizationLayout/components/OrgNavBar/index.tsx @@ -0,0 +1 @@ +export { OrgNavBar } from "./OrgNavBar"; diff --git a/frontend/src/layouts/OrganizationLayout/components/OrgSidebar/OrgSidebar.tsx b/frontend/src/layouts/OrganizationLayout/components/OrgSidebar/OrgSidebar.tsx deleted file mode 100644 index 51e14721e..000000000 --- a/frontend/src/layouts/OrganizationLayout/components/OrgSidebar/OrgSidebar.tsx +++ /dev/null @@ -1,214 +0,0 @@ -import { - faBook, - faCog, - faInfinity, - faMoneyBill, - faNetworkWired, - faPlug, - faShare, - faTable, - faUsers, - faUserTie -} from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Link } from "@tanstack/react-router"; -import { AnimatePresence, motion } from "framer-motion"; - -import { CreateOrgModal } from "@app/components/organization/CreateOrgModal"; -import { Menu, MenuGroup, MenuItem, Tooltip } from "@app/components/v2"; -import { useOrganization, useSubscription, useUser } from "@app/context"; -import { usePopUp } from "@app/hooks"; -import { useGetOrgTrialUrl } from "@app/hooks/api"; - -type Props = { - isHidden?: boolean; -}; - -export const OrgSidebar = ({ isHidden }: Props) => { - const { subscription } = useSubscription(); - - const { user } = useUser(); - const { mutateAsync } = useGetOrgTrialUrl(); - - const { currentOrg } = useOrganization(); - - const { popUp, handlePopUpToggle } = usePopUp(["createOrg"] as const); - - return ( - <> - - {!isHidden && ( - -
- } - > - Server Console - - - )} - - - - )} - - handlePopUpToggle("createOrg", false)} - /> - - ); -}; diff --git a/frontend/src/layouts/OrganizationLayout/components/OrgSidebar/index.tsx b/frontend/src/layouts/OrganizationLayout/components/OrgSidebar/index.tsx deleted file mode 100644 index 315d7ffab..000000000 --- a/frontend/src/layouts/OrganizationLayout/components/OrgSidebar/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { OrgSidebar } from "./OrgSidebar"; diff --git a/frontend/src/layouts/PamLayout/PamLayout.tsx b/frontend/src/layouts/PamLayout/PamLayout.tsx index 48f6af116..f34c29d0d 100644 --- a/frontend/src/layouts/PamLayout/PamLayout.tsx +++ b/frontend/src/layouts/PamLayout/PamLayout.tsx @@ -1,19 +1,9 @@ import { useEffect } from "react"; -import { - faBook, - faBoxOpen, - faCog, - faDisplay, - faHome, - faUser, - faUsers -} from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Link, Outlet } from "@tanstack/react-router"; +import { Link, Outlet, useLocation } from "@tanstack/react-router"; import { motion } from "framer-motion"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; -import { Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2"; +import { Tab, TabList, Tabs } from "@app/components/v2"; import { useProject, useProjectPermission, useSubscription } from "@app/context"; import { usePopUp } from "@app/hooks"; @@ -23,7 +13,7 @@ export const PamLayout = () => { const { currentProject } = useProject(); const { subscription } = useSubscription(); const { assumedPrivilegeDetails } = useProjectPermission(); - + const location = useLocation(); const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"]); useEffect(() => { @@ -35,152 +25,85 @@ export const PamLayout = () => { return ( <>
-
+
-
+ + {({ isActive }) => Resources} + + + {({ isActive }) => Sessions} + + + {({ isActive }) => ( + + Access Control + + )} + + + {({ isActive }) => Audit Logs} + + + {({ isActive }) => Settings} + + + -
- {assumedPrivilegeDetails && } - -
+
+ {assumedPrivilegeDetails && } +
+
{ return ( <> -
+
{!window.isSecureContext && }
- -
+
diff --git a/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx b/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx index 2deefb599..367dd7dc5 100644 --- a/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx +++ b/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx @@ -1,23 +1,10 @@ import { useTranslation } from "react-i18next"; -import { - faBell, - faBook, - faCertificate, - faCog, - faFileLines, - faHome, - faMobile, - faPlug, - faPuzzlePiece, - faSitemap, - faStamp, - faUsers -} from "@fortawesome/free-solid-svg-icons"; +import { faMobile } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Link, Outlet } from "@tanstack/react-router"; +import { Link, Outlet, useLocation } from "@tanstack/react-router"; import { motion } from "framer-motion"; -import { Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2"; +import { Tab, TabList, Tabs } from "@app/components/v2"; import { useProject, useProjectPermission, useSubscription } from "@app/context"; import { useListWorkspaceCertificateTemplates, @@ -43,131 +30,88 @@ export const PkiManagerLayout = () => { const showLegacySection = subscription.pkiLegacyTemplates || hasExistingSubscribers || hasExistingTemplates; + const location = useLocation(); return ( <>
-
+
-
+ + {({ isActive }) => Audit Logs} + + + {({ isActive }) => Settings} + + + -
- {assumedPrivilegeDetails && } - -
+
+ {assumedPrivilegeDetails && } +
+
diff --git a/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/AssumePrivilegeModeBanner.tsx b/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/AssumePrivilegeModeBanner.tsx index 71af774ca..45978e3d7 100644 --- a/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/AssumePrivilegeModeBanner.tsx +++ b/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/AssumePrivilegeModeBanner.tsx @@ -15,7 +15,7 @@ export const AssumePrivilegeModeBanner = () => { if (!assumedPrivilegeDetails) return null; return ( -
+
You are currently viewing the project with privileges of{" "} @@ -24,7 +24,7 @@ export const AssumePrivilegeModeBanner = () => { {assumedPrivilegeDetails?.actorName}
-
+
+
+ )}
)} @@ -425,7 +550,7 @@ export const IdentityKubernetesAuthForm = ({ errorText={error?.message} tooltipText="Optional JWT token for accessing Kubernetes TokenReview API. If provided, this long-lived token will be used to validate service account tokens during authentication. If omitted, the client's own JWT will be used instead, which requires the client to have the system:auth-delegator ClusterRole binding." > - + )} /> @@ -441,7 +566,12 @@ export const IdentityKubernetesAuthForm = ({ errorText={error?.message} tooltipText="A comma-separated list of trusted namespaces that service accounts must belong to authenticate with Infisical." > - + )} /> @@ -456,7 +586,11 @@ export const IdentityKubernetesAuthForm = ({ tooltipText="An optional comma-separated list of trusted service account names that are allowed to authenticate with Infisical. Leave empty to allow any service account." errorText={error?.message} > - + )} /> @@ -628,6 +762,11 @@ export const IdentityKubernetesAuthForm = ({ Cancel
+ handleImportPopUpToggle("importFromVault", isOpen)} + onImport={handleImportFromVault} + /> ); }; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/VaultKubernetesAuthImportModal.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/VaultKubernetesAuthImportModal.tsx new file mode 100644 index 000000000..7491b51d6 --- /dev/null +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/VaultKubernetesAuthImportModal.tsx @@ -0,0 +1,200 @@ +import { useEffect, useState } from "react"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FilterableSelect, + FormControl, + Modal, + ModalClose, + ModalContent +} from "@app/components/v2"; +import { + useGetVaultAuthMounts, + useGetVaultKubernetesAuthRoles, + useGetVaultNamespaces +} from "@app/hooks/api/migration/queries"; +import { VaultKubernetesAuthRole } from "@app/hooks/api/migration/types"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + onImport: (role: VaultKubernetesAuthRole) => void; +}; + +type ContentProps = { + onClose: () => void; + onImport: (role: VaultKubernetesAuthRole) => void; +}; + +const Content = ({ onClose, onImport }: ContentProps) => { + const [selectedNamespace, setSelectedNamespace] = useState(null); + const [selectedMountPath, setSelectedMountPath] = useState(null); + const [selectedRole, setSelectedRole] = useState(null); + const [shouldFetchRoles, setShouldFetchRoles] = useState(false); + const [shouldFetchMounts, setShouldFetchMounts] = useState(false); + + const { data: namespaces, isLoading: isLoadingNamespaces } = useGetVaultNamespaces(); + const { data: authMounts, isLoading: isLoadingMounts } = useGetVaultAuthMounts( + shouldFetchMounts, + selectedNamespace ?? undefined, + "kubernetes" + ); + const { data: roles, isLoading: isLoadingRoles } = useGetVaultKubernetesAuthRoles( + shouldFetchRoles, + selectedNamespace ?? undefined, + selectedMountPath ?? undefined + ); + + // Enable fetching mounts when namespace is selected + useEffect(() => { + if (selectedNamespace) { + setShouldFetchMounts(true); + } + }, [selectedNamespace]); + + // Enable fetching roles when both namespace and mount path are selected + useEffect(() => { + if (selectedNamespace && selectedMountPath) { + setShouldFetchRoles(true); + } else { + setShouldFetchRoles(false); + } + }, [selectedNamespace, selectedMountPath]); + + const handleImportAndApply = () => { + if (!selectedRole) { + createNotification({ + type: "error", + text: "Please select a Kubernetes role to load" + }); + return; + } + + onImport(selectedRole); + onClose(); + }; + + return ( + <> + + <> + ns.name === selectedNamespace)} + onChange={(value) => { + if (value && !Array.isArray(value)) { + const namespace = value as { id: string; name: string }; + setSelectedNamespace(namespace.name); + setSelectedMountPath(null); + setSelectedRole(null); + } + }} + options={namespaces || []} + getOptionValue={(option) => option.name} + getOptionLabel={(option) => (option.name === "/" ? "root" : option.name)} + isDisabled={isLoadingNamespaces} + placeholder="Select namespace..." + className="w-full" + /> +

+ Select the Vault namespace to fetch available auth mounts +

+ +
+ + + <> + mount.path === selectedMountPath) + : null + } + onChange={(value) => { + if (value && !Array.isArray(value)) { + const mount = value as { path: string; type: string }; + setSelectedMountPath(mount.path.replace(/\/$/, "")); // Remove trailing slash + setSelectedRole(null); + } else { + setSelectedMountPath(null); + } + }} + options={authMounts || []} + getOptionValue={(option) => option.path} + getOptionLabel={(option) => option.path.replace(/\/$/, "")} + isDisabled={isLoadingMounts || !authMounts?.length} + placeholder="Select auth engine..." + isClearable + className="w-full" + /> +

+ Choose a Kubernetes auth engine to filter available roles +

+ +
+ + + <> + { + if (value && !Array.isArray(value)) { + setSelectedRole(value as VaultKubernetesAuthRole); + } else { + setSelectedRole(null); + } + }} + options={roles || []} + getOptionValue={(option) => option.name} + getOptionLabel={(option) => option.name} + isDisabled={isLoadingRoles || !roles?.length || !selectedMountPath} + placeholder={ + !selectedMountPath + ? "Select an auth engine first..." + : "Select a Kubernetes role to load..." + } + isClearable + className="w-full" + /> +

+ Select the Kubernetes role to load configuration from +

+ +
+ +
+ + + + +
+ + ); +}; + +export const VaultKubernetesAuthImportModal = ({ isOpen, onOpenChange, onImport }: Props) => { + return ( + + + onOpenChange(false)} onImport={onImport} /> + + + ); +}; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/OrgMembersTab.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/OrgMembersTab.tsx index 1afb9bfbd..c95ffe816 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/OrgMembersTab.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/OrgMembersTab.tsx @@ -1,17 +1,5 @@ -import { motion } from "framer-motion"; - import { OrgMembersSection } from "./components"; export const OrgMembersTab = () => { - return ( - - - - ); + return ; }; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgRoleTabSection/OrgRoleTabSection.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgRoleTabSection/OrgRoleTabSection.tsx index 57353e8d9..3cb458d97 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgRoleTabSection/OrgRoleTabSection.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgRoleTabSection/OrgRoleTabSection.tsx @@ -1,17 +1,5 @@ -import { motion } from "framer-motion"; - import { OrgRoleTable } from "./OrgRoleTable"; export const OrgRoleTabSection = () => { - return ( - - - - ); + return ; }; diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/AppConnectionsPage.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/AppConnectionsPage.tsx index b44fad6ea..3d4f244ca 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/AppConnectionsPage.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/AppConnectionsPage.tsx @@ -20,7 +20,7 @@ export const AppConnectionsPage = withPermission(
-
+
{ - -
-
+
+
{
-
+
{ - const tabsFiltered = isInfisicalCloud() - ? tabs - : [{ name: "Infisical Self-Hosted", key: "tab-infisical-cloud" }]; + if (!isInfisicalCloud()) { + return ; + } return ( - + - {tabsFiltered.map((tab) => ( - + {tabs.map((tab) => ( + {tab.name} ))} @@ -33,19 +33,15 @@ export const BillingTabGroup = withPermission( - {isInfisicalCloud() && ( - <> - - - - - - - - - - - )} + + + + + + + + + ); }, diff --git a/frontend/src/pages/organization/GroupDetailsByIDPage/GroupDetailsByIDPage.tsx b/frontend/src/pages/organization/GroupDetailsByIDPage/GroupDetailsByIDPage.tsx index f0bd24900..d3acd618b 100644 --- a/frontend/src/pages/organization/GroupDetailsByIDPage/GroupDetailsByIDPage.tsx +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/GroupDetailsByIDPage.tsx @@ -1,6 +1,8 @@ import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; -import { useNavigate, useParams } from "@tanstack/react-router"; +import { faChevronLeft } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Link, useNavigate, useParams } from "@tanstack/react-router"; import { twMerge } from "tailwind-merge"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; @@ -80,10 +82,20 @@ const Page = () => { if (isPending) return ; return ( -
+
{data && ( -
- +
+ + + Groups + +
diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx index adf6dfce8..985c8d27d 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx @@ -1,6 +1,8 @@ import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; -import { useNavigate, useParams } from "@tanstack/react-router"; +import { faChevronLeft } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Link, useNavigate, useParams } from "@tanstack/react-router"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { createNotification } from "@app/components/notifications"; @@ -72,10 +74,20 @@ const Page = () => { }; return ( -
+
{data && ( -
- +
+ + + Identities + +
diff --git a/frontend/src/pages/organization/NetworkingPage/NetworkingPage.tsx b/frontend/src/pages/organization/NetworkingPage/NetworkingPage.tsx index e7f3937d5..bc8ae44ef 100644 --- a/frontend/src/pages/organization/NetworkingPage/NetworkingPage.tsx +++ b/frontend/src/pages/organization/NetworkingPage/NetworkingPage.tsx @@ -12,7 +12,7 @@ export const NetworkingPage = () => {
-
+
{ const [selectedTab, setSelectedTab] = useState(search.selectedTab || tabs[0].key); return ( - + {tabs.map((tab) => ( diff --git a/frontend/src/pages/organization/ProjectsPage/ProjectsPage.tsx b/frontend/src/pages/organization/ProjectsPage/ProjectsPage.tsx index 92654c1e7..a528587b3 100644 --- a/frontend/src/pages/organization/ProjectsPage/ProjectsPage.tsx +++ b/frontend/src/pages/organization/ProjectsPage/ProjectsPage.tsx @@ -58,18 +58,16 @@ export const ProjectsPage = () => { : true; return ( -
+
{t("common.head-title", { title: t("settings.members.title") })} -
- -
+ {projectListView === ProjectListView.MyProjects ? ( handlePopUpOpen("addNewWs")} diff --git a/frontend/src/pages/organization/RoleByIDPage/RoleByIDPage.tsx b/frontend/src/pages/organization/RoleByIDPage/RoleByIDPage.tsx index 8b25247be..732d8a4a2 100644 --- a/frontend/src/pages/organization/RoleByIDPage/RoleByIDPage.tsx +++ b/frontend/src/pages/organization/RoleByIDPage/RoleByIDPage.tsx @@ -1,8 +1,8 @@ import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; -import { faCopy, faEllipsisV } from "@fortawesome/free-solid-svg-icons"; +import { faChevronLeft, faCopy, faEllipsisV } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { useNavigate, useParams } from "@tanstack/react-router"; +import { Link, useNavigate, useParams } from "@tanstack/react-router"; import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; @@ -77,20 +77,26 @@ export const Page = () => { const isCustomRole = !["admin", "member", "no-access"].includes(data?.slug ?? ""); return ( -
+
{data && ( -
+
+ + + Roles + -
- {data.name} -

- {data.slug} {data.description && `- ${data.description}`} -

-
-
+ title={data.name} + description={ + <> + {data.slug} {data.description && `- ${data.description}`} + } > {isCustomRole && ( diff --git a/frontend/src/pages/organization/SecretSharingPage/SecretSharingPage.tsx b/frontend/src/pages/organization/SecretSharingPage/SecretSharingPage.tsx index ec3885f5b..ace5b211e 100644 --- a/frontend/src/pages/organization/SecretSharingPage/SecretSharingPage.tsx +++ b/frontend/src/pages/organization/SecretSharingPage/SecretSharingPage.tsx @@ -20,7 +20,7 @@ export const SecretSharingPage = () => {
-
+
{ - + - Share Secrets - + + Share Secrets + + Request Secrets - - New - diff --git a/frontend/src/pages/organization/SecretSharingSettingsPage/SecretSharingSettingsPage.tsx b/frontend/src/pages/organization/SecretSharingSettingsPage/SecretSharingSettingsPage.tsx index 6ee73a03d..8cee365e3 100644 --- a/frontend/src/pages/organization/SecretSharingSettingsPage/SecretSharingSettingsPage.tsx +++ b/frontend/src/pages/organization/SecretSharingSettingsPage/SecretSharingSettingsPage.tsx @@ -20,7 +20,7 @@ export const SecretSharingSettingsPage = withPermission( {t("common.head-title", { title: "Secret Share Settings" })}
-
+
diff --git a/frontend/src/pages/organization/SettingsPage/SettingsPage.tsx b/frontend/src/pages/organization/SettingsPage/SettingsPage.tsx index 52fc99f53..919a1159a 100644 --- a/frontend/src/pages/organization/SettingsPage/SettingsPage.tsx +++ b/frontend/src/pages/organization/SettingsPage/SettingsPage.tsx @@ -14,8 +14,12 @@ export const SettingsPage = () => { {t("common.head-title", { title: t("settings.org.title") })}
-
- +
+
diff --git a/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/ExternalMigrationsTab.tsx b/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/ExternalMigrationsTab.tsx index 4857e8fe3..34f1937b5 100644 --- a/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/ExternalMigrationsTab.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/ExternalMigrationsTab.tsx @@ -7,6 +7,7 @@ import { OrgMembershipRole } from "@app/helpers/roles"; import { usePopUp } from "@app/hooks"; import { SelectImportFromPlatformModal } from "./components/SelectImportFromPlatformModal"; +import { VaultConnectionSection } from "./components/VaultConnectionSection"; export const ExternalMigrationsTab = () => { const { hasOrgRole } = useOrgPermission(); @@ -14,45 +15,70 @@ export const ExternalMigrationsTab = () => { const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["selectImportPlatform"] as const); return ( -
-
-
-

Import from external source

+
+ {/* In-Platform Migration Tooling Section */} +
+
+

In-Platform Migration Tooling

+

+ Configure platform connections to enable migration features throughout Infisical, such + as importing policies and resources directly within the UI. +

+
+ +
- + {/* Bulk Data Import Section */} +
+
+

Bulk Data Import

+

+ Perform one-time bulk imports of data from external platforms. +

- -
-

Import data from another platform to Infisical.

+
+
+
+

+ Import from external source +

+ +
+ + Docs + +
+
+
+

+ Import data from another platform to Infisical. +

+
- handlePopUpToggle("selectImportPlatform", state)} - /> + +
+ + handlePopUpToggle("selectImportPlatform", state)} + /> +
); }; diff --git a/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/components/VaultConnectionSection.tsx b/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/components/VaultConnectionSection.tsx new file mode 100644 index 000000000..c91e9c24c --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/components/VaultConnectionSection.tsx @@ -0,0 +1,197 @@ +import { useState } from "react"; +import { faEdit, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Link } from "@tanstack/react-router"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + DeleteActionModal, + EmptyState, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { useListAppConnections } from "@app/hooks/api/appConnections/queries"; +import { + useDeleteVaultExternalMigrationConfig, + useGetVaultExternalMigrationConfigs +} from "@app/hooks/api/migration"; +import { TVaultExternalMigrationConfig } from "@app/hooks/api/migration/types"; + +import { VaultNamespaceConfigModal } from "./VaultNamespaceConfigModal"; + +export const VaultConnectionSection = () => { + const [selectedConfig, setSelectedConfig] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [configToDelete, setConfigToDelete] = useState(null); + + const { data: configs = [], isPending: isLoadingConfigs } = useGetVaultExternalMigrationConfigs(); + const { data: appConnections = [] } = useListAppConnections(); + const { mutateAsync: deleteConfig } = useDeleteVaultExternalMigrationConfig(); + + const handleEdit = (config: TVaultExternalMigrationConfig) => { + setSelectedConfig(config); + setIsModalOpen(true); + }; + + const handleAdd = () => { + setSelectedConfig(null); + setIsModalOpen(true); + }; + + const handleDeleteClick = (config: TVaultExternalMigrationConfig) => { + setConfigToDelete(config); + setIsDeleteModalOpen(true); + }; + + const handleDeleteConfirm = async () => { + if (!configToDelete) return; + + try { + await deleteConfig({ id: configToDelete.id }); + createNotification({ + type: "success", + text: "Namespace configuration deleted successfully" + }); + setIsDeleteModalOpen(false); + setConfigToDelete(null); + } catch (error) { + console.error("Failed to delete namespace config:", error); + createNotification({ + type: "error", + text: "Failed to delete namespace configuration" + }); + } + }; + + const getConnectionName = (connectionId: string | null) => { + if (!connectionId) return "None"; + const connection = appConnections.find((conn) => conn.id === connectionId); + return connection?.name || "Unknown"; + }; + + return ( +
+
+
+ HashiCorp Vault logo +
+

HashiCorp Vault

+

+ Enable in-platform migration tooling for policy imports, auth methods, and secret + engine migrations +

+
+
+ +
+ + + + + + + + + + + {isLoadingConfigs && ( + + )} + {!isLoadingConfigs && configs.length === 0 && ( + + + + )} + {!isLoadingConfigs && + configs.map((config) => ( + + + + + + ))} + +
NamespaceConnection +
+ +

+ Add a namespace configuration to enable in-platform migration features. +

+
+
{config.namespace}{getConnectionName(config.connectionId)} +
+ + +
+
+
+ +

+ Configure namespace-specific connections to enable in-platform migration features. Manage + connections in the{" "} + + App Connections + {" "} + section. +

+ + { + setIsModalOpen(open); + if (!open) setSelectedConfig(null); + }} + editConfig={selectedConfig || undefined} + /> + + { + setIsDeleteModalOpen(open); + if (!open) setConfigToDelete(null); + }} + deleteKey="confirm" + onDeleteApproved={handleDeleteConfirm} + /> +
+ ); +}; diff --git a/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/components/VaultNamespaceConfigModal.tsx b/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/components/VaultNamespaceConfigModal.tsx new file mode 100644 index 000000000..62fb78b48 --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/components/VaultNamespaceConfigModal.tsx @@ -0,0 +1,189 @@ +import { useEffect, useMemo } 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 { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { useListAppConnections } from "@app/hooks/api/appConnections/queries"; +import { + useCreateVaultExternalMigrationConfig, + useUpdateVaultExternalMigrationConfig +} from "@app/hooks/api/migration"; +import { TVaultExternalMigrationConfig } from "@app/hooks/api/migration/types"; + +const schema = z.object({ + namespace: z + .string() + .min(1, "Namespace is required. If you intend to use the root namespace, use root or /."), + connectionId: z.string().min(1, "Connection is required") +}); + +type FormData = z.infer; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + editConfig?: TVaultExternalMigrationConfig; +}; + +export const VaultNamespaceConfigModal = ({ isOpen, onOpenChange, editConfig }: Props) => { + const isEdit = Boolean(editConfig); + + const { data: appConnections = [], isPending: isLoadingConnections } = useListAppConnections(); + + const vaultConnections = useMemo( + () => appConnections.filter((conn) => conn.app === AppConnection.HCVault), + [appConnections] + ); + + const { mutateAsync: createConfig, isPending: isCreating } = + useCreateVaultExternalMigrationConfig(); + const { mutateAsync: updateConfig, isPending: isUpdating } = + useUpdateVaultExternalMigrationConfig(); + + const { + control, + handleSubmit, + reset, + formState: { errors, isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + namespace: "", + connectionId: "" + } + }); + + // Reset form when editConfig changes or modal opens + useEffect(() => { + if (isOpen) { + reset({ + namespace: editConfig?.namespace || "", + connectionId: editConfig?.connectionId || "" + }); + } + }, [isOpen, editConfig, reset]); + + const onFormSubmit = async (data: FormData) => { + try { + if (isEdit && editConfig) { + await updateConfig({ + id: editConfig.id, + namespace: data.namespace, + connectionId: data.connectionId + }); + createNotification({ + type: "success", + text: "Namespace configuration updated successfully" + }); + } else { + await createConfig({ + namespace: data.namespace, + connectionId: data.connectionId + }); + createNotification({ + type: "success", + text: "Namespace configuration created successfully" + }); + } + reset(); + onOpenChange(false); + } catch (error) { + console.error("Failed to save namespace config:", error); + createNotification({ + type: "error", + text: `Failed to ${isEdit ? "update" : "create"} namespace configuration` + }); + } + }; + + const handleClose = () => { + reset(); + onOpenChange(false); + }; + + return ( + + +
+ ( + + + + )} + /> + + { + const selectedConnection = vaultConnections.find((conn) => conn.id === field.value); + + return ( + + { + const singleValue = Array.isArray(newValue) ? newValue[0] : newValue; + if (singleValue && "id" in singleValue) { + field.onChange(singleValue.id); + } else { + field.onChange(""); + } + }} + isLoading={isLoadingConnections} + options={vaultConnections} + placeholder="Select connection..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + ); + }} + /> + +
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgTabGroup/OrgTabGroup.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgTabGroup/OrgTabGroup.tsx index ad867238b..66af7df1b 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgTabGroup/OrgTabGroup.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgTabGroup/OrgTabGroup.tsx @@ -55,7 +55,7 @@ export const OrgTabGroup = () => { const [selectedTab, setSelectedTab] = useState(search.selectedTab || tabs[0].key); return ( - + {tabs.map((tab) => ( diff --git a/frontend/src/pages/organization/UserDetailsByIDPage/UserDetailsByIDPage.tsx b/frontend/src/pages/organization/UserDetailsByIDPage/UserDetailsByIDPage.tsx index 3153c5fd5..4ef07e81a 100644 --- a/frontend/src/pages/organization/UserDetailsByIDPage/UserDetailsByIDPage.tsx +++ b/frontend/src/pages/organization/UserDetailsByIDPage/UserDetailsByIDPage.tsx @@ -1,6 +1,8 @@ import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; -import { useNavigate, useParams } from "@tanstack/react-router"; +import { faChevronLeft } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Link, useNavigate, useParams } from "@tanstack/react-router"; import { twMerge } from "tailwind-merge"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; @@ -115,16 +117,30 @@ const Page = withPermission( }; return ( -
+
{membership && ( -
+
+ + + Users +
{userId !== membership.user.id && ( diff --git a/frontend/src/pages/pam/PamAccountsPage/PamAccountsPage.tsx b/frontend/src/pages/pam/PamAccountsPage/PamAccountsPage.tsx index da8132193..70c964c32 100644 --- a/frontend/src/pages/pam/PamAccountsPage/PamAccountsPage.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/PamAccountsPage.tsx @@ -5,6 +5,7 @@ import { ProjectPermissionCan } from "@app/components/permissions"; import { PageHeader } from "@app/components/v2"; import { ProjectPermissionSub } from "@app/context"; import { ProjectPermissionPamAccountActions } from "@app/context/ProjectPermissionContext/types"; +import { ProjectType } from "@app/hooks/api/projects/types"; import { PamAccountsSection } from "./components/PamAccountsSection"; @@ -21,10 +22,10 @@ export const PamAccountsPage = () => { a={ProjectPermissionSub.PamAccounts} >
-
-
+
+
diff --git a/frontend/src/pages/pam/PamAccountsPage/components/FolderBreadCrumbs.tsx b/frontend/src/pages/pam/PamAccountsPage/components/FolderBreadCrumbs.tsx new file mode 100644 index 000000000..726af0832 --- /dev/null +++ b/frontend/src/pages/pam/PamAccountsPage/components/FolderBreadCrumbs.tsx @@ -0,0 +1,67 @@ +import { faFolderOpen } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useNavigate } from "@tanstack/react-router"; + +type Props = { + path: string; +}; + +export const FolderBreadCrumbs = ({ path = "/" }: Props) => { + const navigate = useNavigate({ + from: "/projects/pam/$projectId/accounts" + }); + + const onFolderCrumbClick = (index: number) => { + let newAccountPath = `/${path.split("/").filter(Boolean).slice(0, index).join("/")}`; + + if (!newAccountPath.endsWith("/")) { + newAccountPath += "/"; + } + + if (path === newAccountPath) return; + navigate({ + search: (prev) => ({ ...prev, accountPath: newAccountPath }) + }); + }; + + return ( +
+
onFolderCrumbClick(0)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onFolderCrumbClick(0); + } + }} + role="button" + tabIndex={0} + > + +
+ {(path || "") + .split("/") + .filter(Boolean) + .map((pathSegment, index, arr) => ( +
onFolderCrumbClick(index + 1)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onFolderCrumbClick(index + 1); + } + }} + role="button" + tabIndex={0} + > + {pathSegment} +
+ ))} +
+ ); +}; diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountsTable.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountsTable.tsx index 7cdf93dc8..d0e86af0c 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountsTable.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountsTable.tsx @@ -45,6 +45,7 @@ import { OrderByDirection } from "@app/hooks/api/generic/types"; import { PAM_RESOURCE_TYPE_MAP, TPamAccount, TPamFolder } from "@app/hooks/api/pam"; import { AccountView, AccountViewToggle } from "./AccountViewToggle"; +import { FolderBreadCrumbs } from "./FolderBreadCrumbs"; import { PamAccessAccountModal } from "./PamAccessAccountModal"; import { PamAccountRow } from "./PamAccountRow"; import { PamAddAccountModal } from "./PamAddAccountModal"; @@ -255,6 +256,7 @@ export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => { return (
+ {accountView === AccountView.Nested && }
{(isAllowed) => diff --git a/frontend/src/pages/pam/PamResourcesPage/PamResourcesPage.tsx b/frontend/src/pages/pam/PamResourcesPage/PamResourcesPage.tsx index 13e9028e6..2f00c90d8 100644 --- a/frontend/src/pages/pam/PamResourcesPage/PamResourcesPage.tsx +++ b/frontend/src/pages/pam/PamResourcesPage/PamResourcesPage.tsx @@ -5,6 +5,7 @@ import { ProjectPermissionCan } from "@app/components/permissions"; import { PageHeader } from "@app/components/v2"; import { ProjectPermissionSub } from "@app/context"; import { ProjectPermissionPamAccountActions } from "@app/context/ProjectPermissionContext/types"; +import { ProjectType } from "@app/hooks/api/projects/types"; import { PamResourcesSection } from "./components/PamResourcesSection"; @@ -21,10 +22,10 @@ export const PamResourcesPage = () => { a={ProjectPermissionSub.PamResources} >
-
-
+
+
diff --git a/frontend/src/pages/pam/PamSessionsByIDPage/PamSessionByIDPage.tsx b/frontend/src/pages/pam/PamSessionsByIDPage/PamSessionByIDPage.tsx index af5b85fc6..eb6939e26 100644 --- a/frontend/src/pages/pam/PamSessionsByIDPage/PamSessionByIDPage.tsx +++ b/frontend/src/pages/pam/PamSessionsByIDPage/PamSessionByIDPage.tsx @@ -1,12 +1,15 @@ import { Helmet } from "react-helmet"; -import { useParams } from "@tanstack/react-router"; +import { faChevronLeft } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Link, useParams } from "@tanstack/react-router"; import { ProjectPermissionCan } from "@app/components/permissions"; import { PageHeader } from "@app/components/v2"; import { ROUTE_PATHS } from "@app/const/routes"; -import { ProjectPermissionSub } from "@app/context"; +import { ProjectPermissionSub, useProject } from "@app/context"; import { ProjectPermissionPamSessionActions } from "@app/context/ProjectPermissionContext/types"; import { useGetPamSessionById } from "@app/hooks/api/pam"; +import { ProjectType } from "@app/hooks/api/projects/types"; import { PamSessionDetailsSection } from "./components/PamSessionDetailsSection"; import { PamSessionLogsSection } from "./components/PamSessionLogsSection"; @@ -17,13 +20,23 @@ const Page = () => { select: (el) => el.sessionId }); const { data: session } = useGetPamSessionById(sessionId); - + const { currentProject } = useProject(); return ( -
+
{session && ( -
+
+ + + Sessions + diff --git a/frontend/src/pages/pam/PamSessionsPage/PamSessionsPage.tsx b/frontend/src/pages/pam/PamSessionsPage/PamSessionsPage.tsx index 526507564..198d6f42e 100644 --- a/frontend/src/pages/pam/PamSessionsPage/PamSessionsPage.tsx +++ b/frontend/src/pages/pam/PamSessionsPage/PamSessionsPage.tsx @@ -5,6 +5,7 @@ import { ProjectPermissionCan } from "@app/components/permissions"; import { PageHeader } from "@app/components/v2"; import { ProjectPermissionSub } from "@app/context"; import { ProjectPermissionPamSessionActions } from "@app/context/ProjectPermissionContext/types"; +import { ProjectType } from "@app/hooks/api/projects/types"; import { PamSessionSection } from "./components/PamSessionSection"; @@ -21,10 +22,10 @@ export const PamSessionPage = () => { a={ProjectPermissionSub.PamSessions} >
-
-
+
+
diff --git a/frontend/src/pages/pam/SettingsPage/SettingsPage.tsx b/frontend/src/pages/pam/SettingsPage/SettingsPage.tsx index b8b23cebd..fab7feb66 100644 --- a/frontend/src/pages/pam/SettingsPage/SettingsPage.tsx +++ b/frontend/src/pages/pam/SettingsPage/SettingsPage.tsx @@ -2,6 +2,7 @@ import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; +import { ProjectType } from "@app/hooks/api/projects/types"; import { ProjectGeneralTab } from "@app/pages/project/SettingsPage/components/ProjectGeneralTab"; export const SettingsPage = () => { @@ -12,11 +13,17 @@ export const SettingsPage = () => { {t("common.head-title", { title: t("settings.project.title") })} -
- - +
+ + - General + + General + diff --git a/frontend/src/pages/project/AccessControlPage/AccessControlPage.tsx b/frontend/src/pages/project/AccessControlPage/AccessControlPage.tsx index c010fcc39..aaa3a6eb7 100644 --- a/frontend/src/pages/project/AccessControlPage/AccessControlPage.tsx +++ b/frontend/src/pages/project/AccessControlPage/AccessControlPage.tsx @@ -37,26 +37,32 @@ const Page = () => { const isSecretManager = currentProject.type === ProjectType.SecretManager; return ( -
-
+
+
- + - Users - Groups - -
-

Machine Identities

-
+ + Users + + + Groups + + + Identities {isSecretManager && ( - Service Tokens + + Service Tokens + )} - Project Roles + + Roles +
diff --git a/frontend/src/pages/project/AccessControlPage/components/GroupsTab/GroupsTab.tsx b/frontend/src/pages/project/AccessControlPage/components/GroupsTab/GroupsTab.tsx index 4f155a552..4c801a76f 100644 --- a/frontend/src/pages/project/AccessControlPage/components/GroupsTab/GroupsTab.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/GroupsTab/GroupsTab.tsx @@ -1,5 +1,3 @@ -import { motion } from "framer-motion"; - import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { withProjectPermission } from "@app/hoc"; @@ -7,17 +5,7 @@ import { GroupsSection } from "./components"; export const GroupsTab = withProjectPermission( () => { - return ( - - - - ); + return ; }, { action: ProjectPermissionActions.Read, diff --git a/frontend/src/pages/project/AccessControlPage/components/IdentityTab/IdentityTab.tsx b/frontend/src/pages/project/AccessControlPage/components/IdentityTab/IdentityTab.tsx index 652b7047d..6e4ec70fe 100644 --- a/frontend/src/pages/project/AccessControlPage/components/IdentityTab/IdentityTab.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/IdentityTab/IdentityTab.tsx @@ -14,7 +14,6 @@ import { import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNavigate } from "@tanstack/react-router"; import { format } from "date-fns"; -import { motion } from "framer-motion"; import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; @@ -158,307 +157,295 @@ export const IdentityTab = withProjectPermission( }; return ( - -
-
-
-

Identities

- -
- - Docs - -
-
-
- +
+ - setSearch(e.target.value)} - leftIcon={} - placeholder="Search identities by name..." - /> - - - - - - - - - - - - {isPending && } - {!isPending && - data && - data.identityMemberships.length > 0 && - data.identityMemberships.map((identityMember) => { - const { - identity: { id, name }, - roles, - createdAt - } = identityMember; - return ( - { - if (evt.key === "Enter") { - navigate({ - to: `${getProjectBaseURL(currentProject.type)}/identities/$identityId` as const, - params: { - projectId: currentProject.id, - identityId: id - } - }); - } - }} - onClick={() => + + {(isAllowed) => ( + + )} + + + setSearch(e.target.value)} + leftIcon={} + placeholder="Search identities by name..." + /> + +
-
- Name - handleSort(ProjectIdentityOrderBy.Name)} - > - - -
-
RoleAdded on{isFetching ? : null}
+ + + + + + + + + + {isPending && } + {!isPending && + data && + data.identityMemberships.length > 0 && + data.identityMemberships.map((identityMember) => { + const { + identity: { id, name }, + roles, + createdAt + } = identityMember; + return ( + { + if (evt.key === "Enter") { navigate({ to: `${getProjectBaseURL(currentProject.type)}/identities/$identityId` as const, params: { projectId: currentProject.id, identityId: id } - }) + }); } - > - + }} + onClick={() => + navigate({ + to: `${getProjectBaseURL(currentProject.type)}/identities/$identityId` as const, + params: { + projectId: currentProject.id, + identityId: id + } + }) + } + > + - - - - - ); - })} - -
+
+ Name + handleSort(ProjectIdentityOrderBy.Name)} + > + + +
+
RoleAdded on{isFetching ? : null}
{name}{name} -
- {roles - .slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE) - .map( - ({ - role, - customRoleName, - id: roleId, - isTemporary, - temporaryAccessEndTime - }) => { - const isExpired = - new Date() > new Date(temporaryAccessEndTime || ("" as string)); - return ( - -
-
- {formatProjectRoleName(role, customRoleName)} -
- {isTemporary && ( -
- - - -
- )} +
+
+ {roles + .slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE) + .map( + ({ + role, + customRoleName, + id: roleId, + isTemporary, + temporaryAccessEndTime + }) => { + const isExpired = + new Date() > new Date(temporaryAccessEndTime || ("" as string)); + return ( + +
+
+ {formatProjectRoleName(role, customRoleName)}
- - ); - } - )} - {roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && ( - - - +{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE} - - - {roles - .slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE) - .map( - ({ - role, - customRoleName, - id: roleId, - isTemporary, - temporaryAccessEndTime - }) => { - const isExpired = - new Date() > - new Date(temporaryAccessEndTime || ("" as string)); - return ( - -
-
- {formatProjectRoleName(role, customRoleName)} -
- {isTemporary && ( -
- - - new Date( - temporaryAccessEndTime as string - ) && "text-red-600" - )} - /> - -
- )} -
-
- ); - } - )} -
-
+ {isTemporary && ( +
+ + + +
+ )} +
+
+ ); + } )} -
-
{format(new Date(createdAt), "yyyy-MM-dd")} - - - - - - - - - - {(isAllowed) => ( - } - isDisabled={!isAllowed} - onClick={(evt) => { - evt.stopPropagation(); - evt.preventDefault(); - handlePopUpOpen("deleteIdentity", { - identityId: id, - name - }); - }} - > - Remove Identity From Project - + {roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && ( + + + +{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE} + + + {roles + .slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE) + .map( + ({ + role, + customRoleName, + id: roleId, + isTemporary, + temporaryAccessEndTime + }) => { + const isExpired = + new Date() > + new Date(temporaryAccessEndTime || ("" as string)); + return ( + +
+
{formatProjectRoleName(role, customRoleName)}
+ {isTemporary && ( +
+ + + new Date( + temporaryAccessEndTime as string + ) && "text-red-600" + )} + /> + +
+ )} +
+
+ ); + } )} -
-
-
-
-
- {!isPending && data && totalCount > 0 && ( - setPage(newPage)} - onChangePerPage={handlePerPageChange} - /> - )} - {!isPending && data && data?.identityMemberships.length === 0 && ( - 0 - ? "No identities match search filter" - : "No identities have been added to this project" - } - icon={faServer} - /> - )} -
- - handlePopUpToggle("deleteIdentity", isOpen)} - deleteKey="confirm" - onDeleteApproved={() => - onRemoveIdentitySubmit( - (popUp?.deleteIdentity?.data as { identityId: string })?.identityId - ) - } - /> -
- + + + )} +
+ + {format(new Date(createdAt), "yyyy-MM-dd")} + + + + + + + + + + + {(isAllowed) => ( + } + isDisabled={!isAllowed} + onClick={(evt) => { + evt.stopPropagation(); + evt.preventDefault(); + handlePopUpOpen("deleteIdentity", { + identityId: id, + name + }); + }} + > + Remove Identity From Project + + )} + + + + + + + ); + })} + + + {!isPending && data && totalCount > 0 && ( + setPage(newPage)} + onChangePerPage={handlePerPageChange} + /> + )} + {!isPending && data && data?.identityMemberships.length === 0 && ( + 0 + ? "No identities match search filter" + : "No identities have been added to this project" + } + icon={faServer} + /> + )} + + + handlePopUpToggle("deleteIdentity", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemoveIdentitySubmit( + (popUp?.deleteIdentity?.data as { identityId: string })?.identityId + ) + } + /> +
); }, { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Identity } diff --git a/frontend/src/pages/project/AccessControlPage/components/MembersTab/MembersTab.tsx b/frontend/src/pages/project/AccessControlPage/components/MembersTab/MembersTab.tsx index 77a5ba827..bb120627a 100644 --- a/frontend/src/pages/project/AccessControlPage/components/MembersTab/MembersTab.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/MembersTab/MembersTab.tsx @@ -1,5 +1,3 @@ -import { motion } from "framer-motion"; - import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { withProjectPermission } from "@app/hoc"; @@ -7,17 +5,7 @@ import { MembersSection } from "./components"; export const MembersTab = withProjectPermission( () => { - return ( - - - - ); + return ; }, { action: ProjectPermissionActions.Read, diff --git a/frontend/src/pages/project/AccessControlPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx b/frontend/src/pages/project/AccessControlPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx index 89ac57801..0d200477d 100644 --- a/frontend/src/pages/project/AccessControlPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx @@ -1,5 +1,3 @@ -import { motion } from "framer-motion"; - import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { withProjectPermission } from "@app/hoc"; @@ -7,17 +5,7 @@ import { ProjectRoleList } from "./components/ProjectRoleList"; export const ProjectRoleListTab = withProjectPermission( () => { - return ( - - - - ); + return ; }, { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Role } ); diff --git a/frontend/src/pages/project/AccessControlPage/components/ServiceTokenTab/ServiceTokenTab.tsx b/frontend/src/pages/project/AccessControlPage/components/ServiceTokenTab/ServiceTokenTab.tsx index eefd73a09..8327b8322 100644 --- a/frontend/src/pages/project/AccessControlPage/components/ServiceTokenTab/ServiceTokenTab.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/ServiceTokenTab/ServiceTokenTab.tsx @@ -1,20 +1,12 @@ // import { faWarning } from "@fortawesome/free-solid-svg-icons"; // import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { motion } from "framer-motion"; import { ServiceTokenSection } from "./components"; export const ServiceTokenTab = () => { return ( - -
- {/*
+
+ {/*
Deprecation Notice @@ -42,8 +34,7 @@ export const ServiceTokenTab = () => {

*/} - -
- + +
); }; diff --git a/frontend/src/pages/project/AppConnectionsPage/AppConnectionsPage.tsx b/frontend/src/pages/project/AppConnectionsPage/AppConnectionsPage.tsx index 78665e624..6bf14a79b 100644 --- a/frontend/src/pages/project/AppConnectionsPage/AppConnectionsPage.tsx +++ b/frontend/src/pages/project/AppConnectionsPage/AppConnectionsPage.tsx @@ -21,9 +21,9 @@ export const AppConnectionsPage = withProjectPermission(
-
+
{ const { currentProject } = useProject(); return ( -
+
Project Audit Logs
-
+
diff --git a/frontend/src/pages/project/GroupDetailsByIDPage/GroupDetailsByIDPage.tsx b/frontend/src/pages/project/GroupDetailsByIDPage/GroupDetailsByIDPage.tsx index 409d78633..f3f153b5b 100644 --- a/frontend/src/pages/project/GroupDetailsByIDPage/GroupDetailsByIDPage.tsx +++ b/frontend/src/pages/project/GroupDetailsByIDPage/GroupDetailsByIDPage.tsx @@ -1,11 +1,16 @@ import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; -import { useParams } from "@tanstack/react-router"; +import { faChevronLeft } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Link, useParams } from "@tanstack/react-router"; +import { formatRelative } from "date-fns"; import { ProjectPermissionCan } from "@app/components/permissions"; import { EmptyState, PageHeader, Spinner } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub, useProject } from "@app/context"; +import { getProjectBaseURL } from "@app/helpers/project"; import { useGetWorkspaceGroupMembershipDetails } from "@app/hooks/api/projects/queries"; +import { ProjectAccessControlTabs } from "@app/types/project"; import { GroupDetailsSection } from "./components/GroupDetailsSection"; import { GroupMembersSection } from "./components/GroupMembersSection"; @@ -31,10 +36,27 @@ const Page = () => { ); return ( -
+
{groupMembership ? ( -
- +
+ + + Groups + +
diff --git a/frontend/src/pages/project/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx b/frontend/src/pages/project/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx index 061d537b1..e49e05022 100644 --- a/frontend/src/pages/project/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx +++ b/frontend/src/pages/project/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx @@ -1,7 +1,9 @@ import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; import { subject } from "@casl/ability"; -import { useNavigate, useParams } from "@tanstack/react-router"; +import { faChevronLeft } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Link, useNavigate, useParams } from "@tanstack/react-router"; import { formatRelative } from "date-fns"; import { createNotification } from "@app/components/notifications"; @@ -28,6 +30,7 @@ import { useGetWorkspaceIdentityMembershipDetails } from "@app/hooks/api"; import { ActorType } from "@app/hooks/api/auditLogs/enums"; +import { ProjectAccessControlTabs } from "@app/types/project"; import { IdentityProjectAdditionalPrivilegeSection } from "./components/IdentityProjectAdditionalPrivilegeSection"; import { IdentityRoleDetailsSection } from "./components/IdentityRoleDetailsSection"; @@ -113,11 +116,24 @@ const Page = () => { } return ( -
+
{identityMembershipDetails ? ( <> + + + Identities + diff --git a/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx b/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx index 717226cfc..b23456a9f 100644 --- a/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx +++ b/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx @@ -1,6 +1,8 @@ import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; -import { useNavigate, useParams } from "@tanstack/react-router"; +import { faChevronLeft } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Link, useNavigate, useParams } from "@tanstack/react-router"; import { formatRelative } from "date-fns"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; @@ -29,6 +31,7 @@ import { useGetWorkspaceUserDetails } from "@app/hooks/api"; import { ActorType } from "@app/hooks/api/auditLogs/enums"; +import { ProjectAccessControlTabs } from "@app/types/project"; import { MemberProjectAdditionalPrivilegeSection } from "./components/MemberProjectAdditionalPrivilegeSection"; import { MemberRoleDetailsSection } from "./components/MemberRoleDetailsSection"; @@ -115,11 +118,24 @@ export const Page = () => { } return ( -
+
{membershipDetails ? ( <> + + + Users + { ); return ( -
+
{data && ( -
+
+ + + Roles + -
- {data.name} -

- {data.slug} {data.description && `- ${data.description}`} -

-
-
+ scope={currentProject.type} + title={data.name} + description={ + <> + {data.slug} {data.description && `- ${data.description}`} + } > {isCustomRole && ( diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/AddPoliciesButton.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/AddPoliciesButton.tsx index 37f6b3868..16ceb1863 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/AddPoliciesButton.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/AddPoliciesButton.tsx @@ -6,12 +6,17 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, - IconButton + IconButton, + Tooltip } from "@app/components/v2"; +import { useOrgPermission } from "@app/context"; +import { OrgMembershipRole } from "@app/helpers/roles"; import { usePopUp } from "@app/hooks"; +import { useGetVaultExternalMigrationConfigs } from "@app/hooks/api/migration"; import { ProjectType } from "@app/hooks/api/projects/types"; import { PolicySelectionModal } from "@app/pages/project/RoleDetailsBySlugPage/components/PolicySelectionModal"; import { PolicyTemplateModal } from "@app/pages/project/RoleDetailsBySlugPage/components/PolicyTemplateModal"; +import { VaultPolicyImportModal } from "@app/pages/project/RoleDetailsBySlugPage/components/VaultPolicyImportModal"; type Props = { isDisabled?: boolean; @@ -22,9 +27,16 @@ export const AddPoliciesButton = ({ isDisabled, projectType }: Props) => { const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([ "addPolicy", "addPolicyOptions", - "applyTemplate" + "applyTemplate", + "importFromVault" ] as const); + const { hasOrgRole } = useOrgPermission(); + const { data: vaultConfigs = [] } = useGetVaultExternalMigrationConfigs(); + const hasVaultConnection = vaultConfigs.some((config) => config.connectionId); + const isOrgAdmin = hasOrgRole(OrgMembershipRole.Admin); + const isVaultImportDisabled = isDisabled || !isOrgAdmin; + return (
+ {hasVaultConnection && ( + + + + )}
@@ -77,6 +118,10 @@ export const AddPoliciesButton = ({ isDisabled, projectType }: Props) => { isOpen={popUp.applyTemplate.isOpen} onOpenChange={(isOpen) => handlePopUpToggle("applyTemplate", isOpen)} /> + handlePopUpToggle("importFromVault", isOpen)} + />
); }; diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/VaultPolicyImportModal.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/VaultPolicyImportModal.tsx new file mode 100644 index 000000000..d126791f0 --- /dev/null +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/VaultPolicyImportModal.tsx @@ -0,0 +1,265 @@ +import { useEffect, useState } from "react"; +import { useFormContext } from "react-hook-form"; +import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FilterableSelect, + FormControl, + Modal, + ModalClose, + ModalContent, + TextArea +} from "@app/components/v2"; +import { ProjectPermissionSub } from "@app/context"; +import { + useGetVaultMounts, + useGetVaultNamespaces, + useGetVaultPolicies +} from "@app/hooks/api/migration/queries"; + +import { TFormSchema } from "./ProjectRoleModifySection.utils"; +import { parseVaultPolicyToInfisical } from "./VaultPolicyImportModal.utils"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +type ContentProps = { + onClose: () => void; +}; + +const Content = ({ onClose }: ContentProps) => { + const rootForm = useFormContext(); + const [selectedNamespace, setSelectedNamespace] = useState(null); + const [selectedPolicy, setSelectedPolicy] = useState(null); + const [hclPolicy, setHclPolicy] = useState(""); + const [shouldFetchPolicies, setShouldFetchPolicies] = useState(false); + const [shouldFetchMounts, setShouldFetchMounts] = useState(false); + + const { data: namespaces, isLoading: isLoadingNamespaces } = useGetVaultNamespaces(); + const { data: policies, isLoading: isLoadingPolicies } = useGetVaultPolicies( + shouldFetchPolicies, + selectedNamespace ?? undefined + ); + const { data: mounts, isLoading: isLoadingMounts } = useGetVaultMounts( + shouldFetchMounts, + selectedNamespace ?? undefined + ); + + // Enable fetching policies and mounts when namespace is selected + useEffect(() => { + if (selectedNamespace) { + setShouldFetchPolicies(true); + setShouldFetchMounts(true); + } + }, [selectedNamespace]); + + // Auto-populate HCL when a policy is selected + useEffect(() => { + if (selectedPolicy && policies) { + const policy = policies.find((p) => p.name === selectedPolicy); + if (policy) { + setHclPolicy(policy.rules); + } + } + }, [selectedPolicy, policies]); + + const handleTranslateAndApply = () => { + if (!hclPolicy.trim()) { + createNotification({ type: "error", text: "Please provide a Vault HCL policy" }); + return; + } + + if (!mounts || mounts.length === 0) { + createNotification({ + type: "error", + text: "No Vault mounts found. Please ensure you have KV secret engines configured." + }); + return; + } + + try { + const parsedPermissions = parseVaultPolicyToInfisical(hclPolicy, mounts); + + if (!parsedPermissions || Object.keys(parsedPermissions).length === 0) { + createNotification({ + type: "warning", + text: "No translatable permissions found in the policy. Ensure the policy contains KV secret paths (e.g., secret/data/*, secret/metadata/*)." + }); + return; + } + + // Apply the parsed permissions to the form + (Object.keys(parsedPermissions) as ProjectPermissionSub[]).forEach((subjectKey) => { + const value = parsedPermissions[subjectKey]; + if (!value) return; + + const existingValue = rootForm.getValues(`permissions.${subjectKey}`) as unknown[]; + + if (Array.isArray(existingValue) && existingValue.length > 0) { + // Merge with existing permissions + rootForm.setValue(`permissions.${subjectKey}`, [...existingValue, ...value] as never, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true + }); + } else { + rootForm.setValue(`permissions.${subjectKey}`, value as never, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true + }); + } + }); + + createNotification({ + type: "info", + text: "Vault policy translated and prefilled" + }); + + onClose(); + } catch (err) { + console.error("Translation error:", err); + createNotification({ + type: "error", + text: "Failed to translate policy. Please check the HCL format." + }); + } + }; + + return ( + <> +
+
+ +
+
+ How Policy Translation Works +
+
+

+ Policies are translated by identifying KV secret engine mounts and parsing path + structures to extract environments and secret paths. +

+

+ Key assumptions: The first path segment after the mount is treated + as the environment (e.g., secret/data/prod/app → + env: prod, path:{" "} + /app). Vault capabilities and wildcards are + automatically mapped to equivalent Infisical permissions and glob patterns. +

+
+
+
+
+ + + <> + ns.id === selectedNamespace)} + onChange={(value) => { + if (value && !Array.isArray(value)) { + const namespace = value as { id: string; name: string }; + setSelectedNamespace(namespace.name); + setSelectedPolicy(null); + } + }} + options={namespaces || []} + getOptionValue={(option) => option.name} + getOptionLabel={(option) => (option.name === "/" ? "root" : option.name)} + isDisabled={isLoadingNamespaces} + placeholder="Select namespace..." + className="w-full" + /> +

+ Select the Vault namespace to fetch policies and mount information +

+ +
+ + + <> + p.name === selectedPolicy) : null} + onChange={(value) => { + if (value && !Array.isArray(value)) { + const policy = value as { name: string; rules: string }; + setSelectedPolicy(policy.name); + } else { + setSelectedPolicy(null); + } + }} + options={policies || []} + getOptionValue={(option) => option.name} + getOptionLabel={(option) => option.name} + isDisabled={isLoadingPolicies} + placeholder="Choose a policy to import..." + isClearable + className="w-full" + /> +

+ Select a policy to auto-populate the HCL editor below, or skip to paste your own +

+ +
+ + + <> +