diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index c814ab9d8..088d99ee9 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -28,6 +28,10 @@ import { TKmipServiceFactory } from "@app/ee/services/kmip/kmip-service"; import { TLdapConfigServiceFactory } from "@app/ee/services/ldap-config/ldap-config-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TOidcConfigServiceFactory } from "@app/ee/services/oidc/oidc-config-service"; +import { TPamAccountServiceFactory } from "@app/ee/services/pam-account/pam-account-service"; +import { TPamFolderServiceFactory } from "@app/ee/services/pam-folder/pam-folder-service"; +import { TPamResourceServiceFactory } from "@app/ee/services/pam-resource/pam-resource-service"; +import { TPamSessionServiceFactory } from "@app/ee/services/pam-session/pam-session-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { TPitServiceFactory } from "@app/ee/services/pit/pit-service"; import { TProjectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-types"; @@ -315,6 +319,10 @@ declare module "fastify" { identityAuthTemplate: TIdentityAuthTemplateServiceFactory; notification: TNotificationServiceFactory; offlineUsageReport: TOfflineUsageReportServiceFactory; + pamFolder: TPamFolderServiceFactory; + pamResource: TPamResourceServiceFactory; + pamAccount: TPamAccountServiceFactory; + pamSession: TPamSessionServiceFactory; upgradePath: TUpgradePathService; }; // this is exclusive use for middlewares in which we need to inject data diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index b6bde44bc..c4d45ca27 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -530,6 +530,10 @@ import { TMicrosoftTeamsIntegrationsInsert, TMicrosoftTeamsIntegrationsUpdate } from "@app/db/schemas/microsoft-teams-integrations"; +import { TPamAccounts, TPamAccountsInsert, TPamAccountsUpdate } from "@app/db/schemas/pam-accounts"; +import { TPamFolders, TPamFoldersInsert, TPamFoldersUpdate } from "@app/db/schemas/pam-folders"; +import { TPamResources, TPamResourcesInsert, TPamResourcesUpdate } from "@app/db/schemas/pam-resources"; +import { TPamSessions, TPamSessionsInsert, TPamSessionsUpdate } from "@app/db/schemas/pam-sessions"; import { TProjectMicrosoftTeamsConfigs, TProjectMicrosoftTeamsConfigsInsert, @@ -1308,5 +1312,9 @@ declare module "knex/types/tables" { TKeyValueStoreInsert, TKeyValueStoreUpdate >; + [TableName.PamFolder]: KnexOriginal.CompositeTableType; + [TableName.PamResource]: KnexOriginal.CompositeTableType; + [TableName.PamAccount]: KnexOriginal.CompositeTableType; + [TableName.PamSession]: KnexOriginal.CompositeTableType; } } diff --git a/backend/src/db/migrations/20250917052037_pam.ts b/backend/src/db/migrations/20250917052037_pam.ts new file mode 100644 index 000000000..031b407db --- /dev/null +++ b/backend/src/db/migrations/20250917052037_pam.ts @@ -0,0 +1,165 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + // PAM Folders + if (!(await knex.schema.hasTable(TableName.PamFolder))) { + await knex.schema.createTable(TableName.PamFolder, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.index("projectId"); + + t.uuid("parentId").nullable(); + t.foreign("parentId").references("id").inTable(TableName.PamFolder).onDelete("CASCADE"); + t.index("parentId"); + + t.string("name").notNullable(); + t.index("name"); + + // Enforce uniqueness for sub-folders + t.unique(["projectId", "parentId", "name"], { + indexName: "uidx_pam_folder_children_name", + predicate: knex.whereNotNull("parentId") + }); + + // Enforce uniqueness for root-level folders + t.unique(["projectId", "name"], { + indexName: "uidx_pam_folder_root_name", + predicate: knex.whereNull("parentId") + }); + + t.text("description").nullable(); + + t.timestamps(true, true, true); + }); + } + + // PAM Resources + if (!(await knex.schema.hasTable(TableName.PamResource))) { + await knex.schema.createTable(TableName.PamResource, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.index("projectId"); + + t.string("name").notNullable(); + t.index("name"); + + t.uuid("gatewayId").notNullable(); + t.foreign("gatewayId").references("id").inTable(TableName.GatewayV2); + t.index("gatewayId"); + + t.string("resourceType").notNullable(); + t.index("resourceType"); + + t.binary("encryptedConnectionDetails").notNullable(); + + t.timestamps(true, true, true); + }); + } + + // PAM Accounts + if (!(await knex.schema.hasTable(TableName.PamAccount))) { + await knex.schema.createTable(TableName.PamAccount, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.index("projectId"); + + t.uuid("folderId").nullable(); + t.foreign("folderId").references("id").inTable(TableName.PamFolder).onDelete("CASCADE"); + t.index("folderId"); + + t.uuid("resourceId").notNullable(); + t.foreign("resourceId").references("id").inTable(TableName.PamResource); + t.index("resourceId"); + + t.string("name").notNullable(); + t.index("name"); + + // Enforce uniqueness for folders + t.unique(["projectId", "folderId", "name"], { + indexName: "uidx_pam_account_children_name", + predicate: knex.whereNotNull("folderId") + }); + + // Enforce uniqueness for root-level + t.unique(["projectId", "name"], { + indexName: "uidx_pam_account_root_name", + predicate: knex.whereNull("folderId") + }); + + t.text("description").nullable(); + t.binary("encryptedCredentials").notNullable(); + + t.timestamps(true, true, true); + }); + } + + // PAM Sessions + if (!(await knex.schema.hasTable(TableName.PamSession))) { + await knex.schema.createTable(TableName.PamSession, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.index("projectId"); + + t.uuid("accountId").nullable(); + t.foreign("accountId").references("id").inTable(TableName.PamAccount).onDelete("SET NULL"); + t.index("accountId"); + + // To be used in the event of an account deletion + t.string("resourceType").notNullable(); + t.string("resourceName").notNullable(); + t.string("accountName").notNullable(); + + t.uuid("userId").nullable(); + t.foreign("userId").references("id").inTable(TableName.Users).onDelete("SET NULL"); + t.index("userId"); + + // To be used in the event of user deletion + t.string("actorName").notNullable(); + t.string("actorEmail").notNullable(); + + t.string("actorIp").notNullable(); + t.string("actorUserAgent").notNullable(); + + t.string("status").notNullable(); + t.index("status"); + + t.binary("encryptedLogsBlob").nullable(); + + t.datetime("expiresAt").notNullable(); + + t.datetime("startedAt").nullable(); // Not when the row is created, but when the end-to-end connection between user and resource is established + t.datetime("endedAt").nullable(); + t.index(["startedAt", "endedAt"]); + + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.PamFolder); + await createOnUpdateTrigger(knex, TableName.PamResource); + await createOnUpdateTrigger(knex, TableName.PamAccount); + await createOnUpdateTrigger(knex, TableName.PamSession); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.PamSession); + await knex.schema.dropTableIfExists(TableName.PamAccount); + await knex.schema.dropTableIfExists(TableName.PamResource); + await knex.schema.dropTableIfExists(TableName.PamFolder); + + await dropOnUpdateTrigger(knex, TableName.PamSession); + await dropOnUpdateTrigger(knex, TableName.PamAccount); + await dropOnUpdateTrigger(knex, TableName.PamResource); + await dropOnUpdateTrigger(knex, TableName.PamFolder); +} diff --git a/backend/src/db/migrations/20251002113756_add-gateway-pam-key.ts b/backend/src/db/migrations/20251002113756_add-gateway-pam-key.ts new file mode 100644 index 000000000..b2bb10003 --- /dev/null +++ b/backend/src/db/migrations/20251002113756_add-gateway-pam-key.ts @@ -0,0 +1,19 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.GatewayV2, "encryptedPamSessionKey"))) { + await knex.schema.alterTable(TableName.GatewayV2, (t) => { + t.binary("encryptedPamSessionKey"); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.GatewayV2, "encryptedPamSessionKey")) { + await knex.schema.alterTable(TableName.GatewayV2, (t) => { + t.dropColumn("encryptedPamSessionKey"); + }); + } +} diff --git a/backend/src/db/schemas/gateways-v2.ts b/backend/src/db/schemas/gateways-v2.ts index 6aff8a168..1362793f6 100644 --- a/backend/src/db/schemas/gateways-v2.ts +++ b/backend/src/db/schemas/gateways-v2.ts @@ -5,6 +5,8 @@ import { z } from "zod"; +import { zodBuffer } from "@app/lib/zod"; + import { TImmutableDBKeys } from "./models"; export const GatewaysV2Schema = z.object({ @@ -15,7 +17,8 @@ export const GatewaysV2Schema = z.object({ identityId: z.string().uuid(), relayId: z.string().uuid().nullable().optional(), name: z.string(), - heartbeat: z.date().nullable().optional() + heartbeat: z.date().nullable().optional(), + encryptedPamSessionKey: zodBuffer.nullable().optional() }); export type TGatewaysV2 = z.infer; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 8cbaa00bb..f8ac885b4 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -83,6 +83,10 @@ export * from "./org-memberships"; export * from "./org-relay-config"; export * from "./org-roles"; export * from "./organizations"; +export * from "./pam-accounts"; +export * from "./pam-folders"; +export * from "./pam-resources"; +export * from "./pam-sessions"; export * from "./pki-alerts"; export * from "./pki-collection-items"; export * from "./pki-collections"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 385a328b6..09ecb367a 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -189,7 +189,13 @@ export enum TableName { Relay = "relays", GatewayV2 = "gateways_v2", - KeyValueStore = "key_value_store" + KeyValueStore = "key_value_store", + + // PAM + PamFolder = "pam_folders", + PamResource = "pam_resources", + PamAccount = "pam_accounts", + PamSession = "pam_sessions" } export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt" | "commitId"; @@ -281,7 +287,8 @@ export enum ProjectType { CertificateManager = "cert-manager", KMS = "kms", SSH = "ssh", - SecretScanning = "secret-scanning" + SecretScanning = "secret-scanning", + PAM = "pam" } export enum ActionProjectType { @@ -290,6 +297,7 @@ export enum ActionProjectType { KMS = ProjectType.KMS, SSH = ProjectType.SSH, SecretScanning = ProjectType.SecretScanning, + PAM = ProjectType.PAM, // project operations that happen on all types Any = "any" } diff --git a/backend/src/db/schemas/pam-accounts.ts b/backend/src/db/schemas/pam-accounts.ts new file mode 100644 index 000000000..5a9a45617 --- /dev/null +++ b/backend/src/db/schemas/pam-accounts.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 { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const PamAccountsSchema = z.object({ + id: z.string().uuid(), + projectId: z.string(), + folderId: z.string().uuid().nullable().optional(), + resourceId: z.string().uuid(), + name: z.string(), + description: z.string().nullable().optional(), + encryptedCredentials: zodBuffer, + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPamAccounts = z.infer; +export type TPamAccountsInsert = Omit, TImmutableDBKeys>; +export type TPamAccountsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/pam-folders.ts b/backend/src/db/schemas/pam-folders.ts new file mode 100644 index 000000000..80243c1bc --- /dev/null +++ b/backend/src/db/schemas/pam-folders.ts @@ -0,0 +1,22 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const PamFoldersSchema = z.object({ + id: z.string().uuid(), + projectId: z.string(), + parentId: z.string().uuid().nullable().optional(), + name: z.string(), + description: z.string().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPamFolders = z.infer; +export type TPamFoldersInsert = Omit, TImmutableDBKeys>; +export type TPamFoldersUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/pam-resources.ts b/backend/src/db/schemas/pam-resources.ts new file mode 100644 index 000000000..d34017d0f --- /dev/null +++ b/backend/src/db/schemas/pam-resources.ts @@ -0,0 +1,25 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const PamResourcesSchema = z.object({ + id: z.string().uuid(), + projectId: z.string(), + name: z.string(), + gatewayId: z.string().uuid(), + resourceType: z.string(), + encryptedConnectionDetails: zodBuffer, + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPamResources = z.infer; +export type TPamResourcesInsert = Omit, TImmutableDBKeys>; +export type TPamResourcesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/pam-sessions.ts b/backend/src/db/schemas/pam-sessions.ts new file mode 100644 index 000000000..12e4adfcf --- /dev/null +++ b/backend/src/db/schemas/pam-sessions.ts @@ -0,0 +1,35 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const PamSessionsSchema = z.object({ + id: z.string().uuid(), + projectId: z.string(), + accountId: z.string().uuid().nullable().optional(), + resourceType: z.string(), + resourceName: z.string(), + accountName: z.string(), + userId: z.string().uuid().nullable().optional(), + actorName: z.string(), + actorEmail: z.string(), + actorIp: z.string(), + actorUserAgent: z.string(), + status: z.string(), + encryptedLogsBlob: zodBuffer.nullable().optional(), + expiresAt: z.date(), + startedAt: z.date().nullable().optional(), + endedAt: z.date().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPamSessions = z.infer; +export type TPamSessionsInsert = Omit, TImmutableDBKeys>; +export type TPamSessionsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 56d450df3..42392ba55 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -23,6 +23,12 @@ import { registerLdapRouter } from "./ldap-router"; import { registerLicenseRouter } from "./license-router"; import { registerOidcRouter } from "./oidc-router"; import { registerOrgRoleRouter } from "./org-role-router"; +import { PAM_ACCOUNT_REGISTER_ROUTER_MAP } from "./pam-account-routers"; +import { registerPamAccountRouter } from "./pam-account-routers/pam-account-router"; +import { registerPamFolderRouter } from "./pam-folder-router"; +import { PAM_RESOURCE_REGISTER_ROUTER_MAP } from "./pam-resource-routers"; +import { registerPamResourceRouter } from "./pam-resource-routers/pam-resource-router"; +import { registerPamSessionRouter } from "./pam-session-router"; import { registerPITRouter } from "./pit-router"; import { registerProjectRoleRouter } from "./project-role-router"; import { registerProjectRouter } from "./project-router"; @@ -166,4 +172,40 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { }, { prefix: "/kmip" } ); + + await server.register( + async (pamRouter) => { + await pamRouter.register(registerPamFolderRouter, { prefix: "/folders" }); + await pamRouter.register(registerPamSessionRouter, { prefix: "/sessions" }); + + await pamRouter.register( + async (pamAccountRouter) => { + await pamAccountRouter.register(registerPamAccountRouter); + + // Provider-specific endpoints + await Promise.all( + Object.entries(PAM_ACCOUNT_REGISTER_ROUTER_MAP).map(([provider, router]) => + pamAccountRouter.register(router, { prefix: `/${provider}` }) + ) + ); + }, + { prefix: "/accounts" } + ); + + await pamRouter.register( + async (pamResourceRouter) => { + await pamResourceRouter.register(registerPamResourceRouter); + + // Provider-specific endpoints + await Promise.all( + Object.entries(PAM_RESOURCE_REGISTER_ROUTER_MAP).map(([provider, router]) => + pamResourceRouter.register(router, { prefix: `/${provider}` }) + ) + ); + }, + { prefix: "/resources" } + ); + }, + { prefix: "/pam" } + ); }; diff --git a/backend/src/ee/routes/v1/pam-account-routers/index.ts b/backend/src/ee/routes/v1/pam-account-routers/index.ts new file mode 100644 index 000000000..568412c84 --- /dev/null +++ b/backend/src/ee/routes/v1/pam-account-routers/index.ts @@ -0,0 +1,20 @@ +import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums"; +import { + CreatePostgresAccountSchema, + SanitizedPostgresAccountWithResourceSchema, + UpdatePostgresAccountSchema +} from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; + +import { registerPamResourceEndpoints } from "./pam-account-endpoints"; + +export const PAM_ACCOUNT_REGISTER_ROUTER_MAP: Record Promise> = { + [PamResource.Postgres]: async (server: FastifyZodProvider) => { + registerPamResourceEndpoints({ + server, + resourceType: PamResource.Postgres, + accountResponseSchema: SanitizedPostgresAccountWithResourceSchema, + createAccountSchema: CreatePostgresAccountSchema, + updateAccountSchema: UpdatePostgresAccountSchema + }); + } +}; diff --git a/backend/src/ee/routes/v1/pam-account-routers/pam-account-endpoints.ts b/backend/src/ee/routes/v1/pam-account-routers/pam-account-endpoints.ts new file mode 100644 index 000000000..0ed7e238a --- /dev/null +++ b/backend/src/ee/routes/v1/pam-account-routers/pam-account-endpoints.ts @@ -0,0 +1,159 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums"; +import { TPamAccount } from "@app/ee/services/pam-resource/pam-resource-types"; +import { writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerPamResourceEndpoints = ({ + server, + resourceType, + createAccountSchema, + updateAccountSchema, + accountResponseSchema +}: { + server: FastifyZodProvider; + resourceType: PamResource; + createAccountSchema: z.ZodType<{ + credentials: C["credentials"]; + resourceId: C["resourceId"]; + folderId?: C["folderId"]; + name: C["name"]; + description?: C["description"]; + }>; + updateAccountSchema: z.ZodType<{ + credentials?: C["credentials"]; + name?: C["name"]; + description?: C["description"]; + }>; + accountResponseSchema: z.ZodTypeAny; +}) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Create PAM account", + body: createAccountSchema, + response: { + 200: z.object({ + account: accountResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const account = await server.services.pamAccount.create(req.body, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: account.projectId, + event: { + type: EventType.PAM_ACCOUNT_CREATE, + metadata: { + resourceId: req.body.resourceId, + resourceType, + folderId: req.body.folderId, + name: req.body.name, + description: req.body.description + } + } + }); + + return { account }; + } + }); + + server.route({ + method: "PATCH", + url: "/:accountId", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Update PAM account", + params: z.object({ + accountId: z.string().uuid() + }), + body: updateAccountSchema, + response: { + 200: z.object({ + account: accountResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const account = await server.services.pamAccount.updateById( + { + ...req.body, + accountId: req.params.accountId + }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: account.projectId, + event: { + type: EventType.PAM_ACCOUNT_UPDATE, + metadata: { + accountId: req.params.accountId, + resourceId: account.resourceId, + resourceType, + name: req.body.name, + description: req.body.description + } + } + }); + + return { account }; + } + }); + + server.route({ + method: "DELETE", + url: "/:accountId", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Delete PAM account", + params: z.object({ + accountId: z.string().uuid() + }), + response: { + 200: z.object({ + account: accountResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const account = await server.services.pamAccount.deleteById(req.params.accountId, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: account.projectId, + event: { + type: EventType.PAM_ACCOUNT_DELETE, + metadata: { + accountId: req.params.accountId, + accountName: account.name, + resourceId: account.resourceId, + resourceType + } + } + }); + + return { account }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts b/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts new file mode 100644 index 000000000..647f39d8d --- /dev/null +++ b/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts @@ -0,0 +1,131 @@ +import { z } from "zod"; + +import { PamFoldersSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums"; +import { SanitizedPostgresAccountWithResourceSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; +import { BadRequestError } from "@app/lib/errors"; +import { ms } from "@app/lib/ms"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +// Use z.union([...]) when more resources are added +const SanitizedAccountSchema = SanitizedPostgresAccountWithResourceSchema; + +export const registerPamAccountRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + description: "List PAM accounts", + querystring: z.object({ + projectId: z.string().uuid() + }), + response: { + 200: z.object({ + accounts: SanitizedAccountSchema.array(), + folders: PamFoldersSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const response = await server.services.pamAccount.list(req.query.projectId, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: req.query.projectId, + event: { + type: EventType.PAM_ACCOUNT_LIST, + metadata: { + accountCount: response.accounts.length, + folderCount: response.folders.length + } + } + }); + + return response; + } + }); + + server.route({ + method: "POST", + url: "/access", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Access PAM account", + body: z.object({ + accountId: z.string().uuid(), + duration: z + .string() + .min(1) + .transform((val, ctx) => { + const parsedMs = ms(val); + + if (typeof parsedMs !== "number" || parsedMs <= 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Invalid duration format. Must be a positive duration (e.g., '1h', '30m', '2d')." + }); + return z.NEVER; + } + return parsedMs; + }) + }), + response: { + 200: z.object({ + sessionId: z.string(), + resourceType: z.nativeEnum(PamResource), + relayClientCertificate: z.string(), + relayClientPrivateKey: z.string(), + relayServerCertificateChain: z.string(), + gatewayClientCertificate: z.string(), + gatewayClientPrivateKey: z.string(), + gatewayServerCertificateChain: z.string(), + relayHost: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + // To prevent type errors when accessing req.auth + if (req.auth.authMode !== AuthMode.JWT) { + throw new BadRequestError({ message: "You can only access PAM accounts using JWT auth tokens." }); + } + + const response = await server.services.pamAccount.access( + { + actorEmail: req.auth.user.email ?? "", + actorIp: req.realIp, + actorName: `${req.auth.user.firstName ?? ""} ${req.auth.user.lastName ?? ""}`.trim(), + actorUserAgent: req.auditLogInfo.userAgent ?? "", + ...req.body + }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: response.projectId, + event: { + type: EventType.PAM_ACCOUNT_ACCESS, + metadata: { + accountId: req.body.accountId, + accountName: response.account.name, + duration: req.body.duration ? new Date(req.body.duration).toISOString() : undefined + } + } + }); + + return response; + } + }); +}; diff --git a/backend/src/ee/routes/v1/pam-folder-router.ts b/backend/src/ee/routes/v1/pam-folder-router.ts new file mode 100644 index 000000000..cd2506aba --- /dev/null +++ b/backend/src/ee/routes/v1/pam-folder-router.ts @@ -0,0 +1,150 @@ +import { z } from "zod"; + +import { PamFoldersSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { isValidFolderName } from "@app/lib/validator"; +import { writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerPamFolderRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Create PAM folder", + body: z.object({ + projectId: z.string().uuid(), + parentId: z.string().uuid().nullable().optional(), + name: z + .string() + .trim() + .refine((name) => isValidFolderName(name), { + message: "Folder name can only contain alphanumeric characters, dashes, and underscores." + }), + description: z.string().trim().max(512).nullable().optional() + }), + response: { + 200: z.object({ + folder: PamFoldersSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const folder = await server.services.pamFolder.createFolder(req.body, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: req.body.projectId, + event: { + type: EventType.PAM_FOLDER_CREATE, + metadata: { + name: req.body.name, + description: req.body.description, + parentId: req.body.parentId + } + } + }); + + return { folder }; + } + }); + + server.route({ + method: "PATCH", + url: "/:folderId", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Update PAM folder", + params: z.object({ + folderId: z.string().uuid() + }), + body: z.object({ + name: z + .string() + .trim() + .optional() + .refine((name) => (name ? isValidFolderName(name) : true), { + message: "Folder name can only contain alphanumeric characters, dashes, and underscores." + }), + description: z.string().trim().max(512).nullable().optional() + }), + response: { + 200: z.object({ + folder: PamFoldersSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const folder = await server.services.pamFolder.updateFolder( + { + ...req.body, + id: req.params.folderId + }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: folder.projectId, + event: { + type: EventType.PAM_FOLDER_UPDATE, + metadata: { + folderId: req.params.folderId, + name: req.body.name, + description: req.body.description + } + } + }); + + return { folder }; + } + }); + + server.route({ + method: "DELETE", + url: "/:folderId", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Delete PAM folder", + params: z.object({ + folderId: z.string().uuid() + }), + response: { + 200: z.object({ + folder: PamFoldersSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const folder = await server.services.pamFolder.deleteFolder(req.params.folderId, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: folder.projectId, + event: { + type: EventType.PAM_FOLDER_DELETE, + metadata: { + folderName: folder.name, + folderId: req.params.folderId + } + } + }); + + return { folder }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/pam-resource-routers/index.ts b/backend/src/ee/routes/v1/pam-resource-routers/index.ts new file mode 100644 index 000000000..a63b67d94 --- /dev/null +++ b/backend/src/ee/routes/v1/pam-resource-routers/index.ts @@ -0,0 +1,20 @@ +import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums"; +import { + CreatePostgresResourceSchema, + PostgresResourceSchema, + UpdatePostgresResourceSchema +} from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; + +import { registerPamResourceEndpoints } from "./pam-resource-endpoints"; + +export const PAM_RESOURCE_REGISTER_ROUTER_MAP: Record Promise> = { + [PamResource.Postgres]: async (server: FastifyZodProvider) => { + registerPamResourceEndpoints({ + server, + resourceType: PamResource.Postgres, + resourceResponseSchema: PostgresResourceSchema, + createResourceSchema: CreatePostgresResourceSchema, + updateResourceSchema: UpdatePostgresResourceSchema + }); + } +}; diff --git a/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-endpoints.ts b/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-endpoints.ts new file mode 100644 index 000000000..776de8e48 --- /dev/null +++ b/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-endpoints.ts @@ -0,0 +1,198 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums"; +import { TPamResource } from "@app/ee/services/pam-resource/pam-resource-types"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerPamResourceEndpoints = ({ + server, + resourceType, + createResourceSchema, + updateResourceSchema, + resourceResponseSchema +}: { + server: FastifyZodProvider; + resourceType: PamResource; + createResourceSchema: z.ZodType<{ + projectId: T["projectId"]; + connectionDetails: T["connectionDetails"]; + gatewayId: T["gatewayId"]; + name: T["name"]; + }>; + updateResourceSchema: z.ZodType<{ + connectionDetails?: T["connectionDetails"]; + gatewayId?: T["gatewayId"]; + name?: T["name"]; + }>; + resourceResponseSchema: z.ZodTypeAny; +}) => { + server.route({ + method: "GET", + url: "/:resourceId", + config: { + rateLimit: readLimit + }, + schema: { + description: "Get PAM resource", + params: z.object({ + resourceId: z.string().uuid() + }), + response: { + 200: z.object({ + resource: resourceResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const resource = await server.services.pamResource.getById(req.params.resourceId, resourceType, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: resource.projectId, + event: { + type: EventType.PAM_RESOURCE_GET, + metadata: { + resourceId: resource.id, + resourceType: resource.resourceType, + name: resource.name + } + } + }); + + return { resource }; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Create PAM resource", + body: createResourceSchema, + response: { + 200: z.object({ + resource: resourceResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const resource = await server.services.pamResource.create( + { + ...req.body, + resourceType + }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: req.body.projectId, + event: { + type: EventType.PAM_RESOURCE_CREATE, + metadata: { + resourceType, + gatewayId: req.body.gatewayId, + name: req.body.name + } + } + }); + + return { resource }; + } + }); + + server.route({ + method: "PATCH", + url: "/:resourceId", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Update PAM resource", + params: z.object({ + resourceId: z.string().uuid() + }), + body: updateResourceSchema, + response: { + 200: z.object({ + resource: resourceResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const resource = await server.services.pamResource.updateById( + { + ...req.body, + resourceId: req.params.resourceId + }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: resource.projectId, + event: { + type: EventType.PAM_RESOURCE_UPDATE, + metadata: { + resourceId: req.params.resourceId, + resourceType, + gatewayId: req.body.gatewayId, + name: req.body.name + } + } + }); + + return { resource }; + } + }); + + server.route({ + method: "DELETE", + url: "/:resourceId", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Delete PAM resource", + params: z.object({ + resourceId: z.string().uuid() + }), + response: { + 200: z.object({ + resource: resourceResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const resource = await server.services.pamResource.deleteById(req.params.resourceId, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: resource.projectId, + event: { + type: EventType.PAM_RESOURCE_DELETE, + metadata: { + resourceId: req.params.resourceId, + resourceType + } + } + }); + + return { resource }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts b/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts new file mode 100644 index 000000000..c19c2030d --- /dev/null +++ b/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts @@ -0,0 +1,76 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { + PostgresResourceListItemSchema, + PostgresResourceSchema +} from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +// Use z.union([...]) when more resources are added +const ResourceSchema = PostgresResourceSchema; + +const ResourceOptionsSchema = z.discriminatedUnion("resource", [PostgresResourceListItemSchema]); + +export const registerPamResourceRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/options", + config: { + rateLimit: readLimit + }, + schema: { + description: "List PAM resource types", + response: { + 200: z.object({ + resourceOptions: ResourceOptionsSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: () => { + const resourceOptions = server.services.pamResource.listResourceOptions(); + + return { resourceOptions }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + description: "List PAM resources", + querystring: z.object({ + projectId: z.string().uuid() + }), + response: { + 200: z.object({ + resources: ResourceSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const response = await server.services.pamResource.list(req.query.projectId, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: req.query.projectId, + event: { + type: EventType.PAM_RESOURCE_LIST, + metadata: { + count: response.resources.length + } + } + }); + + return response; + } + }); +}; diff --git a/backend/src/ee/routes/v1/pam-session-router.ts b/backend/src/ee/routes/v1/pam-session-router.ts new file mode 100644 index 000000000..c353fddfa --- /dev/null +++ b/backend/src/ee/routes/v1/pam-session-router.ts @@ -0,0 +1,224 @@ +import { z } from "zod"; + +import { PamSessionsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { PostgresSessionCredentialsSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; +import { PamSessionCommandLogSchema, SanitizedSessionSchema } from "@app/ee/services/pam-session/pam-session-schemas"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +// Use z.union([]) once there's multiple +const SessionCredentialsSchema = PostgresSessionCredentialsSchema; + +export const registerPamSessionRouter = async (server: FastifyZodProvider) => { + // Meant to be hit solely by gateway identities + server.route({ + method: "GET", + url: "/:sessionId/credentials", + config: { + rateLimit: readLimit + }, + schema: { + description: "Get PAM session credentials and start session", + params: z.object({ + sessionId: z.string().uuid() + }), + response: { + 200: z.object({ + credentials: SessionCredentialsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { credentials, projectId, account } = await server.services.pamAccount.getSessionCredentials( + req.params.sessionId, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId, + event: { + type: EventType.PAM_SESSION_START, + metadata: { + sessionId: req.params.sessionId, + accountName: account.name + } + } + }); + + return { credentials }; + } + }); + + // Meant to be hit solely by gateway identities + server.route({ + method: "POST", + url: "/:sessionId/logs", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Update PAM session logs", + params: z.object({ + sessionId: z.string().uuid() + }), + body: z.object({ + logs: PamSessionCommandLogSchema.array() + }), + response: { + 200: z.object({ + session: PamSessionsSchema.omit({ + encryptedLogsBlob: true + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { session, projectId } = await server.services.pamSession.updateLogsById( + { + sessionId: req.params.sessionId, + logs: req.body.logs + }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId, + event: { + type: EventType.PAM_SESSION_LOGS_UPDATE, + metadata: { + sessionId: req.params.sessionId, + accountName: session.accountName + } + } + }); + + return { session }; + } + }); + + // Meant to be hit solely by gateway identities + server.route({ + method: "POST", + url: "/:sessionId/end", + config: { + rateLimit: writeLimit + }, + schema: { + description: "End PAM session", + params: z.object({ + sessionId: z.string().uuid() + }), + response: { + 200: z.object({ + session: PamSessionsSchema.omit({ + encryptedLogsBlob: true + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { session, projectId } = await server.services.pamSession.endSessionById( + req.params.sessionId, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId, + event: { + type: EventType.PAM_SESSION_END, + metadata: { + sessionId: req.params.sessionId, + accountName: session.accountName + } + } + }); + + return { session }; + } + }); + + server.route({ + method: "GET", + url: "/:sessionId", + config: { + rateLimit: readLimit + }, + schema: { + description: "Get PAM session", + params: z.object({ + sessionId: z.string().uuid() + }), + response: { + 200: z.object({ + session: SanitizedSessionSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const response = await server.services.pamSession.getById(req.params.sessionId, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: response.session.projectId, + event: { + type: EventType.PAM_SESSION_GET, + metadata: { + sessionId: req.params.sessionId + } + } + }); + + return response; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + description: "List PAM sessions", + querystring: z.object({ + projectId: z.string().uuid() + }), + response: { + 200: z.object({ + sessions: SanitizedSessionSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const response = await server.services.pamSession.list(req.query.projectId, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: req.query.projectId, + event: { + type: EventType.PAM_SESSION_LIST, + metadata: { + count: response.sessions.length + } + } + }); + + return response; + } + }); +}; diff --git a/backend/src/ee/routes/v2/gateway-router.ts b/backend/src/ee/routes/v2/gateway-router.ts index a7e656a64..56284729d 100644 --- a/backend/src/ee/routes/v2/gateway-router.ts +++ b/backend/src/ee/routes/v2/gateway-router.ts @@ -1,6 +1,7 @@ import z from "zod"; import { GatewaysV2Schema } from "@app/db/schemas"; +import { zodBuffer } from "@app/lib/zod"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -130,4 +131,25 @@ export const registerGatewayV2Router = async (server: FastifyZodProvider) => { return gateway; } }); + + server.route({ + method: "GET", + url: "/pam-session-key", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: zodBuffer + } + }, + onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const pamSessionKey = await server.services.gatewayV2.getPamSessionKey({ + orgPermission: req.permission + }); + + return pamSessionKey; + } + }); }; diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index ab72dbc65..bc50283d4 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -500,7 +500,26 @@ export enum EventType { DASHBOARD_LIST_SECRETS = "dashboard-list-secrets", DASHBOARD_GET_SECRET_VALUE = "dashboard-get-secret-value", - DASHBOARD_GET_SECRET_VERSION_VALUE = "dashboard-get-secret-version-value" + DASHBOARD_GET_SECRET_VERSION_VALUE = "dashboard-get-secret-version-value", + + PAM_SESSION_START = "pam-session-start", + PAM_SESSION_LOGS_UPDATE = "pam-session-logs-update", + PAM_SESSION_END = "pam-session-end", + PAM_SESSION_GET = "pam-session-get", + PAM_SESSION_LIST = "pam-session-list", + PAM_FOLDER_CREATE = "pam-folder-create", + PAM_FOLDER_UPDATE = "pam-folder-update", + PAM_FOLDER_DELETE = "pam-folder-delete", + PAM_ACCOUNT_LIST = "pam-account-list", + PAM_ACCOUNT_ACCESS = "pam-account-access", + PAM_ACCOUNT_CREATE = "pam-account-create", + PAM_ACCOUNT_UPDATE = "pam-account-update", + PAM_ACCOUNT_DELETE = "pam-account-delete", + PAM_RESOURCE_LIST = "pam-resource-list", + PAM_RESOURCE_GET = "pam-resource-get", + PAM_RESOURCE_CREATE = "pam-resource-create", + PAM_RESOURCE_UPDATE = "pam-resource-update", + PAM_RESOURCE_DELETE = "pam-resource-delete" } export const filterableSecretEvents: EventType[] = [ @@ -3687,6 +3706,162 @@ interface OrgRoleDeleteEvent { }; } +interface PamSessionStartEvent { + type: EventType.PAM_SESSION_START; + metadata: { + sessionId: string; + accountName: string; + }; +} + +interface PamSessionLogsUpdateEvent { + type: EventType.PAM_SESSION_LOGS_UPDATE; + metadata: { + sessionId: string; + accountName: string; + }; +} + +interface PamSessionEndEvent { + type: EventType.PAM_SESSION_END; + metadata: { + sessionId: string; + accountName: string; + }; +} + +interface PamSessionGetEvent { + type: EventType.PAM_SESSION_GET; + metadata: { + sessionId: string; + }; +} + +interface PamSessionListEvent { + type: EventType.PAM_SESSION_LIST; + metadata: { + count: number; + }; +} + +interface PamFolderCreateEvent { + type: EventType.PAM_FOLDER_CREATE; + metadata: { + parentId?: string | null; + name: string; + description?: string | null; + }; +} + +interface PamFolderUpdateEvent { + type: EventType.PAM_FOLDER_UPDATE; + metadata: { + folderId: string; + name?: string; + description?: string | null; + }; +} + +interface PamFolderDeleteEvent { + type: EventType.PAM_FOLDER_DELETE; + metadata: { + folderId: string; + folderName: string; + }; +} + +interface PamAccountListEvent { + type: EventType.PAM_ACCOUNT_LIST; + metadata: { + accountCount: number; + folderCount: number; + }; +} + +interface PamAccountAccessEvent { + type: EventType.PAM_ACCOUNT_ACCESS; + metadata: { + accountId: string; + accountName: string; + duration?: string; + }; +} + +interface PamAccountCreateEvent { + type: EventType.PAM_ACCOUNT_CREATE; + metadata: { + resourceId: string; + resourceType: string; + folderId?: string | null; + name: string; + description?: string | null; + }; +} + +interface PamAccountUpdateEvent { + type: EventType.PAM_ACCOUNT_UPDATE; + metadata: { + accountId: string; + resourceId: string; + resourceType: string; + name?: string; + description?: string | null; + }; +} + +interface PamAccountDeleteEvent { + type: EventType.PAM_ACCOUNT_DELETE; + metadata: { + accountName: string; + accountId: string; + resourceId: string; + resourceType: string; + }; +} + +interface PamResourceListEvent { + type: EventType.PAM_RESOURCE_LIST; + metadata: { + count: number; + }; +} + +interface PamResourceGetEvent { + type: EventType.PAM_RESOURCE_GET; + metadata: { + resourceId: string; + resourceType: string; + name: string; + }; +} + +interface PamResourceCreateEvent { + type: EventType.PAM_RESOURCE_CREATE; + metadata: { + resourceType: string; + gatewayId: string; + name: string; + }; +} + +interface PamResourceUpdateEvent { + type: EventType.PAM_RESOURCE_UPDATE; + metadata: { + resourceId: string; + resourceType: string; + gatewayId?: string; + name?: string; + }; +} + +interface PamResourceDeleteEvent { + type: EventType.PAM_RESOURCE_DELETE; + metadata: { + resourceId: string; + resourceType: string; + }; +} + export type Event = | GetSecretsEvent | GetSecretEvent @@ -4020,4 +4195,22 @@ export type Event = | ProjectRoleDeleteEvent | OrgRoleCreateEvent | OrgRoleUpdateEvent - | OrgRoleDeleteEvent; + | OrgRoleDeleteEvent + | PamSessionStartEvent + | PamSessionLogsUpdateEvent + | PamSessionEndEvent + | PamSessionGetEvent + | PamSessionListEvent + | PamFolderCreateEvent + | PamFolderUpdateEvent + | PamFolderDeleteEvent + | PamAccountListEvent + | PamAccountAccessEvent + | PamAccountCreateEvent + | PamAccountUpdateEvent + | PamAccountDeleteEvent + | PamResourceListEvent + | PamResourceGetEvent + | PamResourceCreateEvent + | PamResourceUpdateEvent + | PamResourceDeleteEvent; diff --git a/backend/src/ee/services/gateway-v2/gateway-v2-constants.ts b/backend/src/ee/services/gateway-v2/gateway-v2-constants.ts index e67d4e890..7e41de91c 100644 --- a/backend/src/ee/services/gateway-v2/gateway-v2-constants.ts +++ b/backend/src/ee/services/gateway-v2/gateway-v2-constants.ts @@ -1,2 +1,3 @@ export const GATEWAY_ROUTING_INFO_OID = "1.3.6.1.4.1.12345.100.1"; export const GATEWAY_ACTOR_OID = "1.3.6.1.4.1.12345.100.2"; +export const PAM_INFO_OID = "1.3.6.1.4.1.12345.100.3"; diff --git a/backend/src/ee/services/gateway-v2/gateway-v2-service.ts b/backend/src/ee/services/gateway-v2/gateway-v2-service.ts index e8ccc8a5e..4bf6b1aef 100644 --- a/backend/src/ee/services/gateway-v2/gateway-v2-service.ts +++ b/backend/src/ee/services/gateway-v2/gateway-v2-service.ts @@ -22,11 +22,12 @@ import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { TLicenseServiceFactory } from "../license/license-service"; +import { PamResource } from "../pam-resource/pam-resource-enums"; import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TPermissionServiceFactory } from "../permission/permission-service-types"; import { TRelayDALFactory } from "../relay/relay-dal"; import { TRelayServiceFactory } from "../relay/relay-service"; -import { GATEWAY_ACTOR_OID, GATEWAY_ROUTING_INFO_OID } from "./gateway-v2-constants"; +import { GATEWAY_ACTOR_OID, GATEWAY_ROUTING_INFO_OID, PAM_INFO_OID } from "./gateway-v2-constants"; import { TGatewayV2DALFactory } from "./gateway-v2-dal"; import { TOrgGatewayConfigV2DALFactory } from "./org-gateway-config-v2-dal"; @@ -414,6 +415,176 @@ export const gatewayV2ServiceFactory = ({ }; }; + const getPAMConnectionDetails = async ({ + gatewayId, + sessionId, + duration, + resourceType, + host, + port, + actorMetadata + }: { + gatewayId: string; + sessionId: string; + resourceType: PamResource; + duration?: number; + host: string; + port: number; + actorMetadata: { id: string; type: ActorType; name: string }; + }) => { + const gateway = await gatewayV2DAL.findById(gatewayId); + if (!gateway) { + return; + } + + const orgGatewayConfig = await orgGatewayConfigV2DAL.findOne({ orgId: gateway.orgId }); + if (!orgGatewayConfig) { + throw new NotFoundError({ message: `Gateway Config for org ${gateway.orgId} not found.` }); + } + + if (!gateway.relayId) { + throw new BadRequestError({ + message: "Gateway is not associated with a relay" + }); + } + + const orgLicensePlan = await licenseService.getPlan(orgGatewayConfig.orgId); + if (!orgLicensePlan.gateway) { + throw new BadRequestError({ + message: "Please upgrade your instance to Infisical's Enterprise plan to use gateways." + }); + } + + const { decryptor: orgKmsDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: orgGatewayConfig.orgId + }); + + const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048); + + const rootGatewayCaCert = new x509.X509Certificate( + orgKmsDecryptor({ + cipherTextBlob: orgGatewayConfig.encryptedRootGatewayCaCertificate + }) + ); + + const gatewayClientCaCert = new x509.X509Certificate( + orgKmsDecryptor({ + cipherTextBlob: orgGatewayConfig.encryptedGatewayClientCaCertificate + }) + ); + + const gatewayServerCaCert = new x509.X509Certificate( + orgKmsDecryptor({ + cipherTextBlob: orgGatewayConfig.encryptedGatewayServerCaCertificate + }) + ); + + const gatewayClientCaPrivateKey = orgKmsDecryptor({ + cipherTextBlob: orgGatewayConfig.encryptedGatewayClientCaPrivateKey + }); + + const gatewayClientCaSkObj = crypto.nativeCrypto.createPrivateKey({ + key: gatewayClientCaPrivateKey, + format: "der", + type: "pkcs8" + }); + + const importedGatewayClientCaPrivateKey = await crypto.nativeCrypto.subtle.importKey( + "pkcs8", + gatewayClientCaSkObj.export({ format: "der", type: "pkcs8" }), + alg, + true, + ["sign"] + ); + + const clientCertIssuedAt = new Date(); + const clientCertExpiration = new Date(new Date().getTime() + (duration ?? 5 * 60 * 1000)); + const clientKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); + const clientCertSerialNumber = createSerialNumber(); + + const routingInfo = { + targetHost: host, + targetPort: port + }; + + const routingExtension = new x509.Extension( + GATEWAY_ROUTING_INFO_OID, + false, + Buffer.from(JSON.stringify(routingInfo)) + ); + + const pamInfoExtension = new x509.Extension( + PAM_INFO_OID, + false, + Buffer.from( + JSON.stringify({ + sessionId, + resourceType + }) + ) + ); + + const actorExtension = new x509.Extension( + GATEWAY_ACTOR_OID, + false, + Buffer.from(JSON.stringify({ type: actorMetadata.type, id: actorMetadata.id, name: actorMetadata.name })) + ); + + const clientCert = await x509.X509CertificateGenerator.create({ + serialNumber: clientCertSerialNumber, + subject: `O=${orgGatewayConfig.orgId},OU=gateway-client,CN=${actorMetadata.type}:${gatewayId}`, + issuer: gatewayClientCaCert.subject, + notAfter: clientCertExpiration, + notBefore: clientCertIssuedAt, + signingKey: importedGatewayClientCaPrivateKey, + publicKey: clientKeys.publicKey, + signingAlgorithm: alg, + extensions: [ + new x509.BasicConstraintsExtension(false), + await x509.AuthorityKeyIdentifierExtension.create(gatewayClientCaCert, false), + await x509.SubjectKeyIdentifierExtension.create(clientKeys.publicKey), + new x509.CertificatePolicyExtension(["2.5.29.32.0"]), // anyPolicy + new x509.KeyUsagesExtension( + // eslint-disable-next-line no-bitwise + x509.KeyUsageFlags[CertKeyUsage.DIGITAL_SIGNATURE] | + x509.KeyUsageFlags[CertKeyUsage.KEY_ENCIPHERMENT] | + x509.KeyUsageFlags[CertKeyUsage.KEY_AGREEMENT], + true + ), + new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage[CertExtendedKeyUsage.CLIENT_AUTH]], true), + routingExtension, + actorExtension, + pamInfoExtension + ] + }); + + const gatewayClientCertPrivateKey = crypto.nativeCrypto.KeyObject.from(clientKeys.privateKey); + + const relayCredentials = await relayService.getCredentialsForClient({ + relayId: gateway.relayId, + orgId: gateway.orgId, + orgName: gateway.orgName, + gatewayId, + gatewayName: gateway.name, + duration + }); + + return { + relayHost: relayCredentials.relayHost, + gateway: { + clientCertificate: clientCert.toString("pem"), + clientPrivateKey: gatewayClientCertPrivateKey.export({ format: "pem", type: "pkcs8" }).toString(), + serverCertificateChain: constructPemChainFromCerts([gatewayServerCaCert, rootGatewayCaCert]) + }, + relay: { + clientCertificate: relayCredentials.clientCertificate, + clientPrivateKey: relayCredentials.clientPrivateKey, + serverCertificateChain: relayCredentials.serverCertificateChain + } + }; + }; + const registerGateway = async ({ orgId, actorId, @@ -645,14 +816,75 @@ export const gatewayV2ServiceFactory = ({ OrgPermissionSubjects.Gateway ); - return gatewayV2DAL.deleteById(gateway.id); + try { + return await gatewayV2DAL.deleteById(gateway.id); + } catch (err) { + if ( + err instanceof DatabaseError && + (err.error as { code: string })?.code === DatabaseErrorCode.ForeignKeyViolation + ) { + throw new BadRequestError({ + message: "Failed to delete gateway because it is attached to active resources" + }); + } + + throw err; + } + }; + + const getPamSessionKey = async ({ orgPermission }: { orgPermission: OrgServiceActor }) => { + const { permission } = await permissionService.getOrgPermission( + orgPermission.type, + orgPermission.id, + orgPermission.orgId, + orgPermission.authMethod, + orgPermission.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionGatewayActions.CreateGateways, + OrgPermissionSubjects.Gateway + ); + + return gatewayV2DAL.transaction(async (tx) => { + const gateway = await gatewayV2DAL.findOne( + { + identityId: orgPermission.id + }, + tx + ); + + if (!gateway) { + throw new NotFoundError({ message: "Gateway not found" }); + } + + const { encryptor, decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: orgPermission.orgId + }); + + if (gateway.encryptedPamSessionKey) { + return decryptor({ cipherTextBlob: gateway.encryptedPamSessionKey }); + } + + await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.GatewayPamSessionKey(gateway.id)]); + + const newPamSessionKey = crypto.randomBytes(32); + const { cipherTextBlob: encryptedPamSessionKey } = encryptor({ plainText: newPamSessionKey }); + + await gatewayV2DAL.updateById(gateway.id, { encryptedPamSessionKey }, tx); + + return newPamSessionKey; + }); }; return { listGateways, registerGateway, getPlatformConnectionDetailsByGatewayId, + getPAMConnectionDetails, deleteGatewayById, - heartbeat + heartbeat, + getPamSessionKey }; }; diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index cbe194f31..2a3cf82cc 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -66,7 +66,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ enterpriseAppConnections: false, fips: false, eventSubscriptions: false, - machineIdentityAuthTemplates: false + machineIdentityAuthTemplates: false, + pam: false }); export const setupLicenseRequestWithStore = ( diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 266794074..9cdcfcc3d 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -80,6 +80,7 @@ export type TFeatureSet = { machineIdentityAuthTemplates: false; fips: false; eventSubscriptions: false; + pam: false; }; export type TOrgPlansTableDTO = { diff --git a/backend/src/ee/services/pam-account/pam-account-dal.ts b/backend/src/ee/services/pam-account/pam-account-dal.ts new file mode 100644 index 000000000..b62e940fe --- /dev/null +++ b/backend/src/ee/services/pam-account/pam-account-dal.ts @@ -0,0 +1,43 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName, TPamAccounts } from "@app/db/schemas"; +import { buildFindFilter, ormify, prependTableNameToFindFilter, selectAllTableCols } from "@app/lib/knex"; + +export type TPamAccountDALFactory = ReturnType; + +type PamAccountFindFilter = Parameters>[0]; + +export const pamAccountDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.PamAccount); + + const findWithResourceDetails = async (filter: PamAccountFindFilter, tx?: Knex) => { + const query = (tx || db.replicaNode())(TableName.PamAccount) + .leftJoin(TableName.PamResource, `${TableName.PamAccount}.resourceId`, `${TableName.PamResource}.id`) + .select(selectAllTableCols(TableName.PamAccount)) + .select( + // resource + db.ref("name").withSchema(TableName.PamResource).as("resourceName"), + db.ref("resourceType").withSchema(TableName.PamResource) + ); + + if (filter) { + /* eslint-disable @typescript-eslint/no-misused-promises */ + void query.where(buildFindFilter(prependTableNameToFindFilter(TableName.PamAccount, filter))); + } + + const accounts = await query; + + return accounts.map(({ resourceId, resourceName, resourceType, ...account }) => ({ + ...account, + resourceId, + resource: { + id: resourceId, + name: resourceName, + resourceType + } + })); + }; + + return { ...orm, findWithResourceDetails }; +}; diff --git a/backend/src/ee/services/pam-account/pam-account-fns.ts b/backend/src/ee/services/pam-account/pam-account-fns.ts new file mode 100644 index 000000000..fdc440991 --- /dev/null +++ b/backend/src/ee/services/pam-account/pam-account-fns.ts @@ -0,0 +1,61 @@ +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; + +import { TPamAccountCredentials } from "../pam-resource/pam-resource-types"; + +export const encryptAccountCredentials = async ({ + projectId, + credentials, + kmsService +}: { + projectId: string; + credentials: TPamAccountCredentials; + kmsService: Pick; +}) => { + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + + const { cipherTextBlob: encryptedCredentialsBlob } = encryptor({ + plainText: Buffer.from(JSON.stringify(credentials)) + }); + + return encryptedCredentialsBlob; +}; + +export const decryptAccountCredentials = async ({ + projectId, + encryptedCredentials, + kmsService +}: { + projectId: string; + encryptedCredentials: Buffer; + kmsService: Pick; +}) => { + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + + const decryptedPlainTextBlob = decryptor({ + cipherTextBlob: encryptedCredentials + }); + + return JSON.parse(decryptedPlainTextBlob.toString()) as TPamAccountCredentials; +}; + +export const decryptAccount = async ( + account: T, + projectId: string, + kmsService: Pick +): Promise => { + return { + ...account, + credentials: await decryptAccountCredentials({ + encryptedCredentials: account.encryptedCredentials, + projectId, + kmsService + }) + } as T & { credentials: TPamAccountCredentials }; +}; diff --git a/backend/src/ee/services/pam-account/pam-account-service.ts b/backend/src/ee/services/pam-account/pam-account-service.ts new file mode 100644 index 000000000..e9ea76e8c --- /dev/null +++ b/backend/src/ee/services/pam-account/pam-account-service.ts @@ -0,0 +1,527 @@ +import { ForbiddenError, subject } from "@casl/ability"; + +import { ActionProjectType, TPamAccounts, TPamResources } from "@app/db/schemas"; +import { PAM_RESOURCE_FACTORY_MAP } from "@app/ee/services/pam-resource/pam-resource-factory"; +import { decryptResource, decryptResourceConnectionDetails } from "@app/ee/services/pam-resource/pam-resource-fns"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { + ProjectPermissionActions, + ProjectPermissionPamAccountActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { DatabaseErrorCode } from "@app/lib/error-codes"; +import { BadRequestError, DatabaseError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { OrgServiceActor } from "@app/lib/types"; +import { ActorType } from "@app/services/auth/auth-type"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TUserDALFactory } from "@app/services/user/user-dal"; + +import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service"; +import { TLicenseServiceFactory } from "../license/license-service"; +import { TPamFolderDALFactory } from "../pam-folder/pam-folder-dal"; +import { getFullPamFolderPath } from "../pam-folder/pam-folder-fns"; +import { TPamResourceDALFactory } from "../pam-resource/pam-resource-dal"; +import { PamResource } from "../pam-resource/pam-resource-enums"; +import { TPamAccountCredentials } from "../pam-resource/pam-resource-types"; +import { TPamSessionDALFactory } from "../pam-session/pam-session-dal"; +import { PamSessionStatus } from "../pam-session/pam-session-enums"; +import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission"; +import { TPamAccountDALFactory } from "./pam-account-dal"; +import { decryptAccount, decryptAccountCredentials, encryptAccountCredentials } from "./pam-account-fns"; +import { TAccessAccountDTO, TCreateAccountDTO, TUpdateAccountDTO } from "./pam-account-types"; + +type TPamAccountServiceFactoryDep = { + pamResourceDAL: TPamResourceDALFactory; + pamSessionDAL: TPamSessionDALFactory; + pamAccountDAL: TPamAccountDALFactory; + pamFolderDAL: TPamFolderDALFactory; + projectDAL: TProjectDALFactory; + permissionService: Pick; + licenseService: Pick; + kmsService: Pick; + gatewayV2Service: Pick< + TGatewayV2ServiceFactory, + "getPAMConnectionDetails" | "getPlatformConnectionDetailsByGatewayId" + >; + userDAL: TUserDALFactory; +}; + +export type TPamAccountServiceFactory = ReturnType; + +export const pamAccountServiceFactory = ({ + pamResourceDAL, + pamSessionDAL, + pamAccountDAL, + pamFolderDAL, + projectDAL, + userDAL, + permissionService, + licenseService, + kmsService, + gatewayV2Service +}: TPamAccountServiceFactoryDep) => { + const create = async ( + { credentials, resourceId, name, description, folderId }: TCreateAccountDTO, + actor: OrgServiceActor + ) => { + const orgLicensePlan = await licenseService.getPlan(actor.orgId); + if (!orgLicensePlan.pam) { + throw new BadRequestError({ + message: "PAM operation failed due to organization plan restrictions." + }); + } + + const resource = await pamResourceDAL.findById(resourceId); + if (!resource) throw new NotFoundError({ message: `Resource with ID '${resourceId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId: resource.projectId, + actionProjectType: ActionProjectType.PAM + }); + + const accountPath = await getFullPamFolderPath({ + pamFolderDAL, + folderId, + projectId: resource.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPamAccountActions.Create, + subject(ProjectPermissionSub.PamAccounts, { + resourceName: resource.name, + accountName: name, + accountPath + }) + ); + + const connectionDetails = await decryptResourceConnectionDetails({ + projectId: resource.projectId, + encryptedConnectionDetails: resource.encryptedConnectionDetails, + kmsService + }); + + const factory = PAM_RESOURCE_FACTORY_MAP[resource.resourceType as PamResource]( + resource.resourceType as PamResource, + connectionDetails, + resource.gatewayId, + gatewayV2Service + ); + const validatedCredentials = await factory.validateAccountCredentials(credentials); + + const encryptedCredentials = await encryptAccountCredentials({ + credentials: validatedCredentials, + projectId: resource.projectId, + kmsService + }); + + try { + const account = await pamAccountDAL.create({ + projectId: resource.projectId, + resourceId: resource.id, + encryptedCredentials, + name, + description, + folderId + }); + + return { + ...(await decryptAccount(account, resource.projectId, kmsService)), + resource: { id: resource.id, name: resource.name, resourceType: resource.resourceType } + }; + } catch (err) { + if (err instanceof DatabaseError && (err.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation) { + throw new BadRequestError({ + message: `Account with name '${name}' already exists for this path` + }); + } + + throw err; + } + }; + + const updateById = async ( + { accountId, credentials, description, name }: TUpdateAccountDTO, + actor: OrgServiceActor + ) => { + const orgLicensePlan = await licenseService.getPlan(actor.orgId); + if (!orgLicensePlan.pam) { + throw new BadRequestError({ + message: "PAM operation failed due to organization plan restrictions." + }); + } + + const account = await pamAccountDAL.findById(accountId); + if (!account) throw new NotFoundError({ message: `Account with ID '${accountId}' not found` }); + + const resource = await pamResourceDAL.findById(account.resourceId); + if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId: account.projectId, + actionProjectType: ActionProjectType.PAM + }); + + const accountPath = await getFullPamFolderPath({ + pamFolderDAL, + folderId: account.folderId, + projectId: account.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPamAccountActions.Edit, + subject(ProjectPermissionSub.PamAccounts, { + resourceName: resource.name, + accountName: account.name, + accountPath + }) + ); + + const updateDoc: Partial = {}; + + if (name !== undefined) { + updateDoc.name = name; + } + + if (description !== undefined) { + updateDoc.description = description; + } + + if (credentials !== undefined) { + const connectionDetails = await decryptResourceConnectionDetails({ + projectId: account.projectId, + encryptedConnectionDetails: resource.encryptedConnectionDetails, + kmsService + }); + + const factory = PAM_RESOURCE_FACTORY_MAP[resource.resourceType as PamResource]( + resource.resourceType as PamResource, + connectionDetails, + resource.gatewayId, + gatewayV2Service + ); + + // Logic to prevent overwriting unedited censored values + const finalCredentials = { ...credentials }; + if (credentials.password === "******") { + const decryptedCredentials = await decryptAccountCredentials({ + encryptedCredentials: account.encryptedCredentials, + projectId: account.projectId, + kmsService + }); + + finalCredentials.password = decryptedCredentials.password; + } + + const validatedCredentials = await factory.validateAccountCredentials(finalCredentials); + const encryptedCredentials = await encryptAccountCredentials({ + credentials: validatedCredentials, + projectId: account.projectId, + kmsService + }); + updateDoc.encryptedCredentials = encryptedCredentials; + } + + // If nothing was updated, return the fetched account + if (Object.keys(updateDoc).length === 0) { + return decryptAccount(account, account.projectId, kmsService); + } + + const updatedAccount = await pamAccountDAL.updateById(accountId, updateDoc); + + return { + ...(await decryptAccount(updatedAccount, account.projectId, kmsService)), + resource: { id: resource.id, name: resource.name, resourceType: resource.resourceType } + }; + }; + + const deleteById = async (id: string, actor: OrgServiceActor) => { + const account = await pamAccountDAL.findById(id); + if (!account) throw new NotFoundError({ message: `Account with ID '${id}' not found` }); + + const resource = await pamResourceDAL.findById(account.resourceId); + if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId: account.projectId, + actionProjectType: ActionProjectType.PAM + }); + + const accountPath = await getFullPamFolderPath({ + pamFolderDAL, + folderId: account.folderId, + projectId: account.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPamAccountActions.Delete, + subject(ProjectPermissionSub.PamAccounts, { + resourceName: resource.name, + accountName: account.name, + accountPath + }) + ); + + const deletedAccount = await pamAccountDAL.deleteById(id); + + return { + ...(await decryptAccount(deletedAccount, account.projectId, kmsService)), + resource: { id: resource.id, name: resource.name, resourceType: resource.resourceType } + }; + }; + + const list = async (projectId: string, actor: OrgServiceActor) => { + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId, + actionProjectType: ActionProjectType.PAM + }); + + const accountsWithResourceDetails = await pamAccountDAL.findWithResourceDetails({ projectId }); + + const canReadFolders = permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.PamFolders); + + const folders = canReadFolders ? await pamFolderDAL.find({ projectId }) : []; + + const decryptedAndPermittedAccounts: Array< + TPamAccounts & { + resource: Pick; + credentials: TPamAccountCredentials; + } + > = []; + + for await (const account of accountsWithResourceDetails) { + const accountPath = await getFullPamFolderPath({ + pamFolderDAL, + folderId: account.folderId, + projectId: account.projectId + }); + + // Check permission for each individual account + if ( + permission.can( + ProjectPermissionPamAccountActions.Read, + subject(ProjectPermissionSub.PamAccounts, { + resourceName: account.resource.name, + accountName: account.name, + accountPath + }) + ) + ) { + // Decrypt the account only if the user has permission to read it + const decryptedAccount = await decryptAccount(account, account.projectId, kmsService); + decryptedAndPermittedAccounts.push({ + ...decryptedAccount, + resource: { + id: account.resource.id, + name: account.resource.name, + resourceType: account.resource.resourceType + } + }); + } + } + + return { + accounts: decryptedAndPermittedAccounts, + folders + }; + }; + + const access = async ( + { accountId, actorEmail, actorIp, actorName, actorUserAgent, duration }: TAccessAccountDTO, + actor: OrgServiceActor + ) => { + const orgLicensePlan = await licenseService.getPlan(actor.orgId); + if (!orgLicensePlan.pam) { + throw new BadRequestError({ + message: "PAM operation failed due to organization plan restrictions." + }); + } + + const account = await pamAccountDAL.findById(accountId); + if (!account) throw new NotFoundError({ message: `Account with ID '${accountId}' not found` }); + + const resource = await pamResourceDAL.findById(account.resourceId); + if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId: account.projectId, + actionProjectType: ActionProjectType.PAM + }); + + const accountPath = await getFullPamFolderPath({ + pamFolderDAL, + folderId: account.folderId, + projectId: account.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPamAccountActions.Access, + subject(ProjectPermissionSub.PamAccounts, { + resourceName: resource.name, + accountName: account.name, + accountPath + }) + ); + + const session = await pamSessionDAL.create({ + accountName: account.name, + actorEmail, + actorIp, + actorName, + actorUserAgent, + projectId: account.projectId, + resourceName: resource.name, + resourceType: resource.resourceType, + status: PamSessionStatus.Starting, + accountId: account.id, + userId: actor.id, + expiresAt: new Date(Date.now() + duration) + }); + + const { connectionDetails, gatewayId, resourceType } = await decryptResource( + resource, + account.projectId, + kmsService + ); + + const user = await userDAL.findById(actor.id); + if (!user) throw new NotFoundError({ message: `User with ID '${actor.id}' not found` }); + + const gatewayConnectionDetails = await gatewayV2Service.getPAMConnectionDetails({ + gatewayId, + duration, + sessionId: session.id, + resourceType: resource.resourceType as PamResource, + host: connectionDetails.host, + port: connectionDetails.port, + actorMetadata: { + id: actor.id, + type: actor.type, + name: user.email ?? "" + } + }); + + if (!gatewayConnectionDetails) { + throw new NotFoundError({ message: `Gateway connection details for gateway '${gatewayId}' not found.` }); + } + + return { + sessionId: session.id, + resourceType, + relayClientCertificate: gatewayConnectionDetails.relay.clientCertificate, + relayClientPrivateKey: gatewayConnectionDetails.relay.clientPrivateKey, + relayServerCertificateChain: gatewayConnectionDetails.relay.serverCertificateChain, + gatewayClientCertificate: gatewayConnectionDetails.gateway.clientCertificate, + gatewayClientPrivateKey: gatewayConnectionDetails.gateway.clientPrivateKey, + gatewayServerCertificateChain: gatewayConnectionDetails.gateway.serverCertificateChain, + relayHost: gatewayConnectionDetails.relayHost, + projectId: account.projectId, + account + }; + }; + + const getSessionCredentials = async (sessionId: string, actor: OrgServiceActor) => { + const orgLicensePlan = await licenseService.getPlan(actor.orgId); + if (!orgLicensePlan.pam) { + throw new BadRequestError({ + message: "PAM operation failed due to organization plan restrictions." + }); + } + + // To be hit by gateways only + if (actor.type !== ActorType.IDENTITY) { + throw new ForbiddenRequestError({ message: "Only gateways can perform this action" }); + } + + const session = await pamSessionDAL.findById(sessionId); + if (!session) throw new NotFoundError({ message: `Session with ID '${sessionId}' not found` }); + + const project = await projectDAL.findById(session.projectId); + if (!project) throw new NotFoundError({ message: `Project with ID '${session.projectId}' not found` }); + + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + project.orgId, + actor.authMethod, + actor.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionGatewayActions.CreateGateways, + OrgPermissionSubjects.Gateway + ); + + if (!session.accountId) throw new NotFoundError({ message: "Session is missing accountId column" }); + + // Verify that the session has not ended + if (session.endedAt || (session.expiresAt && session.expiresAt < new Date())) { + throw new BadRequestError({ message: "Session has ended or expired" }); + } + + // Verify that the session has not already had credentials fetched + if (session.status !== PamSessionStatus.Starting) { + throw new BadRequestError({ message: "Session has already been started" }); + } + + const account = await pamAccountDAL.findById(session.accountId); + if (!account) throw new NotFoundError({ message: `Account with ID '${session.accountId}' not found` }); + + const resource = await pamResourceDAL.findById(account.resourceId); + if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` }); + + if (resource.gatewayIdentityId !== actor.id) { + throw new ForbiddenRequestError({ + message: "Identity does not have access to fetch the PAM session credentials" + }); + } + + const decryptedAccount = await decryptAccount(account, session.projectId, kmsService); + + const decryptedResource = await decryptResource(resource, session.projectId, kmsService); + + // Mark session as started + await pamSessionDAL.updateById(sessionId, { + status: PamSessionStatus.Active, + startedAt: new Date() + }); + + return { + credentials: { + ...decryptedResource.connectionDetails, + ...decryptedAccount.credentials + }, + projectId: project.id, + account + }; + }; + + return { + create, + updateById, + deleteById, + list, + access, + getSessionCredentials + }; +}; diff --git a/backend/src/ee/services/pam-account/pam-account-types.ts b/backend/src/ee/services/pam-account/pam-account-types.ts new file mode 100644 index 000000000..514d7d780 --- /dev/null +++ b/backend/src/ee/services/pam-account/pam-account-types.ts @@ -0,0 +1,17 @@ +import { TPamAccount } from "../pam-resource/pam-resource-types"; + +// DTOs +export type TCreateAccountDTO = Pick; + +export type TUpdateAccountDTO = Partial> & { + accountId: string; +}; + +export type TAccessAccountDTO = { + accountId: string; + actorEmail: string; + actorIp: string; + actorName: string; + actorUserAgent: string; + duration: number; +}; diff --git a/backend/src/ee/services/pam-folder/pam-folder-dal.ts b/backend/src/ee/services/pam-folder/pam-folder-dal.ts new file mode 100644 index 000000000..aa334618d --- /dev/null +++ b/backend/src/ee/services/pam-folder/pam-folder-dal.ts @@ -0,0 +1,9 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TPamFolderDALFactory = ReturnType; +export const pamFolderDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.PamFolder); + return { ...orm }; +}; diff --git a/backend/src/ee/services/pam-folder/pam-folder-fns.ts b/backend/src/ee/services/pam-folder/pam-folder-fns.ts new file mode 100644 index 000000000..bcba0ab3b --- /dev/null +++ b/backend/src/ee/services/pam-folder/pam-folder-fns.ts @@ -0,0 +1,33 @@ +import { TPamFolderDALFactory } from "./pam-folder-dal"; + +type GetFullFolderPath = { + pamFolderDAL: Pick; + folderId?: string | null; + projectId: string; +}; + +export const getFullPamFolderPath = async ({ + pamFolderDAL, + folderId, + projectId +}: GetFullFolderPath): Promise => { + if (!folderId) return "/"; + + const folders = await pamFolderDAL.find({ projectId }); + const folderMap = new Map(folders.map((folder) => [folder.id, folder])); + + if (!folderMap.has(folderId)) return ""; + + const path: string[] = []; + let currentFolderId: string | null | undefined = folderId; + + while (currentFolderId) { + const folder = folderMap.get(currentFolderId); + if (!folder) break; + + path.unshift(folder.name); + currentFolderId = folder.parentId; + } + + return `/${path.join("/")}`; +}; diff --git a/backend/src/ee/services/pam-folder/pam-folder-service.ts b/backend/src/ee/services/pam-folder/pam-folder-service.ts new file mode 100644 index 000000000..d7fb41f12 --- /dev/null +++ b/backend/src/ee/services/pam-folder/pam-folder-service.ts @@ -0,0 +1,146 @@ +import { ForbiddenError } from "@casl/ability"; + +import { ActionProjectType, TPamFolders } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { DatabaseErrorCode } from "@app/lib/error-codes"; +import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; +import { OrgServiceActor } from "@app/lib/types"; + +import { TLicenseServiceFactory } from "../license/license-service"; +import { TPamFolderDALFactory } from "./pam-folder-dal"; +import { TCreateFolderDTO, TUpdateFolderDTO } from "./pam-folder-types"; + +type TPamFolderServiceFactoryDep = { + pamFolderDAL: TPamFolderDALFactory; + permissionService: Pick; + licenseService: Pick; +}; + +export type TPamFolderServiceFactory = ReturnType; + +export const pamFolderServiceFactory = ({ + pamFolderDAL, + permissionService, + licenseService +}: TPamFolderServiceFactoryDep) => { + const createFolder = async ({ name, description, parentId, projectId }: TCreateFolderDTO, actor: OrgServiceActor) => { + const orgLicensePlan = await licenseService.getPlan(actor.orgId); + if (!orgLicensePlan.pam) { + throw new BadRequestError({ + message: "PAM operation failed due to organization plan restrictions." + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId, + actionProjectType: ActionProjectType.PAM + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.PamFolders); + + if (parentId) { + if (!(await pamFolderDAL.findOne({ id: parentId, projectId }))) { + throw new NotFoundError({ + message: `Parent folder '${parentId}' not found for project '${projectId}'` + }); + } + } + + try { + const folder = await pamFolderDAL.create({ + name, + description: description ?? null, + parentId: parentId || null, + projectId + }); + + return folder; + } catch (err) { + if (err instanceof DatabaseError && (err.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation) { + throw new BadRequestError({ + message: `Folder with name '${name}' already exists for this path` + }); + } + + throw err; + } + }; + + const updateFolder = async ({ id, name, description }: TUpdateFolderDTO, actor: OrgServiceActor) => { + const orgLicensePlan = await licenseService.getPlan(actor.orgId); + if (!orgLicensePlan.pam) { + throw new BadRequestError({ + message: "PAM operation failed due to organization plan restrictions." + }); + } + + const folder = await pamFolderDAL.findById(id); + if (!folder) throw new NotFoundError({ message: `Folder with ID '${id}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId: folder.projectId, + actionProjectType: ActionProjectType.PAM + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.PamFolders); + + const updateDoc: Partial = {}; + + if (name !== undefined) { + updateDoc.name = name; + } + + if (description !== undefined) { + updateDoc.description = description; + } + + if (Object.keys(updateDoc).length === 0) { + return folder; + } + + try { + const updatedFolder = await pamFolderDAL.updateById(id, updateDoc); + + return updatedFolder; + } catch (err) { + if (err instanceof DatabaseError && (err.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation) { + throw new BadRequestError({ + message: `Folder with name '${name}' already exists for this path` + }); + } + + throw err; + } + }; + + const deleteFolder = async (id: string, actor: OrgServiceActor) => { + const folder = await pamFolderDAL.findById(id); + if (!folder) throw new NotFoundError({ message: `Folder with ID '${id}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId: folder.projectId, + actionProjectType: ActionProjectType.PAM + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.PamFolders); + + const deletedFolder = await pamFolderDAL.deleteById(id); + + return deletedFolder; + }; + + return { createFolder, updateFolder, deleteFolder }; +}; diff --git a/backend/src/ee/services/pam-folder/pam-folder-types.ts b/backend/src/ee/services/pam-folder/pam-folder-types.ts new file mode 100644 index 000000000..c8a435637 --- /dev/null +++ b/backend/src/ee/services/pam-folder/pam-folder-types.ts @@ -0,0 +1,13 @@ +// DTOs +export interface TCreateFolderDTO { + projectId: string; + parentId?: string | null; + name: string; + description?: string | null; +} + +export interface TUpdateFolderDTO { + id: string; + name?: string; + description?: string | null; +} diff --git a/backend/src/ee/services/pam-resource/pam-resource-dal.ts b/backend/src/ee/services/pam-resource/pam-resource-dal.ts new file mode 100644 index 000000000..1a408ca27 --- /dev/null +++ b/backend/src/ee/services/pam-resource/pam-resource-dal.ts @@ -0,0 +1,24 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TPamResourceDALFactory = ReturnType; +export const pamResourceDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.PamResource); + + const findById = async (id: string, tx?: Knex) => { + const doc = await (tx || db.replicaNode())(TableName.PamResource) + .join(TableName.GatewayV2, `${TableName.PamResource}.gatewayId`, `${TableName.GatewayV2}.id`) + .select(selectAllTableCols(TableName.PamResource)) + .select(db.ref("name").withSchema(TableName.GatewayV2).as("gatewayName")) + .select(db.ref("identityId").withSchema(TableName.GatewayV2).as("gatewayIdentityId")) + .where(`${TableName.PamResource}.id`, id) + .first(); + + return doc; + }; + + return { ...orm, findById }; +}; diff --git a/backend/src/ee/services/pam-resource/pam-resource-enums.ts b/backend/src/ee/services/pam-resource/pam-resource-enums.ts new file mode 100644 index 000000000..fbc260fba --- /dev/null +++ b/backend/src/ee/services/pam-resource/pam-resource-enums.ts @@ -0,0 +1,3 @@ +export enum PamResource { + Postgres = "postgres" +} diff --git a/backend/src/ee/services/pam-resource/pam-resource-factory.ts b/backend/src/ee/services/pam-resource/pam-resource-factory.ts new file mode 100644 index 000000000..298b1664c --- /dev/null +++ b/backend/src/ee/services/pam-resource/pam-resource-factory.ts @@ -0,0 +1,9 @@ +import { PamResource } from "./pam-resource-enums"; +import { TPamAccountCredentials, TPamResourceConnectionDetails, TPamResourceFactory } from "./pam-resource-types"; +import { sqlResourceFactory } from "./shared/sql/sql-resource-factory"; + +type TPamResourceFactoryImplementation = TPamResourceFactory; + +export const PAM_RESOURCE_FACTORY_MAP: Record = { + [PamResource.Postgres]: sqlResourceFactory as TPamResourceFactoryImplementation +}; diff --git a/backend/src/ee/services/pam-resource/pam-resource-fns.ts b/backend/src/ee/services/pam-resource/pam-resource-fns.ts new file mode 100644 index 000000000..1d79e892e --- /dev/null +++ b/backend/src/ee/services/pam-resource/pam-resource-fns.ts @@ -0,0 +1,68 @@ +import { TPamResources } from "@app/db/schemas"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; + +import { TPamResource, TPamResourceConnectionDetails } from "./pam-resource-types"; +import { getPostgresResourceListItem } from "./postgres/postgres-resource-fns"; + +export const listResourceOptions = () => { + return [getPostgresResourceListItem()].sort((a, b) => a.name.localeCompare(b.name)); +}; + +// Resource +export const encryptResourceConnectionDetails = async ({ + projectId, + connectionDetails, + kmsService +}: { + projectId: string; + connectionDetails: TPamResourceConnectionDetails; + kmsService: Pick; +}) => { + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + + const { cipherTextBlob: encryptedConnectionDetailsBlob } = encryptor({ + plainText: Buffer.from(JSON.stringify(connectionDetails)) + }); + + return encryptedConnectionDetailsBlob; +}; + +export const decryptResourceConnectionDetails = async ({ + projectId, + encryptedConnectionDetails, + kmsService +}: { + projectId: string; + encryptedConnectionDetails: Buffer; + kmsService: Pick; +}) => { + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + + const decryptedPlainTextBlob = decryptor({ + cipherTextBlob: encryptedConnectionDetails + }); + + return JSON.parse(decryptedPlainTextBlob.toString()) as TPamResourceConnectionDetails; +}; + +export const decryptResource = async ( + resource: TPamResources, + projectId: string, + kmsService: Pick +) => { + return { + ...resource, + connectionDetails: await decryptResourceConnectionDetails({ + encryptedConnectionDetails: resource.encryptedConnectionDetails, + projectId, + kmsService + }) + } as TPamResource; +}; diff --git a/backend/src/ee/services/pam-resource/pam-resource-schemas.ts b/backend/src/ee/services/pam-resource/pam-resource-schemas.ts new file mode 100644 index 000000000..80a50a9a4 --- /dev/null +++ b/backend/src/ee/services/pam-resource/pam-resource-schemas.ts @@ -0,0 +1,46 @@ +import { z } from "zod"; + +import { PamAccountsSchema, PamResourcesSchema } from "@app/db/schemas"; +import { slugSchema } from "@app/server/lib/schemas"; + +// Resources +export const BasePamResourceSchema = PamResourcesSchema.omit({ + encryptedConnectionDetails: true, + resourceType: true +}); + +export const BaseCreatePamResourceSchema = z.object({ + projectId: z.string().uuid(), + gatewayId: z.string().uuid(), + name: slugSchema({ field: "name" }) +}); + +export const BaseUpdatePamResourceSchema = z.object({ + gatewayId: z.string().uuid().optional(), + name: slugSchema({ field: "name" }).optional() +}); + +// Accounts +export const BasePamAccountSchema = PamAccountsSchema.omit({ + encryptedCredentials: true +}); + +export const BasePamAccountSchemaWithResource = BasePamAccountSchema.extend({ + resource: PamResourcesSchema.pick({ + id: true, + name: true, + resourceType: true + }) +}); + +export const BaseCreatePamAccountSchema = z.object({ + resourceId: z.string().uuid(), + folderId: z.string().uuid().optional(), + name: slugSchema({ field: "name" }), + description: z.string().max(512).nullable().optional() +}); + +export const BaseUpdatePamAccountSchema = z.object({ + name: slugSchema({ field: "name" }).optional(), + description: z.string().max(512).nullable().optional() +}); diff --git a/backend/src/ee/services/pam-resource/pam-resource-service.ts b/backend/src/ee/services/pam-resource/pam-resource-service.ts new file mode 100644 index 000000000..312795a50 --- /dev/null +++ b/backend/src/ee/services/pam-resource/pam-resource-service.ts @@ -0,0 +1,222 @@ +import { ForbiddenError } from "@casl/ability"; + +import { ActionProjectType, TPamResources } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { DatabaseErrorCode } from "@app/lib/error-codes"; +import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; +import { OrgServiceActor } from "@app/lib/types"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; + +import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service"; +import { TLicenseServiceFactory } from "../license/license-service"; +import { TPamResourceDALFactory } from "./pam-resource-dal"; +import { PamResource } from "./pam-resource-enums"; +import { PAM_RESOURCE_FACTORY_MAP } from "./pam-resource-factory"; +import { decryptResource, encryptResourceConnectionDetails, listResourceOptions } from "./pam-resource-fns"; +import { TCreateResourceDTO, TUpdateResourceDTO } from "./pam-resource-types"; + +type TPamResourceServiceFactoryDep = { + pamResourceDAL: TPamResourceDALFactory; + permissionService: Pick; + licenseService: Pick; + kmsService: Pick; + gatewayV2Service: Pick< + TGatewayV2ServiceFactory, + "getPAMConnectionDetails" | "getPlatformConnectionDetailsByGatewayId" + >; +}; + +export type TPamResourceServiceFactory = ReturnType; + +export const pamResourceServiceFactory = ({ + pamResourceDAL, + permissionService, + licenseService, + kmsService, + gatewayV2Service +}: TPamResourceServiceFactoryDep) => { + const getById = async (id: string, resourceType: PamResource, actor: OrgServiceActor) => { + const resource = await pamResourceDAL.findById(id); + if (!resource) throw new NotFoundError({ message: `Resource with ID '${id}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId: resource.projectId, + actionProjectType: ActionProjectType.PAM + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PamResources); + + if (resource.resourceType !== resourceType) { + throw new BadRequestError({ + message: `Resource with ID '${id}' is not of type '${resourceType}'` + }); + } + + return decryptResource(resource, resource.projectId, kmsService); + }; + + const create = async ( + { resourceType, connectionDetails, gatewayId, name, projectId }: TCreateResourceDTO, + actor: OrgServiceActor + ) => { + const orgLicensePlan = await licenseService.getPlan(actor.orgId); + if (!orgLicensePlan.pam) { + throw new BadRequestError({ + message: "PAM operation failed due to organization plan restrictions." + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId, + actionProjectType: ActionProjectType.PAM + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.PamResources); + + const factory = PAM_RESOURCE_FACTORY_MAP[resourceType]( + resourceType, + connectionDetails, + gatewayId, + gatewayV2Service + ); + const validatedConnectionDetails = await factory.validateConnection(); + + const encryptedConnectionDetails = await encryptResourceConnectionDetails({ + connectionDetails: validatedConnectionDetails, + projectId, + kmsService + }); + + const resource = await pamResourceDAL.create({ + resourceType, + encryptedConnectionDetails, + gatewayId, + name, + projectId + }); + + return decryptResource(resource, projectId, kmsService); + }; + + const updateById = async ({ connectionDetails, resourceId, name }: TUpdateResourceDTO, actor: OrgServiceActor) => { + const orgLicensePlan = await licenseService.getPlan(actor.orgId); + if (!orgLicensePlan.pam) { + throw new BadRequestError({ + message: "PAM operation failed due to organization plan restrictions." + }); + } + + const resource = await pamResourceDAL.findById(resourceId); + if (!resource) throw new NotFoundError({ message: `Resource with ID '${resourceId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId: resource.projectId, + actionProjectType: ActionProjectType.PAM + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.PamResources); + + const updateDoc: Partial = {}; + + if (name !== undefined) { + updateDoc.name = name; + } + + if (connectionDetails !== undefined) { + const factory = PAM_RESOURCE_FACTORY_MAP[resource.resourceType as PamResource]( + resource.resourceType as PamResource, + connectionDetails, + resource.gatewayId, + gatewayV2Service + ); + const validatedConnectionDetails = await factory.validateConnection(); + const encryptedConnectionDetails = await encryptResourceConnectionDetails({ + connectionDetails: validatedConnectionDetails, + projectId: resource.projectId, + kmsService + }); + updateDoc.encryptedConnectionDetails = encryptedConnectionDetails; + } + + // If nothing was updated, return the fetched resource + if (Object.keys(updateDoc).length === 0) { + return decryptResource(resource, resource.projectId, kmsService); + } + + const updatedResource = await pamResourceDAL.updateById(resourceId, updateDoc); + + return decryptResource(updatedResource, resource.projectId, kmsService); + }; + + const deleteById = async (id: string, actor: OrgServiceActor) => { + const resource = await pamResourceDAL.findById(id); + if (!resource) throw new NotFoundError({ message: `Resource with ID '${id}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId: resource.projectId, + actionProjectType: ActionProjectType.PAM + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.PamResources); + + try { + const deletedResource = await pamResourceDAL.deleteById(id); + return await decryptResource(deletedResource, resource.projectId, kmsService); + } catch (err) { + if ( + err instanceof DatabaseError && + (err.error as { code: string })?.code === DatabaseErrorCode.ForeignKeyViolation + ) { + throw new BadRequestError({ + message: "Failed to delete resource because it is attached to active PAM accounts" + }); + } + throw err; + } + }; + + const list = async (projectId: string, actor: OrgServiceActor) => { + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId, + actionProjectType: ActionProjectType.PAM + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PamResources); + + const resources = await pamResourceDAL.find({ projectId }); + + return { + resources: await Promise.all(resources.map((resource) => decryptResource(resource, projectId, kmsService))) + }; + }; + + return { + getById, + create, + updateById, + deleteById, + list, + listResourceOptions + }; +}; diff --git a/backend/src/ee/services/pam-resource/pam-resource-types.ts b/backend/src/ee/services/pam-resource/pam-resource-types.ts new file mode 100644 index 000000000..fb1b669ed --- /dev/null +++ b/backend/src/ee/services/pam-resource/pam-resource-types.ts @@ -0,0 +1,42 @@ +import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service"; +import { PamResource } from "./pam-resource-enums"; +import { + TPostgresAccount, + TPostgresAccountCredentials, + TPostgresResource, + TPostgresResourceConnectionDetails +} from "./postgres/postgres-resource-types"; + +// Resource types +export type TPamResource = TPostgresResource; +export type TPamResourceConnectionDetails = TPostgresResourceConnectionDetails; + +// Account types +export type TPamAccount = TPostgresAccount; +export type TPamAccountCredentials = TPostgresAccountCredentials; + +// Resource DTOs +export type TCreateResourceDTO = Pick< + TPamResource, + "name" | "connectionDetails" | "resourceType" | "gatewayId" | "projectId" +>; + +export type TUpdateResourceDTO = Partial> & { + resourceId: string; +}; + +// Resource factory +export type TPamResourceFactoryValidateConnection = () => Promise; +export type TPamResourceFactoryValidateAccountCredentials = ( + credentials: C +) => Promise; + +export type TPamResourceFactory = ( + resourceType: PamResource, + connectionDetails: T, + gatewayId: string, + gatewayV2Service: Pick +) => { + validateConnection: TPamResourceFactoryValidateConnection; + validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials; +}; diff --git a/backend/src/ee/services/pam-resource/postgres/postgres-resource-fns.ts b/backend/src/ee/services/pam-resource/postgres/postgres-resource-fns.ts new file mode 100644 index 000000000..a3329a9fb --- /dev/null +++ b/backend/src/ee/services/pam-resource/postgres/postgres-resource-fns.ts @@ -0,0 +1,8 @@ +import { PostgresResourceListItemSchema } from "./postgres-resource-schemas"; + +export const getPostgresResourceListItem = () => { + return { + name: PostgresResourceListItemSchema.shape.name.value, + resource: PostgresResourceListItemSchema.shape.resource.value + }; +}; diff --git a/backend/src/ee/services/pam-resource/postgres/postgres-resource-schemas.ts b/backend/src/ee/services/pam-resource/postgres/postgres-resource-schemas.ts new file mode 100644 index 000000000..a97e3f2e7 --- /dev/null +++ b/backend/src/ee/services/pam-resource/postgres/postgres-resource-schemas.ts @@ -0,0 +1,64 @@ +import { z } from "zod"; + +import { PamResource } from "../pam-resource-enums"; +import { + BaseCreatePamAccountSchema, + BaseCreatePamResourceSchema, + BasePamAccountSchema, + BasePamAccountSchemaWithResource, + BasePamResourceSchema, + BaseUpdatePamAccountSchema, + BaseUpdatePamResourceSchema +} from "../pam-resource-schemas"; +import { + BaseSqlAccountCredentialsSchema, + BaseSqlResourceConnectionDetailsSchema +} from "../shared/sql/sql-resource-schemas"; + +// Resources +export const PostgresResourceConnectionDetailsSchema = BaseSqlResourceConnectionDetailsSchema; + +const BasePostgresResourceSchema = BasePamResourceSchema.extend({ resourceType: z.literal(PamResource.Postgres) }); + +export const PostgresResourceSchema = BasePostgresResourceSchema.extend({ + connectionDetails: PostgresResourceConnectionDetailsSchema +}); + +export const PostgresResourceListItemSchema = z.object({ + name: z.literal("PostgreSQL"), + resource: z.literal(PamResource.Postgres) +}); + +export const CreatePostgresResourceSchema = BaseCreatePamResourceSchema.extend({ + connectionDetails: PostgresResourceConnectionDetailsSchema +}); + +export const UpdatePostgresResourceSchema = BaseUpdatePamResourceSchema.extend({ + connectionDetails: PostgresResourceConnectionDetailsSchema.optional() +}); + +// Accounts +export const PostgresAccountCredentialsSchema = BaseSqlAccountCredentialsSchema; + +export const PostgresAccountSchema = BasePamAccountSchema.extend({ + credentials: PostgresAccountCredentialsSchema +}); + +export const CreatePostgresAccountSchema = BaseCreatePamAccountSchema.extend({ + credentials: PostgresAccountCredentialsSchema +}); + +export const UpdatePostgresAccountSchema = BaseUpdatePamAccountSchema.extend({ + credentials: PostgresAccountCredentialsSchema.optional() +}); + +export const SanitizedPostgresAccountWithResourceSchema = BasePamAccountSchemaWithResource.extend({ + credentials: PostgresAccountCredentialsSchema.pick({ + username: true + }) +}); + +// Sessions +export const PostgresSessionCredentialsSchema = PostgresResourceConnectionDetailsSchema.and( + PostgresAccountCredentialsSchema +); diff --git a/backend/src/ee/services/pam-resource/postgres/postgres-resource-types.ts b/backend/src/ee/services/pam-resource/postgres/postgres-resource-types.ts new file mode 100644 index 000000000..223ba6790 --- /dev/null +++ b/backend/src/ee/services/pam-resource/postgres/postgres-resource-types.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +import { + PostgresAccountCredentialsSchema, + PostgresAccountSchema, + PostgresResourceConnectionDetailsSchema, + PostgresResourceSchema +} from "./postgres-resource-schemas"; + +// Resources +export type TPostgresResource = z.infer; +export type TPostgresResourceConnectionDetails = z.infer; + +// Accounts +export type TPostgresAccount = z.infer; +export type TPostgresAccountCredentials = z.infer; diff --git a/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts new file mode 100644 index 000000000..a35765a2c --- /dev/null +++ b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts @@ -0,0 +1,173 @@ +import knex, { Knex } from "knex"; + +import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns"; +import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service"; +import { BadRequestError } from "@app/lib/errors"; +import { GatewayProxyProtocol } from "@app/lib/gateway"; +import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2"; + +import { PamResource } from "../../pam-resource-enums"; +import { TPamResourceFactory, TPamResourceFactoryValidateAccountCredentials } from "../../pam-resource-types"; +import { TSqlAccountCredentials, TSqlResourceConnectionDetails } from "./sql-resource-types"; + +const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; + +const TEST_CONNECTION_USERNAME = "infisical-gateway-connection-test"; +const TEST_CONNECTION_PASSWORD = "infisical-gateway-connection-test-password"; + +const SQL_CONNECTION_CLIENT_MAP = { + [PamResource.Postgres]: "pg" +}; + +const getConnectionConfig = ( + resourceType: PamResource, + { host, sslEnabled, sslRejectUnauthorized, sslCertificate }: TSqlResourceConnectionDetails +) => { + switch (resourceType) { + case PamResource.Postgres: { + return { + ssl: sslEnabled + ? { + rejectUnauthorized: sslRejectUnauthorized, + ca: sslCertificate, + servername: host + } + : false + }; + } + default: + throw new BadRequestError({ + message: `Unhandled SQL Resource Connection Config: ${resourceType as PamResource}` + }); + } +}; + +export const executeWithGateway = async ( + config: { + connectionDetails: TSqlResourceConnectionDetails; + resourceType: PamResource; + gatewayId: string; + username?: string; + password?: string; + }, + gatewayV2Service: Pick, + operation: (client: Knex) => Promise +): Promise => { + const { connectionDetails, resourceType, gatewayId, username, password } = config; + + const [targetHost] = await verifyHostInputValidity(connectionDetails.host, true); + const platformConnectionDetails = await gatewayV2Service.getPlatformConnectionDetailsByGatewayId({ + gatewayId, + targetHost, + targetPort: connectionDetails.port + }); + + if (!platformConnectionDetails) { + throw new BadRequestError({ message: "Unable to connect to gateway, no platform connection details found" }); + } + + return withGatewayV2Proxy( + async (proxyPort) => { + const client = knex({ + client: SQL_CONNECTION_CLIENT_MAP[resourceType], + connection: { + database: connectionDetails.database, + port: proxyPort, + host: "localhost", + user: username ?? TEST_CONNECTION_USERNAME, // Use provided username or fallback + password: password ?? TEST_CONNECTION_PASSWORD, // Use provided password or fallback + connectionTimeoutMillis: EXTERNAL_REQUEST_TIMEOUT, + ...getConnectionConfig(resourceType, connectionDetails) + } + }); + try { + return await operation(client); + } finally { + await client.destroy(); + } + }, + { + protocol: GatewayProxyProtocol.Tcp, + relayHost: platformConnectionDetails.relayHost, + gateway: platformConnectionDetails.gateway, + relay: platformConnectionDetails.relay + } + ); +}; + +export const sqlResourceFactory: TPamResourceFactory = ( + resourceType, + connectionDetails, + gatewayId, + gatewayV2Service +) => { + const validateConnection = async () => { + try { + await executeWithGateway({ connectionDetails, gatewayId, resourceType }, gatewayV2Service, async (client) => { + await client.raw("Select 1"); + }); + return connectionDetails; + } catch (error) { + // Hacky way to know if we successfully hit the database + if (error instanceof BadRequestError) { + if (error.message === `password authentication failed for user "${TEST_CONNECTION_USERNAME}"`) { + return connectionDetails; + } + + if (error.message === "Connection terminated unexpectedly") { + throw new BadRequestError({ + message: "Connection terminated unexpectedly. Verify that host and port are correct" + }); + } + } + + throw new BadRequestError({ + message: `Unable to validate connection to ${resourceType}: ${(error as Error).message || String(error)}` + }); + } + }; + + const validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials = async ( + credentials + ) => { + try { + await executeWithGateway( + { + connectionDetails, + gatewayId, + resourceType, + username: credentials.username, + password: credentials.password + }, + gatewayV2Service, + async (client) => { + await client.raw("Select 1"); + } + ); + return credentials; + } catch (error) { + if (error instanceof BadRequestError) { + if (error.message === `password authentication failed for user "${credentials.username}"`) { + throw new BadRequestError({ + message: "Account credentials invalid: Username or password incorrect" + }); + } + + if (error.message === "Connection terminated unexpectedly") { + throw new BadRequestError({ + message: "Connection terminated unexpectedly. Verify that host and port are correct" + }); + } + } + + throw new BadRequestError({ + message: `Unable to validate account credentials for ${resourceType}: ${(error as Error).message || String(error)}` + }); + } + }; + + return { + validateConnection, + validateAccountCredentials + }; +}; diff --git a/backend/src/ee/services/pam-resource/shared/sql/sql-resource-schemas.ts b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-schemas.ts new file mode 100644 index 000000000..cb3abf109 --- /dev/null +++ b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-schemas.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; + +// Resources +export const BaseSqlResourceConnectionDetailsSchema = z.object({ + host: z.string().trim().min(1).max(255), + port: z.coerce.number(), + database: z.string().trim().min(1).max(255), + sslEnabled: z.boolean(), + sslRejectUnauthorized: z.boolean(), + sslCertificate: z + .string() + .trim() + .transform((value) => value || undefined) + .optional() +}); + +// Accounts +export const BaseSqlAccountCredentialsSchema = z.object({ + username: z.string().trim().min(1), + password: z.string().trim().min(1) +}); diff --git a/backend/src/ee/services/pam-resource/shared/sql/sql-resource-types.ts b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-types.ts new file mode 100644 index 000000000..f56a2a3dc --- /dev/null +++ b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-types.ts @@ -0,0 +1,7 @@ +import { + TPostgresAccountCredentials, + TPostgresResourceConnectionDetails +} from "../../postgres/postgres-resource-types"; + +export type TSqlResourceConnectionDetails = TPostgresResourceConnectionDetails; +export type TSqlAccountCredentials = TPostgresAccountCredentials; diff --git a/backend/src/ee/services/pam-session/pam-session-dal.ts b/backend/src/ee/services/pam-session/pam-session-dal.ts new file mode 100644 index 000000000..f8b3a3393 --- /dev/null +++ b/backend/src/ee/services/pam-session/pam-session-dal.ts @@ -0,0 +1,26 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TPamSessionDALFactory = ReturnType; +export const pamSessionDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.PamSession); + + const findById = async (id: string, tx?: Knex) => { + const session = await (tx || db.replicaNode())(TableName.PamSession) + .leftJoin(TableName.PamAccount, `${TableName.PamSession}.accountId`, `${TableName.PamAccount}.id`) + .leftJoin(TableName.PamResource, `${TableName.PamAccount}.resourceId`, `${TableName.PamResource}.id`) + .leftJoin(TableName.GatewayV2, `${TableName.PamResource}.gatewayId`, `${TableName.GatewayV2}.id`) + .select(selectAllTableCols(TableName.PamSession)) + .select(db.ref("name").withSchema(TableName.GatewayV2).as("gatewayName")) + .select(db.ref("identityId").withSchema(TableName.GatewayV2).as("gatewayIdentityId")) + .where(`${TableName.PamSession}.id`, id) + .first(); + + return session; + }; + + return { ...orm, findById }; +}; diff --git a/backend/src/ee/services/pam-session/pam-session-enums.ts b/backend/src/ee/services/pam-session/pam-session-enums.ts new file mode 100644 index 000000000..87731f577 --- /dev/null +++ b/backend/src/ee/services/pam-session/pam-session-enums.ts @@ -0,0 +1,6 @@ +export enum PamSessionStatus { + Starting = "starting", // Starting, user connecting to resource + Active = "active", // Active, user is connected to resource + Ended = "ended", // Ended by user + Terminated = "terminated" // Terminated by an admin +} diff --git a/backend/src/ee/services/pam-session/pam-session-fns.ts b/backend/src/ee/services/pam-session/pam-session-fns.ts new file mode 100644 index 000000000..4afe205b5 --- /dev/null +++ b/backend/src/ee/services/pam-session/pam-session-fns.ts @@ -0,0 +1,43 @@ +import { TPamSessions } from "@app/db/schemas"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; + +import { TPamSanitizedSession, TPamSessionCommandLog } from "./pam-session.types"; + +export const decryptSessionCommandLogs = async ({ + projectId, + encryptedLogs, + kmsService +}: { + projectId: string; + encryptedLogs: Buffer; + kmsService: Pick; +}) => { + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + + const decryptedPlainTextBlob = decryptor({ + cipherTextBlob: encryptedLogs + }); + + return JSON.parse(decryptedPlainTextBlob.toString()) as TPamSessionCommandLog; +}; + +export const decryptSession = async ( + session: TPamSessions, + projectId: string, + kmsService: Pick +) => { + return { + ...session, + commandLogs: session.encryptedLogsBlob + ? await decryptSessionCommandLogs({ + projectId, + encryptedLogs: session.encryptedLogsBlob, + kmsService + }) + : [] + } as TPamSanitizedSession; +}; diff --git a/backend/src/ee/services/pam-session/pam-session-schemas.ts b/backend/src/ee/services/pam-session/pam-session-schemas.ts new file mode 100644 index 000000000..2bc1d5345 --- /dev/null +++ b/backend/src/ee/services/pam-session/pam-session-schemas.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +import { PamSessionsSchema } from "@app/db/schemas"; + +export const PamSessionCommandLogSchema = z.object({ + input: z.string(), + output: z.string(), + timestamp: z.coerce.date() +}); + +export const SanitizedSessionSchema = PamSessionsSchema.omit({ + encryptedLogsBlob: true +}).extend({ + commandLogs: PamSessionCommandLogSchema.array() +}); diff --git a/backend/src/ee/services/pam-session/pam-session-service.ts b/backend/src/ee/services/pam-session/pam-session-service.ts new file mode 100644 index 000000000..713383306 --- /dev/null +++ b/backend/src/ee/services/pam-session/pam-session-service.ts @@ -0,0 +1,190 @@ +import { ForbiddenError } from "@casl/ability"; + +import { ActionProjectType } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { OrgServiceActor } from "@app/lib/types"; +import { ActorType } from "@app/services/auth/auth-type"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; + +import { TLicenseServiceFactory } from "../license/license-service"; +import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission"; +import { ProjectPermissionPamSessionActions, ProjectPermissionSub } from "../permission/project-permission"; +import { TUpdateSessionLogsDTO } from "./pam-session.types"; +import { TPamSessionDALFactory } from "./pam-session-dal"; +import { PamSessionStatus } from "./pam-session-enums"; +import { decryptSession } from "./pam-session-fns"; + +type TPamSessionServiceFactoryDep = { + pamSessionDAL: TPamSessionDALFactory; + projectDAL: TProjectDALFactory; + permissionService: Pick; + licenseService: Pick; + kmsService: Pick; +}; + +export type TPamSessionServiceFactory = ReturnType; + +export const pamSessionServiceFactory = ({ + pamSessionDAL, + projectDAL, + permissionService, + licenseService, + kmsService +}: TPamSessionServiceFactoryDep) => { + const getById = async (sessionId: string, actor: OrgServiceActor) => { + const session = await pamSessionDAL.findById(sessionId); + if (!session) throw new NotFoundError({ message: `Session with ID '${sessionId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId: session.projectId, + actionProjectType: ActionProjectType.PAM + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPamSessionActions.Read, + ProjectPermissionSub.PamSessions + ); + + return { + session: await decryptSession(session, session.projectId, kmsService) + }; + }; + + const list = async (projectId: string, actor: OrgServiceActor) => { + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId, + actionProjectType: ActionProjectType.PAM + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPamSessionActions.Read, + ProjectPermissionSub.PamSessions + ); + + const sessions = await pamSessionDAL.find({ projectId }); + + return { + sessions: await Promise.all(sessions.map((session) => decryptSession(session, projectId, kmsService))) + }; + }; + + const updateLogsById = async ({ sessionId, logs }: TUpdateSessionLogsDTO, actor: OrgServiceActor) => { + const orgLicensePlan = await licenseService.getPlan(actor.orgId); + if (!orgLicensePlan.pam) { + throw new BadRequestError({ + message: "PAM operation failed due to organization plan restrictions." + }); + } + + // To be hit by gateways only + if (actor.type !== ActorType.IDENTITY) { + throw new ForbiddenRequestError({ message: "Only gateways can perform this action" }); + } + + const session = await pamSessionDAL.findById(sessionId); + if (!session) throw new NotFoundError({ message: `Session with ID '${sessionId}' not found` }); + + if (session.encryptedLogsBlob) { + throw new BadRequestError({ message: "Cannot update logs for sessions with existing logs" }); + } + + const project = await projectDAL.findById(session.projectId); + if (!project) throw new NotFoundError({ message: `Project with ID '${session.projectId}' not found` }); + + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + project.orgId, + actor.authMethod, + actor.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionGatewayActions.CreateGateways, + OrgPermissionSubjects.Gateway + ); + + if (session.gatewayIdentityId !== actor.id) { + throw new ForbiddenRequestError({ message: "Identity does not have access to update logs for this session" }); + } + + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: session.projectId + }); + + const { cipherTextBlob } = encryptor({ + plainText: Buffer.from(JSON.stringify(logs)) + }); + + const updatedSession = await pamSessionDAL.updateById(sessionId, { + encryptedLogsBlob: cipherTextBlob + }); + + return { session: updatedSession, projectId: project.id }; + }; + + const endSessionById = async (sessionId: string, actor: OrgServiceActor) => { + const session = await pamSessionDAL.findById(sessionId); + if (!session) throw new NotFoundError({ message: `Session with ID '${sessionId}' not found` }); + + const project = await projectDAL.findById(session.projectId); + if (!project) throw new NotFoundError({ message: `Project with ID '${session.projectId}' not found` }); + + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + project.orgId, + actor.authMethod, + actor.orgId + ); + + if (actor.type === ActorType.IDENTITY) { + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionGatewayActions.CreateGateways, + OrgPermissionSubjects.Gateway + ); + + if (session.gatewayIdentityId !== actor.id) { + throw new ForbiddenRequestError({ message: "Identity does not have access to end this session" }); + } + } else if (actor.type === ActorType.USER) { + if (session.userId !== actor.id) { + throw new ForbiddenRequestError({ message: "You are not authorized to end this session" }); + } + } else { + throw new ForbiddenRequestError({ message: "Only identities and users can perform this action" }); + } + + if (session.status === PamSessionStatus.Ended) { + return { + session, + projectId: project.id + }; + } + + if (session.status !== PamSessionStatus.Active && session.status !== PamSessionStatus.Starting) { + throw new BadRequestError({ message: "Cannot end sessions that are not active or starting" }); + } + + const updatedSession = await pamSessionDAL.updateById(sessionId, { + endedAt: new Date(), + status: PamSessionStatus.Ended + }); + + return { session: updatedSession, projectId: project.id }; + }; + + return { getById, list, updateLogsById, endSessionById }; +}; diff --git a/backend/src/ee/services/pam-session/pam-session.types.ts b/backend/src/ee/services/pam-session/pam-session.types.ts new file mode 100644 index 000000000..0c87a9fa4 --- /dev/null +++ b/backend/src/ee/services/pam-session/pam-session.types.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +import { PamSessionCommandLogSchema, SanitizedSessionSchema } from "./pam-session-schemas"; + +export type TPamSessionCommandLog = z.infer; +export type TPamSanitizedSession = z.infer; + +// DTOs +export type TUpdateSessionLogsDTO = { + sessionId: string; + logs: TPamSessionCommandLog[]; +}; diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts index 83d1c44d9..b9cabe022 100644 --- a/backend/src/ee/services/permission/default-roles.ts +++ b/backend/src/ee/services/permission/default-roles.ts @@ -12,6 +12,8 @@ import { ProjectPermissionIdentityActions, ProjectPermissionKmipActions, ProjectPermissionMemberActions, + ProjectPermissionPamAccountActions, + ProjectPermissionPamSessionActions, ProjectPermissionPkiSubscriberActions, ProjectPermissionPkiSyncActions, ProjectPermissionPkiTemplateActions, @@ -49,7 +51,9 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.SshCertificateAuthorities, ProjectPermissionSub.SshCertificates, ProjectPermissionSub.SshCertificateTemplates, - ProjectPermissionSub.SshHostGroups + ProjectPermissionSub.SshHostGroups, + ProjectPermissionSub.PamFolders, + ProjectPermissionSub.PamResources ].forEach((el) => { can( [ @@ -290,6 +294,19 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.AppConnections ); + can( + [ + ProjectPermissionPamAccountActions.Access, + ProjectPermissionPamAccountActions.Read, + ProjectPermissionPamAccountActions.Create, + ProjectPermissionPamAccountActions.Edit, + ProjectPermissionPamAccountActions.Delete + ], + ProjectPermissionSub.PamAccounts + ); + + can([ProjectPermissionPamSessionActions.Read], ProjectPermissionSub.PamSessions); + return rules; }; @@ -518,6 +535,15 @@ const buildMemberPermissionRules = () => { can(ProjectPermissionAppConnectionActions.Connect, ProjectPermissionSub.AppConnections); + can([ProjectPermissionActions.Read], ProjectPermissionSub.PamFolders); + + can([ProjectPermissionActions.Read], ProjectPermissionSub.PamResources); + + can( + [ProjectPermissionPamAccountActions.Access, ProjectPermissionPamAccountActions.Read], + ProjectPermissionSub.PamAccounts + ); + return rules; }; @@ -579,6 +605,12 @@ const buildViewerPermissionRules = () => { ProjectPermissionSub.SecretEvents ); + can([ProjectPermissionActions.Read], ProjectPermissionSub.PamFolders); + + can([ProjectPermissionActions.Read], ProjectPermissionSub.PamResources); + + can([ProjectPermissionPamAccountActions.Read], ProjectPermissionSub.PamAccounts); + return rules; }; diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index d79120256..4c7f1faac 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -186,6 +186,19 @@ export enum ProjectPermissionAuditLogsActions { Read = "read" } +export enum ProjectPermissionPamAccountActions { + Access = "access", + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete" +} + +export enum ProjectPermissionPamSessionActions { + Read = "read" + // Terminate = "terminate" +} + export enum ProjectPermissionSub { Role = "role", Member = "member", @@ -228,7 +241,11 @@ export enum ProjectPermissionSub { SecretScanningFindings = "secret-scanning-findings", SecretScanningConfigs = "secret-scanning-configs", SecretEvents = "secret-events", - AppConnections = "app-connections" + AppConnections = "app-connections", + PamFolders = "pam-folders", + PamResources = "pam-resources", + PamAccounts = "pam-accounts", + PamSessions = "pam-sessions" } export type SecretSubjectFields = { @@ -300,6 +317,12 @@ export type AppConnectionSubjectFields = { connectionId: string; }; +export type PamAccountSubjectFields = { + resourceName: string; + accountName: string; + accountPath: string; +}; + export type ProjectPermissionSet = | [ ProjectPermissionSecretActions, @@ -404,7 +427,14 @@ export type ProjectPermissionSet = | ProjectPermissionSub.AppConnections | (ForcedSubject & AppConnectionSubjectFields) ) - ]; + ] + | [ProjectPermissionActions, ProjectPermissionSub.PamFolders] + | [ProjectPermissionActions, ProjectPermissionSub.PamResources] + | [ + ProjectPermissionPamAccountActions, + ProjectPermissionSub.PamAccounts | (ForcedSubject & PamAccountSubjectFields) + ] + | [ProjectPermissionPamSessionActions, ProjectPermissionSub.PamSessions]; const SECRET_PATH_MISSING_SLASH_ERR_MSG = "Invalid Secret Path; it must start with a '/'"; const SECRET_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([ @@ -427,6 +457,27 @@ const SECRET_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([ }) .partial() ]); +const PAM_ACCOUNT_PATH_MISSING_SLASH_ERR_MSG = "Invalid Secret Path; it must start with a '/'"; +const PAM_ACCOUNT_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([ + z.string().refine((val) => val.startsWith("/"), SECRET_PATH_MISSING_SLASH_ERR_MSG), + z + .object({ + [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ].refine( + (val) => val.startsWith("/"), + PAM_ACCOUNT_PATH_MISSING_SLASH_ERR_MSG + ), + [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ].refine( + (val) => val.startsWith("/"), + PAM_ACCOUNT_PATH_MISSING_SLASH_ERR_MSG + ), + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN].refine( + (val) => val.every((el) => el.startsWith("/")), + PAM_ACCOUNT_PATH_MISSING_SLASH_ERR_MSG + ), + [PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB] + }) + .partial() +]); // akhilmhdh: don't modify this for v2 // if you want to update create a new schema const SecretConditionV1Schema = z @@ -650,6 +701,34 @@ const AppConnectionConditionSchema = z }) .partial(); +const PamAccountConditionSchema = z + .object({ + resourceName: z.union([ + z.string(), + z + .object({ + [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], + [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN], + [PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB] + }) + .partial() + ]), + accountName: z.union([ + z.string(), + z + .object({ + [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], + [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN], + [PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB] + }) + .partial() + ]), + accountPath: PAM_ACCOUNT_PATH_PERMISSION_OPERATOR_SCHEMA + }) + .partial(); + const GeneralPermissionSchema = [ z.object({ subject: z.literal(ProjectPermissionSub.SecretApproval).describe("The entity this permission pertains to."), @@ -840,6 +919,34 @@ const GeneralPermissionSchema = [ conditions: AppConnectionConditionSchema.describe( "When specified, only matching conditions will be allowed to access given resource." ).optional() + }), + z.object({ + subject: z.literal(ProjectPermissionSub.PamFolders).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.PamResources).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.PamAccounts).describe("The entity this permission pertains to."), + inverted: z.boolean().optional().describe("Whether rule allows or forbids."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionPamAccountActions).describe( + "Describe what action an entity can take." + ), + conditions: PamAccountConditionSchema.describe( + "When specified, only matching conditions will be allowed to access given resource." + ).optional() + }), + z.object({ + subject: z.literal(ProjectPermissionSub.PamSessions).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionPamSessionActions).describe( + "Describe what action an entity can take." + ) }) ]; diff --git a/backend/src/ee/services/relay/relay-service.ts b/backend/src/ee/services/relay/relay-service.ts index 2fef71259..696bd1f4f 100644 --- a/backend/src/ee/services/relay/relay-service.ts +++ b/backend/src/ee/services/relay/relay-service.ts @@ -708,7 +708,8 @@ export const relayServiceFactory = ({ relayPkiClientCaCertificate, relayPkiClientCaPrivateKey, relayPkiServerCaCertificate, - relayPkiServerCaCertificateChain + relayPkiServerCaCertificateChain, + duration }: { gatewayId: string; gatewayName: string; @@ -718,6 +719,7 @@ export const relayServiceFactory = ({ relayPkiClientCaPrivateKey: Buffer; relayPkiServerCaCertificate: Buffer; relayPkiServerCaCertificateChain: Buffer; + duration?: number; }) => { const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048); const relayClientCaCert = new x509.X509Certificate(relayPkiClientCaCertificate); @@ -737,7 +739,7 @@ export const relayServiceFactory = ({ ); const clientCertIssuedAt = new Date(); - const clientCertExpiration = new Date(new Date().getTime() + 5 * 60 * 1000); + const clientCertExpiration = new Date(new Date().getTime() + (duration ?? 5 * 60 * 1000)); const clientKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); const clientCertPrivateKey = crypto.nativeCrypto.KeyObject.from(clientKeys.privateKey); const clientCertSerialNumber = createSerialNumber(); @@ -866,13 +868,15 @@ export const relayServiceFactory = ({ orgId, orgName, gatewayId, - gatewayName + gatewayName, + duration }: { relayId: string; orgId: string; orgName: string; gatewayId: string; gatewayName: string; + duration?: number; }) => { const relay = await relayDAL.findOne({ id: relayId @@ -896,7 +900,8 @@ export const relayServiceFactory = ({ relayPkiClientCaCertificate: instanceCAs.instanceRelayPkiClientCaCertificate, relayPkiClientCaPrivateKey: instanceCAs.instanceRelayPkiClientCaPrivateKey, relayPkiServerCaCertificate: instanceCAs.instanceRelayPkiServerCaCertificate, - relayPkiServerCaCertificateChain: instanceCAs.instanceRelayPkiServerCaCertificateChain + relayPkiServerCaCertificateChain: instanceCAs.instanceRelayPkiServerCaCertificateChain, + duration }); return { @@ -914,7 +919,8 @@ export const relayServiceFactory = ({ relayPkiClientCaCertificate: orgCAs.relayPkiClientCaCertificate, relayPkiClientCaPrivateKey: orgCAs.relayPkiClientCaPrivateKey, relayPkiServerCaCertificate: orgCAs.relayPkiServerCaCertificate, - relayPkiServerCaCertificateChain: orgCAs.relayPkiServerCaCertificateChain + relayPkiServerCaCertificateChain: orgCAs.relayPkiServerCaCertificateChain, + duration }); return { diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index 17f4aa493..3155fe05c 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -23,6 +23,7 @@ export const PgSqlLock = { InstanceRelayConfigInit: () => pgAdvisoryLockHashText("instance-relay-config-init"), OrgGatewayV2Init: (orgId: string) => pgAdvisoryLockHashText(`org-gateway-v2-init:${orgId}`), OrgRelayConfigInit: (orgId: string) => pgAdvisoryLockHashText(`org-relay-config-init:${orgId}`), + GatewayPamSessionKey: (gatewayId: string) => pgAdvisoryLockHashText(`gateway-pam-session-key:${gatewayId}`), IdentityLogin: (identityId: string, nonce: string) => pgAdvisoryLockHashText(`identity-login:${identityId}:${nonce}`) } as const; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 7c806674a..406daa63d 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -66,6 +66,14 @@ import { licenseDALFactory } from "@app/ee/services/license/license-dal"; import { licenseServiceFactory } from "@app/ee/services/license/license-service"; import { oidcConfigDALFactory } from "@app/ee/services/oidc/oidc-config-dal"; import { oidcConfigServiceFactory } from "@app/ee/services/oidc/oidc-config-service"; +import { pamAccountDALFactory } from "@app/ee/services/pam-account/pam-account-dal"; +import { pamAccountServiceFactory } from "@app/ee/services/pam-account/pam-account-service"; +import { pamFolderDALFactory } from "@app/ee/services/pam-folder/pam-folder-dal"; +import { pamFolderServiceFactory } from "@app/ee/services/pam-folder/pam-folder-service"; +import { pamResourceDALFactory } from "@app/ee/services/pam-resource/pam-resource-dal"; +import { pamResourceServiceFactory } from "@app/ee/services/pam-resource/pam-resource-service"; +import { pamSessionDALFactory } from "@app/ee/services/pam-session/pam-session-dal"; +import { pamSessionServiceFactory } from "@app/ee/services/pam-session/pam-session-service"; import { permissionDALFactory } from "@app/ee/services/permission/permission-dal"; import { permissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { pitServiceFactory } from "@app/ee/services/pit/pit-service"; @@ -2110,6 +2118,46 @@ export const registerRoutes = async ( appConnectionDAL }); + const pamFolderDAL = pamFolderDALFactory(db); + const pamResourceDAL = pamResourceDALFactory(db); + const pamAccountDAL = pamAccountDALFactory(db); + const pamSessionDAL = pamSessionDALFactory(db); + + const pamFolderService = pamFolderServiceFactory({ + pamFolderDAL, + permissionService, + licenseService + }); + + const pamResourceService = pamResourceServiceFactory({ + pamResourceDAL, + permissionService, + licenseService, + kmsService, + gatewayV2Service + }); + + const pamAccountService = pamAccountServiceFactory({ + pamAccountDAL, + gatewayV2Service, + kmsService, + licenseService, + pamFolderDAL, + pamResourceDAL, + pamSessionDAL, + permissionService, + projectDAL, + userDAL + }); + + const pamSessionService = pamSessionServiceFactory({ + pamSessionDAL, + projectDAL, + permissionService, + licenseService, + kmsService + }); + // setup the communication with license key server await licenseService.init(); @@ -2248,6 +2296,10 @@ export const registerRoutes = async ( bus: eventBusService, sse: sseService, notification: notificationService, + pamFolder: pamFolderService, + pamResource: pamResourceService, + pamAccount: pamAccountService, + pamSession: pamSessionService, upgradePath: upgradePathService }); diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 84b1442f6..89865b1a1 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -58,8 +58,8 @@ import { registerSecretRequestsRouter } from "./secret-requests-router"; import { registerSecretSharingRouter } from "./secret-sharing-router"; import { registerSecretTagRouter } from "./secret-tag-router"; import { registerSlackRouter } from "./slack-router"; -import { registerUpgradePathRouter } from "./upgrade-path-router"; import { registerSsoRouter } from "./sso-router"; +import { registerUpgradePathRouter } from "./upgrade-path-router"; import { registerUserActionRouter } from "./user-action-router"; import { registerUserEngagementRouter } from "./user-engagement-router"; import { registerUserRouter } from "./user-router"; diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index a86fa7420..73abef78d 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -215,6 +215,8 @@ export const listAppConnectionOptions = (projectType?: ProjectType) => { return false; case ProjectType.SSH: return false; + case ProjectType.PAM: + return false; default: return true; } diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 2de271180..00dc19a46 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -197,4 +197,4 @@ volumes: driver: local ldap_data: ldap_config: - grafana_storage: \ No newline at end of file + grafana_storage: diff --git a/frontend/src/components/projects/NewProjectModal.tsx b/frontend/src/components/projects/NewProjectModal.tsx index dcb11d873..eb7ef40fe 100644 --- a/frontend/src/components/projects/NewProjectModal.tsx +++ b/frontend/src/components/projects/NewProjectModal.tsx @@ -81,6 +81,10 @@ const PROJECT_TYPE_MENU_ITEMS = [ label: "Secret Scanning", value: ProjectType.SecretScanning } + // { + // label: "PAM", + // value: ProjectType.PAM + // } ]; const NewProjectForm = ({ onOpenChange }: NewProjectFormProps) => { @@ -193,12 +197,12 @@ const NewProjectForm = ({ onOpenChange }: NewProjectFormProps) => { errorText={error?.message} className="flex-1" > -
+
{PROJECT_TYPE_MENU_ITEMS.map((el) => (
field.onChange(el.value)} diff --git a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewRedisCredentialsRotationGeneratedCredentials.tsx b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewRedisCredentialsRotationGeneratedCredentials.tsx index 18feefa09..77fa687e7 100644 --- a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewRedisCredentialsRotationGeneratedCredentials.tsx +++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewRedisCredentialsRotationGeneratedCredentials.tsx @@ -1,7 +1,7 @@ import { CredentialDisplay } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/CredentialDisplay"; +import { TRedisCredentialsRotationGeneratedCredentialsResponse } from "@app/hooks/api/secretRotationsV2/types/redis-credentials-rotation"; import { ViewRotationGeneratedCredentialsDisplay } from "./shared"; -import { TRedisCredentialsRotationGeneratedCredentialsResponse } from "@app/hooks/api/secretRotationsV2/types/redis-credentials-rotation"; type Props = { generatedCredentialsResponse: TRedisCredentialsRotationGeneratedCredentialsResponse; diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/RedisCredentialsRotationParametersFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/RedisCredentialsRotationParametersFields.tsx index 0aeffef21..aed65a424 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/RedisCredentialsRotationParametersFields.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/RedisCredentialsRotationParametersFields.tsx @@ -3,6 +3,7 @@ import { Controller, useFormContext } from "react-hook-form"; import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas"; import { FormControl, Input } from "@app/components/v2"; import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; + import { DEFAULT_PASSWORD_REQUIREMENTS } from "../schemas/shared"; export const RedisCredentialsRotationParametersFields = () => { @@ -18,7 +19,7 @@ export const RedisCredentialsRotationParametersFields = () => { ( = { [SecretRotation.PostgresCredentials]: SqlCredentialsRotationParametersFields, diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx index 05b6ad63c..e484a64b1 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx @@ -11,8 +11,8 @@ import { AwsIamUserSecretRotationReviewFields } from "./AwsIamUserSecretRotation import { AzureClientSecretRotationReviewFields } from "./AzureClientSecretRotationReviewFields"; import { LdapPasswordRotationReviewFields } from "./LdapPasswordRotationReviewFields"; import { OktaClientSecretRotationReviewFields } from "./OktaClientSecretRotationReviewFields"; -import { SqlCredentialsRotationReviewFields } from "./shared"; import { RedisCredentialsRotationReviewFields } from "./RedisCredentialsRotationReviewFields"; +import { SqlCredentialsRotationReviewFields } from "./shared"; const COMPONENT_MAP: Record = { [SecretRotation.PostgresCredentials]: SqlCredentialsRotationReviewFields, diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx index 15338c48a..e05fd31f5 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx @@ -8,8 +8,8 @@ import { AwsIamUserSecretRotationSecretsMappingFields } from "./AwsIamUserSecret import { AzureClientSecretRotationSecretsMappingFields } from "./AzureClientSecretRotationSecretsMappingFields"; import { LdapPasswordRotationSecretsMappingFields } from "./LdapPasswordRotationSecretsMappingFields"; import { OktaClientSecretRotationSecretsMappingFields } from "./OktaClientSecretRotationSecretsMappingFields"; -import { SqlCredentialsRotationSecretsMappingFields } from "./shared"; import { RedisCredentialsRotationSecretsMappingFields } from "./RedisCredentialsRotationSecretsMappingFields"; +import { SqlCredentialsRotationSecretsMappingFields } from "./shared"; const COMPONENT_MAP: Record = { [SecretRotation.PostgresCredentials]: SqlCredentialsRotationSecretsMappingFields, diff --git a/frontend/src/components/v2/HighlightText/HighlightText.tsx b/frontend/src/components/v2/HighlightText/HighlightText.tsx index 3ce7c8f19..c81dab2df 100644 --- a/frontend/src/components/v2/HighlightText/HighlightText.tsx +++ b/frontend/src/components/v2/HighlightText/HighlightText.tsx @@ -8,9 +8,24 @@ export const HighlightText = ({ highlightClassName?: string; }) => { if (!text) return null; + + const renderTextWithNewlines = (input: string, baseKeyPrefix: string = ""): React.ReactNode[] => { + if (!input) return []; + const lines = input.split("\n"); + return lines.flatMap((line, index) => { + const nodes: React.ReactNode[] = [line]; + if (index < lines.length - 1) { + nodes.push(
); + } + return nodes; + }); + }; + const searchTerm = highlight.toLowerCase().trim(); - if (!searchTerm) return {text}; + if (!searchTerm) { + return {renderTextWithNewlines(text, "full-text")}; + } const parts: React.ReactNode[] = []; let lastIndex = 0; @@ -20,12 +35,17 @@ export const HighlightText = ({ text.replace(regex, (match: string, offset: number) => { if (offset > lastIndex) { - parts.push({text.substring(lastIndex, offset)}); + const preMatchText = text.substring(lastIndex, offset); + parts.push( + + {renderTextWithNewlines(preMatchText, `pre-${lastIndex}`)} + + ); } parts.push( - {match} + {renderTextWithNewlines(match, `match-${offset}`)} ); @@ -35,7 +55,12 @@ export const HighlightText = ({ }); if (lastIndex < text.length) { - parts.push({text.substring(lastIndex)}); + const postMatchText = text.substring(lastIndex); + parts.push( + + {renderTextWithNewlines(postMatchText, `post-${lastIndex}`)} + + ); } return parts; diff --git a/frontend/src/const/routes.ts b/frontend/src/const/routes.ts index b1c87dc2d..2e835a1db 100644 --- a/frontend/src/const/routes.ts +++ b/frontend/src/const/routes.ts @@ -350,6 +350,24 @@ export const ROUTE_PATHS = Object.freeze({ "/_authenticate/_inject-org-details/_org-layout/projects/secret-scanning/$projectId/_secret-scanning-layout/findings" ) }, + Pam: { + AccountsPage: setRoute( + "/projects/pam/$projectId/accounts", + "/_authenticate/_inject-org-details/_org-layout/projects/pam/$projectId/_pam-layout/accounts" + ), + ResourcesPage: setRoute( + "/projects/pam/$projectId/resources", + "/_authenticate/_inject-org-details/_org-layout/projects/pam/$projectId/_pam-layout/resources" + ), + SessionsPage: setRoute( + "/projects/pam/$projectId/sessions", + "/_authenticate/_inject-org-details/_org-layout/projects/pam/$projectId/_pam-layout/sessions/" + ), + PamSessionByIDPage: setRoute( + "/projects/pam/$projectId/sessions/$sessionId", + "/_authenticate/_inject-org-details/_org-layout/projects/pam/$projectId/_pam-layout/sessions/$sessionId" + ) + }, Public: { ViewSharedSecretByIDPage: setRoute("/shared/secret/$secretId", "/shared/secret/$secretId"), ViewSecretRequestByIDPage: setRoute( diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index 306d92808..d3019409f 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -187,6 +187,19 @@ export enum ProjectPermissionCommitsActions { PerformRollback = "perform-rollback" } +export enum ProjectPermissionPamAccountActions { + Access = "access", + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete" +} + +export enum ProjectPermissionPamSessionActions { + Read = "read" + // Terminate = "terminate" +} + export type IdentityManagementSubjectFields = { identityId: string; }; @@ -208,7 +221,8 @@ export type ConditionalProjectPermissionSubject = | ProjectPermissionSub.SecretImports | ProjectPermissionSub.SecretRotation | ProjectPermissionSub.SecretEvents - | ProjectPermissionSub.AppConnections; + | ProjectPermissionSub.AppConnections + | ProjectPermissionSub.PamAccounts; export const formatedConditionsOperatorNames: { [K in PermissionConditionOperators]: string } = { [PermissionConditionOperators.$EQ]: "equal to", @@ -289,7 +303,11 @@ export enum ProjectPermissionSub { SecretScanningFindings = "secret-scanning-findings", SecretScanningConfigs = "secret-scanning-configs", SecretEvents = "secret-events", - AppConnections = "app-connections" + AppConnections = "app-connections", + PamFolders = "pam-folders", + PamResources = "pam-resources", + PamAccounts = "pam-accounts", + PamSessions = "pam-sessions" } export type SecretSubjectFields = { @@ -350,6 +368,12 @@ export type PkiTemplateSubjectFields = { // (dangtony98): consider adding [commonName] as a subject field in the future }; +export type PamAccountSubjectFields = { + resourceName: string; + accountName: string; + accountPath: string; +}; + export type ProjectPermissionSet = | [ ProjectPermissionSecretActions, @@ -475,6 +499,16 @@ export type ProjectPermissionSet = | ProjectPermissionSub.AppConnections | (ForcedSubject & AppConnectionSubjectFields) ) - ]; + ] + | [ProjectPermissionActions, ProjectPermissionSub.PamFolders] + | [ProjectPermissionActions, ProjectPermissionSub.PamResources] + | [ + ProjectPermissionPamAccountActions, + ( + | ProjectPermissionSub.PamAccounts + | (ForcedSubject & PamAccountSubjectFields) + ) + ] + | [ProjectPermissionPamSessionActions, ProjectPermissionSub.PamSessions]; export type TProjectPermission = MongoAbility; diff --git a/frontend/src/helpers/project.ts b/frontend/src/helpers/project.ts index 6227b9696..843ded3e3 100644 --- a/frontend/src/helpers/project.ts +++ b/frontend/src/helpers/project.ts @@ -82,6 +82,8 @@ export const getProjectHomePage = (type: ProjectType, environments: ProjectEnv[] return "/projects/cert-management/$projectId/subscribers" as const; case ProjectType.SecretScanning: return `/projects/${type}/$projectId/data-sources` as const; + case ProjectType.PAM: + return `/projects/${type}/$projectId/accounts` as const; default: return `/projects/${type}/$projectId/overview` as const; } @@ -93,7 +95,8 @@ export const getProjectTitle = (type: ProjectType) => { [ProjectType.KMS]: "Key Management", [ProjectType.CertificateManager]: "Cert Management", [ProjectType.SSH]: "SSH", - [ProjectType.SecretScanning]: "Secret Scanning" + [ProjectType.SecretScanning]: "Secret Scanning", + [ProjectType.PAM]: "PAM" }; return titleConvert[type]; }; @@ -104,7 +107,8 @@ export const getProjectLottieIcon = (type: ProjectType) => { [ProjectType.KMS]: "unlock", [ProjectType.CertificateManager]: "note", [ProjectType.SSH]: "terminal", - [ProjectType.SecretScanning]: "secret-scan" + [ProjectType.SecretScanning]: "secret-scan", + [ProjectType.PAM]: "groups" }; return titleConvert[type]; }; diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index a36b4a37a..970e0592d 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -1,3 +1,4 @@ +import { ProjectType } from "../projects/types"; import { EventType, UserAgentType } from "./enums"; export const secretEvents: EventType[] = [ @@ -246,7 +247,26 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.CREATE_ORG_ROLE]: "Create Org Role", [EventType.UPDATE_ORG_ROLE]: "Update Org Role", - [EventType.DELETE_ORG_ROLE]: "Delete Org Role" + [EventType.DELETE_ORG_ROLE]: "Delete Org Role", + + [EventType.PAM_SESSION_START]: "PAM Session Start", + [EventType.PAM_SESSION_LOGS_UPDATE]: "PAM Session Logs Update", + [EventType.PAM_SESSION_END]: "PAM Session End", + [EventType.PAM_SESSION_GET]: "PAM Session Get", + [EventType.PAM_SESSION_LIST]: "PAM Session List", + [EventType.PAM_FOLDER_CREATE]: "PAM Folder Create", + [EventType.PAM_FOLDER_UPDATE]: "PAM Folder Update", + [EventType.PAM_FOLDER_DELETE]: "PAM Folder Delete", + [EventType.PAM_ACCOUNT_LIST]: "PAM Account List", + [EventType.PAM_ACCOUNT_ACCESS]: "PAM Account Access", + [EventType.PAM_ACCOUNT_CREATE]: "PAM Account Create", + [EventType.PAM_ACCOUNT_UPDATE]: "PAM Account Update", + [EventType.PAM_ACCOUNT_DELETE]: "PAM Account Delete", + [EventType.PAM_RESOURCE_LIST]: "PAM Resource List", + [EventType.PAM_RESOURCE_GET]: "PAM Resource Get", + [EventType.PAM_RESOURCE_CREATE]: "PAM Resource Create", + [EventType.PAM_RESOURCE_UPDATE]: "PAM Resource Update", + [EventType.PAM_RESOURCE_DELETE]: "PAM Resource Delete" }; export const userAgentTypeToNameMap: { [K in UserAgentType]: string } = { @@ -258,3 +278,35 @@ export const userAgentTypeToNameMap: { [K in UserAgentType]: string } = { [UserAgentType.PYTHON_SDK]: "InfisicalPythonSDK", [UserAgentType.OTHER]: "Other" }; + +const sharedProjectEvents = [ + EventType.ADD_PROJECT_MEMBER, + EventType.REMOVE_PROJECT_MEMBER, + EventType.CREATE_PROJECT_ROLE, + EventType.UPDATE_PROJECT_ROLE, + EventType.DELETE_PROJECT_ROLE +]; + +export const projectToEventsMap: Partial> = { + [ProjectType.PAM]: [ + ...sharedProjectEvents, + EventType.PAM_SESSION_START, + EventType.PAM_SESSION_LOGS_UPDATE, + EventType.PAM_SESSION_END, + EventType.PAM_SESSION_GET, + EventType.PAM_SESSION_LIST, + EventType.PAM_FOLDER_CREATE, + EventType.PAM_FOLDER_UPDATE, + EventType.PAM_FOLDER_DELETE, + EventType.PAM_ACCOUNT_LIST, + EventType.PAM_ACCOUNT_ACCESS, + EventType.PAM_ACCOUNT_CREATE, + EventType.PAM_ACCOUNT_UPDATE, + EventType.PAM_ACCOUNT_DELETE, + EventType.PAM_RESOURCE_LIST, + EventType.PAM_RESOURCE_GET, + EventType.PAM_RESOURCE_CREATE, + EventType.PAM_RESOURCE_UPDATE, + EventType.PAM_RESOURCE_DELETE + ] +}; diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index 94c41fd11..b10fcb60a 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -240,5 +240,24 @@ export enum EventType { CREATE_ORG_ROLE = "create-org-role", UPDATE_ORG_ROLE = "update-org-role", - DELETE_ORG_ROLE = "delete-org-role" + DELETE_ORG_ROLE = "delete-org-role", + + PAM_SESSION_START = "pam-session-start", + PAM_SESSION_LOGS_UPDATE = "pam-session-logs-update", + PAM_SESSION_END = "pam-session-end", + PAM_SESSION_GET = "pam-session-get", + PAM_SESSION_LIST = "pam-session-list", + PAM_FOLDER_CREATE = "pam-folder-create", + PAM_FOLDER_UPDATE = "pam-folder-update", + PAM_FOLDER_DELETE = "pam-folder-delete", + PAM_ACCOUNT_LIST = "pam-account-list", + PAM_ACCOUNT_ACCESS = "pam-account-access", + PAM_ACCOUNT_CREATE = "pam-account-create", + PAM_ACCOUNT_UPDATE = "pam-account-update", + PAM_ACCOUNT_DELETE = "pam-account-delete", + PAM_RESOURCE_LIST = "pam-resource-list", + PAM_RESOURCE_GET = "pam-resource-get", + PAM_RESOURCE_CREATE = "pam-resource-create", + PAM_RESOURCE_UPDATE = "pam-resource-update", + PAM_RESOURCE_DELETE = "pam-resource-delete" } diff --git a/frontend/src/hooks/api/pam/enums.ts b/frontend/src/hooks/api/pam/enums.ts new file mode 100644 index 000000000..c6c66961b --- /dev/null +++ b/frontend/src/hooks/api/pam/enums.ts @@ -0,0 +1,10 @@ +export enum PamResourceType { + Postgres = "postgres" +} + +export enum PamSessionStatus { + Starting = "starting", + Active = "active", + Ended = "ended", + Terminated = "terminated" +} diff --git a/frontend/src/hooks/api/pam/index.ts b/frontend/src/hooks/api/pam/index.ts new file mode 100644 index 000000000..dda90a234 --- /dev/null +++ b/frontend/src/hooks/api/pam/index.ts @@ -0,0 +1,5 @@ +export * from "./enums"; +export * from "./maps"; +export * from "./mutations"; +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/pam/maps.ts b/frontend/src/hooks/api/pam/maps.ts new file mode 100644 index 000000000..c8dfb9d26 --- /dev/null +++ b/frontend/src/hooks/api/pam/maps.ts @@ -0,0 +1,8 @@ +import { PamResourceType } from "./enums"; + +export const PAM_RESOURCE_TYPE_MAP: Record< + PamResourceType, + { name: string; image: string; size?: number } +> = { + [PamResourceType.Postgres]: { name: "PostgreSQL", image: "Postgres.png" } +}; diff --git a/frontend/src/hooks/api/pam/mutations.tsx b/frontend/src/hooks/api/pam/mutations.tsx new file mode 100644 index 000000000..99a89b425 --- /dev/null +++ b/frontend/src/hooks/api/pam/mutations.tsx @@ -0,0 +1,169 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { pamKeys } from "./queries"; +import { + TCreatePamAccountDTO, + TCreatePamFolderDTO, + TCreatePamResourceDTO, + TDeletePamAccountDTO, + TDeletePamFolderDTO, + TDeletePamResourceDTO, + TPamAccount, + TPamFolder, + TPamResource, + TUpdatePamAccountDTO, + TUpdatePamFolderDTO, + TUpdatePamResourceDTO +} from "./types"; + +// Resources +export const useCreatePamResource = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ resourceType, ...params }: TCreatePamResourceDTO) => { + const { data } = await apiRequest.post<{ resource: TPamResource }>( + `/api/v1/pam/resources/${resourceType}`, + params + ); + + return data.resource; + }, + onSuccess: ({ projectId }) => { + queryClient.invalidateQueries({ queryKey: pamKeys.listResources(projectId) }); + } + }); +}; + +export const useUpdatePamResource = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ resourceId, resourceType, ...params }: TUpdatePamResourceDTO) => { + const { data } = await apiRequest.patch<{ resource: TPamResource }>( + `/api/v1/pam/resources/${resourceType}/${resourceId}`, + params + ); + + return data.resource; + }, + onSuccess: ({ projectId }) => { + queryClient.invalidateQueries({ queryKey: pamKeys.listResources(projectId) }); + } + }); +}; + +export const useDeletePamResource = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ resourceId, resourceType }: TDeletePamResourceDTO) => { + const { data } = await apiRequest.delete<{ resource: TPamResource }>( + `/api/v1/pam/resources/${resourceType}/${resourceId}` + ); + + return data.resource; + }, + onSuccess: ({ projectId }) => { + queryClient.invalidateQueries({ queryKey: pamKeys.listResources(projectId) }); + } + }); +}; + +// Accounts +export const useCreatePamAccount = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ resourceType, ...params }: TCreatePamAccountDTO) => { + const { data } = await apiRequest.post<{ account: TPamAccount }>( + `/api/v1/pam/accounts/${resourceType}`, + params + ); + + return data.account; + }, + onSuccess: ({ projectId }) => { + queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) }); + } + }); +}; + +export const useUpdatePamAccount = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ resourceType, accountId, ...params }: TUpdatePamAccountDTO) => { + const { data } = await apiRequest.patch<{ account: TPamAccount }>( + `/api/v1/pam/accounts/${resourceType}/${accountId}`, + params + ); + + return data.account; + }, + onSuccess: ({ projectId }) => { + queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) }); + } + }); +}; + +export const useDeletePamAccount = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ resourceType, accountId }: TDeletePamAccountDTO) => { + const { data } = await apiRequest.delete<{ account: TPamAccount }>( + `/api/v1/pam/accounts/${resourceType}/${accountId}` + ); + + return data.account; + }, + onSuccess: ({ projectId }) => { + queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) }); + } + }); +}; + +// Folders +export const useCreatePamFolder = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (params: TCreatePamFolderDTO) => { + const { data } = await apiRequest.post<{ folder: TPamFolder }>("/api/v1/pam/folders", params); + + return data.folder; + }, + onSuccess: ({ projectId }) => { + queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) }); + } + }); +}; + +export const useUpdatePamFolder = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ folderId, ...params }: TUpdatePamFolderDTO) => { + const { data } = await apiRequest.patch<{ folder: TPamFolder }>( + `/api/v1/pam/folders/${folderId}`, + params + ); + + return data.folder; + }, + onSuccess: ({ projectId }) => { + queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) }); + } + }); +}; + +export const useDeletePamFolder = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ folderId }: TDeletePamFolderDTO) => { + const { data } = await apiRequest.delete<{ folder: TPamFolder }>( + `/api/v1/pam/folders/${folderId}` + ); + + return data.folder; + }, + onSuccess: ({ projectId }) => { + queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) }); + } + }); +}; diff --git a/frontend/src/hooks/api/pam/queries.tsx b/frontend/src/hooks/api/pam/queries.tsx new file mode 100644 index 000000000..288d65ab9 --- /dev/null +++ b/frontend/src/hooks/api/pam/queries.tsx @@ -0,0 +1,138 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TPamResourceOption } from "./types/resource-options"; +import { TPamAccount, TPamFolder, TPamResource, TPamSession } from "./types"; + +export const pamKeys = { + all: ["pam"] as const, + resource: () => [...pamKeys.all, "resource"] as const, + account: () => [...pamKeys.all, "account"] as const, + session: () => [...pamKeys.all, "session"] as const, + listResourceOptions: () => [...pamKeys.resource(), "options"] as const, + listResources: (projectId: string) => [...pamKeys.resource(), "list", projectId], + listAccounts: (projectId: string) => [...pamKeys.account(), "list", projectId], + getSession: (sessionId: string) => [...pamKeys.session(), "get", sessionId], + listSessions: (projectId: string) => [...pamKeys.session(), "list", projectId] +}; + +// Resources +export const useListPamResourceOptions = ( + options?: Omit< + UseQueryOptions< + TPamResourceOption[], + unknown, + TPamResourceOption[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: pamKeys.listResourceOptions(), + queryFn: async () => { + const { data } = await apiRequest.get<{ resourceOptions: TPamResourceOption[] }>( + "/api/v1/pam/resources/options" + ); + + return data.resourceOptions; + }, + ...options + }); +}; + +export const useListPamResources = ( + projectId: string, + options?: Omit< + UseQueryOptions< + TPamResource[], + unknown, + TPamResource[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: pamKeys.listResources(projectId), + queryFn: async () => { + const { data } = await apiRequest.get<{ resources: TPamResource[] }>( + "/api/v1/pam/resources", + { params: { projectId } } + ); + + return data.resources; + }, + ...options + }); +}; + +// Accounts +export const useListPamAccounts = ( + projectId: string, + options?: Omit< + UseQueryOptions< + { accounts: TPamAccount[]; folders: TPamFolder[] }, + unknown, + { accounts: TPamAccount[]; folders: TPamFolder[] }, + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: pamKeys.listAccounts(projectId), + queryFn: async () => { + const { data } = await apiRequest.get<{ accounts: TPamAccount[]; folders: TPamFolder[] }>( + "/api/v1/pam/accounts", + { params: { projectId } } + ); + + return data; + }, + ...options + }); +}; + +// Sessions +export const useGetPamSessionById = ( + sessionId: string, + options?: Omit< + UseQueryOptions>, + "queryKey" | "queryFn" | "enabled" + > +) => { + return useQuery({ + queryKey: pamKeys.getSession(sessionId), + queryFn: async () => { + const { data } = await apiRequest.get<{ session: TPamSession }>( + `/api/v1/pam/sessions/${sessionId}` + ); + + return data.session; + }, + enabled: !!sessionId, + ...options + }); +}; + +export const useListPamSessions = ( + projectId: string, + options?: Omit< + UseQueryOptions>, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: pamKeys.listSessions(projectId), + queryFn: async () => { + const { data } = await apiRequest.get<{ sessions: TPamSession[] }>("/api/v1/pam/sessions", { + params: { projectId } + }); + + return data.sessions; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/pam/types/base-account.ts b/frontend/src/hooks/api/pam/types/base-account.ts new file mode 100644 index 000000000..9f45b1a4a --- /dev/null +++ b/frontend/src/hooks/api/pam/types/base-account.ts @@ -0,0 +1,17 @@ +import { PamResourceType } from "../enums"; + +export interface TBasePamAccount { + id: string; + projectId: string; + folderId?: string | null; + resourceId: string; + resource: { + id: string; + name: string; + resourceType: PamResourceType; + }; + name: string; + description?: string | null; + createdAt: string; + updatedAt: string; +} diff --git a/frontend/src/hooks/api/pam/types/base-resource.ts b/frontend/src/hooks/api/pam/types/base-resource.ts new file mode 100644 index 000000000..763e564ad --- /dev/null +++ b/frontend/src/hooks/api/pam/types/base-resource.ts @@ -0,0 +1,8 @@ +export interface TBasePamResource { + id: string; + projectId: string; + name: string; + gatewayId: string; + createdAt: string; + updatedAt: string; +} diff --git a/frontend/src/hooks/api/pam/types/index.ts b/frontend/src/hooks/api/pam/types/index.ts new file mode 100644 index 000000000..1b4acf6d4 --- /dev/null +++ b/frontend/src/hooks/api/pam/types/index.ts @@ -0,0 +1,95 @@ +import { PamResourceType, PamSessionStatus } from "../enums"; +import { TPostgresAccount, TPostgresResource } from "./postgres-resource"; + +export * from "./postgres-resource"; + +export type TPamResource = TPostgresResource; + +export type TPamAccount = TPostgresAccount; + +export type TPamFolder = { + id: string; + projectId: string; + parentId?: string | null; + name: string; + description?: string | null; + createdAt: string; + updatedAt: string; +}; + +export type TPamSession = { + id: string; + projectId: string; + accountId?: string | null; + resourceType: PamResourceType; + resourceName: string; + accountName: string; + userId?: string | null; + actorName: string; + actorEmail: string; + actorIp: string; + actorUserAgent: string; + status: PamSessionStatus; + expiresAt?: string | null; + startedAt?: string | null; + endedAt?: string | null; + createdAt: string; + updatedAt: string; + commandLogs: { + input: string; + output: string; + timestamp: string; + }[]; +}; + +// Resource DTOs +export type TCreatePamResourceDTO = Pick< + TPamResource, + "name" | "connectionDetails" | "resourceType" | "gatewayId" | "projectId" +>; + +export type TUpdatePamResourceDTO = Partial< + Pick +> & { + resourceId: string; + resourceType: PamResourceType; +}; + +export type TDeletePamResourceDTO = { + resourceId: string; + resourceType: PamResourceType; +}; + +// Account DTOs +export type TCreatePamAccountDTO = Pick< + TPamAccount, + "name" | "description" | "credentials" | "projectId" | "resourceId" | "folderId" +> & { + resourceType: PamResourceType; +}; + +export type TUpdatePamAccountDTO = Partial< + Pick +> & { + accountId: string; + resourceType: PamResourceType; +}; + +export type TDeletePamAccountDTO = { + accountId: string; + resourceType: PamResourceType; +}; + +// Folder DTOs +export type TCreatePamFolderDTO = Pick< + TPamFolder, + "name" | "description" | "parentId" | "projectId" +>; + +export type TUpdatePamFolderDTO = Partial> & { + folderId: string; +}; + +export type TDeletePamFolderDTO = { + folderId: string; +}; diff --git a/frontend/src/hooks/api/pam/types/postgres-resource.ts b/frontend/src/hooks/api/pam/types/postgres-resource.ts new file mode 100644 index 000000000..513610be1 --- /dev/null +++ b/frontend/src/hooks/api/pam/types/postgres-resource.ts @@ -0,0 +1,14 @@ +import { PamResourceType } from "../enums"; +import { TBaseSqlConnectionDetails, TBaseSqlCredentials } from "./shared/sql-resource"; +import { TBasePamAccount } from "./base-account"; +import { TBasePamResource } from "./base-resource"; + +// Resources +export type TPostgresResource = TBasePamResource & { resourceType: PamResourceType.Postgres } & { + connectionDetails: TBaseSqlConnectionDetails; +}; + +// Accounts +export type TPostgresAccount = TBasePamAccount & { + credentials: TBaseSqlCredentials; +}; diff --git a/frontend/src/hooks/api/pam/types/resource-options.ts b/frontend/src/hooks/api/pam/types/resource-options.ts new file mode 100644 index 000000000..4efb9e3b5 --- /dev/null +++ b/frontend/src/hooks/api/pam/types/resource-options.ts @@ -0,0 +1,11 @@ +import { PamResourceType } from "../enums"; + +export type TPamResourceOptionBase = { + name: string; +}; + +export type TPostgresResourceOption = TPamResourceOptionBase & { + resource: PamResourceType.Postgres; +}; + +export type TPamResourceOption = TPostgresResourceOption; diff --git a/frontend/src/hooks/api/pam/types/shared/sql-resource.ts b/frontend/src/hooks/api/pam/types/shared/sql-resource.ts new file mode 100644 index 000000000..1122fa5ed --- /dev/null +++ b/frontend/src/hooks/api/pam/types/shared/sql-resource.ts @@ -0,0 +1,12 @@ +export type TBaseSqlConnectionDetails = { + host: string; + port: number; + database: string; + sslEnabled: boolean; + sslRejectUnauthorized: boolean; +}; + +export type TBaseSqlCredentials = { + username: string; + password: string; +}; diff --git a/frontend/src/hooks/api/projects/types.ts b/frontend/src/hooks/api/projects/types.ts index 59b242d05..977afd179 100644 --- a/frontend/src/hooks/api/projects/types.ts +++ b/frontend/src/hooks/api/projects/types.ts @@ -13,7 +13,8 @@ export enum ProjectType { CertificateManager = "cert-manager", KMS = "kms", SSH = "ssh", - SecretScanning = "secret-scanning" + SecretScanning = "secret-scanning", + PAM = "pam" } export enum ProjectUserMembershipTemporaryMode { diff --git a/frontend/src/hooks/api/secrets/mutations.tsx b/frontend/src/hooks/api/secrets/mutations.tsx index de960cd04..5c1986b21 100644 --- a/frontend/src/hooks/api/secrets/mutations.tsx +++ b/frontend/src/hooks/api/secrets/mutations.tsx @@ -337,7 +337,7 @@ export const useMoveSecrets = ({ destinationSecretPath, secretIds, shouldOverwrite, - projectId + projectSlug }) => { const { data } = await apiRequest.post<{ isSourceUpdated: boolean; @@ -349,7 +349,7 @@ export const useMoveSecrets = ({ destinationSecretPath, secretIds, shouldOverwrite, - projectId + projectSlug }); return data; diff --git a/frontend/src/hooks/api/secrets/types.ts b/frontend/src/hooks/api/secrets/types.ts index 68a3852c0..6dc59762d 100644 --- a/frontend/src/hooks/api/secrets/types.ts +++ b/frontend/src/hooks/api/secrets/types.ts @@ -239,6 +239,7 @@ export type TDeleteSecretBatchDTO = { export type TMoveSecretsDTO = { projectId: string; + projectSlug: string; sourceEnvironment: string; sourceSecretPath: string; destinationEnvironment: string; diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index f4f3dfe34..ede2f8cf1 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -58,4 +58,5 @@ export type SubscriptionPlan = { cardDeclined?: boolean; cardDeclinedReason?: string; machineIdentityAuthTemplates: boolean; + pam: boolean; }; diff --git a/frontend/src/layouts/PamLayout/PamLayout.tsx b/frontend/src/layouts/PamLayout/PamLayout.tsx new file mode 100644 index 000000000..dd268cce6 --- /dev/null +++ b/frontend/src/layouts/PamLayout/PamLayout.tsx @@ -0,0 +1,195 @@ +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 { motion } from "framer-motion"; + +import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; +import { Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2"; +import { useProject, useProjectPermission, useSubscription } from "@app/context"; +import { usePopUp } from "@app/hooks"; + +import { AssumePrivilegeModeBanner } from "../ProjectLayout/components/AssumePrivilegeModeBanner"; + +export const PamLayout = () => { + const { currentProject } = useProject(); + const { subscription } = useSubscription(); + const { assumedPrivilegeDetails } = useProjectPermission(); + + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"]); + + useEffect(() => { + if (subscription && !subscription.pam) { + handlePopUpOpen("upgradePlan"); + } + }, [subscription]); + + return ( + <> +
+
+ +
+ + +
+ {assumedPrivilegeDetails && } + +
+
+
+ { + handlePopUpToggle("upgradePlan", isOpen); + }} + text="You can use PAM if you switch to a paid Infisical plan." + /> + + ); +}; diff --git a/frontend/src/layouts/PamLayout/index.tsx b/frontend/src/layouts/PamLayout/index.tsx new file mode 100644 index 000000000..eedf27b21 --- /dev/null +++ b/frontend/src/layouts/PamLayout/index.tsx @@ -0,0 +1 @@ +export { PamLayout } from "./PamLayout"; diff --git a/frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncTable/PkiSyncRow.tsx b/frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncTable/PkiSyncRow.tsx index bb96e72f0..491b95d25 100644 --- a/frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncTable/PkiSyncRow.tsx +++ b/frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncTable/PkiSyncRow.tsx @@ -164,7 +164,10 @@ export const PkiSyncRow = ({
{subscriberId ? ( - + ) : ( diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx index bb331bf3d..42b00f44a 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -40,6 +40,7 @@ import { OktaConnectionForm } from "./OktaConnectionForm"; import { OracleDBConnectionForm } from "./OracleDBConnectionForm"; import { PostgresConnectionForm } from "./PostgresConnectionForm"; import { RailwayConnectionForm } from "./RailwayConnectionForm"; +import { RedisConnectionForm } from "./RedisConnectionForm"; import { RenderConnectionForm } from "./RenderConnectionForm"; import { SupabaseConnectionForm } from "./SupabaseConnectionForm"; import { TeamCityConnectionForm } from "./TeamCityConnectionForm"; @@ -47,7 +48,6 @@ import { TerraformCloudConnectionForm } from "./TerraformCloudConnectionForm"; import { VercelConnectionForm } from "./VercelConnectionForm"; import { WindmillConnectionForm } from "./WindmillConnectionForm"; import { ZabbixConnectionForm } from "./ZabbixConnectionForm"; -import { RedisConnectionForm } from "./RedisConnectionForm"; type FormProps = { onComplete: (appConnection: TAppConnection) => void; diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/RedisConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/RedisConnectionForm.tsx index 602b9486c..0de9074bc 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/RedisConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/RedisConnectionForm.tsx @@ -1,9 +1,11 @@ import { useState } from "react"; import { Controller, FormProvider, useForm } from "react-hook-form"; +import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Tab } from "@headlessui/react"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; -import { Tab } from "@headlessui/react"; import { Button, FormControl, @@ -24,8 +26,6 @@ import { genericAppConnectionFieldsSchema, GenericAppConnectionsFields } from "./GenericAppConnectionFields"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; type Props = { appConnection?: TRedisConnection; @@ -123,175 +123,173 @@ export const RedisConnectionForm = ({ appConnection, onSubmit }: Props) => { )} /> - <> - - - - `w-30 -mb-[0.14rem] px-4 py-2 text-sm font-medium outline-none disabled:opacity-60 ${ - selected - ? "border-b-2 border-mineshaft-300 text-mineshaft-200" - : "text-bunker-300" - }` - } - > - Configuration - - - `w-30 -mb-[0.14rem] px-4 py-2 text-sm font-medium outline-none disabled:opacity-60 ${ - selected - ? "border-b-2 border-mineshaft-300 text-mineshaft-200" - : "text-bunker-300" - }` - } - > - SSL ({sslEnabled ? "Enabled" : "Disabled"}) - - - - -
- ( - - - - )} - /> - ( - - - - )} - /> -
-
- ( - - - - )} - /> - ( - - onChange(e.target.value)} - /> - - )} - /> -
-
- + + + + `w-30 -mb-[0.14rem] px-4 py-2 text-sm font-medium outline-none disabled:opacity-60 ${ + selected + ? "border-b-2 border-mineshaft-300 text-mineshaft-200" + : "text-bunker-300" + }` + } + > + Configuration + + + `w-30 -mb-[0.14rem] px-4 py-2 text-sm font-medium outline-none disabled:opacity-60 ${ + selected + ? "border-b-2 border-mineshaft-300 text-mineshaft-200" + : "text-bunker-300" + }` + } + > + SSL ({sslEnabled ? "Enabled" : "Disabled"}) + + + + +
( - - - Enable SSL - + render={({ field, fieldState: { error } }) => ( + + )} /> ( + + + + )} + /> +
+
+ ( -