diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 26da53898..088d99ee9 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -28,6 +28,7 @@ import { TKmipServiceFactory } from "@app/ee/services/kmip/kmip-service"; import { TLdapConfigServiceFactory } from "@app/ee/services/ldap-config/ldap-config-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TOidcConfigServiceFactory } from "@app/ee/services/oidc/oidc-config-service"; +import { TPamAccountServiceFactory } from "@app/ee/services/pam-account/pam-account-service"; import { TPamFolderServiceFactory } from "@app/ee/services/pam-folder/pam-folder-service"; import { TPamResourceServiceFactory } from "@app/ee/services/pam-resource/pam-resource-service"; import { TPamSessionServiceFactory } from "@app/ee/services/pam-session/pam-session-service"; @@ -320,6 +321,7 @@ declare module "fastify" { offlineUsageReport: TOfflineUsageReportServiceFactory; pamFolder: TPamFolderServiceFactory; pamResource: TPamResourceServiceFactory; + pamAccount: TPamAccountServiceFactory; pamSession: TPamSessionServiceFactory; upgradePath: TUpgradePathService; }; diff --git a/backend/src/db/migrations/20250917052037_pam.ts b/backend/src/db/migrations/20250917052037_pam.ts index 874ed271c..031b407db 100644 --- a/backend/src/db/migrations/20250917052037_pam.ts +++ b/backend/src/db/migrations/20250917052037_pam.ts @@ -1,6 +1,7 @@ import { Knex } from "knex"; import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; export async function up(knex: Knex): Promise { // PAM Folders @@ -18,6 +19,19 @@ export async function up(knex: Knex): Promise { t.string("name").notNullable(); t.index("name"); + + // Enforce uniqueness for sub-folders + t.unique(["projectId", "parentId", "name"], { + indexName: "uidx_pam_folder_children_name", + predicate: knex.whereNotNull("parentId") + }); + + // Enforce uniqueness for root-level folders + t.unique(["projectId", "name"], { + indexName: "uidx_pam_folder_root_name", + predicate: knex.whereNull("parentId") + }); + t.text("description").nullable(); t.timestamps(true, true, true); @@ -35,10 +49,14 @@ export async function up(knex: Knex): Promise { t.string("name").notNullable(); t.index("name"); - t.string("gatewayId").notNullable(); + + t.uuid("gatewayId").notNullable(); + t.foreign("gatewayId").references("id").inTable(TableName.GatewayV2); t.index("gatewayId"); + t.string("resourceType").notNullable(); t.index("resourceType"); + t.binary("encryptedConnectionDetails").notNullable(); t.timestamps(true, true, true); @@ -59,13 +77,25 @@ export async function up(knex: Knex): Promise { t.index("folderId"); t.uuid("resourceId").notNullable(); - t.foreign("resourceId").references("id").inTable(TableName.PamResource).onDelete("CASCADE"); + t.foreign("resourceId").references("id").inTable(TableName.PamResource); t.index("resourceId"); t.string("name").notNullable(); t.index("name"); - t.text("description").nullable(); + // Enforce uniqueness for folders + t.unique(["projectId", "folderId", "name"], { + indexName: "uidx_pam_account_children_name", + predicate: knex.whereNotNull("folderId") + }); + + // Enforce uniqueness for root-level + t.unique(["projectId", "name"], { + indexName: "uidx_pam_account_root_name", + predicate: knex.whereNull("folderId") + }); + + t.text("description").nullable(); t.binary("encryptedCredentials").notNullable(); t.timestamps(true, true, true); @@ -106,7 +136,7 @@ export async function up(knex: Knex): Promise { t.binary("encryptedLogsBlob").nullable(); - t.datetime("expiresAt").nullable(); // null means unlimited duration / no expiry + t.datetime("expiresAt").notNullable(); t.datetime("startedAt").nullable(); // Not when the row is created, but when the end-to-end connection between user and resource is established t.datetime("endedAt").nullable(); @@ -115,6 +145,11 @@ export async function up(knex: Knex): Promise { t.timestamps(true, true, true); }); } + + await createOnUpdateTrigger(knex, TableName.PamFolder); + await createOnUpdateTrigger(knex, TableName.PamResource); + await createOnUpdateTrigger(knex, TableName.PamAccount); + await createOnUpdateTrigger(knex, TableName.PamSession); } export async function down(knex: Knex): Promise { @@ -122,4 +157,9 @@ export async function down(knex: Knex): Promise { await knex.schema.dropTableIfExists(TableName.PamAccount); await knex.schema.dropTableIfExists(TableName.PamResource); await knex.schema.dropTableIfExists(TableName.PamFolder); + + await dropOnUpdateTrigger(knex, TableName.PamSession); + await dropOnUpdateTrigger(knex, TableName.PamAccount); + await dropOnUpdateTrigger(knex, TableName.PamResource); + await dropOnUpdateTrigger(knex, TableName.PamFolder); } diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 5623995c7..42392ba55 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -23,7 +23,8 @@ 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 { PAM_ACCOUNT_REGISTER_ROUTER_MAP } from "./pam-account-routers"; +import { registerPamAccountRouter } from "./pam-account-routers/pam-account-router"; import { registerPamFolderRouter } from "./pam-folder-router"; import { PAM_RESOURCE_REGISTER_ROUTER_MAP } from "./pam-resource-routers"; import { registerPamResourceRouter } from "./pam-resource-routers/pam-resource-router"; @@ -172,21 +173,39 @@ 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); + async (pamRouter) => { + await pamRouter.register(registerPamFolderRouter, { prefix: "/folders" }); + await pamRouter.register(registerPamSessionRouter, { prefix: "/sessions" }); - // Provider-specific endpoints - await Promise.all( - Object.entries(PAM_RESOURCE_REGISTER_ROUTER_MAP).map(([provider, router]) => - pamResourceRouter.register(router, { prefix: `/${provider}` }) - ) + await pamRouter.register( + async (pamAccountRouter) => { + await pamAccountRouter.register(registerPamAccountRouter); + + // Provider-specific endpoints + await Promise.all( + Object.entries(PAM_ACCOUNT_REGISTER_ROUTER_MAP).map(([provider, router]) => + pamAccountRouter.register(router, { prefix: `/${provider}` }) + ) + ); + }, + { prefix: "/accounts" } + ); + + await pamRouter.register( + async (pamResourceRouter) => { + await pamResourceRouter.register(registerPamResourceRouter); + + // Provider-specific endpoints + await Promise.all( + Object.entries(PAM_RESOURCE_REGISTER_ROUTER_MAP).map(([provider, router]) => + pamResourceRouter.register(router, { prefix: `/${provider}` }) + ) + ); + }, + { prefix: "/resources" } ); }, - { prefix: "/pam/resources" } + { prefix: "/pam" } ); }; diff --git a/backend/src/ee/routes/v1/pam-account-routers/index.ts b/backend/src/ee/routes/v1/pam-account-routers/index.ts new file mode 100644 index 000000000..568412c84 --- /dev/null +++ b/backend/src/ee/routes/v1/pam-account-routers/index.ts @@ -0,0 +1,20 @@ +import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums"; +import { + CreatePostgresAccountSchema, + SanitizedPostgresAccountWithResourceSchema, + UpdatePostgresAccountSchema +} from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; + +import { registerPamResourceEndpoints } from "./pam-account-endpoints"; + +export const PAM_ACCOUNT_REGISTER_ROUTER_MAP: Record Promise> = { + [PamResource.Postgres]: async (server: FastifyZodProvider) => { + registerPamResourceEndpoints({ + server, + resourceType: PamResource.Postgres, + accountResponseSchema: SanitizedPostgresAccountWithResourceSchema, + createAccountSchema: CreatePostgresAccountSchema, + updateAccountSchema: UpdatePostgresAccountSchema + }); + } +}; diff --git a/backend/src/ee/routes/v1/pam-account-routers/pam-account-endpoints.ts b/backend/src/ee/routes/v1/pam-account-routers/pam-account-endpoints.ts new file mode 100644 index 000000000..0ed7e238a --- /dev/null +++ b/backend/src/ee/routes/v1/pam-account-routers/pam-account-endpoints.ts @@ -0,0 +1,159 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums"; +import { TPamAccount } from "@app/ee/services/pam-resource/pam-resource-types"; +import { writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerPamResourceEndpoints = ({ + server, + resourceType, + createAccountSchema, + updateAccountSchema, + accountResponseSchema +}: { + server: FastifyZodProvider; + resourceType: PamResource; + createAccountSchema: z.ZodType<{ + credentials: C["credentials"]; + resourceId: C["resourceId"]; + folderId?: C["folderId"]; + name: C["name"]; + description?: C["description"]; + }>; + updateAccountSchema: z.ZodType<{ + credentials?: C["credentials"]; + name?: C["name"]; + description?: C["description"]; + }>; + accountResponseSchema: z.ZodTypeAny; +}) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Create PAM account", + body: createAccountSchema, + response: { + 200: z.object({ + account: accountResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const account = await server.services.pamAccount.create(req.body, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: account.projectId, + event: { + type: EventType.PAM_ACCOUNT_CREATE, + metadata: { + resourceId: req.body.resourceId, + resourceType, + folderId: req.body.folderId, + name: req.body.name, + description: req.body.description + } + } + }); + + return { account }; + } + }); + + server.route({ + method: "PATCH", + url: "/:accountId", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Update PAM account", + params: z.object({ + accountId: z.string().uuid() + }), + body: updateAccountSchema, + response: { + 200: z.object({ + account: accountResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const account = await server.services.pamAccount.updateById( + { + ...req.body, + accountId: req.params.accountId + }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: account.projectId, + event: { + type: EventType.PAM_ACCOUNT_UPDATE, + metadata: { + accountId: req.params.accountId, + resourceId: account.resourceId, + resourceType, + name: req.body.name, + description: req.body.description + } + } + }); + + return { account }; + } + }); + + server.route({ + method: "DELETE", + url: "/:accountId", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Delete PAM account", + params: z.object({ + accountId: z.string().uuid() + }), + response: { + 200: z.object({ + account: accountResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const account = await server.services.pamAccount.deleteById(req.params.accountId, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: account.projectId, + event: { + type: EventType.PAM_ACCOUNT_DELETE, + metadata: { + accountId: req.params.accountId, + accountName: account.name, + resourceId: account.resourceId, + resourceType + } + } + }); + + return { account }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/pam-account-router.ts b/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts similarity index 89% rename from backend/src/ee/routes/v1/pam-account-router.ts rename to backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts index cc5cebf10..647f39d8d 100644 --- a/backend/src/ee/routes/v1/pam-account-router.ts +++ b/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts @@ -34,7 +34,7 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const response = await server.services.pamResource.listAccounts(req.query.projectId, req.permission); + const response = await server.services.pamAccount.list(req.query.projectId, req.permission); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, @@ -55,21 +55,18 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", - url: "/:accountId/access", + url: "/access", config: { rateLimit: writeLimit }, schema: { description: "Access PAM account", - params: z.object({ - accountId: z.string().uuid() - }), body: z.object({ + accountId: z.string().uuid(), duration: z .string() - .optional() + .min(1) .transform((val, ctx) => { - if (val === undefined) return undefined; const parsedMs = ms(val); if (typeof parsedMs !== "number" || parsedMs <= 0) { @@ -98,13 +95,13 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { + // To prevent type errors when accessing req.auth if (req.auth.authMode !== AuthMode.JWT) { throw new BadRequestError({ message: "You can only access PAM accounts using JWT auth tokens." }); } - const response = await server.services.pamResource.accessAccount( + const response = await server.services.pamAccount.access( { - accountId: req.params.accountId, actorEmail: req.auth.user.email ?? "", actorIp: req.realIp, actorName: `${req.auth.user.firstName ?? ""} ${req.auth.user.lastName ?? ""}`.trim(), @@ -121,7 +118,8 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => { event: { type: EventType.PAM_ACCOUNT_ACCESS, metadata: { - accountId: req.params.accountId, + accountId: req.body.accountId, + accountName: response.account.name, duration: req.body.duration ? new Date(req.body.duration).toISOString() : undefined } } diff --git a/backend/src/ee/routes/v1/pam-folder-router.ts b/backend/src/ee/routes/v1/pam-folder-router.ts index bdf6afcc4..cd2506aba 100644 --- a/backend/src/ee/routes/v1/pam-folder-router.ts +++ b/backend/src/ee/routes/v1/pam-folder-router.ts @@ -138,6 +138,7 @@ export const registerPamFolderRouter = async (server: FastifyZodProvider) => { event: { type: EventType.PAM_FOLDER_DELETE, metadata: { + folderName: folder.name, folderId: req.params.folderId } } diff --git a/backend/src/ee/routes/v1/pam-resource-routers/index.ts b/backend/src/ee/routes/v1/pam-resource-routers/index.ts index fe6f18d67..a63b67d94 100644 --- a/backend/src/ee/routes/v1/pam-resource-routers/index.ts +++ b/backend/src/ee/routes/v1/pam-resource-routers/index.ts @@ -1,10 +1,7 @@ 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"; @@ -16,11 +13,8 @@ export const PAM_RESOURCE_REGISTER_ROUTER_MAP: Record({ +export const registerPamResourceEndpoints = ({ server, resourceType, createResourceSchema, updateResourceSchema, - createAccountSchema, - updateAccountSchema, - resourceResponseSchema, - accountResponseSchema + resourceResponseSchema }: { server: FastifyZodProvider; resourceType: PamResource; @@ -25,24 +22,12 @@ export const registerPamResourceEndpoints = ; - 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", @@ -210,142 +195,4 @@ export const registerPamResourceEndpoints = { - 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-session-router.ts b/backend/src/ee/routes/v1/pam-session-router.ts index 286422911..ac888b436 100644 --- a/backend/src/ee/routes/v1/pam-session-router.ts +++ b/backend/src/ee/routes/v1/pam-session-router.ts @@ -32,7 +32,7 @@ export const registerPamSessionRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const { credentials, projectId } = await server.services.pamResource.getSessionCredentials( + const { credentials, projectId, account } = await server.services.pamAccount.getSessionCredentials( req.params.sessionId, req.permission ); @@ -44,7 +44,8 @@ export const registerPamSessionRouter = async (server: FastifyZodProvider) => { event: { type: EventType.PAM_SESSION_START, metadata: { - sessionId: req.params.sessionId + sessionId: req.params.sessionId, + accountName: account.name } } }); @@ -93,7 +94,8 @@ export const registerPamSessionRouter = async (server: FastifyZodProvider) => { event: { type: EventType.PAM_SESSION_LOGS_UPDATE, metadata: { - sessionId: req.params.sessionId + sessionId: req.params.sessionId, + accountName: session.accountName } } }); 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 05766aa7c..6f46a4947 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -3710,6 +3710,7 @@ interface PamSessionStartEvent { type: EventType.PAM_SESSION_START; metadata: { sessionId: string; + accountName: string; }; } @@ -3717,6 +3718,7 @@ interface PamSessionLogsUpdateEvent { type: EventType.PAM_SESSION_LOGS_UPDATE; metadata: { sessionId: string; + accountName: string; }; } @@ -3763,6 +3765,7 @@ interface PamFolderDeleteEvent { type: EventType.PAM_FOLDER_DELETE; metadata: { folderId: string; + folderName: string; }; } @@ -3778,6 +3781,7 @@ interface PamAccountAccessEvent { type: EventType.PAM_ACCOUNT_ACCESS; metadata: { accountId: string; + accountName: string; duration?: string; }; } @@ -3807,6 +3811,7 @@ interface PamAccountUpdateEvent { interface PamAccountDeleteEvent { type: EventType.PAM_ACCOUNT_DELETE; metadata: { + accountName: string; accountId: string; resourceId: string; resourceType: string; 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 53e011b96..4bf6b1aef 100644 --- a/backend/src/ee/services/gateway-v2/gateway-v2-service.ts +++ b/backend/src/ee/services/gateway-v2/gateway-v2-service.ts @@ -274,7 +274,6 @@ export const gatewayV2ServiceFactory = ({ gatewayId: string; targetHost: string; targetPort: number; - actorMetadata?: { sessionId?: string; resourceType?: string }; }) => { const gateway = await gatewayV2DAL.findById(gatewayId); if (!gateway) { @@ -817,7 +816,20 @@ export const gatewayV2ServiceFactory = ({ OrgPermissionSubjects.Gateway ); - return gatewayV2DAL.deleteById(gateway.id); + try { + return await gatewayV2DAL.deleteById(gateway.id); + } catch (err) { + if ( + err instanceof DatabaseError && + (err.error as { code: string })?.code === DatabaseErrorCode.ForeignKeyViolation + ) { + throw new BadRequestError({ + message: "Failed to delete gateway because it is attached to active resources" + }); + } + + throw err; + } }; const getPamSessionKey = async ({ orgPermission }: { orgPermission: OrgServiceActor }) => { diff --git a/backend/src/ee/services/pam-resource/pam-account-dal.ts b/backend/src/ee/services/pam-account/pam-account-dal.ts similarity index 100% rename from backend/src/ee/services/pam-resource/pam-account-dal.ts rename to backend/src/ee/services/pam-account/pam-account-dal.ts diff --git a/backend/src/ee/services/pam-account/pam-account-fns.ts b/backend/src/ee/services/pam-account/pam-account-fns.ts new file mode 100644 index 000000000..fdc440991 --- /dev/null +++ b/backend/src/ee/services/pam-account/pam-account-fns.ts @@ -0,0 +1,61 @@ +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; + +import { TPamAccountCredentials } from "../pam-resource/pam-resource-types"; + +export const encryptAccountCredentials = async ({ + projectId, + credentials, + kmsService +}: { + projectId: string; + credentials: TPamAccountCredentials; + kmsService: Pick; +}) => { + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + + const { cipherTextBlob: encryptedCredentialsBlob } = encryptor({ + plainText: Buffer.from(JSON.stringify(credentials)) + }); + + return encryptedCredentialsBlob; +}; + +export const decryptAccountCredentials = async ({ + projectId, + encryptedCredentials, + kmsService +}: { + projectId: string; + encryptedCredentials: Buffer; + kmsService: Pick; +}) => { + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + + const decryptedPlainTextBlob = decryptor({ + cipherTextBlob: encryptedCredentials + }); + + return JSON.parse(decryptedPlainTextBlob.toString()) as TPamAccountCredentials; +}; + +export const decryptAccount = async ( + account: T, + projectId: string, + kmsService: Pick +): Promise => { + return { + ...account, + credentials: await decryptAccountCredentials({ + encryptedCredentials: account.encryptedCredentials, + projectId, + kmsService + }) + } as T & { credentials: TPamAccountCredentials }; +}; diff --git a/backend/src/ee/services/pam-account/pam-account-service.ts b/backend/src/ee/services/pam-account/pam-account-service.ts new file mode 100644 index 000000000..2b2335281 --- /dev/null +++ b/backend/src/ee/services/pam-account/pam-account-service.ts @@ -0,0 +1,520 @@ +import { ForbiddenError, subject } from "@casl/ability"; + +import { ActionProjectType, TPamAccounts, TPamResources } from "@app/db/schemas"; +import { PAM_RESOURCE_FACTORY_MAP } from "@app/ee/services/pam-resource/pam-resource-factory"; +import { decryptResource, decryptResourceConnectionDetails } from "@app/ee/services/pam-resource/pam-resource-fns"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { + ProjectPermissionActions, + ProjectPermissionPamAccountActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { DatabaseErrorCode } from "@app/lib/error-codes"; +import { BadRequestError, DatabaseError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { OrgServiceActor } from "@app/lib/types"; +import { ActorType } from "@app/services/auth/auth-type"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TUserDALFactory } from "@app/services/user/user-dal"; + +import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service"; +import { TLicenseServiceFactory } from "../license/license-service"; +import { TPamFolderDALFactory } from "../pam-folder/pam-folder-dal"; +import { getFullPamFolderPath } from "../pam-folder/pam-folder-fns"; +import { TPamResourceDALFactory } from "../pam-resource/pam-resource-dal"; +import { PamResource } from "../pam-resource/pam-resource-enums"; +import { TPamAccountCredentials } from "../pam-resource/pam-resource-types"; +import { TPamSessionDALFactory } from "../pam-session/pam-session-dal"; +import { PamSessionStatus } from "../pam-session/pam-session-enums"; +import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission"; +import { TPamAccountDALFactory } from "./pam-account-dal"; +import { decryptAccount, decryptAccountCredentials, encryptAccountCredentials } from "./pam-account-fns"; +import { TAccessAccountDTO, TCreateAccountDTO, TUpdateAccountDTO } from "./pam-account-types"; + +type TPamAccountServiceFactoryDep = { + pamResourceDAL: TPamResourceDALFactory; + pamSessionDAL: TPamSessionDALFactory; + pamAccountDAL: TPamAccountDALFactory; + pamFolderDAL: TPamFolderDALFactory; + projectDAL: TProjectDALFactory; + permissionService: Pick; + licenseService: Pick; + kmsService: Pick; + gatewayV2Service: Pick< + TGatewayV2ServiceFactory, + "getPAMConnectionDetails" | "getPlatformConnectionDetailsByGatewayId" + >; + userDAL: TUserDALFactory; +}; + +export type TPamAccountServiceFactory = ReturnType; + +export const pamAccountServiceFactory = ({ + pamResourceDAL, + pamSessionDAL, + pamAccountDAL, + pamFolderDAL, + projectDAL, + userDAL, + permissionService, + licenseService, + kmsService, + gatewayV2Service +}: TPamAccountServiceFactoryDep) => { + const create = async ( + { credentials, resourceId, name, description, folderId }: TCreateAccountDTO, + actor: OrgServiceActor + ) => { + const orgLicensePlan = await licenseService.getPlan(actor.orgId); + if (!orgLicensePlan.pam) { + throw new BadRequestError({ + message: "PAM operation failed due to organization plan restrictions." + }); + } + + const resource = await pamResourceDAL.findById(resourceId); + if (!resource) throw new NotFoundError({ message: `Resource with ID '${resourceId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId: resource.projectId, + actionProjectType: ActionProjectType.PAM + }); + + const accountPath = await getFullPamFolderPath({ + pamFolderDAL, + folderId, + projectId: resource.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPamAccountActions.Create, + subject(ProjectPermissionSub.PamAccounts, { + resourceName: resource.name, + accountName: name, + accountPath + }) + ); + + const connectionDetails = await decryptResourceConnectionDetails({ + projectId: resource.projectId, + encryptedConnectionDetails: resource.encryptedConnectionDetails, + kmsService + }); + + const factory = PAM_RESOURCE_FACTORY_MAP[resource.resourceType as PamResource]( + resource.resourceType as PamResource, + connectionDetails, + resource.gatewayId, + gatewayV2Service + ); + const validatedCredentials = await factory.validateAccountCredentials(credentials); + + const encryptedCredentials = await encryptAccountCredentials({ + credentials: validatedCredentials, + projectId: resource.projectId, + kmsService + }); + + try { + const account = await pamAccountDAL.create({ + projectId: resource.projectId, + resourceId: resource.id, + encryptedCredentials, + name, + description, + folderId + }); + + return { + ...(await decryptAccount(account, resource.projectId, kmsService)), + resource: { id: resource.id, name: resource.name, resourceType: resource.resourceType } + }; + } catch (err) { + if (err instanceof DatabaseError && (err.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation) { + throw new BadRequestError({ + message: `Account with name '${name}' already exists for this path` + }); + } + + throw err; + } + }; + + const updateById = async ( + { accountId, credentials, description, name }: TUpdateAccountDTO, + actor: OrgServiceActor + ) => { + const orgLicensePlan = await licenseService.getPlan(actor.orgId); + if (!orgLicensePlan.pam) { + throw new BadRequestError({ + message: "PAM operation failed due to organization plan restrictions." + }); + } + + const account = await pamAccountDAL.findById(accountId); + if (!account) throw new NotFoundError({ message: `Account with ID '${accountId}' not found` }); + + const resource = await pamResourceDAL.findById(account.resourceId); + if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId: account.projectId, + actionProjectType: ActionProjectType.PAM + }); + + const accountPath = await getFullPamFolderPath({ + pamFolderDAL, + folderId: account.folderId, + projectId: account.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPamAccountActions.Edit, + subject(ProjectPermissionSub.PamAccounts, { + resourceName: resource.name, + accountName: account.name, + accountPath + }) + ); + + const updateDoc: Partial = {}; + + if (name !== undefined) { + updateDoc.name = name; + } + + if (description !== undefined) { + updateDoc.description = description; + } + + if (credentials !== undefined) { + const connectionDetails = await decryptResourceConnectionDetails({ + projectId: account.projectId, + encryptedConnectionDetails: resource.encryptedConnectionDetails, + kmsService + }); + + const factory = PAM_RESOURCE_FACTORY_MAP[resource.resourceType as PamResource]( + resource.resourceType as PamResource, + connectionDetails, + resource.gatewayId, + gatewayV2Service + ); + + // Logic to prevent overwriting unedited censored values + const finalCredentials = { ...credentials }; + if (credentials.password === "******") { + const decryptedCredentials = await decryptAccountCredentials({ + encryptedCredentials: account.encryptedCredentials, + projectId: account.projectId, + kmsService + }); + + finalCredentials.password = decryptedCredentials.password; + } + + const validatedCredentials = await factory.validateAccountCredentials(finalCredentials); + const encryptedCredentials = await encryptAccountCredentials({ + credentials: validatedCredentials, + projectId: account.projectId, + kmsService + }); + updateDoc.encryptedCredentials = encryptedCredentials; + } + + // If nothing was updated, return the fetched account + if (Object.keys(updateDoc).length === 0) { + return decryptAccount(account, account.projectId, kmsService); + } + + const updatedAccount = await pamAccountDAL.updateById(accountId, updateDoc); + + return { + ...(await decryptAccount(updatedAccount, account.projectId, kmsService)), + resource: { id: resource.id, name: resource.name, resourceType: resource.resourceType } + }; + }; + + const deleteById = async (id: string, actor: OrgServiceActor) => { + const account = await pamAccountDAL.findById(id); + if (!account) throw new NotFoundError({ message: `Account with ID '${id}' not found` }); + + const resource = await pamResourceDAL.findById(account.resourceId); + if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId: account.projectId, + actionProjectType: ActionProjectType.PAM + }); + + const accountPath = await getFullPamFolderPath({ + pamFolderDAL, + folderId: account.folderId, + projectId: account.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPamAccountActions.Delete, + subject(ProjectPermissionSub.PamAccounts, { + resourceName: resource.name, + accountName: account.name, + accountPath + }) + ); + + const deletedAccount = await pamAccountDAL.deleteById(id); + + return { + ...(await decryptAccount(deletedAccount, account.projectId, kmsService)), + resource: { id: resource.id, name: resource.name, resourceType: resource.resourceType } + }; + }; + + const list = async (projectId: string, actor: OrgServiceActor) => { + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId, + actionProjectType: ActionProjectType.PAM + }); + + const accountsWithResourceDetails = await pamAccountDAL.findWithResourceDetails({ projectId }); + + const canReadFolders = permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.PamFolders); + + const folders = canReadFolders ? await pamFolderDAL.find({ projectId }) : []; + + const decryptedAndPermittedAccounts: Array< + TPamAccounts & { + resource: Pick; + credentials: TPamAccountCredentials; + } + > = []; + + for await (const account of accountsWithResourceDetails) { + const accountPath = await getFullPamFolderPath({ + pamFolderDAL, + folderId: account.folderId, + projectId: account.projectId + }); + + // Check permission for each individual account + if ( + permission.can( + ProjectPermissionPamAccountActions.Read, + subject(ProjectPermissionSub.PamAccounts, { + resourceName: account.resource.name, + accountName: account.name, + accountPath + }) + ) + ) { + // Decrypt the account only if the user has permission to read it + const decryptedAccount = await decryptAccount(account, account.projectId, kmsService); + decryptedAndPermittedAccounts.push({ + ...decryptedAccount, + resource: { + id: account.resource.id, + name: account.resource.name, + resourceType: account.resource.resourceType + } + }); + } + } + + return { + accounts: decryptedAndPermittedAccounts, + folders + }; + }; + + const access = async ( + { accountId, actorEmail, actorIp, actorName, actorUserAgent, duration }: TAccessAccountDTO, + actor: OrgServiceActor + ) => { + const orgLicensePlan = await licenseService.getPlan(actor.orgId); + if (!orgLicensePlan.pam) { + throw new BadRequestError({ + message: "PAM operation failed due to organization plan restrictions." + }); + } + + const account = await pamAccountDAL.findById(accountId); + if (!account) throw new NotFoundError({ message: `Account with ID '${accountId}' not found` }); + + const resource = await pamResourceDAL.findById(account.resourceId); + if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId: account.projectId, + actionProjectType: ActionProjectType.PAM + }); + + const accountPath = await getFullPamFolderPath({ + pamFolderDAL, + folderId: account.folderId, + projectId: account.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPamAccountActions.Access, + subject(ProjectPermissionSub.PamAccounts, { + resourceName: resource.name, + accountName: account.name, + accountPath + }) + ); + + const session = await pamSessionDAL.create({ + accountName: account.name, + actorEmail, + actorIp, + actorName, + actorUserAgent, + projectId: account.projectId, + resourceName: resource.name, + resourceType: resource.resourceType, + status: PamSessionStatus.Starting, + accountId: account.id, + userId: actor.id, + expiresAt: new Date(Date.now() + duration) + }); + + const { connectionDetails, gatewayId, resourceType } = await decryptResource( + resource, + account.projectId, + kmsService + ); + + const user = await userDAL.findById(actor.id); + + const gatewayConnectionDetails = await gatewayV2Service.getPAMConnectionDetails({ + gatewayId, + duration, + sessionId: session.id, + resourceType: resource.resourceType as PamResource, + host: connectionDetails.host, + port: connectionDetails.port, + actorMetadata: { + id: actor.id, + type: actor.type, + name: user.email ?? "" + } + }); + + if (!gatewayConnectionDetails) { + throw new NotFoundError({ message: `Gateway connection details for gateway '${gatewayId}' not found.` }); + } + + return { + sessionId: session.id, + resourceType, + relayClientCertificate: gatewayConnectionDetails.relay.clientCertificate, + relayClientPrivateKey: gatewayConnectionDetails.relay.clientPrivateKey, + relayServerCertificateChain: gatewayConnectionDetails.relay.serverCertificateChain, + gatewayClientCertificate: gatewayConnectionDetails.gateway.clientCertificate, + gatewayClientPrivateKey: gatewayConnectionDetails.gateway.clientPrivateKey, + gatewayServerCertificateChain: gatewayConnectionDetails.gateway.serverCertificateChain, + relayHost: gatewayConnectionDetails.relayHost, + projectId: account.projectId, + account + }; + }; + + const getSessionCredentials = async (sessionId: string, actor: OrgServiceActor) => { + const orgLicensePlan = await licenseService.getPlan(actor.orgId); + if (!orgLicensePlan.pam) { + throw new BadRequestError({ + message: "PAM operation failed due to organization plan restrictions." + }); + } + + // To be hit by gateways only + if (actor.type !== ActorType.IDENTITY) { + throw new ForbiddenRequestError({ message: "Only gateways can perform this action" }); + } + + const session = await pamSessionDAL.findById(sessionId); + if (!session) throw new NotFoundError({ message: `Session with ID '${sessionId}' not found` }); + + const project = await projectDAL.findById(session.projectId); + if (!project) throw new NotFoundError({ message: `Project with ID '${session.projectId}' not found` }); + + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + project.orgId, + actor.authMethod, + actor.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionGatewayActions.CreateGateways, + OrgPermissionSubjects.Gateway + ); + + if (!session.accountId) throw new NotFoundError({ message: "Session is missing accountId column" }); + + // Verify that the session has not ended + if (session.endedAt || (session.expiresAt && session.expiresAt < new Date())) { + throw new BadRequestError({ message: "Session has ended or expired" }); + } + + // Verify that the session has not already had credentials fetched + if (session.status !== PamSessionStatus.Starting) { + throw new BadRequestError({ message: "Session has already been started" }); + } + + const account = await pamAccountDAL.findById(session.accountId); + if (!account) throw new NotFoundError({ message: `Account with ID '${session.accountId}' not found` }); + + const resource = await pamResourceDAL.findById(account.resourceId); + if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` }); + + const decryptedAccount = await decryptAccount(account, session.projectId, kmsService); + + const decryptedResource = await decryptResource(resource, session.projectId, kmsService); + + // Mark session as started + await pamSessionDAL.updateById(sessionId, { + status: PamSessionStatus.Active, + startedAt: new Date() + }); + + return { + credentials: { + ...decryptedResource.connectionDetails, + ...decryptedAccount.credentials + }, + projectId: project.id, + account + }; + }; + + return { + create, + updateById, + deleteById, + list, + access, + getSessionCredentials + }; +}; diff --git a/backend/src/ee/services/pam-account/pam-account-types.ts b/backend/src/ee/services/pam-account/pam-account-types.ts new file mode 100644 index 000000000..514d7d780 --- /dev/null +++ b/backend/src/ee/services/pam-account/pam-account-types.ts @@ -0,0 +1,17 @@ +import { TPamAccount } from "../pam-resource/pam-resource-types"; + +// DTOs +export type TCreateAccountDTO = Pick; + +export type TUpdateAccountDTO = Partial> & { + accountId: string; +}; + +export type TAccessAccountDTO = { + accountId: string; + actorEmail: string; + actorIp: string; + actorName: string; + actorUserAgent: string; + duration: number; +}; diff --git a/backend/src/ee/services/pam-folder/pam-folder-service.ts b/backend/src/ee/services/pam-folder/pam-folder-service.ts index 0539e257b..d7fb41f12 100644 --- a/backend/src/ee/services/pam-folder/pam-folder-service.ts +++ b/backend/src/ee/services/pam-folder/pam-folder-service.ts @@ -3,7 +3,8 @@ 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 { DatabaseErrorCode } from "@app/lib/error-codes"; +import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; import { OrgServiceActor } from "@app/lib/types"; import { TLicenseServiceFactory } from "../license/license-service"; @@ -50,26 +51,24 @@ export const pamFolderServiceFactory = ({ } } - 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` + try { + const folder = await pamFolderDAL.create({ + name, + description: description ?? null, + parentId: parentId || null, + projectId }); + + return folder; + } catch (err) { + if (err instanceof DatabaseError && (err.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation) { + throw new BadRequestError({ + message: `Folder with name '${name}' already exists for this path` + }); + } + + throw err; } - - const folder = await pamFolderDAL.create({ - name, - description: description ?? null, - parentId: parentId || null, - projectId - }); - - return folder; }; const updateFolder = async ({ id, name, description }: TUpdateFolderDTO, actor: OrgServiceActor) => { @@ -104,27 +103,23 @@ export const pamFolderServiceFactory = ({ 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); + try { + const updatedFolder = await pamFolderDAL.updateById(id, updateDoc); - return updatedFolder; + return updatedFolder; + } catch (err) { + if (err instanceof DatabaseError && (err.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation) { + throw new BadRequestError({ + message: `Folder with name '${name}' already exists for this path` + }); + } + + throw err; + } }; const deleteFolder = async (id: string, actor: OrgServiceActor) => { diff --git a/backend/src/ee/services/pam-resource/pam-resource-fns.ts b/backend/src/ee/services/pam-resource/pam-resource-fns.ts index 182806598..1d79e892e 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-fns.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-fns.ts @@ -2,7 +2,7 @@ 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 { TPamResource, TPamResourceConnectionDetails } from "./pam-resource-types"; import { getPostgresResourceListItem } from "./postgres/postgres-resource-fns"; export const listResourceOptions = () => { @@ -11,17 +11,17 @@ export const listResourceOptions = () => { // Resource export const encryptResourceConnectionDetails = async ({ - orgId, + projectId, connectionDetails, kmsService }: { - orgId: string; + projectId: string; connectionDetails: TPamResourceConnectionDetails; kmsService: Pick; }) => { const { encryptor } = await kmsService.createCipherPairWithDataKey({ - type: KmsDataKey.Organization, - orgId + type: KmsDataKey.SecretManager, + projectId }); const { cipherTextBlob: encryptedConnectionDetailsBlob } = encryptor({ @@ -32,17 +32,17 @@ export const encryptResourceConnectionDetails = async ({ }; export const decryptResourceConnectionDetails = async ({ - orgId, + projectId, encryptedConnectionDetails, kmsService }: { - orgId: string; + projectId: string; encryptedConnectionDetails: Buffer; kmsService: Pick; }) => { const { decryptor } = await kmsService.createCipherPairWithDataKey({ - type: KmsDataKey.Organization, - orgId + type: KmsDataKey.SecretManager, + projectId }); const decryptedPlainTextBlob = decryptor({ @@ -54,73 +54,15 @@ export const decryptResourceConnectionDetails = async ({ export const decryptResource = async ( resource: TPamResources, - orgId: string, + projectId: string, kmsService: Pick ) => { return { ...resource, connectionDetails: await decryptResourceConnectionDetails({ encryptedConnectionDetails: resource.encryptedConnectionDetails, - orgId, + projectId, 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 index 984853614..b0f93110c 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-schemas.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-schemas.ts @@ -34,12 +34,13 @@ export const BasePamAccountSchemaWithResource = BasePamAccountSchema.extend({ }); export const BaseCreatePamAccountSchema = z.object({ + resourceId: z.string().uuid(), folderId: z.string().uuid().optional(), name: slugSchema({ field: "name" }), - description: z.string().max(512).optional() + description: z.string().max(512).nullable().optional() }); export const BaseUpdatePamAccountSchema = z.object({ name: slugSchema({ field: "name" }).optional(), - description: z.string().max(512).optional() + description: z.string().max(512).nullable().optional() }); diff --git a/backend/src/ee/services/pam-resource/pam-resource-service.ts b/backend/src/ee/services/pam-resource/pam-resource-service.ts index 2cc6a1d6f..312795a50 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-service.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-service.ts @@ -1,54 +1,23 @@ -import { ForbiddenError, subject } from "@casl/ability"; +import { ForbiddenError } from "@casl/ability"; -import { ActionProjectType, TPamAccounts, TPamResources } from "@app/db/schemas"; +import { ActionProjectType, 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 { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { DatabaseErrorCode } from "@app/lib/error-codes"; +import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; import { OrgServiceActor } from "@app/lib/types"; -import { ActorType } from "@app/services/auth/auth-type"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; -import { TProjectDALFactory } from "@app/services/project/project-dal"; -import { TUserDALFactory } from "@app/services/user/user-dal"; import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service"; import { TLicenseServiceFactory } from "../license/license-service"; -import { TPamFolderDALFactory } from "../pam-folder/pam-folder-dal"; -import { getFullPamFolderPath } from "../pam-folder/pam-folder-fns"; -import { 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"; +import { decryptResource, encryptResourceConnectionDetails, listResourceOptions } from "./pam-resource-fns"; +import { TCreateResourceDTO, TUpdateResourceDTO } from "./pam-resource-types"; type TPamResourceServiceFactoryDep = { pamResourceDAL: TPamResourceDALFactory; - pamSessionDAL: TPamSessionDALFactory; - pamAccountDAL: TPamAccountDALFactory; - pamFolderDAL: TPamFolderDALFactory; - projectDAL: TProjectDALFactory; permissionService: Pick; licenseService: Pick; kmsService: Pick; @@ -56,18 +25,12 @@ type TPamResourceServiceFactoryDep = { TGatewayV2ServiceFactory, "getPAMConnectionDetails" | "getPlatformConnectionDetailsByGatewayId" >; - userDAL: TUserDALFactory; }; export type TPamResourceServiceFactory = ReturnType; export const pamResourceServiceFactory = ({ pamResourceDAL, - pamSessionDAL, - pamAccountDAL, - pamFolderDAL, - projectDAL, - userDAL, permissionService, licenseService, kmsService, @@ -94,7 +57,7 @@ export const pamResourceServiceFactory = ({ }); } - return decryptResource(resource, actor.orgId, kmsService); + return decryptResource(resource, resource.projectId, kmsService); }; const create = async ( @@ -129,7 +92,7 @@ export const pamResourceServiceFactory = ({ const encryptedConnectionDetails = await encryptResourceConnectionDetails({ connectionDetails: validatedConnectionDetails, - orgId: actor.orgId, + projectId, kmsService }); @@ -141,7 +104,7 @@ export const pamResourceServiceFactory = ({ projectId }); - return decryptResource(resource, actor.orgId, kmsService); + return decryptResource(resource, projectId, kmsService); }; const updateById = async ({ connectionDetails, resourceId, name }: TUpdateResourceDTO, actor: OrgServiceActor) => { @@ -182,7 +145,7 @@ export const pamResourceServiceFactory = ({ const validatedConnectionDetails = await factory.validateConnection(); const encryptedConnectionDetails = await encryptResourceConnectionDetails({ connectionDetails: validatedConnectionDetails, - orgId: actor.orgId, + projectId: resource.projectId, kmsService }); updateDoc.encryptedConnectionDetails = encryptedConnectionDetails; @@ -190,12 +153,12 @@ export const pamResourceServiceFactory = ({ // If nothing was updated, return the fetched resource if (Object.keys(updateDoc).length === 0) { - return decryptResource(resource, actor.orgId, kmsService); + return decryptResource(resource, resource.projectId, kmsService); } const updatedResource = await pamResourceDAL.updateById(resourceId, updateDoc); - return decryptResource(updatedResource, actor.orgId, kmsService); + return decryptResource(updatedResource, resource.projectId, kmsService); }; const deleteById = async (id: string, actor: OrgServiceActor) => { @@ -213,9 +176,20 @@ export const pamResourceServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.PamResources); - const deletedResource = await pamResourceDAL.deleteById(id); - - return decryptResource(deletedResource, actor.orgId, kmsService); + try { + const deletedResource = await pamResourceDAL.deleteById(id); + return await decryptResource(deletedResource, resource.projectId, kmsService); + } catch (err) { + if ( + err instanceof DatabaseError && + (err.error as { code: string })?.code === DatabaseErrorCode.ForeignKeyViolation + ) { + throw new BadRequestError({ + message: "Failed to delete resource because it is attached to active PAM accounts" + }); + } + throw err; + } }; const list = async (projectId: string, actor: OrgServiceActor) => { @@ -233,440 +207,7 @@ export const pamResourceServiceFactory = ({ 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 user = await userDAL.findById(actor.id); - - const gatewayConnectionDetails = await gatewayV2Service.getPAMConnectionDetails({ - gatewayId, - duration, - sessionId: session.id, - resourceType: resource.resourceType as PamResource, - host: connectionDetails.host, - port: connectionDetails.port, - actorMetadata: { - id: actor.id, - type: actor.type, - name: user.email ?? "" - } - }); - - if (!gatewayConnectionDetails) { - throw new NotFoundError({ message: `Gateway connection details for gateway '${gatewayId}' not found.` }); - } - - return { - sessionId: session.id, - resourceType, - relayClientCertificate: gatewayConnectionDetails.relay.clientCertificate, - relayClientPrivateKey: gatewayConnectionDetails.relay.clientPrivateKey, - relayServerCertificateChain: gatewayConnectionDetails.relay.serverCertificateChain, - gatewayClientCertificate: gatewayConnectionDetails.gateway.clientCertificate, - gatewayClientPrivateKey: gatewayConnectionDetails.gateway.clientPrivateKey, - gatewayServerCertificateChain: gatewayConnectionDetails.gateway.serverCertificateChain, - relayHost: gatewayConnectionDetails.relayHost, - projectId: account.projectId - }; - }; - - 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 + resources: await Promise.all(resources.map((resource) => decryptResource(resource, projectId, kmsService))) }; }; @@ -676,12 +217,6 @@ export const pamResourceServiceFactory = ({ updateById, deleteById, list, - listResourceOptions, - createAccount, - updateAccountById, - deleteAccountById, - listAccounts, - accessAccount, - getSessionCredentials + listResourceOptions }; }; diff --git a/backend/src/ee/services/pam-resource/pam-resource-types.ts b/backend/src/ee/services/pam-resource/pam-resource-types.ts index 31572fcd0..fb1b669ed 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-types.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-types.ts @@ -25,22 +25,6 @@ export type TUpdateResourceDTO = Partial; - -export type TUpdateAccountDTO = Partial> & { - accountId: string; -}; - -export type TAccessAccountDTO = { - accountId: string; - actorEmail: string; - actorIp: string; - actorName: string; - actorUserAgent: string; - duration?: number; -}; - // Resource factory export type TPamResourceFactoryValidateConnection = () => Promise; export type TPamResourceFactoryValidateAccountCredentials = ( diff --git a/backend/src/ee/services/pam-session/pam-session-fns.ts b/backend/src/ee/services/pam-session/pam-session-fns.ts index 7b093ef87..4afe205b5 100644 --- a/backend/src/ee/services/pam-session/pam-session-fns.ts +++ b/backend/src/ee/services/pam-session/pam-session-fns.ts @@ -5,17 +5,17 @@ import { KmsDataKey } from "@app/services/kms/kms-types"; import { TPamSanitizedSession, TPamSessionCommandLog } from "./pam-session.types"; export const decryptSessionCommandLogs = async ({ - orgId, + projectId, encryptedLogs, kmsService }: { - orgId: string; + projectId: string; encryptedLogs: Buffer; kmsService: Pick; }) => { const { decryptor } = await kmsService.createCipherPairWithDataKey({ - type: KmsDataKey.Organization, - orgId + type: KmsDataKey.SecretManager, + projectId }); const decryptedPlainTextBlob = decryptor({ @@ -27,14 +27,14 @@ export const decryptSessionCommandLogs = async ({ export const decryptSession = async ( session: TPamSessions, - orgId: string, + projectId: string, kmsService: Pick ) => { return { ...session, commandLogs: session.encryptedLogsBlob ? await decryptSessionCommandLogs({ - orgId, + projectId, encryptedLogs: session.encryptedLogsBlob, kmsService }) diff --git a/backend/src/ee/services/pam-session/pam-session-service.ts b/backend/src/ee/services/pam-session/pam-session-service.ts index 041171cfc..0b37422e5 100644 --- a/backend/src/ee/services/pam-session/pam-session-service.ts +++ b/backend/src/ee/services/pam-session/pam-session-service.ts @@ -53,7 +53,7 @@ export const pamSessionServiceFactory = ({ ); return { - session: await decryptSession(session, actor.orgId, kmsService) + session: await decryptSession(session, session.projectId, kmsService) }; }; @@ -75,7 +75,7 @@ export const pamSessionServiceFactory = ({ const sessions = await pamSessionDAL.find({ projectId }); return { - sessions: await Promise.all(sessions.map((session) => decryptSession(session, actor.orgId, kmsService))) + sessions: await Promise.all(sessions.map((session) => decryptSession(session, projectId, kmsService))) }; }; @@ -112,8 +112,8 @@ export const pamSessionServiceFactory = ({ ); const { encryptor } = await kmsService.createCipherPairWithDataKey({ - type: KmsDataKey.Organization, - orgId: project.orgId + type: KmsDataKey.SecretManager, + projectId: session.projectId }); const { cipherTextBlob } = encryptor({ diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index c2b8eeba8..5764b3530 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -66,9 +66,10 @@ import { licenseDALFactory } from "@app/ee/services/license/license-dal"; import { licenseServiceFactory } from "@app/ee/services/license/license-service"; import { oidcConfigDALFactory } from "@app/ee/services/oidc/oidc-config-dal"; import { oidcConfigServiceFactory } from "@app/ee/services/oidc/oidc-config-service"; +import { pamAccountDALFactory } from "@app/ee/services/pam-account/pam-account-dal"; +import { pamAccountServiceFactory } from "@app/ee/services/pam-account/pam-account-service"; import { pamFolderDALFactory } from "@app/ee/services/pam-folder/pam-folder-dal"; import { pamFolderServiceFactory } from "@app/ee/services/pam-folder/pam-folder-service"; -import { 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"; @@ -2123,14 +2124,22 @@ export const registerRoutes = async ( const pamResourceService = pamResourceServiceFactory({ pamResourceDAL, - pamSessionDAL, - pamAccountDAL, - pamFolderDAL, - projectDAL, permissionService, licenseService, kmsService, + gatewayV2Service + }); + + const pamAccountService = pamAccountServiceFactory({ + pamAccountDAL, gatewayV2Service, + kmsService, + licenseService, + pamFolderDAL, + pamResourceDAL, + pamSessionDAL, + permissionService, + projectDAL, userDAL }); @@ -2282,6 +2291,7 @@ export const registerRoutes = async ( notification: notificationService, pamFolder: pamFolderService, pamResource: pamResourceService, + pamAccount: pamAccountService, pamSession: pamSessionService, upgradePath: upgradePathService }); diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 2de271180..75b8fe2db 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -7,7 +7,7 @@ services: restart: "always" ports: - 8080:80 - - 8443:443 + - 8444:443 volumes: - ./nginx/default.dev.conf:/etc/nginx/conf.d/default.conf:ro depends_on: @@ -197,4 +197,4 @@ volumes: driver: local ldap_data: ldap_config: - grafana_storage: \ No newline at end of file + grafana_storage: diff --git a/frontend/src/hooks/api/pam/mutations.tsx b/frontend/src/hooks/api/pam/mutations.tsx index 63c1d3c4d..99a89b425 100644 --- a/frontend/src/hooks/api/pam/mutations.tsx +++ b/frontend/src/hooks/api/pam/mutations.tsx @@ -73,9 +73,9 @@ export const useDeletePamResource = () => { export const useCreatePamAccount = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ resourceId, resourceType, ...params }: TCreatePamAccountDTO) => { + mutationFn: async ({ resourceType, ...params }: TCreatePamAccountDTO) => { const { data } = await apiRequest.post<{ account: TPamAccount }>( - `/api/v1/pam/resources/${resourceType}/${resourceId}/accounts`, + `/api/v1/pam/accounts/${resourceType}`, params ); @@ -90,14 +90,9 @@ export const useCreatePamAccount = () => { export const useUpdatePamAccount = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ - resourceId, - resourceType, - accountId, - ...params - }: TUpdatePamAccountDTO) => { + mutationFn: async ({ resourceType, accountId, ...params }: TUpdatePamAccountDTO) => { const { data } = await apiRequest.patch<{ account: TPamAccount }>( - `/api/v1/pam/resources/${resourceType}/${resourceId}/accounts/${accountId}`, + `/api/v1/pam/accounts/${resourceType}/${accountId}`, params ); @@ -112,9 +107,9 @@ export const useUpdatePamAccount = () => { export const useDeletePamAccount = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ resourceId, resourceType, accountId }: TDeletePamAccountDTO) => { + mutationFn: async ({ resourceType, accountId }: TDeletePamAccountDTO) => { const { data } = await apiRequest.delete<{ account: TPamAccount }>( - `/api/v1/pam/resources/${resourceType}/${resourceId}/accounts/${accountId}` + `/api/v1/pam/accounts/${resourceType}/${accountId}` ); return data.account; diff --git a/frontend/src/hooks/api/pam/types/index.ts b/frontend/src/hooks/api/pam/types/index.ts index b8f3cac31..1b4acf6d4 100644 --- a/frontend/src/hooks/api/pam/types/index.ts +++ b/frontend/src/hooks/api/pam/types/index.ts @@ -72,13 +72,11 @@ export type TUpdatePamAccountDTO = Partial< Pick > & { accountId: string; - resourceId: string; resourceType: PamResourceType; }; export type TDeletePamAccountDTO = { accountId: string; - resourceId: string; resourceType: PamResourceType; }; diff --git a/frontend/src/pages/organization/AuditLogsPage/components/LogsFilter.tsx b/frontend/src/pages/organization/AuditLogsPage/components/LogsFilter.tsx index ff90b4292..38607b042 100644 --- a/frontend/src/pages/organization/AuditLogsPage/components/LogsFilter.tsx +++ b/frontend/src/pages/organization/AuditLogsPage/components/LogsFilter.tsx @@ -100,7 +100,7 @@ export const LogsFilter = ({ presets, setFilter, filter, project }: Props) => { secretEvents.includes(eventType) ); const showSecretsSection = - project?.type !== ProjectType.PAM && + selectedProject?.type !== ProjectType.PAM && (hasSecretEventFilter || currentSelectedEventTypes.length === 0); const filteredEventTypes = useMemo(() => { diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx index 60aea936d..8ef588e23 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx @@ -16,13 +16,10 @@ type Props = { export const PamAccessAccountModal = ({ isOpen, onOpenChange, account }: Props) => { const [duration, setDuration] = useState("4h"); - const isDurationValid = useMemo(() => ms(duration || "1s") > 0, [duration]); + const isDurationValid = useMemo(() => duration && ms(duration || "1s") > 0, [duration]); const command = useMemo( - () => - account - ? `infisical pam access ${account.id}${duration ? ` --duration ${duration}` : ""}` - : "", + () => (account ? `infisical pam access ${account.id} --duration ${duration}` : ""), [account, duration] ); diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx index ce1d0a4af..ead2b06e1 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx @@ -79,7 +79,6 @@ const UpdateForm = ({ account, onComplete }: UpdateFormProps) => { try { const updatedAccount = await updatePamAccount.mutateAsync({ accountId: account.id, - resourceId: account.resourceId, resourceType: account.resource.resourceType, ...formData }); diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/shared/sql-account-schemas.ts b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/shared/sql-account-schemas.ts index 4a681bde1..d5a983ade 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/shared/sql-account-schemas.ts +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/shared/sql-account-schemas.ts @@ -1,6 +1,10 @@ import { z } from "zod"; export const BaseSqlAccountSchema = z.object({ - username: z.string().trim().min(1, "Username required").max(255, "Username must be 255 characters or less"), + username: z + .string() + .trim() + .min(1, "Username required") + .max(255, "Username must be 255 characters or less"), password: z.string().trim().min(1, "Password required") }); diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamDeleteAccountModal.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamDeleteAccountModal.tsx index bba3fc103..45ac03082 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamDeleteAccountModal.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamDeleteAccountModal.tsx @@ -16,7 +16,6 @@ export const PamDeleteAccountModal = ({ isOpen, onOpenChange, account }: Props) const { id: accountId, name, - resourceId, resource: { resourceType } } = account; @@ -24,7 +23,6 @@ export const PamDeleteAccountModal = ({ isOpen, onOpenChange, account }: Props) try { await deletePamAccount.mutateAsync({ accountId, - resourceId, resourceType }); diff --git a/frontend/src/pages/pam/PamResourcesPage/components/PamResourceHeader.tsx b/frontend/src/pages/pam/PamResourcesPage/components/PamResourceHeader.tsx index 039296424..c774219d6 100644 --- a/frontend/src/pages/pam/PamResourcesPage/components/PamResourceHeader.tsx +++ b/frontend/src/pages/pam/PamResourcesPage/components/PamResourceHeader.tsx @@ -17,7 +17,7 @@ export const PamResourceHeader = ({ resourceType, onBack }: Props) => { />
{details.name}
-

External resource

+

Resource

{onBack && (