diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index e30d55d8c..958a3f6f2 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -28,6 +28,9 @@ 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 { 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"; @@ -314,6 +317,9 @@ declare module "fastify" { identityAuthTemplate: TIdentityAuthTemplateServiceFactory; notification: TNotificationServiceFactory; offlineUsageReport: TOfflineUsageReportServiceFactory; + pamFolder: TPamFolderServiceFactory; + pamResource: TPamResourceServiceFactory; + pamSession: TPamSessionServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 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..874ed271c --- /dev/null +++ b/backend/src/db/migrations/20250917052037_pam.ts @@ -0,0 +1,125 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +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"); + 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.string("gatewayId").notNullable(); + 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).onDelete("CASCADE"); + t.index("resourceId"); + + t.string("name").notNullable(); + t.index("name"); + 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").nullable(); // null means unlimited duration / no expiry + + 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); + }); + } +} + +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); +} 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 1430a7e2e..09ecb367a 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -287,7 +287,8 @@ export enum ProjectType { CertificateManager = "cert-manager", KMS = "kms", SSH = "ssh", - SecretScanning = "secret-scanning" + SecretScanning = "secret-scanning", + PAM = "pam" } export enum ActionProjectType { @@ -296,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..bc3d45fcb --- /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(), + 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..a35f01eaf --- /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().nullable().optional(), + 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..5623995c7 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -23,6 +23,11 @@ import { registerLdapRouter } from "./ldap-router"; import { registerLicenseRouter } from "./license-router"; import { registerOidcRouter } from "./oidc-router"; import { registerOrgRoleRouter } from "./org-role-router"; +import { registerPamAccountRouter } from "./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 +171,22 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { }, { prefix: "/kmip" } ); + + await server.register(registerPamFolderRouter, { prefix: "/pam/folders" }); + await server.register(registerPamAccountRouter, { prefix: "/pam/accounts" }); + await server.register(registerPamSessionRouter, { prefix: "/pam/sessions" }); + + await server.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: "/pam/resources" } + ); }; diff --git a/backend/src/ee/routes/v1/pam-account-router.ts b/backend/src/ee/routes/v1/pam-account-router.ts new file mode 100644 index 000000000..6eb233f49 --- /dev/null +++ b/backend/src/ee/routes/v1/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.pamResource.listAccounts(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: "/:accountId/access", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Access PAM account", + params: z.object({ + accountId: z.string().uuid() + }), + body: z.object({ + duration: z + .string() + .nullable() + .optional() + .transform((val, ctx) => { + if (val === undefined) return undefined; + if (!val || val === "permanent") return null; + 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), + relayCertificate: z.string(), + gatewayCertificate: z.string(), + relayHost: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + 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.pamResource.accessAccount( + { + accountId: req.params.accountId, + 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.params.accountId, + 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..bdf6afcc4 --- /dev/null +++ b/backend/src/ee/routes/v1/pam-folder-router.ts @@ -0,0 +1,149 @@ +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: { + 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..fe6f18d67 --- /dev/null +++ b/backend/src/ee/routes/v1/pam-resource-routers/index.ts @@ -0,0 +1,26 @@ +import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums"; +import { + CreatePostgresAccountSchema, + CreatePostgresResourceSchema, + PostgresResourceSchema, + SanitizedPostgresAccountWithResourceSchema, + UpdatePostgresAccountSchema, + 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, + accountResponseSchema: SanitizedPostgresAccountWithResourceSchema, + createResourceSchema: CreatePostgresResourceSchema, + createAccountSchema: CreatePostgresAccountSchema, + updateResourceSchema: UpdatePostgresResourceSchema, + updateAccountSchema: UpdatePostgresAccountSchema + }); + } +}; 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..b35c6ddcc --- /dev/null +++ b/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-endpoints.ts @@ -0,0 +1,351 @@ +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, 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, + createAccountSchema, + updateAccountSchema, + resourceResponseSchema, + accountResponseSchema +}: { + server: FastifyZodProvider; + resourceType: PamResource; + createResourceSchema: z.ZodType<{ + projectId: T["projectId"]; + connectionDetails: T["connectionDetails"]; + gatewayId: T["gatewayId"]; + name: T["name"]; + }>; + createAccountSchema: z.ZodType<{ + credentials: C["credentials"]; + folderId?: C["folderId"]; + name: C["name"]; + description?: C["description"]; + }>; + updateResourceSchema: z.ZodType<{ + connectionDetails?: T["connectionDetails"]; + gatewayId?: T["gatewayId"]; + name?: T["name"]; + }>; + updateAccountSchema: z.ZodType<{ + credentials?: C["credentials"]; + name?: C["name"]; + description?: C["description"]; + }>; + resourceResponseSchema: z.ZodTypeAny; + accountResponseSchema: 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 }; + } + }); + + // PAM Accounts + server.route({ + method: "POST", + url: "/:resourceId/accounts", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Create PAM resource account", + params: z.object({ + resourceId: z.string().uuid() + }), + body: createAccountSchema, + response: { + 200: z.object({ + account: accountResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const account = await server.services.pamResource.createAccount( + { + ...req.body, + resourceId: req.params.resourceId + }, + 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.params.resourceId, + resourceType, + folderId: req.body.folderId, + name: req.body.name, + description: req.body.description + } + } + }); + + return { account }; + } + }); + + server.route({ + method: "PATCH", + url: "/:resourceId/accounts/:accountId", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Update PAM resource account", + params: z.object({ + resourceId: z.string().uuid(), + accountId: z.string().uuid() + }), + body: updateAccountSchema, + response: { + 200: z.object({ + account: accountResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const account = await server.services.pamResource.updateAccountById( + { + ...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: req.params.resourceId, + resourceType, + name: req.body.name, + description: req.body.description + } + } + }); + + return { account }; + } + }); + + server.route({ + method: "DELETE", + url: "/:resourceId/accounts/:accountId", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Delete PAM resource account", + params: z.object({ + resourceId: z.string().uuid(), + accountId: z.string().uuid() + }), + response: { + 200: z.object({ + account: accountResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const account = await server.services.pamResource.deleteAccountById(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, + resourceId: req.params.resourceId, + resourceType + } + } + }); + + return { account }; + } + }); +}; 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..286422911 --- /dev/null +++ b/backend/src/ee/routes/v1/pam-session-router.ts @@ -0,0 +1,221 @@ +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 } = await server.services.pamResource.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 + } + } + }); + + 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 + } + } + }); + + 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.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 + } + } + }); + + 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/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index ab72dbc65..05766aa7c 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,156 @@ interface OrgRoleDeleteEvent { }; } +interface PamSessionStartEvent { + type: EventType.PAM_SESSION_START; + metadata: { + sessionId: string; + }; +} + +interface PamSessionLogsUpdateEvent { + type: EventType.PAM_SESSION_LOGS_UPDATE; + metadata: { + sessionId: string; + }; +} + +interface PamSessionEndEvent { + type: EventType.PAM_SESSION_END; + metadata: { + sessionId: 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; + }; +} + +interface PamAccountListEvent { + type: EventType.PAM_ACCOUNT_LIST; + metadata: { + accountCount: number; + folderCount: number; + }; +} + +interface PamAccountAccessEvent { + type: EventType.PAM_ACCOUNT_ACCESS; + metadata: { + accountId: 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: { + 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 +4189,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-service.ts b/backend/src/ee/services/gateway-v2/gateway-v2-service.ts index e8ccc8a5e..3dcb2418e 100644 --- a/backend/src/ee/services/gateway-v2/gateway-v2-service.ts +++ b/backend/src/ee/services/gateway-v2/gateway-v2-service.ts @@ -268,11 +268,13 @@ export const gatewayV2ServiceFactory = ({ const getPlatformConnectionDetailsByGatewayId = async ({ gatewayId, targetHost, - targetPort + targetPort, + actorMetadata }: { gatewayId: string; targetHost: string; targetPort: number; + actorMetadata?: { sessionId?: string; resourceType?: string }; }) => { const gateway = await gatewayV2DAL.findById(gatewayId); if (!gateway) { @@ -359,7 +361,9 @@ export const gatewayV2ServiceFactory = ({ const actorExtension = new x509.Extension( GATEWAY_ACTOR_OID, false, - Buffer.from(JSON.stringify({ type: ActorType.PLATFORM })) + Buffer.from( + JSON.stringify(actorMetadata ? { type: ActorType.PLATFORM, ...actorMetadata } : { type: ActorType.PLATFORM }) + ) ); const clientCert = await x509.X509CertificateGenerator.create({ diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index cbe194f31..f4685a6dc 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -58,7 +58,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ enforceMfa: false, projectTemplates: false, kmip: false, - gateway: false, + gateway: true, sshHostGroups: false, secretScanning: false, enterpriseSecretSyncs: false, @@ -66,7 +66,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ enterpriseAppConnections: false, fips: false, eventSubscriptions: false, - machineIdentityAuthTemplates: false + machineIdentityAuthTemplates: false, + pam: true }); 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-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..0539e257b --- /dev/null +++ b/backend/src/ee/services/pam-folder/pam-folder-service.ts @@ -0,0 +1,151 @@ +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 { BadRequestError, 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}'` + }); + } + } + + const existingFolder = await pamFolderDAL.findOne({ + name, + parentId: parentId || null, + projectId + }); + + if (existingFolder) { + throw new BadRequestError({ + message: `Folder with name '${name}' already exists for this parent` + }); + } + + const folder = await pamFolderDAL.create({ + name, + description: description ?? null, + parentId: parentId || null, + projectId + }); + + return folder; + }; + + 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 (name && name !== folder.name) { + const existingFolder = await pamFolderDAL.findOne({ + name, + parentId: folder.parentId || null, + projectId: folder.projectId + }); + + if (existingFolder) { + throw new BadRequestError({ + message: `Folder with name '${name}' already exists for this parent` + }); + } + } + + if (Object.keys(updateDoc).length === 0) { + return folder; + } + + const updatedFolder = await pamFolderDAL.updateById(id, updateDoc); + + return updatedFolder; + }; + + 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-account-dal.ts b/backend/src/ee/services/pam-resource/pam-account-dal.ts new file mode 100644 index 000000000..b62e940fe --- /dev/null +++ b/backend/src/ee/services/pam-resource/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-resource/pam-resource-dal.ts b/backend/src/ee/services/pam-resource/pam-resource-dal.ts new file mode 100644 index 000000000..939e56f27 --- /dev/null +++ b/backend/src/ee/services/pam-resource/pam-resource-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 TPamResourceDALFactory = ReturnType; +export const pamResourceDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.PamResource); + return { ...orm }; +}; 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..182806598 --- /dev/null +++ b/backend/src/ee/services/pam-resource/pam-resource-fns.ts @@ -0,0 +1,126 @@ +import { TPamResources } from "@app/db/schemas"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; + +import { TPamAccountCredentials, 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 ({ + orgId, + connectionDetails, + kmsService +}: { + orgId: string; + connectionDetails: TPamResourceConnectionDetails; + kmsService: Pick; +}) => { + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId + }); + + const { cipherTextBlob: encryptedConnectionDetailsBlob } = encryptor({ + plainText: Buffer.from(JSON.stringify(connectionDetails)) + }); + + return encryptedConnectionDetailsBlob; +}; + +export const decryptResourceConnectionDetails = async ({ + orgId, + encryptedConnectionDetails, + kmsService +}: { + orgId: string; + encryptedConnectionDetails: Buffer; + kmsService: Pick; +}) => { + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId + }); + + const decryptedPlainTextBlob = decryptor({ + cipherTextBlob: encryptedConnectionDetails + }); + + return JSON.parse(decryptedPlainTextBlob.toString()) as TPamResourceConnectionDetails; +}; + +export const decryptResource = async ( + resource: TPamResources, + orgId: string, + kmsService: Pick +) => { + return { + ...resource, + connectionDetails: await decryptResourceConnectionDetails({ + encryptedConnectionDetails: resource.encryptedConnectionDetails, + orgId, + kmsService + }) + } as TPamResource; +}; + +// Account +export const encryptAccountCredentials = async ({ + orgId, + credentials, + kmsService +}: { + orgId: string; + credentials: TPamAccountCredentials; + kmsService: Pick; +}) => { + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId + }); + + const { cipherTextBlob: encryptedCredentialsBlob } = encryptor({ + plainText: Buffer.from(JSON.stringify(credentials)) + }); + + return encryptedCredentialsBlob; +}; + +export const decryptAccountCredentials = async ({ + orgId, + encryptedCredentials, + kmsService +}: { + orgId: string; + encryptedCredentials: Buffer; + kmsService: Pick; +}) => { + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId + }); + + const decryptedPlainTextBlob = decryptor({ + cipherTextBlob: encryptedCredentials + }); + + return JSON.parse(decryptedPlainTextBlob.toString()) as TPamAccountCredentials; +}; + +export const decryptAccount = async ( + account: T, + orgId: string, + kmsService: Pick +): Promise => { + return { + ...account, + credentials: await decryptAccountCredentials({ + encryptedCredentials: account.encryptedCredentials, + orgId, + kmsService + }) + } as T & { credentials: TPamAccountCredentials }; +}; 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..984853614 --- /dev/null +++ b/backend/src/ee/services/pam-resource/pam-resource-schemas.ts @@ -0,0 +1,45 @@ +import { z } from "zod"; + +import { PamAccountsSchema, PamResourcesSchema } from "@app/db/schemas"; +import { slugSchema } from "@app/server/lib/schemas"; + +// Resources +export const BasePamResoureSchema = 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({ + folderId: z.string().uuid().optional(), + name: slugSchema({ field: "name" }), + description: z.string().max(512).optional() +}); + +export const BaseUpdatePamAccountSchema = z.object({ + name: slugSchema({ field: "name" }).optional(), + description: z.string().max(512).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..a981a9955 --- /dev/null +++ b/backend/src/ee/services/pam-resource/pam-resource-service.ts @@ -0,0 +1,671 @@ +import { ForbiddenError, subject } from "@casl/ability"; + +import { ActionProjectType, TPamAccounts, TPamResources } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { + ProjectPermissionActions, + ProjectPermissionPamAccountActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +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 { TProjectDALFactory } from "@app/services/project/project-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 { 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 { TPamResourceDALFactory } from "./pam-resource-dal"; +import { PamResource } from "./pam-resource-enums"; +import { PAM_RESOURCE_FACTORY_MAP } from "./pam-resource-factory"; +import { + decryptAccount, + decryptAccountCredentials, + decryptResource, + decryptResourceConnectionDetails, + encryptAccountCredentials, + encryptResourceConnectionDetails, + listResourceOptions +} from "./pam-resource-fns"; +import { + TAccessAccountDTO, + TCreateAccountDTO, + TCreateResourceDTO, + TPamAccountCredentials, + TUpdateAccountDTO, + TUpdateResourceDTO +} from "./pam-resource-types"; + +type TPamResourceServiceFactoryDep = { + pamResourceDAL: TPamResourceDALFactory; + pamSessionDAL: TPamSessionDALFactory; + pamAccountDAL: TPamAccountDALFactory; + pamFolderDAL: TPamFolderDALFactory; + projectDAL: TProjectDALFactory; + permissionService: Pick; + licenseService: Pick; + kmsService: Pick; + gatewayV2Service: Pick; +}; + +export type TPamResourceServiceFactory = ReturnType; + +export const pamResourceServiceFactory = ({ + pamResourceDAL, + pamSessionDAL, + pamAccountDAL, + pamFolderDAL, + projectDAL, + 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, actor.orgId, 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, + orgId: actor.orgId, + kmsService + }); + + const resource = await pamResourceDAL.create({ + resourceType, + encryptedConnectionDetails, + gatewayId, + name, + projectId + }); + + return decryptResource(resource, actor.orgId, 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, + orgId: actor.orgId, + kmsService + }); + updateDoc.encryptedConnectionDetails = encryptedConnectionDetails; + } + + // If nothing was updated, return the fetched resource + if (Object.keys(updateDoc).length === 0) { + return decryptResource(resource, actor.orgId, kmsService); + } + + const updatedResource = await pamResourceDAL.updateById(resourceId, updateDoc); + + return decryptResource(updatedResource, actor.orgId, 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); + + const deletedResource = await pamResourceDAL.deleteById(id); + + return decryptResource(deletedResource, actor.orgId, 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(ProjectPermissionActions.Read, ProjectPermissionSub.PamResources); + + const resources = await pamResourceDAL.find({ projectId }); + + return { + resources: await Promise.all(resources.map((resource) => decryptResource(resource, actor.orgId, kmsService))) + }; + }; + + // Accounts + const createAccount = 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({ + orgId: actor.orgId, + 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, + orgId: actor.orgId, + kmsService + }); + + const account = await pamAccountDAL.create({ + projectId: resource.projectId, + resourceId: resource.id, + encryptedCredentials, + name, + description, + folderId + }); + + return { + ...(await decryptAccount(account, actor.orgId, kmsService)), + resource: { id: resource.id, name: resource.name, resourceType: resource.resourceType } + }; + }; + + const updateAccountById = 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({ + orgId: actor.orgId, + 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, + orgId: actor.orgId, + kmsService + }); + + finalCredentials.password = decryptedCredentials.password; + } + + const validatedCredentials = await factory.validateAccountCredentials(finalCredentials); + const encryptedCredentials = await encryptAccountCredentials({ + credentials: validatedCredentials, + orgId: actor.orgId, + kmsService + }); + updateDoc.encryptedCredentials = encryptedCredentials; + } + + // If nothing was updated, return the fetched account + if (Object.keys(updateDoc).length === 0) { + return decryptAccount(account, actor.orgId, kmsService); + } + + const updatedAccount = await pamAccountDAL.updateById(accountId, updateDoc); + + return { + ...(await decryptAccount(updatedAccount, actor.orgId, kmsService)), + resource: { id: resource.id, name: resource.name, resourceType: resource.resourceType } + }; + }; + + const deleteAccountById = 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, actor.orgId, kmsService)), + resource: { id: resource.id, name: resource.name, resourceType: resource.resourceType } + }; + }; + + const listAccounts = 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, actor.orgId, kmsService); + decryptedAndPermittedAccounts.push({ + ...decryptedAccount, + resource: { + id: account.resource.id, + name: account.resource.name, + resourceType: account.resource.resourceType + } + }); + } + } + + return { + accounts: decryptedAndPermittedAccounts, + folders + }; + }; + + const accessAccount = 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: duration ? new Date(Date.now() + duration) : null + }); + + const { connectionDetails, gatewayId, resourceType } = await decryptResource(resource, actor.orgId, kmsService); + + const gatewayConnectionDetails = await gatewayV2Service.getPlatformConnectionDetailsByGatewayId({ + gatewayId, + targetHost: connectionDetails.host, + targetPort: connectionDetails.port, + actorMetadata: { + sessionId: session.id, + resourceType: resource.resourceType + } + }); + + if (!gatewayConnectionDetails) { + throw new NotFoundError({ message: `Gateway connection details for gateway '${gatewayId}' not found.` }); + } + + return { + sessionId: session.id, + resourceType, + relayCertificate: gatewayConnectionDetails.relay.clientCertificate, + gatewayCertificate: gatewayConnectionDetails.gateway.clientCertificate, + relayHost: gatewayConnectionDetails.relayHost, + projectId: account.projectId + }; + }; + + 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` }); + + const decryptedAccount = await decryptAccount(account, actor.orgId, kmsService); + + const decryptedResource = await decryptResource(resource, actor.orgId, kmsService); + + // Mark session as started + await pamSessionDAL.updateById(sessionId, { + status: PamSessionStatus.Active, + startedAt: new Date() + }); + + return { + credentials: { + ...decryptedResource.connectionDetails, + ...decryptedAccount.credentials + }, + projectId: project.id + }; + }; + + return { + getById, + create, + updateById, + deleteById, + list, + listResourceOptions, + createAccount, + updateAccountById, + deleteAccountById, + listAccounts, + accessAccount, + getSessionCredentials + }; +}; 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..3a5f73dd6 --- /dev/null +++ b/backend/src/ee/services/pam-resource/pam-resource-types.ts @@ -0,0 +1,58 @@ +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; +}; + +// Account 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 | null; +}; + +// 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..520443248 --- /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, + BasePamResoureSchema, + BaseUpdatePamAccountSchema, + BaseUpdatePamResourceSchema +} from "../pam-resource-schemas"; +import { + BaseSqlAccountCredentialsSchema, + BaseSqlResourceConnectionDetailsSchema +} from "../shared/sql/sql-resource-schemas"; + +// Resources +export const PostgresResourceConnectionDetailsSchema = BaseSqlResourceConnectionDetailsSchema; + +const BasePostgresResourceSchema = BasePamResoureSchema.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..6957b1fde --- /dev/null +++ b/backend/src/ee/services/pam-session/pam-session-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 TPamSessionDALFactory = ReturnType; +export const pamSessionDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.PamSession); + return { ...orm }; +}; 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..7b093ef87 --- /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 ({ + orgId, + encryptedLogs, + kmsService +}: { + orgId: string; + encryptedLogs: Buffer; + kmsService: Pick; +}) => { + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId + }); + + const decryptedPlainTextBlob = decryptor({ + cipherTextBlob: encryptedLogs + }); + + return JSON.parse(decryptedPlainTextBlob.toString()) as TPamSessionCommandLog; +}; + +export const decryptSession = async ( + session: TPamSessions, + orgId: string, + kmsService: Pick +) => { + return { + ...session, + commandLogs: session.encryptedLogsBlob + ? await decryptSessionCommandLogs({ + orgId, + 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..041171cfc --- /dev/null +++ b/backend/src/ee/services/pam-session/pam-session-service.ts @@ -0,0 +1,168 @@ +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, actor.orgId, 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, actor.orgId, 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` }); + + 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 + ); + + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: project.orgId + }); + + 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) => { + // 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.status !== PamSessionStatus.Active) { + throw new BadRequestError({ message: "Cannot end sessions that are not active" }); + } + + 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/server/routes/index.ts b/backend/src/server/routes/index.ts index 2fb7e9ff7..e62f0a947 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -66,6 +66,13 @@ 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 { pamFolderDALFactory } from "@app/ee/services/pam-folder/pam-folder-dal"; +import { pamFolderServiceFactory } from "@app/ee/services/pam-folder/pam-folder-service"; +import { pamAccountDALFactory } from "@app/ee/services/pam-resource/pam-account-dal"; +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"; @@ -2099,6 +2106,37 @@ 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, + pamSessionDAL, + pamAccountDAL, + pamFolderDAL, + projectDAL, + permissionService, + licenseService, + kmsService, + gatewayV2Service + }); + + const pamSessionService = pamSessionServiceFactory({ + pamSessionDAL, + projectDAL, + permissionService, + licenseService, + kmsService + }); + // setup the communication with license key server await licenseService.init(); @@ -2236,7 +2274,10 @@ export const registerRoutes = async ( reminder: reminderService, bus: eventBusService, sse: sseService, - notification: notificationService + notification: notificationService, + pamFolder: pamFolderService, + pamResource: pamResourceService, + pamSession: pamSessionService }); const cronJobs: CronJob[] = []; diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index 8c8f01af7..f502821d2 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -213,6 +213,8 @@ export const listAppConnectionOptions = (projectType?: ProjectType) => { return false; case ProjectType.SSH: return false; + case ProjectType.PAM: + return false; default: return true; } diff --git a/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts b/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts index 521092497..0dea986d6 100644 --- a/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts @@ -24,8 +24,8 @@ import { import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal"; import { TPkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal"; -import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils"; import { TPkiSyncQueueFactory } from "@app/services/pki-sync/pki-sync-queue"; +import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; diff --git a/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-fns.ts b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-fns.ts index 69feed40e..4937a5331 100644 --- a/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-fns.ts @@ -27,8 +27,8 @@ import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal"; import { TPkiSubscriberProperties } from "@app/services/pki-subscriber/pki-subscriber-types"; import { TPkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal"; -import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils"; import { TPkiSyncQueueFactory } from "@app/services/pki-sync/pki-sync-queue"; +import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts index f1dc372b1..6f730fd72 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts @@ -20,8 +20,8 @@ import { } from "@app/services/certificate/certificate-types"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TPkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal"; -import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils"; import { TPkiSyncQueueFactory } from "@app/services/pki-sync/pki-sync-queue"; +import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; diff --git a/backend/src/services/pki-subscriber/pki-subscriber-service.ts b/backend/src/services/pki-subscriber/pki-subscriber-service.ts index 4e00acec6..ebb1ef599 100644 --- a/backend/src/services/pki-subscriber/pki-subscriber-service.ts +++ b/backend/src/services/pki-subscriber/pki-subscriber-service.ts @@ -38,8 +38,8 @@ import { TCertificateAuthoritySecretDALFactory } from "@app/services/certificate import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal"; import { TPkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal"; -import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils"; import { TPkiSyncQueueFactory } from "@app/services/pki-sync/pki-sync-queue"; +import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; diff --git a/frontend/src/components/projects/NewProjectModal.tsx b/frontend/src/components/projects/NewProjectModal.tsx index dcb11d873..5dd2ea4ce 100644 --- a/frontend/src/components/projects/NewProjectModal.tsx +++ b/frontend/src/components/projects/NewProjectModal.tsx @@ -80,6 +80,10 @@ const PROJECT_TYPE_MENU_ITEMS = [ { label: "Secret Scanning", value: ProjectType.SecretScanning + }, + { + label: "PAM", + value: ProjectType.PAM } ]; @@ -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/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..63c1d3c4d --- /dev/null +++ b/frontend/src/hooks/api/pam/mutations.tsx @@ -0,0 +1,174 @@ +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 ({ resourceId, resourceType, ...params }: TCreatePamAccountDTO) => { + const { data } = await apiRequest.post<{ account: TPamAccount }>( + `/api/v1/pam/resources/${resourceType}/${resourceId}/accounts`, + params + ); + + return data.account; + }, + onSuccess: ({ projectId }) => { + queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) }); + } + }); +}; + +export const useUpdatePamAccount = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + resourceId, + resourceType, + accountId, + ...params + }: TUpdatePamAccountDTO) => { + const { data } = await apiRequest.patch<{ account: TPamAccount }>( + `/api/v1/pam/resources/${resourceType}/${resourceId}/accounts/${accountId}`, + params + ); + + return data.account; + }, + onSuccess: ({ projectId }) => { + queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) }); + } + }); +}; + +export const useDeletePamAccount = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ resourceId, resourceType, accountId }: TDeletePamAccountDTO) => { + const { data } = await apiRequest.delete<{ account: TPamAccount }>( + `/api/v1/pam/resources/${resourceType}/${resourceId}/accounts/${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..b8f3cac31 --- /dev/null +++ b/frontend/src/hooks/api/pam/types/index.ts @@ -0,0 +1,97 @@ +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; + resourceId: string; + resourceType: PamResourceType; +}; + +export type TDeletePamAccountDTO = { + accountId: string; + resourceId: 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/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..acfef3b81 --- /dev/null +++ b/frontend/src/layouts/PamLayout/PamLayout.tsx @@ -0,0 +1,195 @@ +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 { Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2"; +import { useProject, useProjectPermission, useSubscription } from "@app/context"; + +import { AssumePrivilegeModeBanner } from "../ProjectLayout/components/AssumePrivilegeModeBanner"; +import { useEffect } from "react"; +import { usePopUp } from "@app/hooks"; +import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; + +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/AuditLogsPage/components/LogsFilter.tsx b/frontend/src/pages/organization/AuditLogsPage/components/LogsFilter.tsx index 40b2f973f..ff90b4292 100644 --- a/frontend/src/pages/organization/AuditLogsPage/components/LogsFilter.tsx +++ b/frontend/src/pages/organization/AuditLogsPage/components/LogsFilter.tsx @@ -24,12 +24,13 @@ import { useOrganization } from "@app/context"; import { useGetUserProjects } from "@app/hooks/api"; import { eventToNameMap, + projectToEventsMap, secretEvents, userAgentTypeToNameMap } from "@app/hooks/api/auditLogs/constants"; import { EventType } from "@app/hooks/api/auditLogs/enums"; import { UserAgentType } from "@app/hooks/api/auth/types"; -import { Project } from "@app/hooks/api/projects/types"; +import { Project, ProjectType } from "@app/hooks/api/projects/types"; import { LogFilterItem } from "./LogFilterItem"; import { auditLogFilterFormSchema, Presets, TAuditLogFilterFormData } from "./types"; @@ -94,10 +95,20 @@ export const LogsFilter = ({ presets, setFilter, filter, project }: Props) => { const selectedEventTypes = watch("eventType") as EventType[] | undefined; const selectedProject = project ?? watch("project"); + const currentSelectedEventTypes = selectedEventTypes ?? []; + const hasSecretEventFilter = currentSelectedEventTypes.some((eventType) => + secretEvents.includes(eventType) + ); const showSecretsSection = - selectedEventTypes?.some( - (eventType) => secretEvents.includes(eventType) && eventType !== EventType.GET_SECRETS - ) || selectedEventTypes?.length === 0; + project?.type !== ProjectType.PAM && + (hasSecretEventFilter || currentSelectedEventTypes.length === 0); + + const filteredEventTypes = useMemo(() => { + const projectEvents = project?.type ? projectToEventsMap[project.type] : undefined; + if (!projectEvents) return eventTypes; + + return eventTypes.filter((v) => projectEvents.includes(v.value as EventType)); + }, [project]); const availableEnvironments = useMemo(() => { if (!selectedProject) return []; @@ -166,7 +177,7 @@ export const LogsFilter = ({ presets, setFilter, filter, project }: Props) => {
{selectedEventTypes?.length === 1 - ? eventTypes.find( + ? filteredEventTypes.find( (eventType) => eventType.value === selectedEventTypes[0] )?.label : selectedEventTypes?.length === 0 @@ -181,8 +192,8 @@ export const LogsFilter = ({ presets, setFilter, filter, project }: Props) => { className="thin-scrollbar z-[100] max-h-80 overflow-hidden" >
- {eventTypes && eventTypes.length > 0 ? ( - eventTypes.map((eventType) => { + {filteredEventTypes.length > 0 ? ( + filteredEventTypes.map((eventType) => { const isSelected = selectedEventTypes?.includes( eventType.value as EventType ); @@ -190,7 +201,7 @@ export const LogsFilter = ({ presets, setFilter, filter, project }: Props) => { return ( - eventTypes.length > 1 && event.preventDefault() + filteredEventTypes.length > 1 && event.preventDefault() } onClick={() => { if ( diff --git a/frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/ProjectTemplateDetailsModal.tsx b/frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/ProjectTemplateDetailsModal.tsx index ad4f2776a..d3cdce7f9 100644 --- a/frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/ProjectTemplateDetailsModal.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/ProjectTemplateDetailsModal.tsx @@ -63,6 +63,10 @@ const PROJECT_TYPE_MENU_ITEMS = [ { label: "Secret Scanning", value: ProjectType.SecretScanning + }, + { + label: "PAM", + value: ProjectType.PAM } ]; @@ -131,12 +135,12 @@ const ProjectTemplateForm = ({ onComplete, projectTemplate }: FormProps) => { errorText={error?.message} className="flex-1" > -
+
{PROJECT_TYPE_MENU_ITEMS.map((el) => (
field.onChange(el.value)} diff --git a/frontend/src/pages/pam/PamAccountsPage/PamAccountsPage.tsx b/frontend/src/pages/pam/PamAccountsPage/PamAccountsPage.tsx new file mode 100644 index 000000000..179d267c4 --- /dev/null +++ b/frontend/src/pages/pam/PamAccountsPage/PamAccountsPage.tsx @@ -0,0 +1,34 @@ +import { Helmet } from "react-helmet"; +import { useTranslation } from "react-i18next"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { PageHeader } from "@app/components/v2"; +import { ProjectPermissionSub } from "@app/context"; +import { ProjectPermissionPamAccountActions } from "@app/context/ProjectPermissionContext/types"; + +import { PamAccountsSection } from "./components/PamAccountsSection"; + +export const PamAccountsPage = () => { + const { t } = useTranslation(); + return ( + <> + + {t("common.head-title", { title: "PAM" })} + + +
+
+
+ + +
+
+
+
+ + ); +}; diff --git a/frontend/src/pages/pam/PamAccountsPage/components/AccountViewToggle.tsx b/frontend/src/pages/pam/PamAccountsPage/components/AccountViewToggle.tsx new file mode 100644 index 000000000..92b2f7859 --- /dev/null +++ b/frontend/src/pages/pam/PamAccountsPage/components/AccountViewToggle.tsx @@ -0,0 +1,42 @@ +import { Button } from "@app/components/v2"; + +export enum AccountView { + Flat = "flat", + Nested = "nested" +} + +type Props = { + value: AccountView; + onChange: (value: AccountView) => void; +}; + +export const AccountViewToggle = ({ value, onChange }: Props) => { + return ( +
+ + +
+ ); +}; diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx new file mode 100644 index 000000000..60aea936d --- /dev/null +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx @@ -0,0 +1,89 @@ +import { useMemo, useState } from "react"; +import { faUpRightFromSquare } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import ms from "ms"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormLabel, Input, Modal, ModalClose, ModalContent } from "@app/components/v2"; +import { TPamAccount } from "@app/hooks/api/pam"; + +type Props = { + account?: TPamAccount; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +export const PamAccessAccountModal = ({ isOpen, onOpenChange, account }: Props) => { + const [duration, setDuration] = useState("4h"); + + const isDurationValid = useMemo(() => ms(duration || "1s") > 0, [duration]); + + const command = useMemo( + () => + account + ? `infisical pam access ${account.id}${duration ? ` --duration ${duration}` : ""}` + : "", + [account, duration] + ); + + if (!account) return null; + + const copyCommand = () => { + navigator.clipboard.writeText(command); + + createNotification({ + text: "Command copied to clipboard", + type: "info" + }); + + onOpenChange(false); + }; + + return ( + + + + setDuration(e.target.value)} + placeholder="permanent" + isError={!isDurationValid} + /> + + + + Install the Infisical CLI + + +
+ + + + +
+
+
+ ); +}; diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/GenericAccountFields.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/GenericAccountFields.tsx new file mode 100644 index 000000000..c20800b9e --- /dev/null +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/GenericAccountFields.tsx @@ -0,0 +1,49 @@ +import { Controller, useFormContext } from "react-hook-form"; +import { z } from "zod"; + +import { FormControl, Input, TextArea } from "@app/components/v2"; +import { slugSchema } from "@app/lib/schemas"; + +export const genericAccountFieldsSchema = z.object({ + name: slugSchema({ min: 1, max: 64, field: "Name" }), + description: z.string().max(512).nullable().optional() +}); + +export const GenericAccountFields = () => { + const { + formState: { errors }, + control + } = useFormContext<{ name: string; description: string }>(); + + return ( + <> + ( + + + + )} + /> + ( + +