addressed reviews

This commit is contained in:
x032205
2025-10-02 18:46:14 -04:00
parent 1678fba06e
commit e77c024af5
37 changed files with 1011 additions and 856 deletions
+2
View File
@@ -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 { TLdapConfigServiceFactory } from "@app/ee/services/ldap-config/ldap-config-service";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { TOidcConfigServiceFactory } from "@app/ee/services/oidc/oidc-config-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 { TPamFolderServiceFactory } from "@app/ee/services/pam-folder/pam-folder-service";
import { TPamResourceServiceFactory } from "@app/ee/services/pam-resource/pam-resource-service"; import { TPamResourceServiceFactory } from "@app/ee/services/pam-resource/pam-resource-service";
import { TPamSessionServiceFactory } from "@app/ee/services/pam-session/pam-session-service"; import { TPamSessionServiceFactory } from "@app/ee/services/pam-session/pam-session-service";
@@ -320,6 +321,7 @@ declare module "fastify" {
offlineUsageReport: TOfflineUsageReportServiceFactory; offlineUsageReport: TOfflineUsageReportServiceFactory;
pamFolder: TPamFolderServiceFactory; pamFolder: TPamFolderServiceFactory;
pamResource: TPamResourceServiceFactory; pamResource: TPamResourceServiceFactory;
pamAccount: TPamAccountServiceFactory;
pamSession: TPamSessionServiceFactory; pamSession: TPamSessionServiceFactory;
upgradePath: TUpgradePathService; upgradePath: TUpgradePathService;
}; };
@@ -1,6 +1,7 @@
import { Knex } from "knex"; import { Knex } from "knex";
import { TableName } from "../schemas"; import { TableName } from "../schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
export async function up(knex: Knex): Promise<void> { export async function up(knex: Knex): Promise<void> {
// PAM Folders // PAM Folders
@@ -18,6 +19,19 @@ export async function up(knex: Knex): Promise<void> {
t.string("name").notNullable(); t.string("name").notNullable();
t.index("name"); 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.text("description").nullable();
t.timestamps(true, true, true); t.timestamps(true, true, true);
@@ -35,10 +49,14 @@ export async function up(knex: Knex): Promise<void> {
t.string("name").notNullable(); t.string("name").notNullable();
t.index("name"); t.index("name");
t.string("gatewayId").notNullable();
t.uuid("gatewayId").notNullable();
t.foreign("gatewayId").references("id").inTable(TableName.GatewayV2);
t.index("gatewayId"); t.index("gatewayId");
t.string("resourceType").notNullable(); t.string("resourceType").notNullable();
t.index("resourceType"); t.index("resourceType");
t.binary("encryptedConnectionDetails").notNullable(); t.binary("encryptedConnectionDetails").notNullable();
t.timestamps(true, true, true); t.timestamps(true, true, true);
@@ -59,13 +77,25 @@ export async function up(knex: Knex): Promise<void> {
t.index("folderId"); t.index("folderId");
t.uuid("resourceId").notNullable(); 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.index("resourceId");
t.string("name").notNullable(); t.string("name").notNullable();
t.index("name"); 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.binary("encryptedCredentials").notNullable();
t.timestamps(true, true, true); t.timestamps(true, true, true);
@@ -106,7 +136,7 @@ export async function up(knex: Knex): Promise<void> {
t.binary("encryptedLogsBlob").nullable(); 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("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.datetime("endedAt").nullable();
@@ -115,6 +145,11 @@ export async function up(knex: Knex): Promise<void> {
t.timestamps(true, true, true); 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<void> { export async function down(knex: Knex): Promise<void> {
@@ -122,4 +157,9 @@ export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.PamAccount); await knex.schema.dropTableIfExists(TableName.PamAccount);
await knex.schema.dropTableIfExists(TableName.PamResource); await knex.schema.dropTableIfExists(TableName.PamResource);
await knex.schema.dropTableIfExists(TableName.PamFolder); 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);
} }
+32 -13
View File
@@ -23,7 +23,8 @@ import { registerLdapRouter } from "./ldap-router";
import { registerLicenseRouter } from "./license-router"; import { registerLicenseRouter } from "./license-router";
import { registerOidcRouter } from "./oidc-router"; import { registerOidcRouter } from "./oidc-router";
import { registerOrgRoleRouter } from "./org-role-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 { registerPamFolderRouter } from "./pam-folder-router";
import { PAM_RESOURCE_REGISTER_ROUTER_MAP } from "./pam-resource-routers"; import { PAM_RESOURCE_REGISTER_ROUTER_MAP } from "./pam-resource-routers";
import { registerPamResourceRouter } from "./pam-resource-routers/pam-resource-router"; import { registerPamResourceRouter } from "./pam-resource-routers/pam-resource-router";
@@ -172,21 +173,39 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => {
{ prefix: "/kmip" } { 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( await server.register(
async (pamResourceRouter) => { async (pamRouter) => {
await pamResourceRouter.register(registerPamResourceRouter); await pamRouter.register(registerPamFolderRouter, { prefix: "/folders" });
await pamRouter.register(registerPamSessionRouter, { prefix: "/sessions" });
// Provider-specific endpoints await pamRouter.register(
await Promise.all( async (pamAccountRouter) => {
Object.entries(PAM_RESOURCE_REGISTER_ROUTER_MAP).map(([provider, router]) => await pamAccountRouter.register(registerPamAccountRouter);
pamResourceRouter.register(router, { prefix: `/${provider}` })
) // 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" }
); );
}; };
@@ -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<PamResource, (server: FastifyZodProvider) => Promise<void>> = {
[PamResource.Postgres]: async (server: FastifyZodProvider) => {
registerPamResourceEndpoints({
server,
resourceType: PamResource.Postgres,
accountResponseSchema: SanitizedPostgresAccountWithResourceSchema,
createAccountSchema: CreatePostgresAccountSchema,
updateAccountSchema: UpdatePostgresAccountSchema
});
}
};
@@ -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 = <C extends TPamAccount>({
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 };
}
});
};
@@ -34,7 +34,7 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
}, },
onRequest: verifyAuth([AuthMode.JWT]), onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => { 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({ await server.services.auditLog.createAuditLog({
...req.auditLogInfo, ...req.auditLogInfo,
@@ -55,21 +55,18 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
server.route({ server.route({
method: "POST", method: "POST",
url: "/:accountId/access", url: "/access",
config: { config: {
rateLimit: writeLimit rateLimit: writeLimit
}, },
schema: { schema: {
description: "Access PAM account", description: "Access PAM account",
params: z.object({
accountId: z.string().uuid()
}),
body: z.object({ body: z.object({
accountId: z.string().uuid(),
duration: z duration: z
.string() .string()
.optional() .min(1)
.transform((val, ctx) => { .transform((val, ctx) => {
if (val === undefined) return undefined;
const parsedMs = ms(val); const parsedMs = ms(val);
if (typeof parsedMs !== "number" || parsedMs <= 0) { if (typeof parsedMs !== "number" || parsedMs <= 0) {
@@ -98,13 +95,13 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
}, },
onRequest: verifyAuth([AuthMode.JWT]), onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => { handler: async (req) => {
// To prevent type errors when accessing req.auth
if (req.auth.authMode !== AuthMode.JWT) { if (req.auth.authMode !== AuthMode.JWT) {
throw new BadRequestError({ message: "You can only access PAM accounts using JWT auth tokens." }); 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 ?? "", actorEmail: req.auth.user.email ?? "",
actorIp: req.realIp, actorIp: req.realIp,
actorName: `${req.auth.user.firstName ?? ""} ${req.auth.user.lastName ?? ""}`.trim(), actorName: `${req.auth.user.firstName ?? ""} ${req.auth.user.lastName ?? ""}`.trim(),
@@ -121,7 +118,8 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
event: { event: {
type: EventType.PAM_ACCOUNT_ACCESS, type: EventType.PAM_ACCOUNT_ACCESS,
metadata: { metadata: {
accountId: req.params.accountId, accountId: req.body.accountId,
accountName: response.account.name,
duration: req.body.duration ? new Date(req.body.duration).toISOString() : undefined duration: req.body.duration ? new Date(req.body.duration).toISOString() : undefined
} }
} }
@@ -138,6 +138,7 @@ export const registerPamFolderRouter = async (server: FastifyZodProvider) => {
event: { event: {
type: EventType.PAM_FOLDER_DELETE, type: EventType.PAM_FOLDER_DELETE,
metadata: { metadata: {
folderName: folder.name,
folderId: req.params.folderId folderId: req.params.folderId
} }
} }
@@ -1,10 +1,7 @@
import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums"; import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums";
import { import {
CreatePostgresAccountSchema,
CreatePostgresResourceSchema, CreatePostgresResourceSchema,
PostgresResourceSchema, PostgresResourceSchema,
SanitizedPostgresAccountWithResourceSchema,
UpdatePostgresAccountSchema,
UpdatePostgresResourceSchema UpdatePostgresResourceSchema
} from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
@@ -16,11 +13,8 @@ export const PAM_RESOURCE_REGISTER_ROUTER_MAP: Record<PamResource, (server: Fast
server, server,
resourceType: PamResource.Postgres, resourceType: PamResource.Postgres,
resourceResponseSchema: PostgresResourceSchema, resourceResponseSchema: PostgresResourceSchema,
accountResponseSchema: SanitizedPostgresAccountWithResourceSchema,
createResourceSchema: CreatePostgresResourceSchema, createResourceSchema: CreatePostgresResourceSchema,
createAccountSchema: CreatePostgresAccountSchema, updateResourceSchema: UpdatePostgresResourceSchema
updateResourceSchema: UpdatePostgresResourceSchema,
updateAccountSchema: UpdatePostgresAccountSchema
}); });
} }
}; };
@@ -2,20 +2,17 @@ import { z } from "zod";
import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums"; import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums";
import { TPamAccount, TPamResource } from "@app/ee/services/pam-resource/pam-resource-types"; import { TPamResource } from "@app/ee/services/pam-resource/pam-resource-types";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type"; import { AuthMode } from "@app/services/auth/auth-type";
export const registerPamResourceEndpoints = <T extends TPamResource, C extends TPamAccount>({ export const registerPamResourceEndpoints = <T extends TPamResource>({
server, server,
resourceType, resourceType,
createResourceSchema, createResourceSchema,
updateResourceSchema, updateResourceSchema,
createAccountSchema, resourceResponseSchema
updateAccountSchema,
resourceResponseSchema,
accountResponseSchema
}: { }: {
server: FastifyZodProvider; server: FastifyZodProvider;
resourceType: PamResource; resourceType: PamResource;
@@ -25,24 +22,12 @@ export const registerPamResourceEndpoints = <T extends TPamResource, C extends T
gatewayId: T["gatewayId"]; gatewayId: T["gatewayId"];
name: T["name"]; name: T["name"];
}>; }>;
createAccountSchema: z.ZodType<{
credentials: C["credentials"];
folderId?: C["folderId"];
name: C["name"];
description?: C["description"];
}>;
updateResourceSchema: z.ZodType<{ updateResourceSchema: z.ZodType<{
connectionDetails?: T["connectionDetails"]; connectionDetails?: T["connectionDetails"];
gatewayId?: T["gatewayId"]; gatewayId?: T["gatewayId"];
name?: T["name"]; name?: T["name"];
}>; }>;
updateAccountSchema: z.ZodType<{
credentials?: C["credentials"];
name?: C["name"];
description?: C["description"];
}>;
resourceResponseSchema: z.ZodTypeAny; resourceResponseSchema: z.ZodTypeAny;
accountResponseSchema: z.ZodTypeAny;
}) => { }) => {
server.route({ server.route({
method: "GET", method: "GET",
@@ -210,142 +195,4 @@ export const registerPamResourceEndpoints = <T extends TPamResource, C extends T
return { resource }; 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 };
}
});
}; };
@@ -32,7 +32,7 @@ export const registerPamSessionRouter = async (server: FastifyZodProvider) => {
}, },
onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]), onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => { handler: async (req) => {
const { credentials, projectId } = await server.services.pamResource.getSessionCredentials( const { credentials, projectId, account } = await server.services.pamAccount.getSessionCredentials(
req.params.sessionId, req.params.sessionId,
req.permission req.permission
); );
@@ -44,7 +44,8 @@ export const registerPamSessionRouter = async (server: FastifyZodProvider) => {
event: { event: {
type: EventType.PAM_SESSION_START, type: EventType.PAM_SESSION_START,
metadata: { metadata: {
sessionId: req.params.sessionId sessionId: req.params.sessionId,
accountName: account.name
} }
} }
}); });
@@ -93,7 +94,8 @@ export const registerPamSessionRouter = async (server: FastifyZodProvider) => {
event: { event: {
type: EventType.PAM_SESSION_LOGS_UPDATE, type: EventType.PAM_SESSION_LOGS_UPDATE,
metadata: { metadata: {
sessionId: req.params.sessionId sessionId: req.params.sessionId,
accountName: session.accountName
} }
} }
}); });
@@ -3710,6 +3710,7 @@ interface PamSessionStartEvent {
type: EventType.PAM_SESSION_START; type: EventType.PAM_SESSION_START;
metadata: { metadata: {
sessionId: string; sessionId: string;
accountName: string;
}; };
} }
@@ -3717,6 +3718,7 @@ interface PamSessionLogsUpdateEvent {
type: EventType.PAM_SESSION_LOGS_UPDATE; type: EventType.PAM_SESSION_LOGS_UPDATE;
metadata: { metadata: {
sessionId: string; sessionId: string;
accountName: string;
}; };
} }
@@ -3763,6 +3765,7 @@ interface PamFolderDeleteEvent {
type: EventType.PAM_FOLDER_DELETE; type: EventType.PAM_FOLDER_DELETE;
metadata: { metadata: {
folderId: string; folderId: string;
folderName: string;
}; };
} }
@@ -3778,6 +3781,7 @@ interface PamAccountAccessEvent {
type: EventType.PAM_ACCOUNT_ACCESS; type: EventType.PAM_ACCOUNT_ACCESS;
metadata: { metadata: {
accountId: string; accountId: string;
accountName: string;
duration?: string; duration?: string;
}; };
} }
@@ -3807,6 +3811,7 @@ interface PamAccountUpdateEvent {
interface PamAccountDeleteEvent { interface PamAccountDeleteEvent {
type: EventType.PAM_ACCOUNT_DELETE; type: EventType.PAM_ACCOUNT_DELETE;
metadata: { metadata: {
accountName: string;
accountId: string; accountId: string;
resourceId: string; resourceId: string;
resourceType: string; resourceType: string;
@@ -274,7 +274,6 @@ export const gatewayV2ServiceFactory = ({
gatewayId: string; gatewayId: string;
targetHost: string; targetHost: string;
targetPort: number; targetPort: number;
actorMetadata?: { sessionId?: string; resourceType?: string };
}) => { }) => {
const gateway = await gatewayV2DAL.findById(gatewayId); const gateway = await gatewayV2DAL.findById(gatewayId);
if (!gateway) { if (!gateway) {
@@ -817,7 +816,20 @@ export const gatewayV2ServiceFactory = ({
OrgPermissionSubjects.Gateway OrgPermissionSubjects.Gateway
); );
return gatewayV2DAL.deleteById(gateway.id); try {
return await gatewayV2DAL.deleteById(gateway.id);
} catch (err) {
if (
err instanceof DatabaseError &&
(err.error as { code: string })?.code === DatabaseErrorCode.ForeignKeyViolation
) {
throw new BadRequestError({
message: "Failed to delete gateway because it is attached to active resources"
});
}
throw err;
}
}; };
const getPamSessionKey = async ({ orgPermission }: { orgPermission: OrgServiceActor }) => { const getPamSessionKey = async ({ orgPermission }: { orgPermission: OrgServiceActor }) => {
@@ -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<TKmsServiceFactory, "createCipherPairWithDataKey">;
}) => {
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<TKmsServiceFactory, "createCipherPairWithDataKey">;
}) => {
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 <T extends { encryptedCredentials: Buffer }>(
account: T,
projectId: string,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
): Promise<T & { credentials: TPamAccountCredentials }> => {
return {
...account,
credentials: await decryptAccountCredentials({
encryptedCredentials: account.encryptedCredentials,
projectId,
kmsService
})
} as T & { credentials: TPamAccountCredentials };
};
@@ -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<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
gatewayV2Service: Pick<
TGatewayV2ServiceFactory,
"getPAMConnectionDetails" | "getPlatformConnectionDetailsByGatewayId"
>;
userDAL: TUserDALFactory;
};
export type TPamAccountServiceFactory = ReturnType<typeof pamAccountServiceFactory>;
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<TPamAccounts> = {};
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<TPamResources, "id" | "name" | "resourceType">;
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
};
};
@@ -0,0 +1,17 @@
import { TPamAccount } from "../pam-resource/pam-resource-types";
// DTOs
export type TCreateAccountDTO = Pick<TPamAccount, "name" | "description" | "credentials" | "folderId" | "resourceId">;
export type TUpdateAccountDTO = Partial<Omit<TCreateAccountDTO, "folderId" | "resourceId">> & {
accountId: string;
};
export type TAccessAccountDTO = {
accountId: string;
actorEmail: string;
actorIp: string;
actorName: string;
actorUserAgent: string;
duration: number;
};
@@ -3,7 +3,8 @@ import { ForbiddenError } from "@casl/ability";
import { ActionProjectType, TPamFolders } from "@app/db/schemas"; import { ActionProjectType, TPamFolders } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; 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 { OrgServiceActor } from "@app/lib/types";
import { TLicenseServiceFactory } from "../license/license-service"; import { TLicenseServiceFactory } from "../license/license-service";
@@ -50,26 +51,24 @@ export const pamFolderServiceFactory = ({
} }
} }
const existingFolder = await pamFolderDAL.findOne({ try {
name, const folder = await pamFolderDAL.create({
parentId: parentId || null, name,
projectId description: description ?? null,
}); parentId: parentId || null,
projectId
if (existingFolder) {
throw new BadRequestError({
message: `Folder with name '${name}' already exists for this parent`
}); });
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) => { const updateFolder = async ({ id, name, description }: TUpdateFolderDTO, actor: OrgServiceActor) => {
@@ -104,27 +103,23 @@ export const pamFolderServiceFactory = ({
updateDoc.description = description; 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) { if (Object.keys(updateDoc).length === 0) {
return folder; 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) => { const deleteFolder = async (id: string, actor: OrgServiceActor) => {
@@ -2,7 +2,7 @@ import { TPamResources } from "@app/db/schemas";
import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/kms/kms-types"; 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"; import { getPostgresResourceListItem } from "./postgres/postgres-resource-fns";
export const listResourceOptions = () => { export const listResourceOptions = () => {
@@ -11,17 +11,17 @@ export const listResourceOptions = () => {
// Resource // Resource
export const encryptResourceConnectionDetails = async ({ export const encryptResourceConnectionDetails = async ({
orgId, projectId,
connectionDetails, connectionDetails,
kmsService kmsService
}: { }: {
orgId: string; projectId: string;
connectionDetails: TPamResourceConnectionDetails; connectionDetails: TPamResourceConnectionDetails;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">; kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
}) => { }) => {
const { encryptor } = await kmsService.createCipherPairWithDataKey({ const { encryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.Organization, type: KmsDataKey.SecretManager,
orgId projectId
}); });
const { cipherTextBlob: encryptedConnectionDetailsBlob } = encryptor({ const { cipherTextBlob: encryptedConnectionDetailsBlob } = encryptor({
@@ -32,17 +32,17 @@ export const encryptResourceConnectionDetails = async ({
}; };
export const decryptResourceConnectionDetails = async ({ export const decryptResourceConnectionDetails = async ({
orgId, projectId,
encryptedConnectionDetails, encryptedConnectionDetails,
kmsService kmsService
}: { }: {
orgId: string; projectId: string;
encryptedConnectionDetails: Buffer; encryptedConnectionDetails: Buffer;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">; kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
}) => { }) => {
const { decryptor } = await kmsService.createCipherPairWithDataKey({ const { decryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.Organization, type: KmsDataKey.SecretManager,
orgId projectId
}); });
const decryptedPlainTextBlob = decryptor({ const decryptedPlainTextBlob = decryptor({
@@ -54,73 +54,15 @@ export const decryptResourceConnectionDetails = async ({
export const decryptResource = async ( export const decryptResource = async (
resource: TPamResources, resource: TPamResources,
orgId: string, projectId: string,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey"> kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
) => { ) => {
return { return {
...resource, ...resource,
connectionDetails: await decryptResourceConnectionDetails({ connectionDetails: await decryptResourceConnectionDetails({
encryptedConnectionDetails: resource.encryptedConnectionDetails, encryptedConnectionDetails: resource.encryptedConnectionDetails,
orgId, projectId,
kmsService kmsService
}) })
} as TPamResource; } as TPamResource;
}; };
// Account
export const encryptAccountCredentials = async ({
orgId,
credentials,
kmsService
}: {
orgId: string;
credentials: TPamAccountCredentials;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
}) => {
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<TKmsServiceFactory, "createCipherPairWithDataKey">;
}) => {
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 <T extends { encryptedCredentials: Buffer }>(
account: T,
orgId: string,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
): Promise<T & { credentials: TPamAccountCredentials }> => {
return {
...account,
credentials: await decryptAccountCredentials({
encryptedCredentials: account.encryptedCredentials,
orgId,
kmsService
})
} as T & { credentials: TPamAccountCredentials };
};
@@ -34,12 +34,13 @@ export const BasePamAccountSchemaWithResource = BasePamAccountSchema.extend({
}); });
export const BaseCreatePamAccountSchema = z.object({ export const BaseCreatePamAccountSchema = z.object({
resourceId: z.string().uuid(),
folderId: z.string().uuid().optional(), folderId: z.string().uuid().optional(),
name: slugSchema({ field: "name" }), name: slugSchema({ field: "name" }),
description: z.string().max(512).optional() description: z.string().max(512).nullable().optional()
}); });
export const BaseUpdatePamAccountSchema = z.object({ export const BaseUpdatePamAccountSchema = z.object({
name: slugSchema({ field: "name" }).optional(), name: slugSchema({ field: "name" }).optional(),
description: z.string().max(512).optional() description: z.string().max(512).nullable().optional()
}); });
@@ -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 { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
ProjectPermissionActions, import { DatabaseErrorCode } from "@app/lib/error-codes";
ProjectPermissionPamAccountActions, import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors";
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { OrgServiceActor } from "@app/lib/types"; import { OrgServiceActor } from "@app/lib/types";
import { ActorType } from "@app/services/auth/auth-type";
import { TKmsServiceFactory } from "@app/services/kms/kms-service"; 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 { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service";
import { TLicenseServiceFactory } from "../license/license-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 { TPamResourceDALFactory } from "./pam-resource-dal";
import { PamResource } from "./pam-resource-enums"; import { PamResource } from "./pam-resource-enums";
import { PAM_RESOURCE_FACTORY_MAP } from "./pam-resource-factory"; import { PAM_RESOURCE_FACTORY_MAP } from "./pam-resource-factory";
import { import { decryptResource, encryptResourceConnectionDetails, listResourceOptions } from "./pam-resource-fns";
decryptAccount, import { TCreateResourceDTO, TUpdateResourceDTO } from "./pam-resource-types";
decryptAccountCredentials,
decryptResource,
decryptResourceConnectionDetails,
encryptAccountCredentials,
encryptResourceConnectionDetails,
listResourceOptions
} from "./pam-resource-fns";
import {
TAccessAccountDTO,
TCreateAccountDTO,
TCreateResourceDTO,
TPamAccountCredentials,
TUpdateAccountDTO,
TUpdateResourceDTO
} from "./pam-resource-types";
type TPamResourceServiceFactoryDep = { type TPamResourceServiceFactoryDep = {
pamResourceDAL: TPamResourceDALFactory; pamResourceDAL: TPamResourceDALFactory;
pamSessionDAL: TPamSessionDALFactory;
pamAccountDAL: TPamAccountDALFactory;
pamFolderDAL: TPamFolderDALFactory;
projectDAL: TProjectDALFactory;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">; permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">; licenseService: Pick<TLicenseServiceFactory, "getPlan">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">; kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
@@ -56,18 +25,12 @@ type TPamResourceServiceFactoryDep = {
TGatewayV2ServiceFactory, TGatewayV2ServiceFactory,
"getPAMConnectionDetails" | "getPlatformConnectionDetailsByGatewayId" "getPAMConnectionDetails" | "getPlatformConnectionDetailsByGatewayId"
>; >;
userDAL: TUserDALFactory;
}; };
export type TPamResourceServiceFactory = ReturnType<typeof pamResourceServiceFactory>; export type TPamResourceServiceFactory = ReturnType<typeof pamResourceServiceFactory>;
export const pamResourceServiceFactory = ({ export const pamResourceServiceFactory = ({
pamResourceDAL, pamResourceDAL,
pamSessionDAL,
pamAccountDAL,
pamFolderDAL,
projectDAL,
userDAL,
permissionService, permissionService,
licenseService, licenseService,
kmsService, kmsService,
@@ -94,7 +57,7 @@ export const pamResourceServiceFactory = ({
}); });
} }
return decryptResource(resource, actor.orgId, kmsService); return decryptResource(resource, resource.projectId, kmsService);
}; };
const create = async ( const create = async (
@@ -129,7 +92,7 @@ export const pamResourceServiceFactory = ({
const encryptedConnectionDetails = await encryptResourceConnectionDetails({ const encryptedConnectionDetails = await encryptResourceConnectionDetails({
connectionDetails: validatedConnectionDetails, connectionDetails: validatedConnectionDetails,
orgId: actor.orgId, projectId,
kmsService kmsService
}); });
@@ -141,7 +104,7 @@ export const pamResourceServiceFactory = ({
projectId projectId
}); });
return decryptResource(resource, actor.orgId, kmsService); return decryptResource(resource, projectId, kmsService);
}; };
const updateById = async ({ connectionDetails, resourceId, name }: TUpdateResourceDTO, actor: OrgServiceActor) => { const updateById = async ({ connectionDetails, resourceId, name }: TUpdateResourceDTO, actor: OrgServiceActor) => {
@@ -182,7 +145,7 @@ export const pamResourceServiceFactory = ({
const validatedConnectionDetails = await factory.validateConnection(); const validatedConnectionDetails = await factory.validateConnection();
const encryptedConnectionDetails = await encryptResourceConnectionDetails({ const encryptedConnectionDetails = await encryptResourceConnectionDetails({
connectionDetails: validatedConnectionDetails, connectionDetails: validatedConnectionDetails,
orgId: actor.orgId, projectId: resource.projectId,
kmsService kmsService
}); });
updateDoc.encryptedConnectionDetails = encryptedConnectionDetails; updateDoc.encryptedConnectionDetails = encryptedConnectionDetails;
@@ -190,12 +153,12 @@ export const pamResourceServiceFactory = ({
// If nothing was updated, return the fetched resource // If nothing was updated, return the fetched resource
if (Object.keys(updateDoc).length === 0) { 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); 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) => { const deleteById = async (id: string, actor: OrgServiceActor) => {
@@ -213,9 +176,20 @@ export const pamResourceServiceFactory = ({
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.PamResources); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.PamResources);
const deletedResource = await pamResourceDAL.deleteById(id); try {
const deletedResource = await pamResourceDAL.deleteById(id);
return decryptResource(deletedResource, actor.orgId, kmsService); return await decryptResource(deletedResource, resource.projectId, kmsService);
} catch (err) {
if (
err instanceof DatabaseError &&
(err.error as { code: string })?.code === DatabaseErrorCode.ForeignKeyViolation
) {
throw new BadRequestError({
message: "Failed to delete resource because it is attached to active PAM accounts"
});
}
throw err;
}
}; };
const list = async (projectId: string, actor: OrgServiceActor) => { const list = async (projectId: string, actor: OrgServiceActor) => {
@@ -233,440 +207,7 @@ export const pamResourceServiceFactory = ({
const resources = await pamResourceDAL.find({ projectId }); const resources = await pamResourceDAL.find({ projectId });
return { return {
resources: await Promise.all(resources.map((resource) => decryptResource(resource, actor.orgId, kmsService))) resources: await Promise.all(resources.map((resource) => decryptResource(resource, projectId, 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<TPamAccounts> = {};
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<TPamResources, "id" | "name" | "resourceType">;
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
}; };
}; };
@@ -676,12 +217,6 @@ export const pamResourceServiceFactory = ({
updateById, updateById,
deleteById, deleteById,
list, list,
listResourceOptions, listResourceOptions
createAccount,
updateAccountById,
deleteAccountById,
listAccounts,
accessAccount,
getSessionCredentials
}; };
}; };
@@ -25,22 +25,6 @@ export type TUpdateResourceDTO = Partial<Omit<TCreateResourceDTO, "resourceType"
resourceId: string; resourceId: string;
}; };
// Account DTOs
export type TCreateAccountDTO = Pick<TPamAccount, "name" | "description" | "credentials" | "folderId" | "resourceId">;
export type TUpdateAccountDTO = Partial<Omit<TCreateAccountDTO, "folderId" | "resourceId">> & {
accountId: string;
};
export type TAccessAccountDTO = {
accountId: string;
actorEmail: string;
actorIp: string;
actorName: string;
actorUserAgent: string;
duration?: number;
};
// Resource factory // Resource factory
export type TPamResourceFactoryValidateConnection<T extends TPamResourceConnectionDetails> = () => Promise<T>; export type TPamResourceFactoryValidateConnection<T extends TPamResourceConnectionDetails> = () => Promise<T>;
export type TPamResourceFactoryValidateAccountCredentials<C extends TPamAccountCredentials> = ( export type TPamResourceFactoryValidateAccountCredentials<C extends TPamAccountCredentials> = (
@@ -5,17 +5,17 @@ import { KmsDataKey } from "@app/services/kms/kms-types";
import { TPamSanitizedSession, TPamSessionCommandLog } from "./pam-session.types"; import { TPamSanitizedSession, TPamSessionCommandLog } from "./pam-session.types";
export const decryptSessionCommandLogs = async ({ export const decryptSessionCommandLogs = async ({
orgId, projectId,
encryptedLogs, encryptedLogs,
kmsService kmsService
}: { }: {
orgId: string; projectId: string;
encryptedLogs: Buffer; encryptedLogs: Buffer;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">; kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
}) => { }) => {
const { decryptor } = await kmsService.createCipherPairWithDataKey({ const { decryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.Organization, type: KmsDataKey.SecretManager,
orgId projectId
}); });
const decryptedPlainTextBlob = decryptor({ const decryptedPlainTextBlob = decryptor({
@@ -27,14 +27,14 @@ export const decryptSessionCommandLogs = async ({
export const decryptSession = async ( export const decryptSession = async (
session: TPamSessions, session: TPamSessions,
orgId: string, projectId: string,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey"> kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
) => { ) => {
return { return {
...session, ...session,
commandLogs: session.encryptedLogsBlob commandLogs: session.encryptedLogsBlob
? await decryptSessionCommandLogs({ ? await decryptSessionCommandLogs({
orgId, projectId,
encryptedLogs: session.encryptedLogsBlob, encryptedLogs: session.encryptedLogsBlob,
kmsService kmsService
}) })
@@ -53,7 +53,7 @@ export const pamSessionServiceFactory = ({
); );
return { 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 }); const sessions = await pamSessionDAL.find({ projectId });
return { 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({ const { encryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.Organization, type: KmsDataKey.SecretManager,
orgId: project.orgId projectId: session.projectId
}); });
const { cipherTextBlob } = encryptor({ const { cipherTextBlob } = encryptor({
+15 -5
View File
@@ -66,9 +66,10 @@ import { licenseDALFactory } from "@app/ee/services/license/license-dal";
import { licenseServiceFactory } from "@app/ee/services/license/license-service"; import { licenseServiceFactory } from "@app/ee/services/license/license-service";
import { oidcConfigDALFactory } from "@app/ee/services/oidc/oidc-config-dal"; import { oidcConfigDALFactory } from "@app/ee/services/oidc/oidc-config-dal";
import { oidcConfigServiceFactory } from "@app/ee/services/oidc/oidc-config-service"; 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 { pamFolderDALFactory } from "@app/ee/services/pam-folder/pam-folder-dal";
import { pamFolderServiceFactory } from "@app/ee/services/pam-folder/pam-folder-service"; 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 { pamResourceDALFactory } from "@app/ee/services/pam-resource/pam-resource-dal";
import { pamResourceServiceFactory } from "@app/ee/services/pam-resource/pam-resource-service"; import { pamResourceServiceFactory } from "@app/ee/services/pam-resource/pam-resource-service";
import { pamSessionDALFactory } from "@app/ee/services/pam-session/pam-session-dal"; import { pamSessionDALFactory } from "@app/ee/services/pam-session/pam-session-dal";
@@ -2123,14 +2124,22 @@ export const registerRoutes = async (
const pamResourceService = pamResourceServiceFactory({ const pamResourceService = pamResourceServiceFactory({
pamResourceDAL, pamResourceDAL,
pamSessionDAL,
pamAccountDAL,
pamFolderDAL,
projectDAL,
permissionService, permissionService,
licenseService, licenseService,
kmsService, kmsService,
gatewayV2Service
});
const pamAccountService = pamAccountServiceFactory({
pamAccountDAL,
gatewayV2Service, gatewayV2Service,
kmsService,
licenseService,
pamFolderDAL,
pamResourceDAL,
pamSessionDAL,
permissionService,
projectDAL,
userDAL userDAL
}); });
@@ -2282,6 +2291,7 @@ export const registerRoutes = async (
notification: notificationService, notification: notificationService,
pamFolder: pamFolderService, pamFolder: pamFolderService,
pamResource: pamResourceService, pamResource: pamResourceService,
pamAccount: pamAccountService,
pamSession: pamSessionService, pamSession: pamSessionService,
upgradePath: upgradePathService upgradePath: upgradePathService
}); });
+2 -2
View File
@@ -7,7 +7,7 @@ services:
restart: "always" restart: "always"
ports: ports:
- 8080:80 - 8080:80
- 8443:443 - 8444:443
volumes: volumes:
- ./nginx/default.dev.conf:/etc/nginx/conf.d/default.conf:ro - ./nginx/default.dev.conf:/etc/nginx/conf.d/default.conf:ro
depends_on: depends_on:
@@ -197,4 +197,4 @@ volumes:
driver: local driver: local
ldap_data: ldap_data:
ldap_config: ldap_config:
grafana_storage: grafana_storage:
+6 -11
View File
@@ -73,9 +73,9 @@ export const useDeletePamResource = () => {
export const useCreatePamAccount = () => { export const useCreatePamAccount = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async ({ resourceId, resourceType, ...params }: TCreatePamAccountDTO) => { mutationFn: async ({ resourceType, ...params }: TCreatePamAccountDTO) => {
const { data } = await apiRequest.post<{ account: TPamAccount }>( const { data } = await apiRequest.post<{ account: TPamAccount }>(
`/api/v1/pam/resources/${resourceType}/${resourceId}/accounts`, `/api/v1/pam/accounts/${resourceType}`,
params params
); );
@@ -90,14 +90,9 @@ export const useCreatePamAccount = () => {
export const useUpdatePamAccount = () => { export const useUpdatePamAccount = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async ({ mutationFn: async ({ resourceType, accountId, ...params }: TUpdatePamAccountDTO) => {
resourceId,
resourceType,
accountId,
...params
}: TUpdatePamAccountDTO) => {
const { data } = await apiRequest.patch<{ account: TPamAccount }>( const { data } = await apiRequest.patch<{ account: TPamAccount }>(
`/api/v1/pam/resources/${resourceType}/${resourceId}/accounts/${accountId}`, `/api/v1/pam/accounts/${resourceType}/${accountId}`,
params params
); );
@@ -112,9 +107,9 @@ export const useUpdatePamAccount = () => {
export const useDeletePamAccount = () => { export const useDeletePamAccount = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async ({ resourceId, resourceType, accountId }: TDeletePamAccountDTO) => { mutationFn: async ({ resourceType, accountId }: TDeletePamAccountDTO) => {
const { data } = await apiRequest.delete<{ account: TPamAccount }>( const { data } = await apiRequest.delete<{ account: TPamAccount }>(
`/api/v1/pam/resources/${resourceType}/${resourceId}/accounts/${accountId}` `/api/v1/pam/accounts/${resourceType}/${accountId}`
); );
return data.account; return data.account;
@@ -72,13 +72,11 @@ export type TUpdatePamAccountDTO = Partial<
Pick<TPamAccount, "name" | "description" | "credentials"> Pick<TPamAccount, "name" | "description" | "credentials">
> & { > & {
accountId: string; accountId: string;
resourceId: string;
resourceType: PamResourceType; resourceType: PamResourceType;
}; };
export type TDeletePamAccountDTO = { export type TDeletePamAccountDTO = {
accountId: string; accountId: string;
resourceId: string;
resourceType: PamResourceType; resourceType: PamResourceType;
}; };
@@ -100,7 +100,7 @@ export const LogsFilter = ({ presets, setFilter, filter, project }: Props) => {
secretEvents.includes(eventType) secretEvents.includes(eventType)
); );
const showSecretsSection = const showSecretsSection =
project?.type !== ProjectType.PAM && selectedProject?.type !== ProjectType.PAM &&
(hasSecretEventFilter || currentSelectedEventTypes.length === 0); (hasSecretEventFilter || currentSelectedEventTypes.length === 0);
const filteredEventTypes = useMemo(() => { const filteredEventTypes = useMemo(() => {
@@ -16,13 +16,10 @@ type Props = {
export const PamAccessAccountModal = ({ isOpen, onOpenChange, account }: Props) => { export const PamAccessAccountModal = ({ isOpen, onOpenChange, account }: Props) => {
const [duration, setDuration] = useState("4h"); 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( const command = useMemo(
() => () => (account ? `infisical pam access ${account.id} --duration ${duration}` : ""),
account
? `infisical pam access ${account.id}${duration ? ` --duration ${duration}` : ""}`
: "",
[account, duration] [account, duration]
); );
@@ -79,7 +79,6 @@ const UpdateForm = ({ account, onComplete }: UpdateFormProps) => {
try { try {
const updatedAccount = await updatePamAccount.mutateAsync({ const updatedAccount = await updatePamAccount.mutateAsync({
accountId: account.id, accountId: account.id,
resourceId: account.resourceId,
resourceType: account.resource.resourceType, resourceType: account.resource.resourceType,
...formData ...formData
}); });
@@ -1,6 +1,10 @@
import { z } from "zod"; import { z } from "zod";
export const BaseSqlAccountSchema = z.object({ 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") password: z.string().trim().min(1, "Password required")
}); });
@@ -16,7 +16,6 @@ export const PamDeleteAccountModal = ({ isOpen, onOpenChange, account }: Props)
const { const {
id: accountId, id: accountId,
name, name,
resourceId,
resource: { resourceType } resource: { resourceType }
} = account; } = account;
@@ -24,7 +23,6 @@ export const PamDeleteAccountModal = ({ isOpen, onOpenChange, account }: Props)
try { try {
await deletePamAccount.mutateAsync({ await deletePamAccount.mutateAsync({
accountId, accountId,
resourceId,
resourceType resourceType
}); });
@@ -17,7 +17,7 @@ export const PamResourceHeader = ({ resourceType, onBack }: Props) => {
/> />
<div> <div>
<div className="flex items-center text-mineshaft-300">{details.name}</div> <div className="flex items-center text-mineshaft-300">{details.name}</div>
<p className="text-sm leading-4 text-mineshaft-400">External resource</p> <p className="text-sm leading-4 text-mineshaft-400">Resource</p>
</div> </div>
{onBack && ( {onBack && (
<button <button
@@ -15,7 +15,7 @@ export const Route = createFileRoute(
{ {
label: "Access Control", label: "Access Control",
link: linkOptions({ link: linkOptions({
to: "/projects/secret-management/$projectId/access-management", to: "/projects/pam/$projectId/access-management",
params: { params: {
projectId: params.projectId projectId: params.projectId
}, },
@@ -15,7 +15,7 @@ export const Route = createFileRoute(
{ {
label: "Access Control", label: "Access Control",
link: linkOptions({ link: linkOptions({
to: "/projects/secret-management/$projectId/access-management", to: "/projects/pam/$projectId/access-management",
params: { params: {
projectId: params.projectId projectId: params.projectId
}, },
@@ -15,7 +15,7 @@ export const Route = createFileRoute(
{ {
label: "Access Control", label: "Access Control",
link: linkOptions({ link: linkOptions({
to: "/projects/secret-scanning/$projectId/access-management", to: "/projects/pam/$projectId/access-management",
params: { params: {
projectId: params.projectId projectId: params.projectId
}, },
@@ -15,7 +15,7 @@ export const Route = createFileRoute(
{ {
label: "Access Control", label: "Access Control",
link: linkOptions({ link: linkOptions({
to: "/projects/secret-management/$projectId/access-management", to: "/projects/pam/$projectId/access-management",
params: { params: {
projectId: params.projectId projectId: params.projectId
}, },