feat(pam): PAM Platform V1

This commit is contained in:
x032205
2025-09-27 01:57:43 -04:00
parent 270d8237fe
commit 4b1664c30f
141 changed files with 9327 additions and 46 deletions

View File

@@ -28,6 +28,9 @@ import { TKmipServiceFactory } from "@app/ee/services/kmip/kmip-service";
import { TLdapConfigServiceFactory } from "@app/ee/services/ldap-config/ldap-config-service";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { TOidcConfigServiceFactory } from "@app/ee/services/oidc/oidc-config-service";
import { TPamFolderServiceFactory } from "@app/ee/services/pam-folder/pam-folder-service";
import { TPamResourceServiceFactory } from "@app/ee/services/pam-resource/pam-resource-service";
import { TPamSessionServiceFactory } from "@app/ee/services/pam-session/pam-session-service";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { TPitServiceFactory } from "@app/ee/services/pit/pit-service";
import { TProjectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-types";
@@ -314,6 +317,9 @@ declare module "fastify" {
identityAuthTemplate: TIdentityAuthTemplateServiceFactory;
notification: TNotificationServiceFactory;
offlineUsageReport: TOfflineUsageReportServiceFactory;
pamFolder: TPamFolderServiceFactory;
pamResource: TPamResourceServiceFactory;
pamSession: TPamSessionServiceFactory;
};
// this is exclusive use for middlewares in which we need to inject data
// everywhere else access using service layer

View File

@@ -530,6 +530,10 @@ import {
TMicrosoftTeamsIntegrationsInsert,
TMicrosoftTeamsIntegrationsUpdate
} from "@app/db/schemas/microsoft-teams-integrations";
import { TPamAccounts, TPamAccountsInsert, TPamAccountsUpdate } from "@app/db/schemas/pam-accounts";
import { TPamFolders, TPamFoldersInsert, TPamFoldersUpdate } from "@app/db/schemas/pam-folders";
import { TPamResources, TPamResourcesInsert, TPamResourcesUpdate } from "@app/db/schemas/pam-resources";
import { TPamSessions, TPamSessionsInsert, TPamSessionsUpdate } from "@app/db/schemas/pam-sessions";
import {
TProjectMicrosoftTeamsConfigs,
TProjectMicrosoftTeamsConfigsInsert,
@@ -1308,5 +1312,9 @@ declare module "knex/types/tables" {
TKeyValueStoreInsert,
TKeyValueStoreUpdate
>;
[TableName.PamFolder]: KnexOriginal.CompositeTableType<TPamFolders, TPamFoldersInsert, TPamFoldersUpdate>;
[TableName.PamResource]: KnexOriginal.CompositeTableType<TPamResources, TPamResourcesInsert, TPamResourcesUpdate>;
[TableName.PamAccount]: KnexOriginal.CompositeTableType<TPamAccounts, TPamAccountsInsert, TPamAccountsUpdate>;
[TableName.PamSession]: KnexOriginal.CompositeTableType<TPamSessions, TPamSessionsInsert, TPamSessionsUpdate>;
}
}

View File

@@ -0,0 +1,125 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
// PAM Folders
if (!(await knex.schema.hasTable(TableName.PamFolder))) {
await knex.schema.createTable(TableName.PamFolder, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("projectId").notNullable();
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
t.index("projectId");
t.uuid("parentId").nullable();
t.foreign("parentId").references("id").inTable(TableName.PamFolder).onDelete("CASCADE");
t.index("parentId");
t.string("name").notNullable();
t.index("name");
t.text("description").nullable();
t.timestamps(true, true, true);
});
}
// PAM Resources
if (!(await knex.schema.hasTable(TableName.PamResource))) {
await knex.schema.createTable(TableName.PamResource, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("projectId").notNullable();
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
t.index("projectId");
t.string("name").notNullable();
t.index("name");
t.string("gatewayId").notNullable();
t.index("gatewayId");
t.string("resourceType").notNullable();
t.index("resourceType");
t.binary("encryptedConnectionDetails").notNullable();
t.timestamps(true, true, true);
});
}
// PAM Accounts
if (!(await knex.schema.hasTable(TableName.PamAccount))) {
await knex.schema.createTable(TableName.PamAccount, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("projectId").notNullable();
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
t.index("projectId");
t.uuid("folderId").nullable();
t.foreign("folderId").references("id").inTable(TableName.PamFolder).onDelete("CASCADE");
t.index("folderId");
t.uuid("resourceId").notNullable();
t.foreign("resourceId").references("id").inTable(TableName.PamResource).onDelete("CASCADE");
t.index("resourceId");
t.string("name").notNullable();
t.index("name");
t.text("description").nullable();
t.binary("encryptedCredentials").notNullable();
t.timestamps(true, true, true);
});
}
// PAM Sessions
if (!(await knex.schema.hasTable(TableName.PamSession))) {
await knex.schema.createTable(TableName.PamSession, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("projectId").notNullable();
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
t.index("projectId");
t.uuid("accountId").nullable();
t.foreign("accountId").references("id").inTable(TableName.PamAccount).onDelete("SET NULL");
t.index("accountId");
// To be used in the event of an account deletion
t.string("resourceType").notNullable();
t.string("resourceName").notNullable();
t.string("accountName").notNullable();
t.uuid("userId").nullable();
t.foreign("userId").references("id").inTable(TableName.Users).onDelete("SET NULL");
t.index("userId");
// To be used in the event of user deletion
t.string("actorName").notNullable();
t.string("actorEmail").notNullable();
t.string("actorIp").notNullable();
t.string("actorUserAgent").notNullable();
t.string("status").notNullable();
t.index("status");
t.binary("encryptedLogsBlob").nullable();
t.datetime("expiresAt").nullable(); // null means unlimited duration / no expiry
t.datetime("startedAt").nullable(); // Not when the row is created, but when the end-to-end connection between user and resource is established
t.datetime("endedAt").nullable();
t.index(["startedAt", "endedAt"]);
t.timestamps(true, true, true);
});
}
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.PamSession);
await knex.schema.dropTableIfExists(TableName.PamAccount);
await knex.schema.dropTableIfExists(TableName.PamResource);
await knex.schema.dropTableIfExists(TableName.PamFolder);
}

View File

@@ -83,6 +83,10 @@ export * from "./org-memberships";
export * from "./org-relay-config";
export * from "./org-roles";
export * from "./organizations";
export * from "./pam-accounts";
export * from "./pam-folders";
export * from "./pam-resources";
export * from "./pam-sessions";
export * from "./pki-alerts";
export * from "./pki-collection-items";
export * from "./pki-collections";

View File

@@ -287,7 +287,8 @@ export enum ProjectType {
CertificateManager = "cert-manager",
KMS = "kms",
SSH = "ssh",
SecretScanning = "secret-scanning"
SecretScanning = "secret-scanning",
PAM = "pam"
}
export enum ActionProjectType {
@@ -296,6 +297,7 @@ export enum ActionProjectType {
KMS = ProjectType.KMS,
SSH = ProjectType.SSH,
SecretScanning = ProjectType.SecretScanning,
PAM = ProjectType.PAM,
// project operations that happen on all types
Any = "any"
}

View File

@@ -0,0 +1,26 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { zodBuffer } from "@app/lib/zod";
import { TImmutableDBKeys } from "./models";
export const PamAccountsSchema = z.object({
id: z.string().uuid(),
projectId: z.string(),
folderId: z.string().uuid().nullable().optional(),
resourceId: z.string().uuid(),
name: z.string(),
description: z.string().nullable().optional(),
encryptedCredentials: zodBuffer,
createdAt: z.date(),
updatedAt: z.date()
});
export type TPamAccounts = z.infer<typeof PamAccountsSchema>;
export type TPamAccountsInsert = Omit<z.input<typeof PamAccountsSchema>, TImmutableDBKeys>;
export type TPamAccountsUpdate = Partial<Omit<z.input<typeof PamAccountsSchema>, TImmutableDBKeys>>;

View File

@@ -0,0 +1,22 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const PamFoldersSchema = z.object({
id: z.string().uuid(),
projectId: z.string(),
parentId: z.string().uuid().nullable().optional(),
name: z.string(),
description: z.string().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date()
});
export type TPamFolders = z.infer<typeof PamFoldersSchema>;
export type TPamFoldersInsert = Omit<z.input<typeof PamFoldersSchema>, TImmutableDBKeys>;
export type TPamFoldersUpdate = Partial<Omit<z.input<typeof PamFoldersSchema>, TImmutableDBKeys>>;

View File

@@ -0,0 +1,25 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { zodBuffer } from "@app/lib/zod";
import { TImmutableDBKeys } from "./models";
export const PamResourcesSchema = z.object({
id: z.string().uuid(),
projectId: z.string(),
name: z.string(),
gatewayId: z.string(),
resourceType: z.string(),
encryptedConnectionDetails: zodBuffer,
createdAt: z.date(),
updatedAt: z.date()
});
export type TPamResources = z.infer<typeof PamResourcesSchema>;
export type TPamResourcesInsert = Omit<z.input<typeof PamResourcesSchema>, TImmutableDBKeys>;
export type TPamResourcesUpdate = Partial<Omit<z.input<typeof PamResourcesSchema>, TImmutableDBKeys>>;

View File

@@ -0,0 +1,35 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { zodBuffer } from "@app/lib/zod";
import { TImmutableDBKeys } from "./models";
export const PamSessionsSchema = z.object({
id: z.string().uuid(),
projectId: z.string(),
accountId: z.string().uuid().nullable().optional(),
resourceType: z.string(),
resourceName: z.string(),
accountName: z.string(),
userId: z.string().uuid().nullable().optional(),
actorName: z.string(),
actorEmail: z.string(),
actorIp: z.string(),
actorUserAgent: z.string(),
status: z.string(),
encryptedLogsBlob: zodBuffer.nullable().optional(),
expiresAt: z.date().nullable().optional(),
startedAt: z.date().nullable().optional(),
endedAt: z.date().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date()
});
export type TPamSessions = z.infer<typeof PamSessionsSchema>;
export type TPamSessionsInsert = Omit<z.input<typeof PamSessionsSchema>, TImmutableDBKeys>;
export type TPamSessionsUpdate = Partial<Omit<z.input<typeof PamSessionsSchema>, TImmutableDBKeys>>;

View File

@@ -23,6 +23,11 @@ import { registerLdapRouter } from "./ldap-router";
import { registerLicenseRouter } from "./license-router";
import { registerOidcRouter } from "./oidc-router";
import { registerOrgRoleRouter } from "./org-role-router";
import { registerPamAccountRouter } from "./pam-account-router";
import { registerPamFolderRouter } from "./pam-folder-router";
import { PAM_RESOURCE_REGISTER_ROUTER_MAP } from "./pam-resource-routers";
import { registerPamResourceRouter } from "./pam-resource-routers/pam-resource-router";
import { registerPamSessionRouter } from "./pam-session-router";
import { registerPITRouter } from "./pit-router";
import { registerProjectRoleRouter } from "./project-role-router";
import { registerProjectRouter } from "./project-router";
@@ -166,4 +171,22 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => {
},
{ prefix: "/kmip" }
);
await server.register(registerPamFolderRouter, { prefix: "/pam/folders" });
await server.register(registerPamAccountRouter, { prefix: "/pam/accounts" });
await server.register(registerPamSessionRouter, { prefix: "/pam/sessions" });
await server.register(
async (pamResourceRouter) => {
await pamResourceRouter.register(registerPamResourceRouter);
// Provider-specific endpoints
await Promise.all(
Object.entries(PAM_RESOURCE_REGISTER_ROUTER_MAP).map(([provider, router]) =>
pamResourceRouter.register(router, { prefix: `/${provider}` })
)
);
},
{ prefix: "/pam/resources" }
);
};

View File

@@ -0,0 +1,131 @@
import { z } from "zod";
import { PamFoldersSchema } from "@app/db/schemas";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums";
import { SanitizedPostgresAccountWithResourceSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
import { BadRequestError } from "@app/lib/errors";
import { ms } from "@app/lib/ms";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
// Use z.union([...]) when more resources are added
const SanitizedAccountSchema = SanitizedPostgresAccountWithResourceSchema;
export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
server.route({
method: "GET",
url: "/",
config: {
rateLimit: readLimit
},
schema: {
description: "List PAM accounts",
querystring: z.object({
projectId: z.string().uuid()
}),
response: {
200: z.object({
accounts: SanitizedAccountSchema.array(),
folders: PamFoldersSchema.array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const response = await server.services.pamResource.listAccounts(req.query.projectId, req.permission);
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId: req.query.projectId,
event: {
type: EventType.PAM_ACCOUNT_LIST,
metadata: {
accountCount: response.accounts.length,
folderCount: response.folders.length
}
}
});
return response;
}
});
server.route({
method: "POST",
url: "/:accountId/access",
config: {
rateLimit: writeLimit
},
schema: {
description: "Access PAM account",
params: z.object({
accountId: z.string().uuid()
}),
body: z.object({
duration: z
.string()
.nullable()
.optional()
.transform((val, ctx) => {
if (val === undefined) return undefined;
if (!val || val === "permanent") return null;
const parsedMs = ms(val);
if (typeof parsedMs !== "number" || parsedMs <= 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid duration format. Must be a positive duration (e.g., '1h', '30m', '2d')."
});
return z.NEVER;
}
return parsedMs;
})
}),
response: {
200: z.object({
sessionId: z.string(),
resourceType: z.nativeEnum(PamResource),
relayCertificate: z.string(),
gatewayCertificate: z.string(),
relayHost: z.string()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
if (req.auth.authMode !== AuthMode.JWT) {
throw new BadRequestError({ message: "You can only access PAM accounts using JWT auth tokens." });
}
const response = await server.services.pamResource.accessAccount(
{
accountId: req.params.accountId,
actorEmail: req.auth.user.email ?? "",
actorIp: req.realIp,
actorName: `${req.auth.user.firstName ?? ""} ${req.auth.user.lastName ?? ""}`.trim(),
actorUserAgent: req.auditLogInfo.userAgent ?? "",
...req.body
},
req.permission
);
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId: response.projectId,
event: {
type: EventType.PAM_ACCOUNT_ACCESS,
metadata: {
accountId: req.params.accountId,
duration: req.body.duration ? new Date(req.body.duration).toISOString() : undefined
}
}
});
return response;
}
});
};

View File

@@ -0,0 +1,149 @@
import { z } from "zod";
import { PamFoldersSchema } from "@app/db/schemas";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { isValidFolderName } from "@app/lib/validator";
import { writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
export const registerPamFolderRouter = async (server: FastifyZodProvider) => {
server.route({
method: "POST",
url: "/",
config: {
rateLimit: writeLimit
},
schema: {
description: "Create PAM folder",
body: z.object({
projectId: z.string().uuid(),
parentId: z.string().uuid().nullable().optional(),
name: z
.string()
.trim()
.refine((name) => isValidFolderName(name), {
message: "Folder name can only contain alphanumeric characters, dashes, and underscores."
}),
description: z.string().trim().max(512).nullable().optional()
}),
response: {
200: z.object({
folder: PamFoldersSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const folder = await server.services.pamFolder.createFolder(req.body, req.permission);
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId: req.body.projectId,
event: {
type: EventType.PAM_FOLDER_CREATE,
metadata: {
name: req.body.name,
description: req.body.description,
parentId: req.body.parentId
}
}
});
return { folder };
}
});
server.route({
method: "PATCH",
url: "/:folderId",
config: {
rateLimit: writeLimit
},
schema: {
description: "Update PAM folder",
params: z.object({
folderId: z.string().uuid()
}),
body: z.object({
name: z
.string()
.trim()
.optional()
.refine((name) => (name ? isValidFolderName(name) : true), {
message: "Folder name can only contain alphanumeric characters, dashes, and underscores."
}),
description: z.string().trim().max(512).nullable().optional()
}),
response: {
200: z.object({
folder: PamFoldersSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const folder = await server.services.pamFolder.updateFolder(
{
...req.body,
id: req.params.folderId
},
req.permission
);
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId: folder.projectId,
event: {
type: EventType.PAM_FOLDER_UPDATE,
metadata: {
folderId: req.params.folderId,
name: req.body.name,
description: req.body.description
}
}
});
return { folder };
}
});
server.route({
method: "DELETE",
url: "/:folderId",
config: {
rateLimit: writeLimit
},
schema: {
description: "Delete PAM folder",
params: z.object({
folderId: z.string().uuid()
}),
response: {
200: z.object({
folder: PamFoldersSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const folder = await server.services.pamFolder.deleteFolder(req.params.folderId, req.permission);
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId: folder.projectId,
event: {
type: EventType.PAM_FOLDER_DELETE,
metadata: {
folderId: req.params.folderId
}
}
});
return { folder };
}
});
};

View File

@@ -0,0 +1,26 @@
import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums";
import {
CreatePostgresAccountSchema,
CreatePostgresResourceSchema,
PostgresResourceSchema,
SanitizedPostgresAccountWithResourceSchema,
UpdatePostgresAccountSchema,
UpdatePostgresResourceSchema
} from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
import { registerPamResourceEndpoints } from "./pam-resource-endpoints";
export const PAM_RESOURCE_REGISTER_ROUTER_MAP: Record<PamResource, (server: FastifyZodProvider) => Promise<void>> = {
[PamResource.Postgres]: async (server: FastifyZodProvider) => {
registerPamResourceEndpoints({
server,
resourceType: PamResource.Postgres,
resourceResponseSchema: PostgresResourceSchema,
accountResponseSchema: SanitizedPostgresAccountWithResourceSchema,
createResourceSchema: CreatePostgresResourceSchema,
createAccountSchema: CreatePostgresAccountSchema,
updateResourceSchema: UpdatePostgresResourceSchema,
updateAccountSchema: UpdatePostgresAccountSchema
});
}
};

View File

@@ -0,0 +1,351 @@
import { z } from "zod";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums";
import { TPamAccount, TPamResource } from "@app/ee/services/pam-resource/pam-resource-types";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
export const registerPamResourceEndpoints = <T extends TPamResource, C extends TPamAccount>({
server,
resourceType,
createResourceSchema,
updateResourceSchema,
createAccountSchema,
updateAccountSchema,
resourceResponseSchema,
accountResponseSchema
}: {
server: FastifyZodProvider;
resourceType: PamResource;
createResourceSchema: z.ZodType<{
projectId: T["projectId"];
connectionDetails: T["connectionDetails"];
gatewayId: T["gatewayId"];
name: T["name"];
}>;
createAccountSchema: z.ZodType<{
credentials: C["credentials"];
folderId?: C["folderId"];
name: C["name"];
description?: C["description"];
}>;
updateResourceSchema: z.ZodType<{
connectionDetails?: T["connectionDetails"];
gatewayId?: T["gatewayId"];
name?: T["name"];
}>;
updateAccountSchema: z.ZodType<{
credentials?: C["credentials"];
name?: C["name"];
description?: C["description"];
}>;
resourceResponseSchema: z.ZodTypeAny;
accountResponseSchema: z.ZodTypeAny;
}) => {
server.route({
method: "GET",
url: "/:resourceId",
config: {
rateLimit: readLimit
},
schema: {
description: "Get PAM resource",
params: z.object({
resourceId: z.string().uuid()
}),
response: {
200: z.object({
resource: resourceResponseSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const resource = await server.services.pamResource.getById(req.params.resourceId, resourceType, req.permission);
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId: resource.projectId,
event: {
type: EventType.PAM_RESOURCE_GET,
metadata: {
resourceId: resource.id,
resourceType: resource.resourceType,
name: resource.name
}
}
});
return { resource };
}
});
server.route({
method: "POST",
url: "/",
config: {
rateLimit: writeLimit
},
schema: {
description: "Create PAM resource",
body: createResourceSchema,
response: {
200: z.object({
resource: resourceResponseSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const resource = await server.services.pamResource.create(
{
...req.body,
resourceType
},
req.permission
);
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId: req.body.projectId,
event: {
type: EventType.PAM_RESOURCE_CREATE,
metadata: {
resourceType,
gatewayId: req.body.gatewayId,
name: req.body.name
}
}
});
return { resource };
}
});
server.route({
method: "PATCH",
url: "/:resourceId",
config: {
rateLimit: writeLimit
},
schema: {
description: "Update PAM resource",
params: z.object({
resourceId: z.string().uuid()
}),
body: updateResourceSchema,
response: {
200: z.object({
resource: resourceResponseSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const resource = await server.services.pamResource.updateById(
{
...req.body,
resourceId: req.params.resourceId
},
req.permission
);
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId: resource.projectId,
event: {
type: EventType.PAM_RESOURCE_UPDATE,
metadata: {
resourceId: req.params.resourceId,
resourceType,
gatewayId: req.body.gatewayId,
name: req.body.name
}
}
});
return { resource };
}
});
server.route({
method: "DELETE",
url: "/:resourceId",
config: {
rateLimit: writeLimit
},
schema: {
description: "Delete PAM resource",
params: z.object({
resourceId: z.string().uuid()
}),
response: {
200: z.object({
resource: resourceResponseSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const resource = await server.services.pamResource.deleteById(req.params.resourceId, req.permission);
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId: resource.projectId,
event: {
type: EventType.PAM_RESOURCE_DELETE,
metadata: {
resourceId: req.params.resourceId,
resourceType
}
}
});
return { resource };
}
});
// PAM Accounts
server.route({
method: "POST",
url: "/:resourceId/accounts",
config: {
rateLimit: writeLimit
},
schema: {
description: "Create PAM resource account",
params: z.object({
resourceId: z.string().uuid()
}),
body: createAccountSchema,
response: {
200: z.object({
account: accountResponseSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const account = await server.services.pamResource.createAccount(
{
...req.body,
resourceId: req.params.resourceId
},
req.permission
);
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId: account.projectId,
event: {
type: EventType.PAM_ACCOUNT_CREATE,
metadata: {
resourceId: req.params.resourceId,
resourceType,
folderId: req.body.folderId,
name: req.body.name,
description: req.body.description
}
}
});
return { account };
}
});
server.route({
method: "PATCH",
url: "/:resourceId/accounts/:accountId",
config: {
rateLimit: writeLimit
},
schema: {
description: "Update PAM resource account",
params: z.object({
resourceId: z.string().uuid(),
accountId: z.string().uuid()
}),
body: updateAccountSchema,
response: {
200: z.object({
account: accountResponseSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const account = await server.services.pamResource.updateAccountById(
{
...req.body,
accountId: req.params.accountId
},
req.permission
);
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId: account.projectId,
event: {
type: EventType.PAM_ACCOUNT_UPDATE,
metadata: {
accountId: req.params.accountId,
resourceId: req.params.resourceId,
resourceType,
name: req.body.name,
description: req.body.description
}
}
});
return { account };
}
});
server.route({
method: "DELETE",
url: "/:resourceId/accounts/:accountId",
config: {
rateLimit: writeLimit
},
schema: {
description: "Delete PAM resource account",
params: z.object({
resourceId: z.string().uuid(),
accountId: z.string().uuid()
}),
response: {
200: z.object({
account: accountResponseSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const account = await server.services.pamResource.deleteAccountById(req.params.accountId, req.permission);
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId: account.projectId,
event: {
type: EventType.PAM_ACCOUNT_DELETE,
metadata: {
accountId: req.params.accountId,
resourceId: req.params.resourceId,
resourceType
}
}
});
return { account };
}
});
};

View File

@@ -0,0 +1,76 @@
import { z } from "zod";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import {
PostgresResourceListItemSchema,
PostgresResourceSchema
} from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
import { readLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
// Use z.union([...]) when more resources are added
const ResourceSchema = PostgresResourceSchema;
const ResourceOptionsSchema = z.discriminatedUnion("resource", [PostgresResourceListItemSchema]);
export const registerPamResourceRouter = async (server: FastifyZodProvider) => {
server.route({
method: "GET",
url: "/options",
config: {
rateLimit: readLimit
},
schema: {
description: "List PAM resource types",
response: {
200: z.object({
resourceOptions: ResourceOptionsSchema.array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: () => {
const resourceOptions = server.services.pamResource.listResourceOptions();
return { resourceOptions };
}
});
server.route({
method: "GET",
url: "/",
config: {
rateLimit: readLimit
},
schema: {
description: "List PAM resources",
querystring: z.object({
projectId: z.string().uuid()
}),
response: {
200: z.object({
resources: ResourceSchema.array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const response = await server.services.pamResource.list(req.query.projectId, req.permission);
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId: req.query.projectId,
event: {
type: EventType.PAM_RESOURCE_LIST,
metadata: {
count: response.resources.length
}
}
});
return response;
}
});
};

View File

@@ -0,0 +1,221 @@
import { z } from "zod";
import { PamSessionsSchema } from "@app/db/schemas";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { PostgresSessionCredentialsSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
import { PamSessionCommandLogSchema, SanitizedSessionSchema } from "@app/ee/services/pam-session/pam-session-schemas";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
// Use z.union([]) once there's multiple
const SessionCredentialsSchema = PostgresSessionCredentialsSchema;
export const registerPamSessionRouter = async (server: FastifyZodProvider) => {
// Meant to be hit solely by gateway identities
server.route({
method: "GET",
url: "/:sessionId/credentials",
config: {
rateLimit: readLimit
},
schema: {
description: "Get PAM session credentials and start session",
params: z.object({
sessionId: z.string().uuid()
}),
response: {
200: z.object({
credentials: SessionCredentialsSchema
})
}
},
onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { credentials, projectId } = await server.services.pamResource.getSessionCredentials(
req.params.sessionId,
req.permission
);
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId,
event: {
type: EventType.PAM_SESSION_START,
metadata: {
sessionId: req.params.sessionId
}
}
});
return { credentials };
}
});
// Meant to be hit solely by gateway identities
server.route({
method: "POST",
url: "/:sessionId/logs",
config: {
rateLimit: writeLimit
},
schema: {
description: "Update PAM session logs",
params: z.object({
sessionId: z.string().uuid()
}),
body: z.object({
logs: PamSessionCommandLogSchema.array()
}),
response: {
200: z.object({
session: PamSessionsSchema.omit({
encryptedLogsBlob: true
})
})
}
},
onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { session, projectId } = await server.services.pamSession.updateLogsById(
{
sessionId: req.params.sessionId,
logs: req.body.logs
},
req.permission
);
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId,
event: {
type: EventType.PAM_SESSION_LOGS_UPDATE,
metadata: {
sessionId: req.params.sessionId
}
}
});
return { session };
}
});
// Meant to be hit solely by gateway identities
server.route({
method: "POST",
url: "/:sessionId/end",
config: {
rateLimit: writeLimit
},
schema: {
description: "End PAM session",
params: z.object({
sessionId: z.string().uuid()
}),
response: {
200: z.object({
session: PamSessionsSchema.omit({
encryptedLogsBlob: true
})
})
}
},
onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { session, projectId } = await server.services.pamSession.endSessionById(
req.params.sessionId,
req.permission
);
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId,
event: {
type: EventType.PAM_SESSION_END,
metadata: {
sessionId: req.params.sessionId
}
}
});
return { session };
}
});
server.route({
method: "GET",
url: "/:sessionId",
config: {
rateLimit: readLimit
},
schema: {
description: "Get PAM session",
params: z.object({
sessionId: z.string().uuid()
}),
response: {
200: z.object({
session: SanitizedSessionSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const response = await server.services.pamSession.getById(req.params.sessionId, req.permission);
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId: response.session.projectId,
event: {
type: EventType.PAM_SESSION_GET,
metadata: {
sessionId: req.params.sessionId
}
}
});
return response;
}
});
server.route({
method: "GET",
url: "/",
config: {
rateLimit: readLimit
},
schema: {
description: "List PAM sessions",
querystring: z.object({
projectId: z.string().uuid()
}),
response: {
200: z.object({
sessions: SanitizedSessionSchema.array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const response = await server.services.pamSession.list(req.query.projectId, req.permission);
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId: req.query.projectId,
event: {
type: EventType.PAM_SESSION_LIST,
metadata: {
count: response.sessions.length
}
}
});
return response;
}
});
};

View File

@@ -500,7 +500,26 @@ export enum EventType {
DASHBOARD_LIST_SECRETS = "dashboard-list-secrets",
DASHBOARD_GET_SECRET_VALUE = "dashboard-get-secret-value",
DASHBOARD_GET_SECRET_VERSION_VALUE = "dashboard-get-secret-version-value"
DASHBOARD_GET_SECRET_VERSION_VALUE = "dashboard-get-secret-version-value",
PAM_SESSION_START = "pam-session-start",
PAM_SESSION_LOGS_UPDATE = "pam-session-logs-update",
PAM_SESSION_END = "pam-session-end",
PAM_SESSION_GET = "pam-session-get",
PAM_SESSION_LIST = "pam-session-list",
PAM_FOLDER_CREATE = "pam-folder-create",
PAM_FOLDER_UPDATE = "pam-folder-update",
PAM_FOLDER_DELETE = "pam-folder-delete",
PAM_ACCOUNT_LIST = "pam-account-list",
PAM_ACCOUNT_ACCESS = "pam-account-access",
PAM_ACCOUNT_CREATE = "pam-account-create",
PAM_ACCOUNT_UPDATE = "pam-account-update",
PAM_ACCOUNT_DELETE = "pam-account-delete",
PAM_RESOURCE_LIST = "pam-resource-list",
PAM_RESOURCE_GET = "pam-resource-get",
PAM_RESOURCE_CREATE = "pam-resource-create",
PAM_RESOURCE_UPDATE = "pam-resource-update",
PAM_RESOURCE_DELETE = "pam-resource-delete"
}
export const filterableSecretEvents: EventType[] = [
@@ -3687,6 +3706,156 @@ interface OrgRoleDeleteEvent {
};
}
interface PamSessionStartEvent {
type: EventType.PAM_SESSION_START;
metadata: {
sessionId: string;
};
}
interface PamSessionLogsUpdateEvent {
type: EventType.PAM_SESSION_LOGS_UPDATE;
metadata: {
sessionId: string;
};
}
interface PamSessionEndEvent {
type: EventType.PAM_SESSION_END;
metadata: {
sessionId: string;
};
}
interface PamSessionGetEvent {
type: EventType.PAM_SESSION_GET;
metadata: {
sessionId: string;
};
}
interface PamSessionListEvent {
type: EventType.PAM_SESSION_LIST;
metadata: {
count: number;
};
}
interface PamFolderCreateEvent {
type: EventType.PAM_FOLDER_CREATE;
metadata: {
parentId?: string | null;
name: string;
description?: string | null;
};
}
interface PamFolderUpdateEvent {
type: EventType.PAM_FOLDER_UPDATE;
metadata: {
folderId: string;
name?: string;
description?: string | null;
};
}
interface PamFolderDeleteEvent {
type: EventType.PAM_FOLDER_DELETE;
metadata: {
folderId: string;
};
}
interface PamAccountListEvent {
type: EventType.PAM_ACCOUNT_LIST;
metadata: {
accountCount: number;
folderCount: number;
};
}
interface PamAccountAccessEvent {
type: EventType.PAM_ACCOUNT_ACCESS;
metadata: {
accountId: string;
duration?: string;
};
}
interface PamAccountCreateEvent {
type: EventType.PAM_ACCOUNT_CREATE;
metadata: {
resourceId: string;
resourceType: string;
folderId?: string | null;
name: string;
description?: string | null;
};
}
interface PamAccountUpdateEvent {
type: EventType.PAM_ACCOUNT_UPDATE;
metadata: {
accountId: string;
resourceId: string;
resourceType: string;
name?: string;
description?: string | null;
};
}
interface PamAccountDeleteEvent {
type: EventType.PAM_ACCOUNT_DELETE;
metadata: {
accountId: string;
resourceId: string;
resourceType: string;
};
}
interface PamResourceListEvent {
type: EventType.PAM_RESOURCE_LIST;
metadata: {
count: number;
};
}
interface PamResourceGetEvent {
type: EventType.PAM_RESOURCE_GET;
metadata: {
resourceId: string;
resourceType: string;
name: string;
};
}
interface PamResourceCreateEvent {
type: EventType.PAM_RESOURCE_CREATE;
metadata: {
resourceType: string;
gatewayId: string;
name: string;
};
}
interface PamResourceUpdateEvent {
type: EventType.PAM_RESOURCE_UPDATE;
metadata: {
resourceId: string;
resourceType: string;
gatewayId?: string;
name?: string;
};
}
interface PamResourceDeleteEvent {
type: EventType.PAM_RESOURCE_DELETE;
metadata: {
resourceId: string;
resourceType: string;
};
}
export type Event =
| GetSecretsEvent
| GetSecretEvent
@@ -4020,4 +4189,22 @@ export type Event =
| ProjectRoleDeleteEvent
| OrgRoleCreateEvent
| OrgRoleUpdateEvent
| OrgRoleDeleteEvent;
| OrgRoleDeleteEvent
| PamSessionStartEvent
| PamSessionLogsUpdateEvent
| PamSessionEndEvent
| PamSessionGetEvent
| PamSessionListEvent
| PamFolderCreateEvent
| PamFolderUpdateEvent
| PamFolderDeleteEvent
| PamAccountListEvent
| PamAccountAccessEvent
| PamAccountCreateEvent
| PamAccountUpdateEvent
| PamAccountDeleteEvent
| PamResourceListEvent
| PamResourceGetEvent
| PamResourceCreateEvent
| PamResourceUpdateEvent
| PamResourceDeleteEvent;

View File

@@ -268,11 +268,13 @@ export const gatewayV2ServiceFactory = ({
const getPlatformConnectionDetailsByGatewayId = async ({
gatewayId,
targetHost,
targetPort
targetPort,
actorMetadata
}: {
gatewayId: string;
targetHost: string;
targetPort: number;
actorMetadata?: { sessionId?: string; resourceType?: string };
}) => {
const gateway = await gatewayV2DAL.findById(gatewayId);
if (!gateway) {
@@ -359,7 +361,9 @@ export const gatewayV2ServiceFactory = ({
const actorExtension = new x509.Extension(
GATEWAY_ACTOR_OID,
false,
Buffer.from(JSON.stringify({ type: ActorType.PLATFORM }))
Buffer.from(
JSON.stringify(actorMetadata ? { type: ActorType.PLATFORM, ...actorMetadata } : { type: ActorType.PLATFORM })
)
);
const clientCert = await x509.X509CertificateGenerator.create({

View File

@@ -58,7 +58,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
enforceMfa: false,
projectTemplates: false,
kmip: false,
gateway: false,
gateway: true,
sshHostGroups: false,
secretScanning: false,
enterpriseSecretSyncs: false,
@@ -66,7 +66,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
enterpriseAppConnections: false,
fips: false,
eventSubscriptions: false,
machineIdentityAuthTemplates: false
machineIdentityAuthTemplates: false,
pam: true
});
export const setupLicenseRequestWithStore = (

View File

@@ -80,6 +80,7 @@ export type TFeatureSet = {
machineIdentityAuthTemplates: false;
fips: false;
eventSubscriptions: false;
pam: false;
};
export type TOrgPlansTableDTO = {

View File

@@ -0,0 +1,9 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TPamFolderDALFactory = ReturnType<typeof pamFolderDALFactory>;
export const pamFolderDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.PamFolder);
return { ...orm };
};

View File

@@ -0,0 +1,33 @@
import { TPamFolderDALFactory } from "./pam-folder-dal";
type GetFullFolderPath = {
pamFolderDAL: Pick<TPamFolderDALFactory, "find">;
folderId?: string | null;
projectId: string;
};
export const getFullPamFolderPath = async ({
pamFolderDAL,
folderId,
projectId
}: GetFullFolderPath): Promise<string> => {
if (!folderId) return "/";
const folders = await pamFolderDAL.find({ projectId });
const folderMap = new Map(folders.map((folder) => [folder.id, folder]));
if (!folderMap.has(folderId)) return "";
const path: string[] = [];
let currentFolderId: string | null | undefined = folderId;
while (currentFolderId) {
const folder = folderMap.get(currentFolderId);
if (!folder) break;
path.unshift(folder.name);
currentFolderId = folder.parentId;
}
return `/${path.join("/")}`;
};

View File

@@ -0,0 +1,151 @@
import { ForbiddenError } from "@casl/ability";
import { ActionProjectType, TPamFolders } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { OrgServiceActor } from "@app/lib/types";
import { TLicenseServiceFactory } from "../license/license-service";
import { TPamFolderDALFactory } from "./pam-folder-dal";
import { TCreateFolderDTO, TUpdateFolderDTO } from "./pam-folder-types";
type TPamFolderServiceFactoryDep = {
pamFolderDAL: TPamFolderDALFactory;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
};
export type TPamFolderServiceFactory = ReturnType<typeof pamFolderServiceFactory>;
export const pamFolderServiceFactory = ({
pamFolderDAL,
permissionService,
licenseService
}: TPamFolderServiceFactoryDep) => {
const createFolder = async ({ name, description, parentId, projectId }: TCreateFolderDTO, actor: OrgServiceActor) => {
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
if (!orgLicensePlan.pam) {
throw new BadRequestError({
message: "PAM operation failed due to organization plan restrictions."
});
}
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId,
actionProjectType: ActionProjectType.PAM
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.PamFolders);
if (parentId) {
if (!(await pamFolderDAL.findOne({ id: parentId, projectId }))) {
throw new NotFoundError({
message: `Parent folder '${parentId}' not found for project '${projectId}'`
});
}
}
const existingFolder = await pamFolderDAL.findOne({
name,
parentId: parentId || null,
projectId
});
if (existingFolder) {
throw new BadRequestError({
message: `Folder with name '${name}' already exists for this parent`
});
}
const folder = await pamFolderDAL.create({
name,
description: description ?? null,
parentId: parentId || null,
projectId
});
return folder;
};
const updateFolder = async ({ id, name, description }: TUpdateFolderDTO, actor: OrgServiceActor) => {
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
if (!orgLicensePlan.pam) {
throw new BadRequestError({
message: "PAM operation failed due to organization plan restrictions."
});
}
const folder = await pamFolderDAL.findById(id);
if (!folder) throw new NotFoundError({ message: `Folder with ID '${id}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId: folder.projectId,
actionProjectType: ActionProjectType.PAM
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.PamFolders);
const updateDoc: Partial<TPamFolders> = {};
if (name !== undefined) {
updateDoc.name = name;
}
if (description !== undefined) {
updateDoc.description = description;
}
if (name && name !== folder.name) {
const existingFolder = await pamFolderDAL.findOne({
name,
parentId: folder.parentId || null,
projectId: folder.projectId
});
if (existingFolder) {
throw new BadRequestError({
message: `Folder with name '${name}' already exists for this parent`
});
}
}
if (Object.keys(updateDoc).length === 0) {
return folder;
}
const updatedFolder = await pamFolderDAL.updateById(id, updateDoc);
return updatedFolder;
};
const deleteFolder = async (id: string, actor: OrgServiceActor) => {
const folder = await pamFolderDAL.findById(id);
if (!folder) throw new NotFoundError({ message: `Folder with ID '${id}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId: folder.projectId,
actionProjectType: ActionProjectType.PAM
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.PamFolders);
const deletedFolder = await pamFolderDAL.deleteById(id);
return deletedFolder;
};
return { createFolder, updateFolder, deleteFolder };
};

View File

@@ -0,0 +1,13 @@
// DTOs
export interface TCreateFolderDTO {
projectId: string;
parentId?: string | null;
name: string;
description?: string | null;
}
export interface TUpdateFolderDTO {
id: string;
name?: string;
description?: string | null;
}

View File

@@ -0,0 +1,43 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName, TPamAccounts } from "@app/db/schemas";
import { buildFindFilter, ormify, prependTableNameToFindFilter, selectAllTableCols } from "@app/lib/knex";
export type TPamAccountDALFactory = ReturnType<typeof pamAccountDALFactory>;
type PamAccountFindFilter = Parameters<typeof buildFindFilter<TPamAccounts>>[0];
export const pamAccountDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.PamAccount);
const findWithResourceDetails = async (filter: PamAccountFindFilter, tx?: Knex) => {
const query = (tx || db.replicaNode())(TableName.PamAccount)
.leftJoin(TableName.PamResource, `${TableName.PamAccount}.resourceId`, `${TableName.PamResource}.id`)
.select(selectAllTableCols(TableName.PamAccount))
.select(
// resource
db.ref("name").withSchema(TableName.PamResource).as("resourceName"),
db.ref("resourceType").withSchema(TableName.PamResource)
);
if (filter) {
/* eslint-disable @typescript-eslint/no-misused-promises */
void query.where(buildFindFilter(prependTableNameToFindFilter(TableName.PamAccount, filter)));
}
const accounts = await query;
return accounts.map(({ resourceId, resourceName, resourceType, ...account }) => ({
...account,
resourceId,
resource: {
id: resourceId,
name: resourceName,
resourceType
}
}));
};
return { ...orm, findWithResourceDetails };
};

View File

@@ -0,0 +1,9 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TPamResourceDALFactory = ReturnType<typeof pamResourceDALFactory>;
export const pamResourceDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.PamResource);
return { ...orm };
};

View File

@@ -0,0 +1,3 @@
export enum PamResource {
Postgres = "postgres"
}

View File

@@ -0,0 +1,9 @@
import { PamResource } from "./pam-resource-enums";
import { TPamAccountCredentials, TPamResourceConnectionDetails, TPamResourceFactory } from "./pam-resource-types";
import { sqlResourceFactory } from "./shared/sql/sql-resource-factory";
type TPamResourceFactoryImplementation = TPamResourceFactory<TPamResourceConnectionDetails, TPamAccountCredentials>;
export const PAM_RESOURCE_FACTORY_MAP: Record<PamResource, TPamResourceFactoryImplementation> = {
[PamResource.Postgres]: sqlResourceFactory as TPamResourceFactoryImplementation
};

View File

@@ -0,0 +1,126 @@
import { TPamResources } from "@app/db/schemas";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/kms/kms-types";
import { TPamAccountCredentials, TPamResource, TPamResourceConnectionDetails } from "./pam-resource-types";
import { getPostgresResourceListItem } from "./postgres/postgres-resource-fns";
export const listResourceOptions = () => {
return [getPostgresResourceListItem()].sort((a, b) => a.name.localeCompare(b.name));
};
// Resource
export const encryptResourceConnectionDetails = async ({
orgId,
connectionDetails,
kmsService
}: {
orgId: string;
connectionDetails: TPamResourceConnectionDetails;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
}) => {
const { encryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.Organization,
orgId
});
const { cipherTextBlob: encryptedConnectionDetailsBlob } = encryptor({
plainText: Buffer.from(JSON.stringify(connectionDetails))
});
return encryptedConnectionDetailsBlob;
};
export const decryptResourceConnectionDetails = async ({
orgId,
encryptedConnectionDetails,
kmsService
}: {
orgId: string;
encryptedConnectionDetails: Buffer;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
}) => {
const { decryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.Organization,
orgId
});
const decryptedPlainTextBlob = decryptor({
cipherTextBlob: encryptedConnectionDetails
});
return JSON.parse(decryptedPlainTextBlob.toString()) as TPamResourceConnectionDetails;
};
export const decryptResource = async (
resource: TPamResources,
orgId: string,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
) => {
return {
...resource,
connectionDetails: await decryptResourceConnectionDetails({
encryptedConnectionDetails: resource.encryptedConnectionDetails,
orgId,
kmsService
})
} as TPamResource;
};
// Account
export const encryptAccountCredentials = async ({
orgId,
credentials,
kmsService
}: {
orgId: string;
credentials: TPamAccountCredentials;
kmsService: Pick<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 };
};

View File

@@ -0,0 +1,45 @@
import { z } from "zod";
import { PamAccountsSchema, PamResourcesSchema } from "@app/db/schemas";
import { slugSchema } from "@app/server/lib/schemas";
// Resources
export const BasePamResoureSchema = PamResourcesSchema.omit({
encryptedConnectionDetails: true,
resourceType: true
});
export const BaseCreatePamResourceSchema = z.object({
projectId: z.string().uuid(),
gatewayId: z.string().uuid(),
name: slugSchema({ field: "name" })
});
export const BaseUpdatePamResourceSchema = z.object({
gatewayId: z.string().uuid().optional(),
name: slugSchema({ field: "name" }).optional()
});
// Accounts
export const BasePamAccountSchema = PamAccountsSchema.omit({
encryptedCredentials: true
});
export const BasePamAccountSchemaWithResource = BasePamAccountSchema.extend({
resource: PamResourcesSchema.pick({
id: true,
name: true,
resourceType: true
})
});
export const BaseCreatePamAccountSchema = z.object({
folderId: z.string().uuid().optional(),
name: slugSchema({ field: "name" }),
description: z.string().max(512).optional()
});
export const BaseUpdatePamAccountSchema = z.object({
name: slugSchema({ field: "name" }).optional(),
description: z.string().max(512).optional()
});

View File

@@ -0,0 +1,671 @@
import { ForbiddenError, subject } from "@casl/ability";
import { ActionProjectType, TPamAccounts, TPamResources } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import {
ProjectPermissionActions,
ProjectPermissionPamAccountActions,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { OrgServiceActor } from "@app/lib/types";
import { ActorType } from "@app/services/auth/auth-type";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service";
import { TLicenseServiceFactory } from "../license/license-service";
import { TPamFolderDALFactory } from "../pam-folder/pam-folder-dal";
import { getFullPamFolderPath } from "../pam-folder/pam-folder-fns";
import { TPamSessionDALFactory } from "../pam-session/pam-session-dal";
import { PamSessionStatus } from "../pam-session/pam-session-enums";
import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission";
import { TPamAccountDALFactory } from "./pam-account-dal";
import { TPamResourceDALFactory } from "./pam-resource-dal";
import { PamResource } from "./pam-resource-enums";
import { PAM_RESOURCE_FACTORY_MAP } from "./pam-resource-factory";
import {
decryptAccount,
decryptAccountCredentials,
decryptResource,
decryptResourceConnectionDetails,
encryptAccountCredentials,
encryptResourceConnectionDetails,
listResourceOptions
} from "./pam-resource-fns";
import {
TAccessAccountDTO,
TCreateAccountDTO,
TCreateResourceDTO,
TPamAccountCredentials,
TUpdateAccountDTO,
TUpdateResourceDTO
} from "./pam-resource-types";
type TPamResourceServiceFactoryDep = {
pamResourceDAL: TPamResourceDALFactory;
pamSessionDAL: TPamSessionDALFactory;
pamAccountDAL: TPamAccountDALFactory;
pamFolderDAL: TPamFolderDALFactory;
projectDAL: TProjectDALFactory;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
gatewayV2Service: Pick<TGatewayV2ServiceFactory, "getPlatformConnectionDetailsByGatewayId">;
};
export type TPamResourceServiceFactory = ReturnType<typeof pamResourceServiceFactory>;
export const pamResourceServiceFactory = ({
pamResourceDAL,
pamSessionDAL,
pamAccountDAL,
pamFolderDAL,
projectDAL,
permissionService,
licenseService,
kmsService,
gatewayV2Service
}: TPamResourceServiceFactoryDep) => {
const getById = async (id: string, resourceType: PamResource, actor: OrgServiceActor) => {
const resource = await pamResourceDAL.findById(id);
if (!resource) throw new NotFoundError({ message: `Resource with ID '${id}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId: resource.projectId,
actionProjectType: ActionProjectType.PAM
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PamResources);
if (resource.resourceType !== resourceType) {
throw new BadRequestError({
message: `Resource with ID '${id}' is not of type '${resourceType}'`
});
}
return decryptResource(resource, actor.orgId, kmsService);
};
const create = async (
{ resourceType, connectionDetails, gatewayId, name, projectId }: TCreateResourceDTO,
actor: OrgServiceActor
) => {
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
if (!orgLicensePlan.pam) {
throw new BadRequestError({
message: "PAM operation failed due to organization plan restrictions."
});
}
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId,
actionProjectType: ActionProjectType.PAM
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.PamResources);
const factory = PAM_RESOURCE_FACTORY_MAP[resourceType](
resourceType,
connectionDetails,
gatewayId,
gatewayV2Service
);
const validatedConnectionDetails = await factory.validateConnection();
const encryptedConnectionDetails = await encryptResourceConnectionDetails({
connectionDetails: validatedConnectionDetails,
orgId: actor.orgId,
kmsService
});
const resource = await pamResourceDAL.create({
resourceType,
encryptedConnectionDetails,
gatewayId,
name,
projectId
});
return decryptResource(resource, actor.orgId, kmsService);
};
const updateById = async ({ connectionDetails, resourceId, name }: TUpdateResourceDTO, actor: OrgServiceActor) => {
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
if (!orgLicensePlan.pam) {
throw new BadRequestError({
message: "PAM operation failed due to organization plan restrictions."
});
}
const resource = await pamResourceDAL.findById(resourceId);
if (!resource) throw new NotFoundError({ message: `Resource with ID '${resourceId}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId: resource.projectId,
actionProjectType: ActionProjectType.PAM
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.PamResources);
const updateDoc: Partial<TPamResources> = {};
if (name !== undefined) {
updateDoc.name = name;
}
if (connectionDetails !== undefined) {
const factory = PAM_RESOURCE_FACTORY_MAP[resource.resourceType as PamResource](
resource.resourceType as PamResource,
connectionDetails,
resource.gatewayId,
gatewayV2Service
);
const validatedConnectionDetails = await factory.validateConnection();
const encryptedConnectionDetails = await encryptResourceConnectionDetails({
connectionDetails: validatedConnectionDetails,
orgId: actor.orgId,
kmsService
});
updateDoc.encryptedConnectionDetails = encryptedConnectionDetails;
}
// If nothing was updated, return the fetched resource
if (Object.keys(updateDoc).length === 0) {
return decryptResource(resource, actor.orgId, kmsService);
}
const updatedResource = await pamResourceDAL.updateById(resourceId, updateDoc);
return decryptResource(updatedResource, actor.orgId, kmsService);
};
const deleteById = async (id: string, actor: OrgServiceActor) => {
const resource = await pamResourceDAL.findById(id);
if (!resource) throw new NotFoundError({ message: `Resource with ID '${id}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId: resource.projectId,
actionProjectType: ActionProjectType.PAM
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.PamResources);
const deletedResource = await pamResourceDAL.deleteById(id);
return decryptResource(deletedResource, actor.orgId, kmsService);
};
const list = async (projectId: string, actor: OrgServiceActor) => {
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId,
actionProjectType: ActionProjectType.PAM
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PamResources);
const resources = await pamResourceDAL.find({ projectId });
return {
resources: await Promise.all(resources.map((resource) => decryptResource(resource, actor.orgId, kmsService)))
};
};
// Accounts
const createAccount = async (
{ credentials, resourceId, name, description, folderId }: TCreateAccountDTO,
actor: OrgServiceActor
) => {
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
if (!orgLicensePlan.pam) {
throw new BadRequestError({
message: "PAM operation failed due to organization plan restrictions."
});
}
const resource = await pamResourceDAL.findById(resourceId);
if (!resource) throw new NotFoundError({ message: `Resource with ID '${resourceId}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId: resource.projectId,
actionProjectType: ActionProjectType.PAM
});
const accountPath = await getFullPamFolderPath({
pamFolderDAL,
folderId,
projectId: resource.projectId
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPamAccountActions.Create,
subject(ProjectPermissionSub.PamAccounts, {
resourceName: resource.name,
accountName: name,
accountPath
})
);
const connectionDetails = await decryptResourceConnectionDetails({
orgId: actor.orgId,
encryptedConnectionDetails: resource.encryptedConnectionDetails,
kmsService
});
const factory = PAM_RESOURCE_FACTORY_MAP[resource.resourceType as PamResource](
resource.resourceType as PamResource,
connectionDetails,
resource.gatewayId,
gatewayV2Service
);
const validatedCredentials = await factory.validateAccountCredentials(credentials);
const encryptedCredentials = await encryptAccountCredentials({
credentials: validatedCredentials,
orgId: actor.orgId,
kmsService
});
const account = await pamAccountDAL.create({
projectId: resource.projectId,
resourceId: resource.id,
encryptedCredentials,
name,
description,
folderId
});
return {
...(await decryptAccount(account, actor.orgId, kmsService)),
resource: { id: resource.id, name: resource.name, resourceType: resource.resourceType }
};
};
const updateAccountById = async (
{ accountId, credentials, description, name }: TUpdateAccountDTO,
actor: OrgServiceActor
) => {
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
if (!orgLicensePlan.pam) {
throw new BadRequestError({
message: "PAM operation failed due to organization plan restrictions."
});
}
const account = await pamAccountDAL.findById(accountId);
if (!account) throw new NotFoundError({ message: `Account with ID '${accountId}' not found` });
const resource = await pamResourceDAL.findById(account.resourceId);
if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId: account.projectId,
actionProjectType: ActionProjectType.PAM
});
const accountPath = await getFullPamFolderPath({
pamFolderDAL,
folderId: account.folderId,
projectId: account.projectId
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPamAccountActions.Edit,
subject(ProjectPermissionSub.PamAccounts, {
resourceName: resource.name,
accountName: account.name,
accountPath
})
);
const updateDoc: Partial<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 gatewayConnectionDetails = await gatewayV2Service.getPlatformConnectionDetailsByGatewayId({
gatewayId,
targetHost: connectionDetails.host,
targetPort: connectionDetails.port,
actorMetadata: {
sessionId: session.id,
resourceType: resource.resourceType
}
});
if (!gatewayConnectionDetails) {
throw new NotFoundError({ message: `Gateway connection details for gateway '${gatewayId}' not found.` });
}
return {
sessionId: session.id,
resourceType,
relayCertificate: gatewayConnectionDetails.relay.clientCertificate,
gatewayCertificate: gatewayConnectionDetails.gateway.clientCertificate,
relayHost: gatewayConnectionDetails.relayHost,
projectId: account.projectId
};
};
const getSessionCredentials = async (sessionId: string, actor: OrgServiceActor) => {
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
if (!orgLicensePlan.pam) {
throw new BadRequestError({
message: "PAM operation failed due to organization plan restrictions."
});
}
// To be hit by gateways only
if (actor.type !== ActorType.IDENTITY) {
throw new ForbiddenRequestError({ message: "Only gateways can perform this action" });
}
const session = await pamSessionDAL.findById(sessionId);
if (!session) throw new NotFoundError({ message: `Session with ID '${sessionId}' not found` });
const project = await projectDAL.findById(session.projectId);
if (!project) throw new NotFoundError({ message: `Project with ID '${session.projectId}' not found` });
const { permission } = await permissionService.getOrgPermission(
actor.type,
actor.id,
project.orgId,
actor.authMethod,
actor.orgId
);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionGatewayActions.CreateGateways,
OrgPermissionSubjects.Gateway
);
if (!session.accountId) throw new NotFoundError({ message: "Session is missing accountId column" });
// Verify that the session has not ended
if (session.endedAt || (session.expiresAt && session.expiresAt < new Date())) {
throw new BadRequestError({ message: "Session has ended or expired" });
}
// Verify that the session has not already had credentials fetched
if (session.status !== PamSessionStatus.Starting) {
throw new BadRequestError({ message: "Session has already been started" });
}
const account = await pamAccountDAL.findById(session.accountId);
if (!account) throw new NotFoundError({ message: `Account with ID '${session.accountId}' not found` });
const resource = await pamResourceDAL.findById(account.resourceId);
if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` });
const decryptedAccount = await decryptAccount(account, actor.orgId, kmsService);
const decryptedResource = await decryptResource(resource, actor.orgId, kmsService);
// Mark session as started
await pamSessionDAL.updateById(sessionId, {
status: PamSessionStatus.Active,
startedAt: new Date()
});
return {
credentials: {
...decryptedResource.connectionDetails,
...decryptedAccount.credentials
},
projectId: project.id
};
};
return {
getById,
create,
updateById,
deleteById,
list,
listResourceOptions,
createAccount,
updateAccountById,
deleteAccountById,
listAccounts,
accessAccount,
getSessionCredentials
};
};

View File

@@ -0,0 +1,58 @@
import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service";
import { PamResource } from "./pam-resource-enums";
import {
TPostgresAccount,
TPostgresAccountCredentials,
TPostgresResource,
TPostgresResourceConnectionDetails
} from "./postgres/postgres-resource-types";
// Resource types
export type TPamResource = TPostgresResource;
export type TPamResourceConnectionDetails = TPostgresResourceConnectionDetails;
// Account types
export type TPamAccount = TPostgresAccount;
export type TPamAccountCredentials = TPostgresAccountCredentials;
// Resource DTOs
export type TCreateResourceDTO = Pick<
TPamResource,
"name" | "connectionDetails" | "resourceType" | "gatewayId" | "projectId"
>;
export type TUpdateResourceDTO = Partial<Omit<TCreateResourceDTO, "resourceType" | "projectId">> & {
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 | null;
};
// Resource factory
export type TPamResourceFactoryValidateConnection<T extends TPamResourceConnectionDetails> = () => Promise<T>;
export type TPamResourceFactoryValidateAccountCredentials<C extends TPamAccountCredentials> = (
credentials: C
) => Promise<C>;
export type TPamResourceFactory<T extends TPamResourceConnectionDetails, C extends TPamAccountCredentials> = (
resourceType: PamResource,
connectionDetails: T,
gatewayId: string,
gatewayV2Service: Pick<TGatewayV2ServiceFactory, "getPlatformConnectionDetailsByGatewayId">
) => {
validateConnection: TPamResourceFactoryValidateConnection<T>;
validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials<C>;
};

View File

@@ -0,0 +1,8 @@
import { PostgresResourceListItemSchema } from "./postgres-resource-schemas";
export const getPostgresResourceListItem = () => {
return {
name: PostgresResourceListItemSchema.shape.name.value,
resource: PostgresResourceListItemSchema.shape.resource.value
};
};

View File

@@ -0,0 +1,64 @@
import { z } from "zod";
import { PamResource } from "../pam-resource-enums";
import {
BaseCreatePamAccountSchema,
BaseCreatePamResourceSchema,
BasePamAccountSchema,
BasePamAccountSchemaWithResource,
BasePamResoureSchema,
BaseUpdatePamAccountSchema,
BaseUpdatePamResourceSchema
} from "../pam-resource-schemas";
import {
BaseSqlAccountCredentialsSchema,
BaseSqlResourceConnectionDetailsSchema
} from "../shared/sql/sql-resource-schemas";
// Resources
export const PostgresResourceConnectionDetailsSchema = BaseSqlResourceConnectionDetailsSchema;
const BasePostgresResourceSchema = BasePamResoureSchema.extend({ resourceType: z.literal(PamResource.Postgres) });
export const PostgresResourceSchema = BasePostgresResourceSchema.extend({
connectionDetails: PostgresResourceConnectionDetailsSchema
});
export const PostgresResourceListItemSchema = z.object({
name: z.literal("PostgreSQL"),
resource: z.literal(PamResource.Postgres)
});
export const CreatePostgresResourceSchema = BaseCreatePamResourceSchema.extend({
connectionDetails: PostgresResourceConnectionDetailsSchema
});
export const UpdatePostgresResourceSchema = BaseUpdatePamResourceSchema.extend({
connectionDetails: PostgresResourceConnectionDetailsSchema.optional()
});
// Accounts
export const PostgresAccountCredentialsSchema = BaseSqlAccountCredentialsSchema;
export const PostgresAccountSchema = BasePamAccountSchema.extend({
credentials: PostgresAccountCredentialsSchema
});
export const CreatePostgresAccountSchema = BaseCreatePamAccountSchema.extend({
credentials: PostgresAccountCredentialsSchema
});
export const UpdatePostgresAccountSchema = BaseUpdatePamAccountSchema.extend({
credentials: PostgresAccountCredentialsSchema.optional()
});
export const SanitizedPostgresAccountWithResourceSchema = BasePamAccountSchemaWithResource.extend({
credentials: PostgresAccountCredentialsSchema.pick({
username: true
})
});
// Sessions
export const PostgresSessionCredentialsSchema = PostgresResourceConnectionDetailsSchema.and(
PostgresAccountCredentialsSchema
);

View File

@@ -0,0 +1,16 @@
import { z } from "zod";
import {
PostgresAccountCredentialsSchema,
PostgresAccountSchema,
PostgresResourceConnectionDetailsSchema,
PostgresResourceSchema
} from "./postgres-resource-schemas";
// Resources
export type TPostgresResource = z.infer<typeof PostgresResourceSchema>;
export type TPostgresResourceConnectionDetails = z.infer<typeof PostgresResourceConnectionDetailsSchema>;
// Accounts
export type TPostgresAccount = z.infer<typeof PostgresAccountSchema>;
export type TPostgresAccountCredentials = z.infer<typeof PostgresAccountCredentialsSchema>;

View File

@@ -0,0 +1,173 @@
import knex, { Knex } from "knex";
import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns";
import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service";
import { BadRequestError } from "@app/lib/errors";
import { GatewayProxyProtocol } from "@app/lib/gateway";
import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2";
import { PamResource } from "../../pam-resource-enums";
import { TPamResourceFactory, TPamResourceFactoryValidateAccountCredentials } from "../../pam-resource-types";
import { TSqlAccountCredentials, TSqlResourceConnectionDetails } from "./sql-resource-types";
const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000;
const TEST_CONNECTION_USERNAME = "infisical-gateway-connection-test";
const TEST_CONNECTION_PASSWORD = "infisical-gateway-connection-test-password";
const SQL_CONNECTION_CLIENT_MAP = {
[PamResource.Postgres]: "pg"
};
const getConnectionConfig = (
resourceType: PamResource,
{ host, sslEnabled, sslRejectUnauthorized, sslCertificate }: TSqlResourceConnectionDetails
) => {
switch (resourceType) {
case PamResource.Postgres: {
return {
ssl: sslEnabled
? {
rejectUnauthorized: sslRejectUnauthorized,
ca: sslCertificate,
servername: host
}
: false
};
}
default:
throw new BadRequestError({
message: `Unhandled SQL Resource Connection Config: ${resourceType as PamResource}`
});
}
};
export const executeWithGateway = async <T>(
config: {
connectionDetails: TSqlResourceConnectionDetails;
resourceType: PamResource;
gatewayId: string;
username?: string;
password?: string;
},
gatewayV2Service: Pick<TGatewayV2ServiceFactory, "getPlatformConnectionDetailsByGatewayId">,
operation: (client: Knex) => Promise<T>
): Promise<T> => {
const { connectionDetails, resourceType, gatewayId, username, password } = config;
const [targetHost] = await verifyHostInputValidity(connectionDetails.host, true);
const platformConnectionDetails = await gatewayV2Service.getPlatformConnectionDetailsByGatewayId({
gatewayId,
targetHost,
targetPort: connectionDetails.port
});
if (!platformConnectionDetails) {
throw new BadRequestError({ message: "Unable to connect to gateway, no platform connection details found" });
}
return withGatewayV2Proxy(
async (proxyPort) => {
const client = knex({
client: SQL_CONNECTION_CLIENT_MAP[resourceType],
connection: {
database: connectionDetails.database,
port: proxyPort,
host: "localhost",
user: username ?? TEST_CONNECTION_USERNAME, // Use provided username or fallback
password: password ?? TEST_CONNECTION_PASSWORD, // Use provided password or fallback
connectionTimeoutMillis: EXTERNAL_REQUEST_TIMEOUT,
...getConnectionConfig(resourceType, connectionDetails)
}
});
try {
return await operation(client);
} finally {
await client.destroy();
}
},
{
protocol: GatewayProxyProtocol.Tcp,
relayHost: platformConnectionDetails.relayHost,
gateway: platformConnectionDetails.gateway,
relay: platformConnectionDetails.relay
}
);
};
export const sqlResourceFactory: TPamResourceFactory<TSqlResourceConnectionDetails, TSqlAccountCredentials> = (
resourceType,
connectionDetails,
gatewayId,
gatewayV2Service
) => {
const validateConnection = async () => {
try {
await executeWithGateway({ connectionDetails, gatewayId, resourceType }, gatewayV2Service, async (client) => {
await client.raw("Select 1");
});
return connectionDetails;
} catch (error) {
// Hacky way to know if we successfully hit the database
if (error instanceof BadRequestError) {
if (error.message === `password authentication failed for user "${TEST_CONNECTION_USERNAME}"`) {
return connectionDetails;
}
if (error.message === "Connection terminated unexpectedly") {
throw new BadRequestError({
message: "Connection terminated unexpectedly. Verify that host and port are correct"
});
}
}
throw new BadRequestError({
message: `Unable to validate connection to ${resourceType}: ${(error as Error).message || String(error)}`
});
}
};
const validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials<TSqlAccountCredentials> = async (
credentials
) => {
try {
await executeWithGateway(
{
connectionDetails,
gatewayId,
resourceType,
username: credentials.username,
password: credentials.password
},
gatewayV2Service,
async (client) => {
await client.raw("Select 1");
}
);
return credentials;
} catch (error) {
if (error instanceof BadRequestError) {
if (error.message === `password authentication failed for user "${credentials.username}"`) {
throw new BadRequestError({
message: "Account credentials invalid: Username or password incorrect"
});
}
if (error.message === "Connection terminated unexpectedly") {
throw new BadRequestError({
message: "Connection terminated unexpectedly. Verify that host and port are correct"
});
}
}
throw new BadRequestError({
message: `Unable to validate account credentials for ${resourceType}: ${(error as Error).message || String(error)}`
});
}
};
return {
validateConnection,
validateAccountCredentials
};
};

View File

@@ -0,0 +1,21 @@
import { z } from "zod";
// Resources
export const BaseSqlResourceConnectionDetailsSchema = z.object({
host: z.string().trim().min(1).max(255),
port: z.coerce.number(),
database: z.string().trim().min(1).max(255),
sslEnabled: z.boolean(),
sslRejectUnauthorized: z.boolean(),
sslCertificate: z
.string()
.trim()
.transform((value) => value || undefined)
.optional()
});
// Accounts
export const BaseSqlAccountCredentialsSchema = z.object({
username: z.string().trim().min(1),
password: z.string().trim().min(1)
});

View File

@@ -0,0 +1,7 @@
import {
TPostgresAccountCredentials,
TPostgresResourceConnectionDetails
} from "../../postgres/postgres-resource-types";
export type TSqlResourceConnectionDetails = TPostgresResourceConnectionDetails;
export type TSqlAccountCredentials = TPostgresAccountCredentials;

View File

@@ -0,0 +1,9 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TPamSessionDALFactory = ReturnType<typeof pamSessionDALFactory>;
export const pamSessionDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.PamSession);
return { ...orm };
};

View File

@@ -0,0 +1,6 @@
export enum PamSessionStatus {
Starting = "starting", // Starting, user connecting to resource
Active = "active", // Active, user is connected to resource
Ended = "ended", // Ended by user
Terminated = "terminated" // Terminated by an admin
}

View File

@@ -0,0 +1,43 @@
import { TPamSessions } from "@app/db/schemas";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/kms/kms-types";
import { TPamSanitizedSession, TPamSessionCommandLog } from "./pam-session.types";
export const decryptSessionCommandLogs = async ({
orgId,
encryptedLogs,
kmsService
}: {
orgId: string;
encryptedLogs: Buffer;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
}) => {
const { decryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.Organization,
orgId
});
const decryptedPlainTextBlob = decryptor({
cipherTextBlob: encryptedLogs
});
return JSON.parse(decryptedPlainTextBlob.toString()) as TPamSessionCommandLog;
};
export const decryptSession = async (
session: TPamSessions,
orgId: string,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
) => {
return {
...session,
commandLogs: session.encryptedLogsBlob
? await decryptSessionCommandLogs({
orgId,
encryptedLogs: session.encryptedLogsBlob,
kmsService
})
: []
} as TPamSanitizedSession;
};

View File

@@ -0,0 +1,15 @@
import { z } from "zod";
import { PamSessionsSchema } from "@app/db/schemas";
export const PamSessionCommandLogSchema = z.object({
input: z.string(),
output: z.string(),
timestamp: z.coerce.date()
});
export const SanitizedSessionSchema = PamSessionsSchema.omit({
encryptedLogsBlob: true
}).extend({
commandLogs: PamSessionCommandLogSchema.array()
});

View File

@@ -0,0 +1,168 @@
import { ForbiddenError } from "@casl/ability";
import { ActionProjectType } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { OrgServiceActor } from "@app/lib/types";
import { ActorType } from "@app/services/auth/auth-type";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/kms/kms-types";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TLicenseServiceFactory } from "../license/license-service";
import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission";
import { ProjectPermissionPamSessionActions, ProjectPermissionSub } from "../permission/project-permission";
import { TUpdateSessionLogsDTO } from "./pam-session.types";
import { TPamSessionDALFactory } from "./pam-session-dal";
import { PamSessionStatus } from "./pam-session-enums";
import { decryptSession } from "./pam-session-fns";
type TPamSessionServiceFactoryDep = {
pamSessionDAL: TPamSessionDALFactory;
projectDAL: TProjectDALFactory;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
};
export type TPamSessionServiceFactory = ReturnType<typeof pamSessionServiceFactory>;
export const pamSessionServiceFactory = ({
pamSessionDAL,
projectDAL,
permissionService,
licenseService,
kmsService
}: TPamSessionServiceFactoryDep) => {
const getById = async (sessionId: string, actor: OrgServiceActor) => {
const session = await pamSessionDAL.findById(sessionId);
if (!session) throw new NotFoundError({ message: `Session with ID '${sessionId}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId: session.projectId,
actionProjectType: ActionProjectType.PAM
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPamSessionActions.Read,
ProjectPermissionSub.PamSessions
);
return {
session: await decryptSession(session, actor.orgId, kmsService)
};
};
const list = async (projectId: string, actor: OrgServiceActor) => {
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId,
actionProjectType: ActionProjectType.PAM
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPamSessionActions.Read,
ProjectPermissionSub.PamSessions
);
const sessions = await pamSessionDAL.find({ projectId });
return {
sessions: await Promise.all(sessions.map((session) => decryptSession(session, actor.orgId, kmsService)))
};
};
const updateLogsById = async ({ sessionId, logs }: TUpdateSessionLogsDTO, actor: OrgServiceActor) => {
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
if (!orgLicensePlan.pam) {
throw new BadRequestError({
message: "PAM operation failed due to organization plan restrictions."
});
}
// To be hit by gateways only
if (actor.type !== ActorType.IDENTITY) {
throw new ForbiddenRequestError({ message: "Only gateways can perform this action" });
}
const session = await pamSessionDAL.findById(sessionId);
if (!session) throw new NotFoundError({ message: `Session with ID '${sessionId}' not found` });
const project = await projectDAL.findById(session.projectId);
if (!project) throw new NotFoundError({ message: `Project with ID '${session.projectId}' not found` });
const { permission } = await permissionService.getOrgPermission(
actor.type,
actor.id,
project.orgId,
actor.authMethod,
actor.orgId
);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionGatewayActions.CreateGateways,
OrgPermissionSubjects.Gateway
);
const { encryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.Organization,
orgId: project.orgId
});
const { cipherTextBlob } = encryptor({
plainText: Buffer.from(JSON.stringify(logs))
});
const updatedSession = await pamSessionDAL.updateById(sessionId, {
encryptedLogsBlob: cipherTextBlob
});
return { session: updatedSession, projectId: project.id };
};
const endSessionById = async (sessionId: string, actor: OrgServiceActor) => {
// To be hit by gateways only
if (actor.type !== ActorType.IDENTITY) {
throw new ForbiddenRequestError({ message: "Only gateways can perform this action" });
}
const session = await pamSessionDAL.findById(sessionId);
if (!session) throw new NotFoundError({ message: `Session with ID '${sessionId}' not found` });
const project = await projectDAL.findById(session.projectId);
if (!project) throw new NotFoundError({ message: `Project with ID '${session.projectId}' not found` });
const { permission } = await permissionService.getOrgPermission(
actor.type,
actor.id,
project.orgId,
actor.authMethod,
actor.orgId
);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionGatewayActions.CreateGateways,
OrgPermissionSubjects.Gateway
);
if (session.status !== PamSessionStatus.Active) {
throw new BadRequestError({ message: "Cannot end sessions that are not active" });
}
const updatedSession = await pamSessionDAL.updateById(sessionId, {
endedAt: new Date(),
status: PamSessionStatus.Ended
});
return { session: updatedSession, projectId: project.id };
};
return { getById, list, updateLogsById, endSessionById };
};

View File

@@ -0,0 +1,12 @@
import { z } from "zod";
import { PamSessionCommandLogSchema, SanitizedSessionSchema } from "./pam-session-schemas";
export type TPamSessionCommandLog = z.infer<typeof PamSessionCommandLogSchema>;
export type TPamSanitizedSession = z.infer<typeof SanitizedSessionSchema>;
// DTOs
export type TUpdateSessionLogsDTO = {
sessionId: string;
logs: TPamSessionCommandLog[];
};

View File

@@ -12,6 +12,8 @@ import {
ProjectPermissionIdentityActions,
ProjectPermissionKmipActions,
ProjectPermissionMemberActions,
ProjectPermissionPamAccountActions,
ProjectPermissionPamSessionActions,
ProjectPermissionPkiSubscriberActions,
ProjectPermissionPkiSyncActions,
ProjectPermissionPkiTemplateActions,
@@ -49,7 +51,9 @@ const buildAdminPermissionRules = () => {
ProjectPermissionSub.SshCertificateAuthorities,
ProjectPermissionSub.SshCertificates,
ProjectPermissionSub.SshCertificateTemplates,
ProjectPermissionSub.SshHostGroups
ProjectPermissionSub.SshHostGroups,
ProjectPermissionSub.PamFolders,
ProjectPermissionSub.PamResources
].forEach((el) => {
can(
[
@@ -290,6 +294,19 @@ const buildAdminPermissionRules = () => {
ProjectPermissionSub.AppConnections
);
can(
[
ProjectPermissionPamAccountActions.Access,
ProjectPermissionPamAccountActions.Read,
ProjectPermissionPamAccountActions.Create,
ProjectPermissionPamAccountActions.Edit,
ProjectPermissionPamAccountActions.Delete
],
ProjectPermissionSub.PamAccounts
);
can([ProjectPermissionPamSessionActions.Read], ProjectPermissionSub.PamSessions);
return rules;
};
@@ -518,6 +535,15 @@ const buildMemberPermissionRules = () => {
can(ProjectPermissionAppConnectionActions.Connect, ProjectPermissionSub.AppConnections);
can([ProjectPermissionActions.Read], ProjectPermissionSub.PamFolders);
can([ProjectPermissionActions.Read], ProjectPermissionSub.PamResources);
can(
[ProjectPermissionPamAccountActions.Access, ProjectPermissionPamAccountActions.Read],
ProjectPermissionSub.PamAccounts
);
return rules;
};
@@ -579,6 +605,12 @@ const buildViewerPermissionRules = () => {
ProjectPermissionSub.SecretEvents
);
can([ProjectPermissionActions.Read], ProjectPermissionSub.PamFolders);
can([ProjectPermissionActions.Read], ProjectPermissionSub.PamResources);
can([ProjectPermissionPamAccountActions.Read], ProjectPermissionSub.PamAccounts);
return rules;
};

View File

@@ -186,6 +186,19 @@ export enum ProjectPermissionAuditLogsActions {
Read = "read"
}
export enum ProjectPermissionPamAccountActions {
Access = "access",
Read = "read",
Create = "create",
Edit = "edit",
Delete = "delete"
}
export enum ProjectPermissionPamSessionActions {
Read = "read"
// Terminate = "terminate"
}
export enum ProjectPermissionSub {
Role = "role",
Member = "member",
@@ -228,7 +241,11 @@ export enum ProjectPermissionSub {
SecretScanningFindings = "secret-scanning-findings",
SecretScanningConfigs = "secret-scanning-configs",
SecretEvents = "secret-events",
AppConnections = "app-connections"
AppConnections = "app-connections",
PamFolders = "pam-folders",
PamResources = "pam-resources",
PamAccounts = "pam-accounts",
PamSessions = "pam-sessions"
}
export type SecretSubjectFields = {
@@ -300,6 +317,12 @@ export type AppConnectionSubjectFields = {
connectionId: string;
};
export type PamAccountSubjectFields = {
resourceName: string;
accountName: string;
accountPath: string;
};
export type ProjectPermissionSet =
| [
ProjectPermissionSecretActions,
@@ -404,7 +427,14 @@ export type ProjectPermissionSet =
| ProjectPermissionSub.AppConnections
| (ForcedSubject<ProjectPermissionSub.AppConnections> & AppConnectionSubjectFields)
)
];
]
| [ProjectPermissionActions, ProjectPermissionSub.PamFolders]
| [ProjectPermissionActions, ProjectPermissionSub.PamResources]
| [
ProjectPermissionPamAccountActions,
ProjectPermissionSub.PamAccounts | (ForcedSubject<ProjectPermissionSub.PamAccounts> & PamAccountSubjectFields)
]
| [ProjectPermissionPamSessionActions, ProjectPermissionSub.PamSessions];
const SECRET_PATH_MISSING_SLASH_ERR_MSG = "Invalid Secret Path; it must start with a '/'";
const SECRET_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([
@@ -427,6 +457,27 @@ const SECRET_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([
})
.partial()
]);
const PAM_ACCOUNT_PATH_MISSING_SLASH_ERR_MSG = "Invalid Secret Path; it must start with a '/'";
const PAM_ACCOUNT_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([
z.string().refine((val) => val.startsWith("/"), SECRET_PATH_MISSING_SLASH_ERR_MSG),
z
.object({
[PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ].refine(
(val) => val.startsWith("/"),
PAM_ACCOUNT_PATH_MISSING_SLASH_ERR_MSG
),
[PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ].refine(
(val) => val.startsWith("/"),
PAM_ACCOUNT_PATH_MISSING_SLASH_ERR_MSG
),
[PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN].refine(
(val) => val.every((el) => el.startsWith("/")),
PAM_ACCOUNT_PATH_MISSING_SLASH_ERR_MSG
),
[PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB]
})
.partial()
]);
// akhilmhdh: don't modify this for v2
// if you want to update create a new schema
const SecretConditionV1Schema = z
@@ -650,6 +701,34 @@ const AppConnectionConditionSchema = z
})
.partial();
const PamAccountConditionSchema = z
.object({
resourceName: z.union([
z.string(),
z
.object({
[PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ],
[PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ],
[PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN],
[PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB]
})
.partial()
]),
accountName: z.union([
z.string(),
z
.object({
[PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ],
[PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ],
[PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN],
[PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB]
})
.partial()
]),
accountPath: PAM_ACCOUNT_PATH_PERMISSION_OPERATOR_SCHEMA
})
.partial();
const GeneralPermissionSchema = [
z.object({
subject: z.literal(ProjectPermissionSub.SecretApproval).describe("The entity this permission pertains to."),
@@ -840,6 +919,34 @@ const GeneralPermissionSchema = [
conditions: AppConnectionConditionSchema.describe(
"When specified, only matching conditions will be allowed to access given resource."
).optional()
}),
z.object({
subject: z.literal(ProjectPermissionSub.PamFolders).describe("The entity this permission pertains to."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
"Describe what action an entity can take."
)
}),
z.object({
subject: z.literal(ProjectPermissionSub.PamResources).describe("The entity this permission pertains to."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
"Describe what action an entity can take."
)
}),
z.object({
subject: z.literal(ProjectPermissionSub.PamAccounts).describe("The entity this permission pertains to."),
inverted: z.boolean().optional().describe("Whether rule allows or forbids."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionPamAccountActions).describe(
"Describe what action an entity can take."
),
conditions: PamAccountConditionSchema.describe(
"When specified, only matching conditions will be allowed to access given resource."
).optional()
}),
z.object({
subject: z.literal(ProjectPermissionSub.PamSessions).describe("The entity this permission pertains to."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionPamSessionActions).describe(
"Describe what action an entity can take."
)
})
];

View File

@@ -66,6 +66,13 @@ import { licenseDALFactory } from "@app/ee/services/license/license-dal";
import { licenseServiceFactory } from "@app/ee/services/license/license-service";
import { oidcConfigDALFactory } from "@app/ee/services/oidc/oidc-config-dal";
import { oidcConfigServiceFactory } from "@app/ee/services/oidc/oidc-config-service";
import { pamFolderDALFactory } from "@app/ee/services/pam-folder/pam-folder-dal";
import { pamFolderServiceFactory } from "@app/ee/services/pam-folder/pam-folder-service";
import { pamAccountDALFactory } from "@app/ee/services/pam-resource/pam-account-dal";
import { pamResourceDALFactory } from "@app/ee/services/pam-resource/pam-resource-dal";
import { pamResourceServiceFactory } from "@app/ee/services/pam-resource/pam-resource-service";
import { pamSessionDALFactory } from "@app/ee/services/pam-session/pam-session-dal";
import { pamSessionServiceFactory } from "@app/ee/services/pam-session/pam-session-service";
import { permissionDALFactory } from "@app/ee/services/permission/permission-dal";
import { permissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { pitServiceFactory } from "@app/ee/services/pit/pit-service";
@@ -2099,6 +2106,37 @@ export const registerRoutes = async (
appConnectionDAL
});
const pamFolderDAL = pamFolderDALFactory(db);
const pamResourceDAL = pamResourceDALFactory(db);
const pamAccountDAL = pamAccountDALFactory(db);
const pamSessionDAL = pamSessionDALFactory(db);
const pamFolderService = pamFolderServiceFactory({
pamFolderDAL,
permissionService,
licenseService
});
const pamResourceService = pamResourceServiceFactory({
pamResourceDAL,
pamSessionDAL,
pamAccountDAL,
pamFolderDAL,
projectDAL,
permissionService,
licenseService,
kmsService,
gatewayV2Service
});
const pamSessionService = pamSessionServiceFactory({
pamSessionDAL,
projectDAL,
permissionService,
licenseService,
kmsService
});
// setup the communication with license key server
await licenseService.init();
@@ -2236,7 +2274,10 @@ export const registerRoutes = async (
reminder: reminderService,
bus: eventBusService,
sse: sseService,
notification: notificationService
notification: notificationService,
pamFolder: pamFolderService,
pamResource: pamResourceService,
pamSession: pamSessionService
});
const cronJobs: CronJob[] = [];

View File

@@ -213,6 +213,8 @@ export const listAppConnectionOptions = (projectType?: ProjectType) => {
return false;
case ProjectType.SSH:
return false;
case ProjectType.PAM:
return false;
default:
return true;
}

View File

@@ -24,8 +24,8 @@ import {
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal";
import { TPkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal";
import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils";
import { TPkiSyncQueueFactory } from "@app/services/pki-sync/pki-sync-queue";
import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns";

View File

@@ -27,8 +27,8 @@ import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal";
import { TPkiSubscriberProperties } from "@app/services/pki-subscriber/pki-subscriber-types";
import { TPkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal";
import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils";
import { TPkiSyncQueueFactory } from "@app/services/pki-sync/pki-sync-queue";
import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns";

View File

@@ -20,8 +20,8 @@ import {
} from "@app/services/certificate/certificate-types";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TPkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal";
import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils";
import { TPkiSyncQueueFactory } from "@app/services/pki-sync/pki-sync-queue";
import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns";

View File

@@ -38,8 +38,8 @@ import { TCertificateAuthoritySecretDALFactory } from "@app/services/certificate
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal";
import { TPkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal";
import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils";
import { TPkiSyncQueueFactory } from "@app/services/pki-sync/pki-sync-queue";
import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns";