Merge pull request #4590 from Infisical/ENG-3723

feat(pam): PAM Platform V1
This commit is contained in:
x032205
2025-10-03 15:35:54 -04:00
committed by GitHub
164 changed files with 10032 additions and 209 deletions

View File

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

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,165 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
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");
// Enforce uniqueness for sub-folders
t.unique(["projectId", "parentId", "name"], {
indexName: "uidx_pam_folder_children_name",
predicate: knex.whereNotNull("parentId")
});
// Enforce uniqueness for root-level folders
t.unique(["projectId", "name"], {
indexName: "uidx_pam_folder_root_name",
predicate: knex.whereNull("parentId")
});
t.text("description").nullable();
t.timestamps(true, true, true);
});
}
// PAM Resources
if (!(await knex.schema.hasTable(TableName.PamResource))) {
await knex.schema.createTable(TableName.PamResource, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("projectId").notNullable();
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
t.index("projectId");
t.string("name").notNullable();
t.index("name");
t.uuid("gatewayId").notNullable();
t.foreign("gatewayId").references("id").inTable(TableName.GatewayV2);
t.index("gatewayId");
t.string("resourceType").notNullable();
t.index("resourceType");
t.binary("encryptedConnectionDetails").notNullable();
t.timestamps(true, true, true);
});
}
// PAM Accounts
if (!(await knex.schema.hasTable(TableName.PamAccount))) {
await knex.schema.createTable(TableName.PamAccount, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("projectId").notNullable();
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
t.index("projectId");
t.uuid("folderId").nullable();
t.foreign("folderId").references("id").inTable(TableName.PamFolder).onDelete("CASCADE");
t.index("folderId");
t.uuid("resourceId").notNullable();
t.foreign("resourceId").references("id").inTable(TableName.PamResource);
t.index("resourceId");
t.string("name").notNullable();
t.index("name");
// Enforce uniqueness for folders
t.unique(["projectId", "folderId", "name"], {
indexName: "uidx_pam_account_children_name",
predicate: knex.whereNotNull("folderId")
});
// Enforce uniqueness for root-level
t.unique(["projectId", "name"], {
indexName: "uidx_pam_account_root_name",
predicate: knex.whereNull("folderId")
});
t.text("description").nullable();
t.binary("encryptedCredentials").notNullable();
t.timestamps(true, true, true);
});
}
// PAM Sessions
if (!(await knex.schema.hasTable(TableName.PamSession))) {
await knex.schema.createTable(TableName.PamSession, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("projectId").notNullable();
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
t.index("projectId");
t.uuid("accountId").nullable();
t.foreign("accountId").references("id").inTable(TableName.PamAccount).onDelete("SET NULL");
t.index("accountId");
// To be used in the event of an account deletion
t.string("resourceType").notNullable();
t.string("resourceName").notNullable();
t.string("accountName").notNullable();
t.uuid("userId").nullable();
t.foreign("userId").references("id").inTable(TableName.Users).onDelete("SET NULL");
t.index("userId");
// To be used in the event of user deletion
t.string("actorName").notNullable();
t.string("actorEmail").notNullable();
t.string("actorIp").notNullable();
t.string("actorUserAgent").notNullable();
t.string("status").notNullable();
t.index("status");
t.binary("encryptedLogsBlob").nullable();
t.datetime("expiresAt").notNullable();
t.datetime("startedAt").nullable(); // Not when the row is created, but when the end-to-end connection between user and resource is established
t.datetime("endedAt").nullable();
t.index(["startedAt", "endedAt"]);
t.timestamps(true, true, true);
});
}
await createOnUpdateTrigger(knex, TableName.PamFolder);
await createOnUpdateTrigger(knex, TableName.PamResource);
await createOnUpdateTrigger(knex, TableName.PamAccount);
await createOnUpdateTrigger(knex, TableName.PamSession);
}
export async function down(knex: Knex): Promise<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);
await dropOnUpdateTrigger(knex, TableName.PamSession);
await dropOnUpdateTrigger(knex, TableName.PamAccount);
await dropOnUpdateTrigger(knex, TableName.PamResource);
await dropOnUpdateTrigger(knex, TableName.PamFolder);
}

View File

@@ -0,0 +1,19 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasColumn(TableName.GatewayV2, "encryptedPamSessionKey"))) {
await knex.schema.alterTable(TableName.GatewayV2, (t) => {
t.binary("encryptedPamSessionKey");
});
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasColumn(TableName.GatewayV2, "encryptedPamSessionKey")) {
await knex.schema.alterTable(TableName.GatewayV2, (t) => {
t.dropColumn("encryptedPamSessionKey");
});
}
}

View File

@@ -5,6 +5,8 @@
import { z } from "zod";
import { zodBuffer } from "@app/lib/zod";
import { TImmutableDBKeys } from "./models";
export const GatewaysV2Schema = z.object({
@@ -15,7 +17,8 @@ export const GatewaysV2Schema = z.object({
identityId: z.string().uuid(),
relayId: z.string().uuid().nullable().optional(),
name: z.string(),
heartbeat: z.date().nullable().optional()
heartbeat: z.date().nullable().optional(),
encryptedPamSessionKey: zodBuffer.nullable().optional()
});
export type TGatewaysV2 = z.infer<typeof GatewaysV2Schema>;

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

@@ -189,7 +189,13 @@ export enum TableName {
Relay = "relays",
GatewayV2 = "gateways_v2",
KeyValueStore = "key_value_store"
KeyValueStore = "key_value_store",
// PAM
PamFolder = "pam_folders",
PamResource = "pam_resources",
PamAccount = "pam_accounts",
PamSession = "pam_sessions"
}
export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt" | "commitId";
@@ -281,7 +287,8 @@ export enum ProjectType {
CertificateManager = "cert-manager",
KMS = "kms",
SSH = "ssh",
SecretScanning = "secret-scanning"
SecretScanning = "secret-scanning",
PAM = "pam"
}
export enum ActionProjectType {
@@ -290,6 +297,7 @@ export enum ActionProjectType {
KMS = ProjectType.KMS,
SSH = ProjectType.SSH,
SecretScanning = ProjectType.SecretScanning,
PAM = ProjectType.PAM,
// project operations that happen on all types
Any = "any"
}

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

View File

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

View File

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

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.pamAccount.list(req.query.projectId, req.permission);
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId: req.query.projectId,
event: {
type: EventType.PAM_ACCOUNT_LIST,
metadata: {
accountCount: response.accounts.length,
folderCount: response.folders.length
}
}
});
return response;
}
});
server.route({
method: "POST",
url: "/access",
config: {
rateLimit: writeLimit
},
schema: {
description: "Access PAM account",
body: z.object({
accountId: z.string().uuid(),
duration: z
.string()
.min(1)
.transform((val, ctx) => {
const parsedMs = ms(val);
if (typeof parsedMs !== "number" || parsedMs <= 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid duration format. Must be a positive duration (e.g., '1h', '30m', '2d')."
});
return z.NEVER;
}
return parsedMs;
})
}),
response: {
200: z.object({
sessionId: z.string(),
resourceType: z.nativeEnum(PamResource),
relayClientCertificate: z.string(),
relayClientPrivateKey: z.string(),
relayServerCertificateChain: z.string(),
gatewayClientCertificate: z.string(),
gatewayClientPrivateKey: z.string(),
gatewayServerCertificateChain: z.string(),
relayHost: z.string()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
// To prevent type errors when accessing req.auth
if (req.auth.authMode !== AuthMode.JWT) {
throw new BadRequestError({ message: "You can only access PAM accounts using JWT auth tokens." });
}
const response = await server.services.pamAccount.access(
{
actorEmail: req.auth.user.email ?? "",
actorIp: req.realIp,
actorName: `${req.auth.user.firstName ?? ""} ${req.auth.user.lastName ?? ""}`.trim(),
actorUserAgent: req.auditLogInfo.userAgent ?? "",
...req.body
},
req.permission
);
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId: response.projectId,
event: {
type: EventType.PAM_ACCOUNT_ACCESS,
metadata: {
accountId: req.body.accountId,
accountName: response.account.name,
duration: req.body.duration ? new Date(req.body.duration).toISOString() : undefined
}
}
});
return response;
}
});
};

View File

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

View File

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

View File

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

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

View File

@@ -1,6 +1,7 @@
import z from "zod";
import { GatewaysV2Schema } from "@app/db/schemas";
import { zodBuffer } from "@app/lib/zod";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { slugSchema } from "@app/server/lib/schemas";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
@@ -130,4 +131,25 @@ export const registerGatewayV2Router = async (server: FastifyZodProvider) => {
return gateway;
}
});
server.route({
method: "GET",
url: "/pam-session-key",
config: {
rateLimit: readLimit
},
schema: {
response: {
200: zodBuffer
}
},
onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const pamSessionKey = await server.services.gatewayV2.getPamSessionKey({
orgPermission: req.permission
});
return pamSessionKey;
}
});
};

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,162 @@ interface OrgRoleDeleteEvent {
};
}
interface PamSessionStartEvent {
type: EventType.PAM_SESSION_START;
metadata: {
sessionId: string;
accountName: string;
};
}
interface PamSessionLogsUpdateEvent {
type: EventType.PAM_SESSION_LOGS_UPDATE;
metadata: {
sessionId: string;
accountName: string;
};
}
interface PamSessionEndEvent {
type: EventType.PAM_SESSION_END;
metadata: {
sessionId: string;
accountName: string;
};
}
interface PamSessionGetEvent {
type: EventType.PAM_SESSION_GET;
metadata: {
sessionId: string;
};
}
interface PamSessionListEvent {
type: EventType.PAM_SESSION_LIST;
metadata: {
count: number;
};
}
interface PamFolderCreateEvent {
type: EventType.PAM_FOLDER_CREATE;
metadata: {
parentId?: string | null;
name: string;
description?: string | null;
};
}
interface PamFolderUpdateEvent {
type: EventType.PAM_FOLDER_UPDATE;
metadata: {
folderId: string;
name?: string;
description?: string | null;
};
}
interface PamFolderDeleteEvent {
type: EventType.PAM_FOLDER_DELETE;
metadata: {
folderId: string;
folderName: string;
};
}
interface PamAccountListEvent {
type: EventType.PAM_ACCOUNT_LIST;
metadata: {
accountCount: number;
folderCount: number;
};
}
interface PamAccountAccessEvent {
type: EventType.PAM_ACCOUNT_ACCESS;
metadata: {
accountId: string;
accountName: string;
duration?: string;
};
}
interface PamAccountCreateEvent {
type: EventType.PAM_ACCOUNT_CREATE;
metadata: {
resourceId: string;
resourceType: string;
folderId?: string | null;
name: string;
description?: string | null;
};
}
interface PamAccountUpdateEvent {
type: EventType.PAM_ACCOUNT_UPDATE;
metadata: {
accountId: string;
resourceId: string;
resourceType: string;
name?: string;
description?: string | null;
};
}
interface PamAccountDeleteEvent {
type: EventType.PAM_ACCOUNT_DELETE;
metadata: {
accountName: string;
accountId: string;
resourceId: string;
resourceType: string;
};
}
interface PamResourceListEvent {
type: EventType.PAM_RESOURCE_LIST;
metadata: {
count: number;
};
}
interface PamResourceGetEvent {
type: EventType.PAM_RESOURCE_GET;
metadata: {
resourceId: string;
resourceType: string;
name: string;
};
}
interface PamResourceCreateEvent {
type: EventType.PAM_RESOURCE_CREATE;
metadata: {
resourceType: string;
gatewayId: string;
name: string;
};
}
interface PamResourceUpdateEvent {
type: EventType.PAM_RESOURCE_UPDATE;
metadata: {
resourceId: string;
resourceType: string;
gatewayId?: string;
name?: string;
};
}
interface PamResourceDeleteEvent {
type: EventType.PAM_RESOURCE_DELETE;
metadata: {
resourceId: string;
resourceType: string;
};
}
export type Event =
| GetSecretsEvent
| GetSecretEvent
@@ -4020,4 +4195,22 @@ export type Event =
| ProjectRoleDeleteEvent
| OrgRoleCreateEvent
| OrgRoleUpdateEvent
| OrgRoleDeleteEvent;
| OrgRoleDeleteEvent
| PamSessionStartEvent
| PamSessionLogsUpdateEvent
| PamSessionEndEvent
| PamSessionGetEvent
| PamSessionListEvent
| PamFolderCreateEvent
| PamFolderUpdateEvent
| PamFolderDeleteEvent
| PamAccountListEvent
| PamAccountAccessEvent
| PamAccountCreateEvent
| PamAccountUpdateEvent
| PamAccountDeleteEvent
| PamResourceListEvent
| PamResourceGetEvent
| PamResourceCreateEvent
| PamResourceUpdateEvent
| PamResourceDeleteEvent;

View File

@@ -1,2 +1,3 @@
export const GATEWAY_ROUTING_INFO_OID = "1.3.6.1.4.1.12345.100.1";
export const GATEWAY_ACTOR_OID = "1.3.6.1.4.1.12345.100.2";
export const PAM_INFO_OID = "1.3.6.1.4.1.12345.100.3";

View File

@@ -22,11 +22,12 @@ import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/kms/kms-types";
import { TLicenseServiceFactory } from "../license/license-service";
import { PamResource } from "../pam-resource/pam-resource-enums";
import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission";
import { TPermissionServiceFactory } from "../permission/permission-service-types";
import { TRelayDALFactory } from "../relay/relay-dal";
import { TRelayServiceFactory } from "../relay/relay-service";
import { GATEWAY_ACTOR_OID, GATEWAY_ROUTING_INFO_OID } from "./gateway-v2-constants";
import { GATEWAY_ACTOR_OID, GATEWAY_ROUTING_INFO_OID, PAM_INFO_OID } from "./gateway-v2-constants";
import { TGatewayV2DALFactory } from "./gateway-v2-dal";
import { TOrgGatewayConfigV2DALFactory } from "./org-gateway-config-v2-dal";
@@ -414,6 +415,176 @@ export const gatewayV2ServiceFactory = ({
};
};
const getPAMConnectionDetails = async ({
gatewayId,
sessionId,
duration,
resourceType,
host,
port,
actorMetadata
}: {
gatewayId: string;
sessionId: string;
resourceType: PamResource;
duration?: number;
host: string;
port: number;
actorMetadata: { id: string; type: ActorType; name: string };
}) => {
const gateway = await gatewayV2DAL.findById(gatewayId);
if (!gateway) {
return;
}
const orgGatewayConfig = await orgGatewayConfigV2DAL.findOne({ orgId: gateway.orgId });
if (!orgGatewayConfig) {
throw new NotFoundError({ message: `Gateway Config for org ${gateway.orgId} not found.` });
}
if (!gateway.relayId) {
throw new BadRequestError({
message: "Gateway is not associated with a relay"
});
}
const orgLicensePlan = await licenseService.getPlan(orgGatewayConfig.orgId);
if (!orgLicensePlan.gateway) {
throw new BadRequestError({
message: "Please upgrade your instance to Infisical's Enterprise plan to use gateways."
});
}
const { decryptor: orgKmsDecryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.Organization,
orgId: orgGatewayConfig.orgId
});
const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048);
const rootGatewayCaCert = new x509.X509Certificate(
orgKmsDecryptor({
cipherTextBlob: orgGatewayConfig.encryptedRootGatewayCaCertificate
})
);
const gatewayClientCaCert = new x509.X509Certificate(
orgKmsDecryptor({
cipherTextBlob: orgGatewayConfig.encryptedGatewayClientCaCertificate
})
);
const gatewayServerCaCert = new x509.X509Certificate(
orgKmsDecryptor({
cipherTextBlob: orgGatewayConfig.encryptedGatewayServerCaCertificate
})
);
const gatewayClientCaPrivateKey = orgKmsDecryptor({
cipherTextBlob: orgGatewayConfig.encryptedGatewayClientCaPrivateKey
});
const gatewayClientCaSkObj = crypto.nativeCrypto.createPrivateKey({
key: gatewayClientCaPrivateKey,
format: "der",
type: "pkcs8"
});
const importedGatewayClientCaPrivateKey = await crypto.nativeCrypto.subtle.importKey(
"pkcs8",
gatewayClientCaSkObj.export({ format: "der", type: "pkcs8" }),
alg,
true,
["sign"]
);
const clientCertIssuedAt = new Date();
const clientCertExpiration = new Date(new Date().getTime() + (duration ?? 5 * 60 * 1000));
const clientKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]);
const clientCertSerialNumber = createSerialNumber();
const routingInfo = {
targetHost: host,
targetPort: port
};
const routingExtension = new x509.Extension(
GATEWAY_ROUTING_INFO_OID,
false,
Buffer.from(JSON.stringify(routingInfo))
);
const pamInfoExtension = new x509.Extension(
PAM_INFO_OID,
false,
Buffer.from(
JSON.stringify({
sessionId,
resourceType
})
)
);
const actorExtension = new x509.Extension(
GATEWAY_ACTOR_OID,
false,
Buffer.from(JSON.stringify({ type: actorMetadata.type, id: actorMetadata.id, name: actorMetadata.name }))
);
const clientCert = await x509.X509CertificateGenerator.create({
serialNumber: clientCertSerialNumber,
subject: `O=${orgGatewayConfig.orgId},OU=gateway-client,CN=${actorMetadata.type}:${gatewayId}`,
issuer: gatewayClientCaCert.subject,
notAfter: clientCertExpiration,
notBefore: clientCertIssuedAt,
signingKey: importedGatewayClientCaPrivateKey,
publicKey: clientKeys.publicKey,
signingAlgorithm: alg,
extensions: [
new x509.BasicConstraintsExtension(false),
await x509.AuthorityKeyIdentifierExtension.create(gatewayClientCaCert, false),
await x509.SubjectKeyIdentifierExtension.create(clientKeys.publicKey),
new x509.CertificatePolicyExtension(["2.5.29.32.0"]), // anyPolicy
new x509.KeyUsagesExtension(
// eslint-disable-next-line no-bitwise
x509.KeyUsageFlags[CertKeyUsage.DIGITAL_SIGNATURE] |
x509.KeyUsageFlags[CertKeyUsage.KEY_ENCIPHERMENT] |
x509.KeyUsageFlags[CertKeyUsage.KEY_AGREEMENT],
true
),
new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage[CertExtendedKeyUsage.CLIENT_AUTH]], true),
routingExtension,
actorExtension,
pamInfoExtension
]
});
const gatewayClientCertPrivateKey = crypto.nativeCrypto.KeyObject.from(clientKeys.privateKey);
const relayCredentials = await relayService.getCredentialsForClient({
relayId: gateway.relayId,
orgId: gateway.orgId,
orgName: gateway.orgName,
gatewayId,
gatewayName: gateway.name,
duration
});
return {
relayHost: relayCredentials.relayHost,
gateway: {
clientCertificate: clientCert.toString("pem"),
clientPrivateKey: gatewayClientCertPrivateKey.export({ format: "pem", type: "pkcs8" }).toString(),
serverCertificateChain: constructPemChainFromCerts([gatewayServerCaCert, rootGatewayCaCert])
},
relay: {
clientCertificate: relayCredentials.clientCertificate,
clientPrivateKey: relayCredentials.clientPrivateKey,
serverCertificateChain: relayCredentials.serverCertificateChain
}
};
};
const registerGateway = async ({
orgId,
actorId,
@@ -645,14 +816,75 @@ export const gatewayV2ServiceFactory = ({
OrgPermissionSubjects.Gateway
);
return gatewayV2DAL.deleteById(gateway.id);
try {
return await gatewayV2DAL.deleteById(gateway.id);
} catch (err) {
if (
err instanceof DatabaseError &&
(err.error as { code: string })?.code === DatabaseErrorCode.ForeignKeyViolation
) {
throw new BadRequestError({
message: "Failed to delete gateway because it is attached to active resources"
});
}
throw err;
}
};
const getPamSessionKey = async ({ orgPermission }: { orgPermission: OrgServiceActor }) => {
const { permission } = await permissionService.getOrgPermission(
orgPermission.type,
orgPermission.id,
orgPermission.orgId,
orgPermission.authMethod,
orgPermission.orgId
);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionGatewayActions.CreateGateways,
OrgPermissionSubjects.Gateway
);
return gatewayV2DAL.transaction(async (tx) => {
const gateway = await gatewayV2DAL.findOne(
{
identityId: orgPermission.id
},
tx
);
if (!gateway) {
throw new NotFoundError({ message: "Gateway not found" });
}
const { encryptor, decryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.Organization,
orgId: orgPermission.orgId
});
if (gateway.encryptedPamSessionKey) {
return decryptor({ cipherTextBlob: gateway.encryptedPamSessionKey });
}
await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.GatewayPamSessionKey(gateway.id)]);
const newPamSessionKey = crypto.randomBytes(32);
const { cipherTextBlob: encryptedPamSessionKey } = encryptor({ plainText: newPamSessionKey });
await gatewayV2DAL.updateById(gateway.id, { encryptedPamSessionKey }, tx);
return newPamSessionKey;
});
};
return {
listGateways,
registerGateway,
getPlatformConnectionDetailsByGatewayId,
getPAMConnectionDetails,
deleteGatewayById,
heartbeat
heartbeat,
getPamSessionKey
};
};

View File

@@ -66,7 +66,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
enterpriseAppConnections: false,
fips: false,
eventSubscriptions: false,
machineIdentityAuthTemplates: false
machineIdentityAuthTemplates: false,
pam: false
});
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,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,61 @@
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/kms/kms-types";
import { TPamAccountCredentials } from "../pam-resource/pam-resource-types";
export const encryptAccountCredentials = async ({
projectId,
credentials,
kmsService
}: {
projectId: string;
credentials: TPamAccountCredentials;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
}) => {
const { encryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
projectId
});
const { cipherTextBlob: encryptedCredentialsBlob } = encryptor({
plainText: Buffer.from(JSON.stringify(credentials))
});
return encryptedCredentialsBlob;
};
export const decryptAccountCredentials = async ({
projectId,
encryptedCredentials,
kmsService
}: {
projectId: string;
encryptedCredentials: Buffer;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
}) => {
const { decryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
projectId
});
const decryptedPlainTextBlob = decryptor({
cipherTextBlob: encryptedCredentials
});
return JSON.parse(decryptedPlainTextBlob.toString()) as TPamAccountCredentials;
};
export const decryptAccount = async <T extends { encryptedCredentials: Buffer }>(
account: T,
projectId: string,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
): Promise<T & { credentials: TPamAccountCredentials }> => {
return {
...account,
credentials: await decryptAccountCredentials({
encryptedCredentials: account.encryptedCredentials,
projectId,
kmsService
})
} as T & { credentials: TPamAccountCredentials };
};

View File

@@ -0,0 +1,527 @@
import { ForbiddenError, subject } from "@casl/ability";
import { ActionProjectType, TPamAccounts, TPamResources } from "@app/db/schemas";
import { PAM_RESOURCE_FACTORY_MAP } from "@app/ee/services/pam-resource/pam-resource-factory";
import { decryptResource, decryptResourceConnectionDetails } from "@app/ee/services/pam-resource/pam-resource-fns";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import {
ProjectPermissionActions,
ProjectPermissionPamAccountActions,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { DatabaseErrorCode } from "@app/lib/error-codes";
import { BadRequestError, DatabaseError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { OrgServiceActor } from "@app/lib/types";
import { ActorType } from "@app/services/auth/auth-type";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TUserDALFactory } from "@app/services/user/user-dal";
import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service";
import { TLicenseServiceFactory } from "../license/license-service";
import { TPamFolderDALFactory } from "../pam-folder/pam-folder-dal";
import { getFullPamFolderPath } from "../pam-folder/pam-folder-fns";
import { TPamResourceDALFactory } from "../pam-resource/pam-resource-dal";
import { PamResource } from "../pam-resource/pam-resource-enums";
import { TPamAccountCredentials } from "../pam-resource/pam-resource-types";
import { TPamSessionDALFactory } from "../pam-session/pam-session-dal";
import { PamSessionStatus } from "../pam-session/pam-session-enums";
import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission";
import { TPamAccountDALFactory } from "./pam-account-dal";
import { decryptAccount, decryptAccountCredentials, encryptAccountCredentials } from "./pam-account-fns";
import { TAccessAccountDTO, TCreateAccountDTO, TUpdateAccountDTO } from "./pam-account-types";
type TPamAccountServiceFactoryDep = {
pamResourceDAL: TPamResourceDALFactory;
pamSessionDAL: TPamSessionDALFactory;
pamAccountDAL: TPamAccountDALFactory;
pamFolderDAL: TPamFolderDALFactory;
projectDAL: TProjectDALFactory;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
gatewayV2Service: Pick<
TGatewayV2ServiceFactory,
"getPAMConnectionDetails" | "getPlatformConnectionDetailsByGatewayId"
>;
userDAL: TUserDALFactory;
};
export type TPamAccountServiceFactory = ReturnType<typeof pamAccountServiceFactory>;
export const pamAccountServiceFactory = ({
pamResourceDAL,
pamSessionDAL,
pamAccountDAL,
pamFolderDAL,
projectDAL,
userDAL,
permissionService,
licenseService,
kmsService,
gatewayV2Service
}: TPamAccountServiceFactoryDep) => {
const create = async (
{ credentials, resourceId, name, description, folderId }: TCreateAccountDTO,
actor: OrgServiceActor
) => {
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
if (!orgLicensePlan.pam) {
throw new BadRequestError({
message: "PAM operation failed due to organization plan restrictions."
});
}
const resource = await pamResourceDAL.findById(resourceId);
if (!resource) throw new NotFoundError({ message: `Resource with ID '${resourceId}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId: resource.projectId,
actionProjectType: ActionProjectType.PAM
});
const accountPath = await getFullPamFolderPath({
pamFolderDAL,
folderId,
projectId: resource.projectId
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPamAccountActions.Create,
subject(ProjectPermissionSub.PamAccounts, {
resourceName: resource.name,
accountName: name,
accountPath
})
);
const connectionDetails = await decryptResourceConnectionDetails({
projectId: resource.projectId,
encryptedConnectionDetails: resource.encryptedConnectionDetails,
kmsService
});
const factory = PAM_RESOURCE_FACTORY_MAP[resource.resourceType as PamResource](
resource.resourceType as PamResource,
connectionDetails,
resource.gatewayId,
gatewayV2Service
);
const validatedCredentials = await factory.validateAccountCredentials(credentials);
const encryptedCredentials = await encryptAccountCredentials({
credentials: validatedCredentials,
projectId: resource.projectId,
kmsService
});
try {
const account = await pamAccountDAL.create({
projectId: resource.projectId,
resourceId: resource.id,
encryptedCredentials,
name,
description,
folderId
});
return {
...(await decryptAccount(account, resource.projectId, kmsService)),
resource: { id: resource.id, name: resource.name, resourceType: resource.resourceType }
};
} catch (err) {
if (err instanceof DatabaseError && (err.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation) {
throw new BadRequestError({
message: `Account with name '${name}' already exists for this path`
});
}
throw err;
}
};
const updateById = async (
{ accountId, credentials, description, name }: TUpdateAccountDTO,
actor: OrgServiceActor
) => {
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
if (!orgLicensePlan.pam) {
throw new BadRequestError({
message: "PAM operation failed due to organization plan restrictions."
});
}
const account = await pamAccountDAL.findById(accountId);
if (!account) throw new NotFoundError({ message: `Account with ID '${accountId}' not found` });
const resource = await pamResourceDAL.findById(account.resourceId);
if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId: account.projectId,
actionProjectType: ActionProjectType.PAM
});
const accountPath = await getFullPamFolderPath({
pamFolderDAL,
folderId: account.folderId,
projectId: account.projectId
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPamAccountActions.Edit,
subject(ProjectPermissionSub.PamAccounts, {
resourceName: resource.name,
accountName: account.name,
accountPath
})
);
const updateDoc: Partial<TPamAccounts> = {};
if (name !== undefined) {
updateDoc.name = name;
}
if (description !== undefined) {
updateDoc.description = description;
}
if (credentials !== undefined) {
const connectionDetails = await decryptResourceConnectionDetails({
projectId: account.projectId,
encryptedConnectionDetails: resource.encryptedConnectionDetails,
kmsService
});
const factory = PAM_RESOURCE_FACTORY_MAP[resource.resourceType as PamResource](
resource.resourceType as PamResource,
connectionDetails,
resource.gatewayId,
gatewayV2Service
);
// Logic to prevent overwriting unedited censored values
const finalCredentials = { ...credentials };
if (credentials.password === "******") {
const decryptedCredentials = await decryptAccountCredentials({
encryptedCredentials: account.encryptedCredentials,
projectId: account.projectId,
kmsService
});
finalCredentials.password = decryptedCredentials.password;
}
const validatedCredentials = await factory.validateAccountCredentials(finalCredentials);
const encryptedCredentials = await encryptAccountCredentials({
credentials: validatedCredentials,
projectId: account.projectId,
kmsService
});
updateDoc.encryptedCredentials = encryptedCredentials;
}
// If nothing was updated, return the fetched account
if (Object.keys(updateDoc).length === 0) {
return decryptAccount(account, account.projectId, kmsService);
}
const updatedAccount = await pamAccountDAL.updateById(accountId, updateDoc);
return {
...(await decryptAccount(updatedAccount, account.projectId, kmsService)),
resource: { id: resource.id, name: resource.name, resourceType: resource.resourceType }
};
};
const deleteById = async (id: string, actor: OrgServiceActor) => {
const account = await pamAccountDAL.findById(id);
if (!account) throw new NotFoundError({ message: `Account with ID '${id}' not found` });
const resource = await pamResourceDAL.findById(account.resourceId);
if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId: account.projectId,
actionProjectType: ActionProjectType.PAM
});
const accountPath = await getFullPamFolderPath({
pamFolderDAL,
folderId: account.folderId,
projectId: account.projectId
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPamAccountActions.Delete,
subject(ProjectPermissionSub.PamAccounts, {
resourceName: resource.name,
accountName: account.name,
accountPath
})
);
const deletedAccount = await pamAccountDAL.deleteById(id);
return {
...(await decryptAccount(deletedAccount, account.projectId, kmsService)),
resource: { id: resource.id, name: resource.name, resourceType: resource.resourceType }
};
};
const list = async (projectId: string, actor: OrgServiceActor) => {
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId,
actionProjectType: ActionProjectType.PAM
});
const accountsWithResourceDetails = await pamAccountDAL.findWithResourceDetails({ projectId });
const canReadFolders = permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.PamFolders);
const folders = canReadFolders ? await pamFolderDAL.find({ projectId }) : [];
const decryptedAndPermittedAccounts: Array<
TPamAccounts & {
resource: Pick<TPamResources, "id" | "name" | "resourceType">;
credentials: TPamAccountCredentials;
}
> = [];
for await (const account of accountsWithResourceDetails) {
const accountPath = await getFullPamFolderPath({
pamFolderDAL,
folderId: account.folderId,
projectId: account.projectId
});
// Check permission for each individual account
if (
permission.can(
ProjectPermissionPamAccountActions.Read,
subject(ProjectPermissionSub.PamAccounts, {
resourceName: account.resource.name,
accountName: account.name,
accountPath
})
)
) {
// Decrypt the account only if the user has permission to read it
const decryptedAccount = await decryptAccount(account, account.projectId, kmsService);
decryptedAndPermittedAccounts.push({
...decryptedAccount,
resource: {
id: account.resource.id,
name: account.resource.name,
resourceType: account.resource.resourceType
}
});
}
}
return {
accounts: decryptedAndPermittedAccounts,
folders
};
};
const access = async (
{ accountId, actorEmail, actorIp, actorName, actorUserAgent, duration }: TAccessAccountDTO,
actor: OrgServiceActor
) => {
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
if (!orgLicensePlan.pam) {
throw new BadRequestError({
message: "PAM operation failed due to organization plan restrictions."
});
}
const account = await pamAccountDAL.findById(accountId);
if (!account) throw new NotFoundError({ message: `Account with ID '${accountId}' not found` });
const resource = await pamResourceDAL.findById(account.resourceId);
if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId: account.projectId,
actionProjectType: ActionProjectType.PAM
});
const accountPath = await getFullPamFolderPath({
pamFolderDAL,
folderId: account.folderId,
projectId: account.projectId
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPamAccountActions.Access,
subject(ProjectPermissionSub.PamAccounts, {
resourceName: resource.name,
accountName: account.name,
accountPath
})
);
const session = await pamSessionDAL.create({
accountName: account.name,
actorEmail,
actorIp,
actorName,
actorUserAgent,
projectId: account.projectId,
resourceName: resource.name,
resourceType: resource.resourceType,
status: PamSessionStatus.Starting,
accountId: account.id,
userId: actor.id,
expiresAt: new Date(Date.now() + duration)
});
const { connectionDetails, gatewayId, resourceType } = await decryptResource(
resource,
account.projectId,
kmsService
);
const user = await userDAL.findById(actor.id);
if (!user) throw new NotFoundError({ message: `User with ID '${actor.id}' not found` });
const gatewayConnectionDetails = await gatewayV2Service.getPAMConnectionDetails({
gatewayId,
duration,
sessionId: session.id,
resourceType: resource.resourceType as PamResource,
host: connectionDetails.host,
port: connectionDetails.port,
actorMetadata: {
id: actor.id,
type: actor.type,
name: user.email ?? ""
}
});
if (!gatewayConnectionDetails) {
throw new NotFoundError({ message: `Gateway connection details for gateway '${gatewayId}' not found.` });
}
return {
sessionId: session.id,
resourceType,
relayClientCertificate: gatewayConnectionDetails.relay.clientCertificate,
relayClientPrivateKey: gatewayConnectionDetails.relay.clientPrivateKey,
relayServerCertificateChain: gatewayConnectionDetails.relay.serverCertificateChain,
gatewayClientCertificate: gatewayConnectionDetails.gateway.clientCertificate,
gatewayClientPrivateKey: gatewayConnectionDetails.gateway.clientPrivateKey,
gatewayServerCertificateChain: gatewayConnectionDetails.gateway.serverCertificateChain,
relayHost: gatewayConnectionDetails.relayHost,
projectId: account.projectId,
account
};
};
const getSessionCredentials = async (sessionId: string, actor: OrgServiceActor) => {
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
if (!orgLicensePlan.pam) {
throw new BadRequestError({
message: "PAM operation failed due to organization plan restrictions."
});
}
// To be hit by gateways only
if (actor.type !== ActorType.IDENTITY) {
throw new ForbiddenRequestError({ message: "Only gateways can perform this action" });
}
const session = await pamSessionDAL.findById(sessionId);
if (!session) throw new NotFoundError({ message: `Session with ID '${sessionId}' not found` });
const project = await projectDAL.findById(session.projectId);
if (!project) throw new NotFoundError({ message: `Project with ID '${session.projectId}' not found` });
const { permission } = await permissionService.getOrgPermission(
actor.type,
actor.id,
project.orgId,
actor.authMethod,
actor.orgId
);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionGatewayActions.CreateGateways,
OrgPermissionSubjects.Gateway
);
if (!session.accountId) throw new NotFoundError({ message: "Session is missing accountId column" });
// Verify that the session has not ended
if (session.endedAt || (session.expiresAt && session.expiresAt < new Date())) {
throw new BadRequestError({ message: "Session has ended or expired" });
}
// Verify that the session has not already had credentials fetched
if (session.status !== PamSessionStatus.Starting) {
throw new BadRequestError({ message: "Session has already been started" });
}
const account = await pamAccountDAL.findById(session.accountId);
if (!account) throw new NotFoundError({ message: `Account with ID '${session.accountId}' not found` });
const resource = await pamResourceDAL.findById(account.resourceId);
if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` });
if (resource.gatewayIdentityId !== actor.id) {
throw new ForbiddenRequestError({
message: "Identity does not have access to fetch the PAM session credentials"
});
}
const decryptedAccount = await decryptAccount(account, session.projectId, kmsService);
const decryptedResource = await decryptResource(resource, session.projectId, kmsService);
// Mark session as started
await pamSessionDAL.updateById(sessionId, {
status: PamSessionStatus.Active,
startedAt: new Date()
});
return {
credentials: {
...decryptedResource.connectionDetails,
...decryptedAccount.credentials
},
projectId: project.id,
account
};
};
return {
create,
updateById,
deleteById,
list,
access,
getSessionCredentials
};
};

View File

@@ -0,0 +1,17 @@
import { TPamAccount } from "../pam-resource/pam-resource-types";
// DTOs
export type TCreateAccountDTO = Pick<TPamAccount, "name" | "description" | "credentials" | "folderId" | "resourceId">;
export type TUpdateAccountDTO = Partial<Omit<TCreateAccountDTO, "folderId" | "resourceId">> & {
accountId: string;
};
export type TAccessAccountDTO = {
accountId: string;
actorEmail: string;
actorIp: string;
actorName: string;
actorUserAgent: string;
duration: number;
};

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

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,24 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify, selectAllTableCols } from "@app/lib/knex";
export type TPamResourceDALFactory = ReturnType<typeof pamResourceDALFactory>;
export const pamResourceDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.PamResource);
const findById = async (id: string, tx?: Knex) => {
const doc = await (tx || db.replicaNode())(TableName.PamResource)
.join(TableName.GatewayV2, `${TableName.PamResource}.gatewayId`, `${TableName.GatewayV2}.id`)
.select(selectAllTableCols(TableName.PamResource))
.select(db.ref("name").withSchema(TableName.GatewayV2).as("gatewayName"))
.select(db.ref("identityId").withSchema(TableName.GatewayV2).as("gatewayIdentityId"))
.where(`${TableName.PamResource}.id`, id)
.first();
return doc;
};
return { ...orm, findById };
};

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

View File

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

View File

@@ -0,0 +1,222 @@
import { ForbiddenError } from "@casl/ability";
import { ActionProjectType, TPamResources } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { DatabaseErrorCode } from "@app/lib/error-codes";
import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors";
import { OrgServiceActor } from "@app/lib/types";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service";
import { TLicenseServiceFactory } from "../license/license-service";
import { TPamResourceDALFactory } from "./pam-resource-dal";
import { PamResource } from "./pam-resource-enums";
import { PAM_RESOURCE_FACTORY_MAP } from "./pam-resource-factory";
import { decryptResource, encryptResourceConnectionDetails, listResourceOptions } from "./pam-resource-fns";
import { TCreateResourceDTO, TUpdateResourceDTO } from "./pam-resource-types";
type TPamResourceServiceFactoryDep = {
pamResourceDAL: TPamResourceDALFactory;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
gatewayV2Service: Pick<
TGatewayV2ServiceFactory,
"getPAMConnectionDetails" | "getPlatformConnectionDetailsByGatewayId"
>;
};
export type TPamResourceServiceFactory = ReturnType<typeof pamResourceServiceFactory>;
export const pamResourceServiceFactory = ({
pamResourceDAL,
permissionService,
licenseService,
kmsService,
gatewayV2Service
}: TPamResourceServiceFactoryDep) => {
const getById = async (id: string, resourceType: PamResource, actor: OrgServiceActor) => {
const resource = await pamResourceDAL.findById(id);
if (!resource) throw new NotFoundError({ message: `Resource with ID '${id}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId: resource.projectId,
actionProjectType: ActionProjectType.PAM
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PamResources);
if (resource.resourceType !== resourceType) {
throw new BadRequestError({
message: `Resource with ID '${id}' is not of type '${resourceType}'`
});
}
return decryptResource(resource, resource.projectId, kmsService);
};
const create = async (
{ resourceType, connectionDetails, gatewayId, name, projectId }: TCreateResourceDTO,
actor: OrgServiceActor
) => {
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
if (!orgLicensePlan.pam) {
throw new BadRequestError({
message: "PAM operation failed due to organization plan restrictions."
});
}
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId,
actionProjectType: ActionProjectType.PAM
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.PamResources);
const factory = PAM_RESOURCE_FACTORY_MAP[resourceType](
resourceType,
connectionDetails,
gatewayId,
gatewayV2Service
);
const validatedConnectionDetails = await factory.validateConnection();
const encryptedConnectionDetails = await encryptResourceConnectionDetails({
connectionDetails: validatedConnectionDetails,
projectId,
kmsService
});
const resource = await pamResourceDAL.create({
resourceType,
encryptedConnectionDetails,
gatewayId,
name,
projectId
});
return decryptResource(resource, projectId, kmsService);
};
const updateById = async ({ connectionDetails, resourceId, name }: TUpdateResourceDTO, actor: OrgServiceActor) => {
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
if (!orgLicensePlan.pam) {
throw new BadRequestError({
message: "PAM operation failed due to organization plan restrictions."
});
}
const resource = await pamResourceDAL.findById(resourceId);
if (!resource) throw new NotFoundError({ message: `Resource with ID '${resourceId}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId: resource.projectId,
actionProjectType: ActionProjectType.PAM
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.PamResources);
const updateDoc: Partial<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,
projectId: resource.projectId,
kmsService
});
updateDoc.encryptedConnectionDetails = encryptedConnectionDetails;
}
// If nothing was updated, return the fetched resource
if (Object.keys(updateDoc).length === 0) {
return decryptResource(resource, resource.projectId, kmsService);
}
const updatedResource = await pamResourceDAL.updateById(resourceId, updateDoc);
return decryptResource(updatedResource, resource.projectId, kmsService);
};
const deleteById = async (id: string, actor: OrgServiceActor) => {
const resource = await pamResourceDAL.findById(id);
if (!resource) throw new NotFoundError({ message: `Resource with ID '${id}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId: resource.projectId,
actionProjectType: ActionProjectType.PAM
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.PamResources);
try {
const deletedResource = await pamResourceDAL.deleteById(id);
return await decryptResource(deletedResource, resource.projectId, kmsService);
} catch (err) {
if (
err instanceof DatabaseError &&
(err.error as { code: string })?.code === DatabaseErrorCode.ForeignKeyViolation
) {
throw new BadRequestError({
message: "Failed to delete resource because it is attached to active PAM accounts"
});
}
throw err;
}
};
const list = async (projectId: string, actor: OrgServiceActor) => {
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId,
actionProjectType: ActionProjectType.PAM
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PamResources);
const resources = await pamResourceDAL.find({ projectId });
return {
resources: await Promise.all(resources.map((resource) => decryptResource(resource, projectId, kmsService)))
};
};
return {
getById,
create,
updateById,
deleteById,
list,
listResourceOptions
};
};

View File

@@ -0,0 +1,42 @@
import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service";
import { PamResource } from "./pam-resource-enums";
import {
TPostgresAccount,
TPostgresAccountCredentials,
TPostgresResource,
TPostgresResourceConnectionDetails
} from "./postgres/postgres-resource-types";
// Resource types
export type TPamResource = TPostgresResource;
export type TPamResourceConnectionDetails = TPostgresResourceConnectionDetails;
// Account types
export type TPamAccount = TPostgresAccount;
export type TPamAccountCredentials = TPostgresAccountCredentials;
// Resource DTOs
export type TCreateResourceDTO = Pick<
TPamResource,
"name" | "connectionDetails" | "resourceType" | "gatewayId" | "projectId"
>;
export type TUpdateResourceDTO = Partial<Omit<TCreateResourceDTO, "resourceType" | "projectId">> & {
resourceId: string;
};
// 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,
BasePamResourceSchema,
BaseUpdatePamAccountSchema,
BaseUpdatePamResourceSchema
} from "../pam-resource-schemas";
import {
BaseSqlAccountCredentialsSchema,
BaseSqlResourceConnectionDetailsSchema
} from "../shared/sql/sql-resource-schemas";
// Resources
export const PostgresResourceConnectionDetailsSchema = BaseSqlResourceConnectionDetailsSchema;
const BasePostgresResourceSchema = BasePamResourceSchema.extend({ resourceType: z.literal(PamResource.Postgres) });
export const PostgresResourceSchema = BasePostgresResourceSchema.extend({
connectionDetails: PostgresResourceConnectionDetailsSchema
});
export const PostgresResourceListItemSchema = z.object({
name: z.literal("PostgreSQL"),
resource: z.literal(PamResource.Postgres)
});
export const CreatePostgresResourceSchema = BaseCreatePamResourceSchema.extend({
connectionDetails: PostgresResourceConnectionDetailsSchema
});
export const UpdatePostgresResourceSchema = BaseUpdatePamResourceSchema.extend({
connectionDetails: PostgresResourceConnectionDetailsSchema.optional()
});
// Accounts
export const PostgresAccountCredentialsSchema = BaseSqlAccountCredentialsSchema;
export const PostgresAccountSchema = BasePamAccountSchema.extend({
credentials: PostgresAccountCredentialsSchema
});
export const CreatePostgresAccountSchema = BaseCreatePamAccountSchema.extend({
credentials: PostgresAccountCredentialsSchema
});
export const UpdatePostgresAccountSchema = BaseUpdatePamAccountSchema.extend({
credentials: PostgresAccountCredentialsSchema.optional()
});
export const SanitizedPostgresAccountWithResourceSchema = BasePamAccountSchemaWithResource.extend({
credentials: PostgresAccountCredentialsSchema.pick({
username: true
})
});
// Sessions
export const PostgresSessionCredentialsSchema = PostgresResourceConnectionDetailsSchema.and(
PostgresAccountCredentialsSchema
);

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,26 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify, selectAllTableCols } from "@app/lib/knex";
export type TPamSessionDALFactory = ReturnType<typeof pamSessionDALFactory>;
export const pamSessionDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.PamSession);
const findById = async (id: string, tx?: Knex) => {
const session = await (tx || db.replicaNode())(TableName.PamSession)
.leftJoin(TableName.PamAccount, `${TableName.PamSession}.accountId`, `${TableName.PamAccount}.id`)
.leftJoin(TableName.PamResource, `${TableName.PamAccount}.resourceId`, `${TableName.PamResource}.id`)
.leftJoin(TableName.GatewayV2, `${TableName.PamResource}.gatewayId`, `${TableName.GatewayV2}.id`)
.select(selectAllTableCols(TableName.PamSession))
.select(db.ref("name").withSchema(TableName.GatewayV2).as("gatewayName"))
.select(db.ref("identityId").withSchema(TableName.GatewayV2).as("gatewayIdentityId"))
.where(`${TableName.PamSession}.id`, id)
.first();
return session;
};
return { ...orm, findById };
};

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 ({
projectId,
encryptedLogs,
kmsService
}: {
projectId: string;
encryptedLogs: Buffer;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
}) => {
const { decryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
projectId
});
const decryptedPlainTextBlob = decryptor({
cipherTextBlob: encryptedLogs
});
return JSON.parse(decryptedPlainTextBlob.toString()) as TPamSessionCommandLog;
};
export const decryptSession = async (
session: TPamSessions,
projectId: string,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
) => {
return {
...session,
commandLogs: session.encryptedLogsBlob
? await decryptSessionCommandLogs({
projectId,
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,190 @@
import { ForbiddenError } from "@casl/ability";
import { ActionProjectType } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { OrgServiceActor } from "@app/lib/types";
import { ActorType } from "@app/services/auth/auth-type";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/kms/kms-types";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TLicenseServiceFactory } from "../license/license-service";
import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission";
import { ProjectPermissionPamSessionActions, ProjectPermissionSub } from "../permission/project-permission";
import { TUpdateSessionLogsDTO } from "./pam-session.types";
import { TPamSessionDALFactory } from "./pam-session-dal";
import { PamSessionStatus } from "./pam-session-enums";
import { decryptSession } from "./pam-session-fns";
type TPamSessionServiceFactoryDep = {
pamSessionDAL: TPamSessionDALFactory;
projectDAL: TProjectDALFactory;
permissionService: Pick<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, session.projectId, kmsService)
};
};
const list = async (projectId: string, actor: OrgServiceActor) => {
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId,
actionProjectType: ActionProjectType.PAM
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPamSessionActions.Read,
ProjectPermissionSub.PamSessions
);
const sessions = await pamSessionDAL.find({ projectId });
return {
sessions: await Promise.all(sessions.map((session) => decryptSession(session, projectId, kmsService)))
};
};
const updateLogsById = async ({ sessionId, logs }: TUpdateSessionLogsDTO, actor: OrgServiceActor) => {
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
if (!orgLicensePlan.pam) {
throw new BadRequestError({
message: "PAM operation failed due to organization plan restrictions."
});
}
// To be hit by gateways only
if (actor.type !== ActorType.IDENTITY) {
throw new ForbiddenRequestError({ message: "Only gateways can perform this action" });
}
const session = await pamSessionDAL.findById(sessionId);
if (!session) throw new NotFoundError({ message: `Session with ID '${sessionId}' not found` });
if (session.encryptedLogsBlob) {
throw new BadRequestError({ message: "Cannot update logs for sessions with existing logs" });
}
const project = await projectDAL.findById(session.projectId);
if (!project) throw new NotFoundError({ message: `Project with ID '${session.projectId}' not found` });
const { permission } = await permissionService.getOrgPermission(
actor.type,
actor.id,
project.orgId,
actor.authMethod,
actor.orgId
);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionGatewayActions.CreateGateways,
OrgPermissionSubjects.Gateway
);
if (session.gatewayIdentityId !== actor.id) {
throw new ForbiddenRequestError({ message: "Identity does not have access to update logs for this session" });
}
const { encryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
projectId: session.projectId
});
const { cipherTextBlob } = encryptor({
plainText: Buffer.from(JSON.stringify(logs))
});
const updatedSession = await pamSessionDAL.updateById(sessionId, {
encryptedLogsBlob: cipherTextBlob
});
return { session: updatedSession, projectId: project.id };
};
const endSessionById = async (sessionId: string, actor: OrgServiceActor) => {
const session = await pamSessionDAL.findById(sessionId);
if (!session) throw new NotFoundError({ message: `Session with ID '${sessionId}' not found` });
const project = await projectDAL.findById(session.projectId);
if (!project) throw new NotFoundError({ message: `Project with ID '${session.projectId}' not found` });
const { permission } = await permissionService.getOrgPermission(
actor.type,
actor.id,
project.orgId,
actor.authMethod,
actor.orgId
);
if (actor.type === ActorType.IDENTITY) {
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionGatewayActions.CreateGateways,
OrgPermissionSubjects.Gateway
);
if (session.gatewayIdentityId !== actor.id) {
throw new ForbiddenRequestError({ message: "Identity does not have access to end this session" });
}
} else if (actor.type === ActorType.USER) {
if (session.userId !== actor.id) {
throw new ForbiddenRequestError({ message: "You are not authorized to end this session" });
}
} else {
throw new ForbiddenRequestError({ message: "Only identities and users can perform this action" });
}
if (session.status === PamSessionStatus.Ended) {
return {
session,
projectId: project.id
};
}
if (session.status !== PamSessionStatus.Active && session.status !== PamSessionStatus.Starting) {
throw new BadRequestError({ message: "Cannot end sessions that are not active or starting" });
}
const updatedSession = await pamSessionDAL.updateById(sessionId, {
endedAt: new Date(),
status: PamSessionStatus.Ended
});
return { session: updatedSession, projectId: project.id };
};
return { getById, list, updateLogsById, endSessionById };
};

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

@@ -708,7 +708,8 @@ export const relayServiceFactory = ({
relayPkiClientCaCertificate,
relayPkiClientCaPrivateKey,
relayPkiServerCaCertificate,
relayPkiServerCaCertificateChain
relayPkiServerCaCertificateChain,
duration
}: {
gatewayId: string;
gatewayName: string;
@@ -718,6 +719,7 @@ export const relayServiceFactory = ({
relayPkiClientCaPrivateKey: Buffer;
relayPkiServerCaCertificate: Buffer;
relayPkiServerCaCertificateChain: Buffer;
duration?: number;
}) => {
const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048);
const relayClientCaCert = new x509.X509Certificate(relayPkiClientCaCertificate);
@@ -737,7 +739,7 @@ export const relayServiceFactory = ({
);
const clientCertIssuedAt = new Date();
const clientCertExpiration = new Date(new Date().getTime() + 5 * 60 * 1000);
const clientCertExpiration = new Date(new Date().getTime() + (duration ?? 5 * 60 * 1000));
const clientKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]);
const clientCertPrivateKey = crypto.nativeCrypto.KeyObject.from(clientKeys.privateKey);
const clientCertSerialNumber = createSerialNumber();
@@ -866,13 +868,15 @@ export const relayServiceFactory = ({
orgId,
orgName,
gatewayId,
gatewayName
gatewayName,
duration
}: {
relayId: string;
orgId: string;
orgName: string;
gatewayId: string;
gatewayName: string;
duration?: number;
}) => {
const relay = await relayDAL.findOne({
id: relayId
@@ -896,7 +900,8 @@ export const relayServiceFactory = ({
relayPkiClientCaCertificate: instanceCAs.instanceRelayPkiClientCaCertificate,
relayPkiClientCaPrivateKey: instanceCAs.instanceRelayPkiClientCaPrivateKey,
relayPkiServerCaCertificate: instanceCAs.instanceRelayPkiServerCaCertificate,
relayPkiServerCaCertificateChain: instanceCAs.instanceRelayPkiServerCaCertificateChain
relayPkiServerCaCertificateChain: instanceCAs.instanceRelayPkiServerCaCertificateChain,
duration
});
return {
@@ -914,7 +919,8 @@ export const relayServiceFactory = ({
relayPkiClientCaCertificate: orgCAs.relayPkiClientCaCertificate,
relayPkiClientCaPrivateKey: orgCAs.relayPkiClientCaPrivateKey,
relayPkiServerCaCertificate: orgCAs.relayPkiServerCaCertificate,
relayPkiServerCaCertificateChain: orgCAs.relayPkiServerCaCertificateChain
relayPkiServerCaCertificateChain: orgCAs.relayPkiServerCaCertificateChain,
duration
});
return {

View File

@@ -23,6 +23,7 @@ export const PgSqlLock = {
InstanceRelayConfigInit: () => pgAdvisoryLockHashText("instance-relay-config-init"),
OrgGatewayV2Init: (orgId: string) => pgAdvisoryLockHashText(`org-gateway-v2-init:${orgId}`),
OrgRelayConfigInit: (orgId: string) => pgAdvisoryLockHashText(`org-relay-config-init:${orgId}`),
GatewayPamSessionKey: (gatewayId: string) => pgAdvisoryLockHashText(`gateway-pam-session-key:${gatewayId}`),
IdentityLogin: (identityId: string, nonce: string) => pgAdvisoryLockHashText(`identity-login:${identityId}:${nonce}`)
} as const;

View File

@@ -66,6 +66,14 @@ import { licenseDALFactory } from "@app/ee/services/license/license-dal";
import { licenseServiceFactory } from "@app/ee/services/license/license-service";
import { oidcConfigDALFactory } from "@app/ee/services/oidc/oidc-config-dal";
import { oidcConfigServiceFactory } from "@app/ee/services/oidc/oidc-config-service";
import { pamAccountDALFactory } from "@app/ee/services/pam-account/pam-account-dal";
import { pamAccountServiceFactory } from "@app/ee/services/pam-account/pam-account-service";
import { pamFolderDALFactory } from "@app/ee/services/pam-folder/pam-folder-dal";
import { pamFolderServiceFactory } from "@app/ee/services/pam-folder/pam-folder-service";
import { pamResourceDALFactory } from "@app/ee/services/pam-resource/pam-resource-dal";
import { pamResourceServiceFactory } from "@app/ee/services/pam-resource/pam-resource-service";
import { pamSessionDALFactory } from "@app/ee/services/pam-session/pam-session-dal";
import { pamSessionServiceFactory } from "@app/ee/services/pam-session/pam-session-service";
import { permissionDALFactory } from "@app/ee/services/permission/permission-dal";
import { permissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { pitServiceFactory } from "@app/ee/services/pit/pit-service";
@@ -2110,6 +2118,46 @@ export const registerRoutes = async (
appConnectionDAL
});
const pamFolderDAL = pamFolderDALFactory(db);
const pamResourceDAL = pamResourceDALFactory(db);
const pamAccountDAL = pamAccountDALFactory(db);
const pamSessionDAL = pamSessionDALFactory(db);
const pamFolderService = pamFolderServiceFactory({
pamFolderDAL,
permissionService,
licenseService
});
const pamResourceService = pamResourceServiceFactory({
pamResourceDAL,
permissionService,
licenseService,
kmsService,
gatewayV2Service
});
const pamAccountService = pamAccountServiceFactory({
pamAccountDAL,
gatewayV2Service,
kmsService,
licenseService,
pamFolderDAL,
pamResourceDAL,
pamSessionDAL,
permissionService,
projectDAL,
userDAL
});
const pamSessionService = pamSessionServiceFactory({
pamSessionDAL,
projectDAL,
permissionService,
licenseService,
kmsService
});
// setup the communication with license key server
await licenseService.init();
@@ -2248,6 +2296,10 @@ export const registerRoutes = async (
bus: eventBusService,
sse: sseService,
notification: notificationService,
pamFolder: pamFolderService,
pamResource: pamResourceService,
pamAccount: pamAccountService,
pamSession: pamSessionService,
upgradePath: upgradePathService
});

View File

@@ -58,8 +58,8 @@ import { registerSecretRequestsRouter } from "./secret-requests-router";
import { registerSecretSharingRouter } from "./secret-sharing-router";
import { registerSecretTagRouter } from "./secret-tag-router";
import { registerSlackRouter } from "./slack-router";
import { registerUpgradePathRouter } from "./upgrade-path-router";
import { registerSsoRouter } from "./sso-router";
import { registerUpgradePathRouter } from "./upgrade-path-router";
import { registerUserActionRouter } from "./user-action-router";
import { registerUserEngagementRouter } from "./user-engagement-router";
import { registerUserRouter } from "./user-router";

View File

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

View File

@@ -197,4 +197,4 @@ volumes:
driver: local
ldap_data:
ldap_config:
grafana_storage:
grafana_storage:

View File

@@ -81,6 +81,10 @@ const PROJECT_TYPE_MENU_ITEMS = [
label: "Secret Scanning",
value: ProjectType.SecretScanning
}
// {
// label: "PAM",
// value: ProjectType.PAM
// }
];
const NewProjectForm = ({ onOpenChange }: NewProjectFormProps) => {
@@ -193,12 +197,12 @@ const NewProjectForm = ({ onOpenChange }: NewProjectFormProps) => {
errorText={error?.message}
className="flex-1"
>
<div className="mt-2 grid grid-cols-5 gap-4">
<div className="mt-2 grid grid-cols-3 gap-3">
{PROJECT_TYPE_MENU_ITEMS.map((el) => (
<div
key={el.value}
className={twMerge(
"flex cursor-pointer flex-col items-center gap-2 rounded border border-mineshaft-600 p-4 opacity-75 transition-all hover:border-primary-400 hover:bg-mineshaft-600",
"flex cursor-pointer flex-col items-center gap-2 rounded border border-mineshaft-600 px-2 py-4 opacity-75 transition-all hover:border-primary-400 hover:bg-mineshaft-600",
field.value === el.value && "border-primary-400 bg-mineshaft-600 opacity-100"
)}
onClick={() => field.onChange(el.value)}

View File

@@ -1,7 +1,7 @@
import { CredentialDisplay } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/CredentialDisplay";
import { TRedisCredentialsRotationGeneratedCredentialsResponse } from "@app/hooks/api/secretRotationsV2/types/redis-credentials-rotation";
import { ViewRotationGeneratedCredentialsDisplay } from "./shared";
import { TRedisCredentialsRotationGeneratedCredentialsResponse } from "@app/hooks/api/secretRotationsV2/types/redis-credentials-rotation";
type Props = {
generatedCredentialsResponse: TRedisCredentialsRotationGeneratedCredentialsResponse;

View File

@@ -3,6 +3,7 @@ import { Controller, useFormContext } from "react-hook-form";
import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas";
import { FormControl, Input } from "@app/components/v2";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
import { DEFAULT_PASSWORD_REQUIREMENTS } from "../schemas/shared";
export const RedisCredentialsRotationParametersFields = () => {
@@ -18,7 +19,7 @@ export const RedisCredentialsRotationParametersFields = () => {
<Controller
control={control}
name="parameters.permissionScope"
defaultValue={""}
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
tooltipClassName="max-w-[40rem] w-full"

View File

@@ -8,8 +8,8 @@ import { AwsIamUserSecretRotationParametersFields } from "./AwsIamUserSecretRota
import { AzureClientSecretRotationParametersFields } from "./AzureClientSecretRotationParametersFields";
import { LdapPasswordRotationParametersFields } from "./LdapPasswordRotationParametersFields";
import { OktaClientSecretRotationParametersFields } from "./OktaClientSecretRotationParametersFields";
import { SqlCredentialsRotationParametersFields } from "./shared";
import { RedisCredentialsRotationParametersFields } from "./RedisCredentialsRotationParametersFields";
import { SqlCredentialsRotationParametersFields } from "./shared";
const COMPONENT_MAP: Record<SecretRotation, React.FC> = {
[SecretRotation.PostgresCredentials]: SqlCredentialsRotationParametersFields,

View File

@@ -11,8 +11,8 @@ import { AwsIamUserSecretRotationReviewFields } from "./AwsIamUserSecretRotation
import { AzureClientSecretRotationReviewFields } from "./AzureClientSecretRotationReviewFields";
import { LdapPasswordRotationReviewFields } from "./LdapPasswordRotationReviewFields";
import { OktaClientSecretRotationReviewFields } from "./OktaClientSecretRotationReviewFields";
import { SqlCredentialsRotationReviewFields } from "./shared";
import { RedisCredentialsRotationReviewFields } from "./RedisCredentialsRotationReviewFields";
import { SqlCredentialsRotationReviewFields } from "./shared";
const COMPONENT_MAP: Record<SecretRotation, React.FC> = {
[SecretRotation.PostgresCredentials]: SqlCredentialsRotationReviewFields,

View File

@@ -8,8 +8,8 @@ import { AwsIamUserSecretRotationSecretsMappingFields } from "./AwsIamUserSecret
import { AzureClientSecretRotationSecretsMappingFields } from "./AzureClientSecretRotationSecretsMappingFields";
import { LdapPasswordRotationSecretsMappingFields } from "./LdapPasswordRotationSecretsMappingFields";
import { OktaClientSecretRotationSecretsMappingFields } from "./OktaClientSecretRotationSecretsMappingFields";
import { SqlCredentialsRotationSecretsMappingFields } from "./shared";
import { RedisCredentialsRotationSecretsMappingFields } from "./RedisCredentialsRotationSecretsMappingFields";
import { SqlCredentialsRotationSecretsMappingFields } from "./shared";
const COMPONENT_MAP: Record<SecretRotation, React.FC> = {
[SecretRotation.PostgresCredentials]: SqlCredentialsRotationSecretsMappingFields,

View File

@@ -8,9 +8,24 @@ export const HighlightText = ({
highlightClassName?: string;
}) => {
if (!text) return null;
const renderTextWithNewlines = (input: string, baseKeyPrefix: string = ""): React.ReactNode[] => {
if (!input) return [];
const lines = input.split("\n");
return lines.flatMap((line, index) => {
const nodes: React.ReactNode[] = [line];
if (index < lines.length - 1) {
nodes.push(<br key={`${baseKeyPrefix}-br-${line}`} />);
}
return nodes;
});
};
const searchTerm = highlight.toLowerCase().trim();
if (!searchTerm) return <span>{text}</span>;
if (!searchTerm) {
return <span>{renderTextWithNewlines(text, "full-text")}</span>;
}
const parts: React.ReactNode[] = [];
let lastIndex = 0;
@@ -20,12 +35,17 @@ export const HighlightText = ({
text.replace(regex, (match: string, offset: number) => {
if (offset > lastIndex) {
parts.push(<span key={`pre-${lastIndex}`}>{text.substring(lastIndex, offset)}</span>);
const preMatchText = text.substring(lastIndex, offset);
parts.push(
<span key={`pre-${lastIndex}`}>
{renderTextWithNewlines(preMatchText, `pre-${lastIndex}`)}
</span>
);
}
parts.push(
<span key={`match-${offset}`} className={highlightClassName || "bg-yellow/30"}>
{match}
{renderTextWithNewlines(match, `match-${offset}`)}
</span>
);
@@ -35,7 +55,12 @@ export const HighlightText = ({
});
if (lastIndex < text.length) {
parts.push(<span key={`post-${lastIndex}`}>{text.substring(lastIndex)}</span>);
const postMatchText = text.substring(lastIndex);
parts.push(
<span key={`post-${lastIndex}`}>
{renderTextWithNewlines(postMatchText, `post-${lastIndex}`)}
</span>
);
}
return parts;

View File

@@ -350,6 +350,24 @@ export const ROUTE_PATHS = Object.freeze({
"/_authenticate/_inject-org-details/_org-layout/projects/secret-scanning/$projectId/_secret-scanning-layout/findings"
)
},
Pam: {
AccountsPage: setRoute(
"/projects/pam/$projectId/accounts",
"/_authenticate/_inject-org-details/_org-layout/projects/pam/$projectId/_pam-layout/accounts"
),
ResourcesPage: setRoute(
"/projects/pam/$projectId/resources",
"/_authenticate/_inject-org-details/_org-layout/projects/pam/$projectId/_pam-layout/resources"
),
SessionsPage: setRoute(
"/projects/pam/$projectId/sessions",
"/_authenticate/_inject-org-details/_org-layout/projects/pam/$projectId/_pam-layout/sessions/"
),
PamSessionByIDPage: setRoute(
"/projects/pam/$projectId/sessions/$sessionId",
"/_authenticate/_inject-org-details/_org-layout/projects/pam/$projectId/_pam-layout/sessions/$sessionId"
)
},
Public: {
ViewSharedSecretByIDPage: setRoute("/shared/secret/$secretId", "/shared/secret/$secretId"),
ViewSecretRequestByIDPage: setRoute(

View File

@@ -187,6 +187,19 @@ export enum ProjectPermissionCommitsActions {
PerformRollback = "perform-rollback"
}
export enum ProjectPermissionPamAccountActions {
Access = "access",
Read = "read",
Create = "create",
Edit = "edit",
Delete = "delete"
}
export enum ProjectPermissionPamSessionActions {
Read = "read"
// Terminate = "terminate"
}
export type IdentityManagementSubjectFields = {
identityId: string;
};
@@ -208,7 +221,8 @@ export type ConditionalProjectPermissionSubject =
| ProjectPermissionSub.SecretImports
| ProjectPermissionSub.SecretRotation
| ProjectPermissionSub.SecretEvents
| ProjectPermissionSub.AppConnections;
| ProjectPermissionSub.AppConnections
| ProjectPermissionSub.PamAccounts;
export const formatedConditionsOperatorNames: { [K in PermissionConditionOperators]: string } = {
[PermissionConditionOperators.$EQ]: "equal to",
@@ -289,7 +303,11 @@ export enum ProjectPermissionSub {
SecretScanningFindings = "secret-scanning-findings",
SecretScanningConfigs = "secret-scanning-configs",
SecretEvents = "secret-events",
AppConnections = "app-connections"
AppConnections = "app-connections",
PamFolders = "pam-folders",
PamResources = "pam-resources",
PamAccounts = "pam-accounts",
PamSessions = "pam-sessions"
}
export type SecretSubjectFields = {
@@ -350,6 +368,12 @@ export type PkiTemplateSubjectFields = {
// (dangtony98): consider adding [commonName] as a subject field in the future
};
export type PamAccountSubjectFields = {
resourceName: string;
accountName: string;
accountPath: string;
};
export type ProjectPermissionSet =
| [
ProjectPermissionSecretActions,
@@ -475,6 +499,16 @@ export type ProjectPermissionSet =
| ProjectPermissionSub.AppConnections
| (ForcedSubject<ProjectPermissionSub.AppConnections> & AppConnectionSubjectFields)
)
];
]
| [ProjectPermissionActions, ProjectPermissionSub.PamFolders]
| [ProjectPermissionActions, ProjectPermissionSub.PamResources]
| [
ProjectPermissionPamAccountActions,
(
| ProjectPermissionSub.PamAccounts
| (ForcedSubject<ProjectPermissionSub.PamAccounts> & PamAccountSubjectFields)
)
]
| [ProjectPermissionPamSessionActions, ProjectPermissionSub.PamSessions];
export type TProjectPermission = MongoAbility<ProjectPermissionSet>;

View File

@@ -82,6 +82,8 @@ export const getProjectHomePage = (type: ProjectType, environments: ProjectEnv[]
return "/projects/cert-management/$projectId/subscribers" as const;
case ProjectType.SecretScanning:
return `/projects/${type}/$projectId/data-sources` as const;
case ProjectType.PAM:
return `/projects/${type}/$projectId/accounts` as const;
default:
return `/projects/${type}/$projectId/overview` as const;
}
@@ -93,7 +95,8 @@ export const getProjectTitle = (type: ProjectType) => {
[ProjectType.KMS]: "Key Management",
[ProjectType.CertificateManager]: "Cert Management",
[ProjectType.SSH]: "SSH",
[ProjectType.SecretScanning]: "Secret Scanning"
[ProjectType.SecretScanning]: "Secret Scanning",
[ProjectType.PAM]: "PAM"
};
return titleConvert[type];
};
@@ -104,7 +107,8 @@ export const getProjectLottieIcon = (type: ProjectType) => {
[ProjectType.KMS]: "unlock",
[ProjectType.CertificateManager]: "note",
[ProjectType.SSH]: "terminal",
[ProjectType.SecretScanning]: "secret-scan"
[ProjectType.SecretScanning]: "secret-scan",
[ProjectType.PAM]: "groups"
};
return titleConvert[type];
};

View File

@@ -1,3 +1,4 @@
import { ProjectType } from "../projects/types";
import { EventType, UserAgentType } from "./enums";
export const secretEvents: EventType[] = [
@@ -246,7 +247,26 @@ export const eventToNameMap: { [K in EventType]: string } = {
[EventType.CREATE_ORG_ROLE]: "Create Org Role",
[EventType.UPDATE_ORG_ROLE]: "Update Org Role",
[EventType.DELETE_ORG_ROLE]: "Delete Org Role"
[EventType.DELETE_ORG_ROLE]: "Delete Org Role",
[EventType.PAM_SESSION_START]: "PAM Session Start",
[EventType.PAM_SESSION_LOGS_UPDATE]: "PAM Session Logs Update",
[EventType.PAM_SESSION_END]: "PAM Session End",
[EventType.PAM_SESSION_GET]: "PAM Session Get",
[EventType.PAM_SESSION_LIST]: "PAM Session List",
[EventType.PAM_FOLDER_CREATE]: "PAM Folder Create",
[EventType.PAM_FOLDER_UPDATE]: "PAM Folder Update",
[EventType.PAM_FOLDER_DELETE]: "PAM Folder Delete",
[EventType.PAM_ACCOUNT_LIST]: "PAM Account List",
[EventType.PAM_ACCOUNT_ACCESS]: "PAM Account Access",
[EventType.PAM_ACCOUNT_CREATE]: "PAM Account Create",
[EventType.PAM_ACCOUNT_UPDATE]: "PAM Account Update",
[EventType.PAM_ACCOUNT_DELETE]: "PAM Account Delete",
[EventType.PAM_RESOURCE_LIST]: "PAM Resource List",
[EventType.PAM_RESOURCE_GET]: "PAM Resource Get",
[EventType.PAM_RESOURCE_CREATE]: "PAM Resource Create",
[EventType.PAM_RESOURCE_UPDATE]: "PAM Resource Update",
[EventType.PAM_RESOURCE_DELETE]: "PAM Resource Delete"
};
export const userAgentTypeToNameMap: { [K in UserAgentType]: string } = {
@@ -258,3 +278,35 @@ export const userAgentTypeToNameMap: { [K in UserAgentType]: string } = {
[UserAgentType.PYTHON_SDK]: "InfisicalPythonSDK",
[UserAgentType.OTHER]: "Other"
};
const sharedProjectEvents = [
EventType.ADD_PROJECT_MEMBER,
EventType.REMOVE_PROJECT_MEMBER,
EventType.CREATE_PROJECT_ROLE,
EventType.UPDATE_PROJECT_ROLE,
EventType.DELETE_PROJECT_ROLE
];
export const projectToEventsMap: Partial<Record<ProjectType, EventType[]>> = {
[ProjectType.PAM]: [
...sharedProjectEvents,
EventType.PAM_SESSION_START,
EventType.PAM_SESSION_LOGS_UPDATE,
EventType.PAM_SESSION_END,
EventType.PAM_SESSION_GET,
EventType.PAM_SESSION_LIST,
EventType.PAM_FOLDER_CREATE,
EventType.PAM_FOLDER_UPDATE,
EventType.PAM_FOLDER_DELETE,
EventType.PAM_ACCOUNT_LIST,
EventType.PAM_ACCOUNT_ACCESS,
EventType.PAM_ACCOUNT_CREATE,
EventType.PAM_ACCOUNT_UPDATE,
EventType.PAM_ACCOUNT_DELETE,
EventType.PAM_RESOURCE_LIST,
EventType.PAM_RESOURCE_GET,
EventType.PAM_RESOURCE_CREATE,
EventType.PAM_RESOURCE_UPDATE,
EventType.PAM_RESOURCE_DELETE
]
};

View File

@@ -240,5 +240,24 @@ export enum EventType {
CREATE_ORG_ROLE = "create-org-role",
UPDATE_ORG_ROLE = "update-org-role",
DELETE_ORG_ROLE = "delete-org-role"
DELETE_ORG_ROLE = "delete-org-role",
PAM_SESSION_START = "pam-session-start",
PAM_SESSION_LOGS_UPDATE = "pam-session-logs-update",
PAM_SESSION_END = "pam-session-end",
PAM_SESSION_GET = "pam-session-get",
PAM_SESSION_LIST = "pam-session-list",
PAM_FOLDER_CREATE = "pam-folder-create",
PAM_FOLDER_UPDATE = "pam-folder-update",
PAM_FOLDER_DELETE = "pam-folder-delete",
PAM_ACCOUNT_LIST = "pam-account-list",
PAM_ACCOUNT_ACCESS = "pam-account-access",
PAM_ACCOUNT_CREATE = "pam-account-create",
PAM_ACCOUNT_UPDATE = "pam-account-update",
PAM_ACCOUNT_DELETE = "pam-account-delete",
PAM_RESOURCE_LIST = "pam-resource-list",
PAM_RESOURCE_GET = "pam-resource-get",
PAM_RESOURCE_CREATE = "pam-resource-create",
PAM_RESOURCE_UPDATE = "pam-resource-update",
PAM_RESOURCE_DELETE = "pam-resource-delete"
}

View File

@@ -0,0 +1,10 @@
export enum PamResourceType {
Postgres = "postgres"
}
export enum PamSessionStatus {
Starting = "starting",
Active = "active",
Ended = "ended",
Terminated = "terminated"
}

View File

@@ -0,0 +1,5 @@
export * from "./enums";
export * from "./maps";
export * from "./mutations";
export * from "./queries";
export * from "./types";

View File

@@ -0,0 +1,8 @@
import { PamResourceType } from "./enums";
export const PAM_RESOURCE_TYPE_MAP: Record<
PamResourceType,
{ name: string; image: string; size?: number }
> = {
[PamResourceType.Postgres]: { name: "PostgreSQL", image: "Postgres.png" }
};

View File

@@ -0,0 +1,169 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { pamKeys } from "./queries";
import {
TCreatePamAccountDTO,
TCreatePamFolderDTO,
TCreatePamResourceDTO,
TDeletePamAccountDTO,
TDeletePamFolderDTO,
TDeletePamResourceDTO,
TPamAccount,
TPamFolder,
TPamResource,
TUpdatePamAccountDTO,
TUpdatePamFolderDTO,
TUpdatePamResourceDTO
} from "./types";
// Resources
export const useCreatePamResource = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ resourceType, ...params }: TCreatePamResourceDTO) => {
const { data } = await apiRequest.post<{ resource: TPamResource }>(
`/api/v1/pam/resources/${resourceType}`,
params
);
return data.resource;
},
onSuccess: ({ projectId }) => {
queryClient.invalidateQueries({ queryKey: pamKeys.listResources(projectId) });
}
});
};
export const useUpdatePamResource = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ resourceId, resourceType, ...params }: TUpdatePamResourceDTO) => {
const { data } = await apiRequest.patch<{ resource: TPamResource }>(
`/api/v1/pam/resources/${resourceType}/${resourceId}`,
params
);
return data.resource;
},
onSuccess: ({ projectId }) => {
queryClient.invalidateQueries({ queryKey: pamKeys.listResources(projectId) });
}
});
};
export const useDeletePamResource = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ resourceId, resourceType }: TDeletePamResourceDTO) => {
const { data } = await apiRequest.delete<{ resource: TPamResource }>(
`/api/v1/pam/resources/${resourceType}/${resourceId}`
);
return data.resource;
},
onSuccess: ({ projectId }) => {
queryClient.invalidateQueries({ queryKey: pamKeys.listResources(projectId) });
}
});
};
// Accounts
export const useCreatePamAccount = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ resourceType, ...params }: TCreatePamAccountDTO) => {
const { data } = await apiRequest.post<{ account: TPamAccount }>(
`/api/v1/pam/accounts/${resourceType}`,
params
);
return data.account;
},
onSuccess: ({ projectId }) => {
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) });
}
});
};
export const useUpdatePamAccount = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ resourceType, accountId, ...params }: TUpdatePamAccountDTO) => {
const { data } = await apiRequest.patch<{ account: TPamAccount }>(
`/api/v1/pam/accounts/${resourceType}/${accountId}`,
params
);
return data.account;
},
onSuccess: ({ projectId }) => {
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) });
}
});
};
export const useDeletePamAccount = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ resourceType, accountId }: TDeletePamAccountDTO) => {
const { data } = await apiRequest.delete<{ account: TPamAccount }>(
`/api/v1/pam/accounts/${resourceType}/${accountId}`
);
return data.account;
},
onSuccess: ({ projectId }) => {
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) });
}
});
};
// Folders
export const useCreatePamFolder = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (params: TCreatePamFolderDTO) => {
const { data } = await apiRequest.post<{ folder: TPamFolder }>("/api/v1/pam/folders", params);
return data.folder;
},
onSuccess: ({ projectId }) => {
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) });
}
});
};
export const useUpdatePamFolder = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ folderId, ...params }: TUpdatePamFolderDTO) => {
const { data } = await apiRequest.patch<{ folder: TPamFolder }>(
`/api/v1/pam/folders/${folderId}`,
params
);
return data.folder;
},
onSuccess: ({ projectId }) => {
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) });
}
});
};
export const useDeletePamFolder = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ folderId }: TDeletePamFolderDTO) => {
const { data } = await apiRequest.delete<{ folder: TPamFolder }>(
`/api/v1/pam/folders/${folderId}`
);
return data.folder;
},
onSuccess: ({ projectId }) => {
queryClient.invalidateQueries({ queryKey: pamKeys.listAccounts(projectId) });
}
});
};

View File

@@ -0,0 +1,138 @@
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { TPamResourceOption } from "./types/resource-options";
import { TPamAccount, TPamFolder, TPamResource, TPamSession } from "./types";
export const pamKeys = {
all: ["pam"] as const,
resource: () => [...pamKeys.all, "resource"] as const,
account: () => [...pamKeys.all, "account"] as const,
session: () => [...pamKeys.all, "session"] as const,
listResourceOptions: () => [...pamKeys.resource(), "options"] as const,
listResources: (projectId: string) => [...pamKeys.resource(), "list", projectId],
listAccounts: (projectId: string) => [...pamKeys.account(), "list", projectId],
getSession: (sessionId: string) => [...pamKeys.session(), "get", sessionId],
listSessions: (projectId: string) => [...pamKeys.session(), "list", projectId]
};
// Resources
export const useListPamResourceOptions = (
options?: Omit<
UseQueryOptions<
TPamResourceOption[],
unknown,
TPamResourceOption[],
ReturnType<typeof pamKeys.listResourceOptions>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: pamKeys.listResourceOptions(),
queryFn: async () => {
const { data } = await apiRequest.get<{ resourceOptions: TPamResourceOption[] }>(
"/api/v1/pam/resources/options"
);
return data.resourceOptions;
},
...options
});
};
export const useListPamResources = (
projectId: string,
options?: Omit<
UseQueryOptions<
TPamResource[],
unknown,
TPamResource[],
ReturnType<typeof pamKeys.listResources>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: pamKeys.listResources(projectId),
queryFn: async () => {
const { data } = await apiRequest.get<{ resources: TPamResource[] }>(
"/api/v1/pam/resources",
{ params: { projectId } }
);
return data.resources;
},
...options
});
};
// Accounts
export const useListPamAccounts = (
projectId: string,
options?: Omit<
UseQueryOptions<
{ accounts: TPamAccount[]; folders: TPamFolder[] },
unknown,
{ accounts: TPamAccount[]; folders: TPamFolder[] },
ReturnType<typeof pamKeys.listAccounts>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: pamKeys.listAccounts(projectId),
queryFn: async () => {
const { data } = await apiRequest.get<{ accounts: TPamAccount[]; folders: TPamFolder[] }>(
"/api/v1/pam/accounts",
{ params: { projectId } }
);
return data;
},
...options
});
};
// Sessions
export const useGetPamSessionById = (
sessionId: string,
options?: Omit<
UseQueryOptions<TPamSession, unknown, TPamSession, ReturnType<typeof pamKeys.getSession>>,
"queryKey" | "queryFn" | "enabled"
>
) => {
return useQuery({
queryKey: pamKeys.getSession(sessionId),
queryFn: async () => {
const { data } = await apiRequest.get<{ session: TPamSession }>(
`/api/v1/pam/sessions/${sessionId}`
);
return data.session;
},
enabled: !!sessionId,
...options
});
};
export const useListPamSessions = (
projectId: string,
options?: Omit<
UseQueryOptions<TPamSession[], unknown, TPamSession[], ReturnType<typeof pamKeys.listSessions>>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: pamKeys.listSessions(projectId),
queryFn: async () => {
const { data } = await apiRequest.get<{ sessions: TPamSession[] }>("/api/v1/pam/sessions", {
params: { projectId }
});
return data.sessions;
},
...options
});
};

View File

@@ -0,0 +1,17 @@
import { PamResourceType } from "../enums";
export interface TBasePamAccount {
id: string;
projectId: string;
folderId?: string | null;
resourceId: string;
resource: {
id: string;
name: string;
resourceType: PamResourceType;
};
name: string;
description?: string | null;
createdAt: string;
updatedAt: string;
}

View File

@@ -0,0 +1,8 @@
export interface TBasePamResource {
id: string;
projectId: string;
name: string;
gatewayId: string;
createdAt: string;
updatedAt: string;
}

View File

@@ -0,0 +1,95 @@
import { PamResourceType, PamSessionStatus } from "../enums";
import { TPostgresAccount, TPostgresResource } from "./postgres-resource";
export * from "./postgres-resource";
export type TPamResource = TPostgresResource;
export type TPamAccount = TPostgresAccount;
export type TPamFolder = {
id: string;
projectId: string;
parentId?: string | null;
name: string;
description?: string | null;
createdAt: string;
updatedAt: string;
};
export type TPamSession = {
id: string;
projectId: string;
accountId?: string | null;
resourceType: PamResourceType;
resourceName: string;
accountName: string;
userId?: string | null;
actorName: string;
actorEmail: string;
actorIp: string;
actorUserAgent: string;
status: PamSessionStatus;
expiresAt?: string | null;
startedAt?: string | null;
endedAt?: string | null;
createdAt: string;
updatedAt: string;
commandLogs: {
input: string;
output: string;
timestamp: string;
}[];
};
// Resource DTOs
export type TCreatePamResourceDTO = Pick<
TPamResource,
"name" | "connectionDetails" | "resourceType" | "gatewayId" | "projectId"
>;
export type TUpdatePamResourceDTO = Partial<
Pick<TPamResource, "name" | "connectionDetails" | "gatewayId">
> & {
resourceId: string;
resourceType: PamResourceType;
};
export type TDeletePamResourceDTO = {
resourceId: string;
resourceType: PamResourceType;
};
// Account DTOs
export type TCreatePamAccountDTO = Pick<
TPamAccount,
"name" | "description" | "credentials" | "projectId" | "resourceId" | "folderId"
> & {
resourceType: PamResourceType;
};
export type TUpdatePamAccountDTO = Partial<
Pick<TPamAccount, "name" | "description" | "credentials">
> & {
accountId: string;
resourceType: PamResourceType;
};
export type TDeletePamAccountDTO = {
accountId: string;
resourceType: PamResourceType;
};
// Folder DTOs
export type TCreatePamFolderDTO = Pick<
TPamFolder,
"name" | "description" | "parentId" | "projectId"
>;
export type TUpdatePamFolderDTO = Partial<Pick<TPamFolder, "name" | "description">> & {
folderId: string;
};
export type TDeletePamFolderDTO = {
folderId: string;
};

View File

@@ -0,0 +1,14 @@
import { PamResourceType } from "../enums";
import { TBaseSqlConnectionDetails, TBaseSqlCredentials } from "./shared/sql-resource";
import { TBasePamAccount } from "./base-account";
import { TBasePamResource } from "./base-resource";
// Resources
export type TPostgresResource = TBasePamResource & { resourceType: PamResourceType.Postgres } & {
connectionDetails: TBaseSqlConnectionDetails;
};
// Accounts
export type TPostgresAccount = TBasePamAccount & {
credentials: TBaseSqlCredentials;
};

View File

@@ -0,0 +1,11 @@
import { PamResourceType } from "../enums";
export type TPamResourceOptionBase = {
name: string;
};
export type TPostgresResourceOption = TPamResourceOptionBase & {
resource: PamResourceType.Postgres;
};
export type TPamResourceOption = TPostgresResourceOption;

View File

@@ -0,0 +1,12 @@
export type TBaseSqlConnectionDetails = {
host: string;
port: number;
database: string;
sslEnabled: boolean;
sslRejectUnauthorized: boolean;
};
export type TBaseSqlCredentials = {
username: string;
password: string;
};

View File

@@ -13,7 +13,8 @@ export enum ProjectType {
CertificateManager = "cert-manager",
KMS = "kms",
SSH = "ssh",
SecretScanning = "secret-scanning"
SecretScanning = "secret-scanning",
PAM = "pam"
}
export enum ProjectUserMembershipTemporaryMode {

View File

@@ -337,7 +337,7 @@ export const useMoveSecrets = ({
destinationSecretPath,
secretIds,
shouldOverwrite,
projectId
projectSlug
}) => {
const { data } = await apiRequest.post<{
isSourceUpdated: boolean;
@@ -349,7 +349,7 @@ export const useMoveSecrets = ({
destinationSecretPath,
secretIds,
shouldOverwrite,
projectId
projectSlug
});
return data;

View File

@@ -239,6 +239,7 @@ export type TDeleteSecretBatchDTO = {
export type TMoveSecretsDTO = {
projectId: string;
projectSlug: string;
sourceEnvironment: string;
sourceSecretPath: string;
destinationEnvironment: string;

View File

@@ -58,4 +58,5 @@ export type SubscriptionPlan = {
cardDeclined?: boolean;
cardDeclinedReason?: string;
machineIdentityAuthTemplates: boolean;
pam: boolean;
};

View File

@@ -0,0 +1,195 @@
import { useEffect } from "react";
import {
faBook,
faBoxOpen,
faCog,
faDisplay,
faHome,
faUser,
faUsers
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, Outlet } from "@tanstack/react-router";
import { motion } from "framer-motion";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2";
import { useProject, useProjectPermission, useSubscription } from "@app/context";
import { usePopUp } from "@app/hooks";
import { AssumePrivilegeModeBanner } from "../ProjectLayout/components/AssumePrivilegeModeBanner";
export const PamLayout = () => {
const { currentProject } = useProject();
const { subscription } = useSubscription();
const { assumedPrivilegeDetails } = useProjectPermission();
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"]);
useEffect(() => {
if (subscription && !subscription.pam) {
handlePopUpOpen("upgradePlan");
}
}, [subscription]);
return (
<>
<div className="dark hidden h-full w-full flex-col overflow-x-hidden md:flex">
<div className="flex flex-grow flex-col overflow-y-hidden md:flex-row">
<motion.div
key="menu-project-items"
initial={{ x: -150 }}
animate={{ x: 0 }}
exit={{ x: -150 }}
transition={{ duration: 0.2 }}
className="dark w-full border-r border-mineshaft-600 bg-gradient-to-tr from-mineshaft-700 via-mineshaft-800 to-mineshaft-900 md:w-60"
>
<nav className="items-between flex h-full flex-col overflow-y-auto dark:[color-scheme:dark]">
<div className="flex items-center gap-3 border-b border-mineshaft-600 px-4 py-3.5 text-lg text-white">
<Lottie className="inline-block h-5 w-5 shrink-0" icon="groups" />
PAM
</div>
<div className="flex-1">
<Menu>
<MenuGroup title="Resources">
<Link
to="/projects/pam/$projectId/accounts"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faUser} />
</div>
Accounts
</div>
</MenuItem>
)}
</Link>
<Link
to="/projects/pam/$projectId/resources"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faBoxOpen} />
</div>
Resources
</div>
</MenuItem>
)}
</Link>
<Link
to="/projects/pam/$projectId/sessions"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faDisplay} />
</div>
Sessions
</div>
</MenuItem>
)}
</Link>
</MenuGroup>
<MenuGroup title="Others">
<Link
to="/projects/pam/$projectId/access-management"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faUsers} />
</div>
Access Management
</div>
</MenuItem>
)}
</Link>
<Link
to="/projects/pam/$projectId/audit-logs"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faBook} />
</div>
Audit Logs
</div>
</MenuItem>
)}
</Link>
<Link
to="/projects/pam/$projectId/settings"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<MenuItem isSelected={isActive}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faCog} />
</div>
Settings
</div>
</MenuItem>
)}
</Link>
</MenuGroup>
</Menu>
</div>
<div>
<Menu>
<Link to="/organization/projects">
<MenuItem
className="relative flex items-center gap-2 overflow-hidden text-sm text-mineshaft-400 hover:text-mineshaft-300"
leftIcon={
<div className="w-6">
<FontAwesomeIcon className="mx-1 inline-block shrink-0" icon={faHome} />
</div>
}
>
Organization Home
</MenuItem>
</Link>
</Menu>
</div>
</nav>
</motion.div>
<div className="flex-1 overflow-y-auto overflow-x-hidden bg-bunker-800 p-4 pt-8">
{assumedPrivilegeDetails && <AssumePrivilegeModeBanner />}
<Outlet />
</div>
</div>
</div>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("upgradePlan", isOpen);
}}
text="You can use PAM if you switch to a paid Infisical plan."
/>
</>
);
};

View File

@@ -0,0 +1 @@
export { PamLayout } from "./PamLayout";

View File

@@ -164,7 +164,10 @@ export const PkiSyncRow = ({
</div>
</Td>
{subscriberId ? (
<PkiSyncTableCell primaryText={pkiSync.subscriber?.name || subscriberId} secondaryText="PKI Subscriber" />
<PkiSyncTableCell
primaryText={pkiSync.subscriber?.name || subscriberId}
secondaryText="PKI Subscriber"
/>
) : (
<Td>
<Tooltip content="The PKI subscriber for this sync has been deleted. Configure a new source or remove this sync.">

View File

@@ -40,6 +40,7 @@ import { OktaConnectionForm } from "./OktaConnectionForm";
import { OracleDBConnectionForm } from "./OracleDBConnectionForm";
import { PostgresConnectionForm } from "./PostgresConnectionForm";
import { RailwayConnectionForm } from "./RailwayConnectionForm";
import { RedisConnectionForm } from "./RedisConnectionForm";
import { RenderConnectionForm } from "./RenderConnectionForm";
import { SupabaseConnectionForm } from "./SupabaseConnectionForm";
import { TeamCityConnectionForm } from "./TeamCityConnectionForm";
@@ -47,7 +48,6 @@ import { TerraformCloudConnectionForm } from "./TerraformCloudConnectionForm";
import { VercelConnectionForm } from "./VercelConnectionForm";
import { WindmillConnectionForm } from "./WindmillConnectionForm";
import { ZabbixConnectionForm } from "./ZabbixConnectionForm";
import { RedisConnectionForm } from "./RedisConnectionForm";
type FormProps = {
onComplete: (appConnection: TAppConnection) => void;

View File

@@ -1,9 +1,11 @@
import { useState } from "react";
import { Controller, FormProvider, useForm } from "react-hook-form";
import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Tab } from "@headlessui/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Tab } from "@headlessui/react";
import {
Button,
FormControl,
@@ -24,8 +26,6 @@ import {
genericAppConnectionFieldsSchema,
GenericAppConnectionsFields
} from "./GenericAppConnectionFields";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
type Props = {
appConnection?: TRedisConnection;
@@ -123,175 +123,173 @@ export const RedisConnectionForm = ({ appConnection, onSubmit }: Props) => {
)}
/>
<>
<Tab.Group selectedIndex={selectedTabIndex} onChange={setSelectedTabIndex}>
<Tab.List className="-pb-1 mb-6 w-full border-b-2 border-mineshaft-600">
<Tab
className={({ selected }) =>
`w-30 -mb-[0.14rem] px-4 py-2 text-sm font-medium outline-none disabled:opacity-60 ${
selected
? "border-b-2 border-mineshaft-300 text-mineshaft-200"
: "text-bunker-300"
}`
}
>
Configuration
</Tab>
<Tab
className={({ selected }) =>
`w-30 -mb-[0.14rem] px-4 py-2 text-sm font-medium outline-none disabled:opacity-60 ${
selected
? "border-b-2 border-mineshaft-300 text-mineshaft-200"
: "text-bunker-300"
}`
}
>
SSL ({sslEnabled ? "Enabled" : "Disabled"})
</Tab>
</Tab.List>
<Tab.Panels className="mb-4 rounded border border-mineshaft-600 bg-mineshaft-700/70 p-3 pb-0">
<Tab.Panel>
<div className="mt-[0.675rem] flex items-start gap-2">
<Controller
name="credentials.host"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
className="flex-1"
errorText={error?.message}
isError={Boolean(error?.message)}
label="Host"
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
name="credentials.port"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
className="w-28"
errorText={error?.message}
isError={Boolean(error?.message)}
label="Port"
>
<Input type="number" {...field} />
</FormControl>
)}
/>
</div>
<div className="mb-[0.675rem] flex items-start gap-2">
<Controller
name="credentials.username"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Username"
className="flex-1"
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
name="credentials.password"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Password"
className="flex-1"
>
<SecretInput
containerClassName="text-gray-400 w-full group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</FormControl>
)}
/>
</div>
</Tab.Panel>
<Tab.Panel>
<Tab.Group selectedIndex={selectedTabIndex} onChange={setSelectedTabIndex}>
<Tab.List className="-pb-1 mb-6 w-full border-b-2 border-mineshaft-600">
<Tab
className={({ selected }) =>
`w-30 -mb-[0.14rem] px-4 py-2 text-sm font-medium outline-none disabled:opacity-60 ${
selected
? "border-b-2 border-mineshaft-300 text-mineshaft-200"
: "text-bunker-300"
}`
}
>
Configuration
</Tab>
<Tab
className={({ selected }) =>
`w-30 -mb-[0.14rem] px-4 py-2 text-sm font-medium outline-none disabled:opacity-60 ${
selected
? "border-b-2 border-mineshaft-300 text-mineshaft-200"
: "text-bunker-300"
}`
}
>
SSL ({sslEnabled ? "Enabled" : "Disabled"})
</Tab>
</Tab.List>
<Tab.Panels className="mb-4 rounded border border-mineshaft-600 bg-mineshaft-700/70 p-3 pb-0">
<Tab.Panel>
<div className="mt-[0.675rem] flex items-start gap-2">
<Controller
name="credentials.sslEnabled"
name="credentials.host"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error?.message)} errorText={error?.message}>
<Switch
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-green/80"
id="ssl-enabled"
thumbClassName="bg-mineshaft-800"
isChecked={value}
onCheckedChange={onChange}
>
Enable SSL
</Switch>
render={({ field, fieldState: { error } }) => (
<FormControl
className="flex-1"
errorText={error?.message}
isError={Boolean(error?.message)}
label="Host"
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
name="credentials.sslCertificate"
name="credentials.port"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
className="w-28"
errorText={error?.message}
isError={Boolean(error?.message)}
label="Port"
>
<Input type="number" {...field} />
</FormControl>
)}
/>
</div>
<div className="mb-[0.675rem] flex items-start gap-2">
<Controller
name="credentials.username"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
className={sslEnabled ? "" : "opacity-50"}
label="SSL Certificate"
isOptional
label="Username"
className="flex-1"
>
<TextArea
className="h-[3.5rem] !resize-none"
{...field}
isDisabled={!sslEnabled}
/>
<Input {...field} />
</FormControl>
)}
/>
<Controller
name="credentials.sslRejectUnauthorized"
name="credentials.password"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
className={sslEnabled ? "" : "opacity-50"}
isError={Boolean(error?.message)}
errorText={error?.message}
isError={Boolean(error?.message)}
label="Password"
className="flex-1"
>
<Switch
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-green/80"
id="ssl-reject-unauthorized"
thumbClassName="bg-mineshaft-800"
isChecked={sslEnabled ? value : false}
onCheckedChange={onChange}
isDisabled={!sslEnabled}
>
<p className="w-[9.5rem]">
Reject Unauthorized
<Tooltip
className="max-w-md"
content={
<p>
If enabled, Infisical will only connect to the server if it has a
valid, trusted SSL certificate.
</p>
}
>
<FontAwesomeIcon icon={faQuestionCircle} size="sm" className="ml-1" />
</Tooltip>
</p>
</Switch>
<SecretInput
containerClassName="text-gray-400 w-full group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</FormControl>
)}
/>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</>
</div>
</Tab.Panel>
<Tab.Panel>
<Controller
name="credentials.sslEnabled"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error?.message)} errorText={error?.message}>
<Switch
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-green/80"
id="ssl-enabled"
thumbClassName="bg-mineshaft-800"
isChecked={value}
onCheckedChange={onChange}
>
Enable SSL
</Switch>
</FormControl>
)}
/>
<Controller
name="credentials.sslCertificate"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
className={sslEnabled ? "" : "opacity-50"}
label="SSL Certificate"
isOptional
>
<TextArea
className="h-[3.5rem] !resize-none"
{...field}
isDisabled={!sslEnabled}
/>
</FormControl>
)}
/>
<Controller
name="credentials.sslRejectUnauthorized"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
className={sslEnabled ? "" : "opacity-50"}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Switch
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-green/80"
id="ssl-reject-unauthorized"
thumbClassName="bg-mineshaft-800"
isChecked={sslEnabled ? value : false}
onCheckedChange={onChange}
isDisabled={!sslEnabled}
>
<p className="w-[9.5rem]">
Reject Unauthorized
<Tooltip
className="max-w-md"
content={
<p>
If enabled, Infisical will only connect to the server if it has a
valid, trusted SSL certificate.
</p>
}
>
<FontAwesomeIcon icon={faQuestionCircle} size="sm" className="ml-1" />
</Tooltip>
</p>
</Switch>
</FormControl>
)}
/>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
<div className="mt-6 flex items-center">
<Button

View File

@@ -24,12 +24,13 @@ import { useOrganization } from "@app/context";
import { useGetUserProjects } from "@app/hooks/api";
import {
eventToNameMap,
projectToEventsMap,
secretEvents,
userAgentTypeToNameMap
} from "@app/hooks/api/auditLogs/constants";
import { EventType } from "@app/hooks/api/auditLogs/enums";
import { UserAgentType } from "@app/hooks/api/auth/types";
import { Project } from "@app/hooks/api/projects/types";
import { Project, ProjectType } from "@app/hooks/api/projects/types";
import { LogFilterItem } from "./LogFilterItem";
import { auditLogFilterFormSchema, Presets, TAuditLogFilterFormData } from "./types";
@@ -94,10 +95,20 @@ export const LogsFilter = ({ presets, setFilter, filter, project }: Props) => {
const selectedEventTypes = watch("eventType") as EventType[] | undefined;
const selectedProject = project ?? watch("project");
const currentSelectedEventTypes = selectedEventTypes ?? [];
const hasSecretEventFilter = currentSelectedEventTypes.some((eventType) =>
secretEvents.includes(eventType)
);
const showSecretsSection =
selectedEventTypes?.some(
(eventType) => secretEvents.includes(eventType) && eventType !== EventType.GET_SECRETS
) || selectedEventTypes?.length === 0;
selectedProject?.type !== ProjectType.PAM &&
(hasSecretEventFilter || currentSelectedEventTypes.length === 0);
const filteredEventTypes = useMemo(() => {
const projectEvents = project?.type ? projectToEventsMap[project.type] : undefined;
if (!projectEvents) return eventTypes;
return eventTypes.filter((v) => projectEvents.includes(v.value as EventType));
}, [project]);
const availableEnvironments = useMemo(() => {
if (!selectedProject) return [];
@@ -166,7 +177,7 @@ export const LogsFilter = ({ presets, setFilter, filter, project }: Props) => {
<DropdownMenuTrigger asChild>
<div className="thin-scrollbar inline-flex w-full cursor-pointer items-center justify-between whitespace-nowrap rounded-md border border-mineshaft-500 bg-mineshaft-700 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
{selectedEventTypes?.length === 1
? eventTypes.find(
? filteredEventTypes.find(
(eventType) => eventType.value === selectedEventTypes[0]
)?.label
: selectedEventTypes?.length === 0
@@ -181,8 +192,8 @@ export const LogsFilter = ({ presets, setFilter, filter, project }: Props) => {
className="thin-scrollbar z-[100] max-h-80 overflow-hidden"
>
<div className="max-h-80 overflow-y-auto">
{eventTypes && eventTypes.length > 0 ? (
eventTypes.map((eventType) => {
{filteredEventTypes.length > 0 ? (
filteredEventTypes.map((eventType) => {
const isSelected = selectedEventTypes?.includes(
eventType.value as EventType
);
@@ -190,7 +201,7 @@ export const LogsFilter = ({ presets, setFilter, filter, project }: Props) => {
return (
<DropdownMenuItem
onSelect={(event) =>
eventTypes.length > 1 && event.preventDefault()
filteredEventTypes.length > 1 && event.preventDefault()
}
onClick={() => {
if (

View File

@@ -63,6 +63,10 @@ const PROJECT_TYPE_MENU_ITEMS = [
{
label: "Secret Scanning",
value: ProjectType.SecretScanning
},
{
label: "PAM",
value: ProjectType.PAM
}
];
@@ -131,12 +135,12 @@ const ProjectTemplateForm = ({ onComplete, projectTemplate }: FormProps) => {
errorText={error?.message}
className="flex-1"
>
<div className="mt-2 grid grid-cols-5 gap-4">
<div className="mt-2 grid grid-cols-3 gap-3">
{PROJECT_TYPE_MENU_ITEMS.map((el) => (
<div
key={el.value}
className={twMerge(
"flex cursor-pointer flex-col items-center gap-2 rounded border border-mineshaft-600 p-4 opacity-75 transition-all hover:border-primary-400 hover:bg-mineshaft-600",
"flex cursor-pointer flex-col items-center gap-2 rounded border border-mineshaft-600 px-2 py-4 opacity-75 transition-all hover:border-primary-400 hover:bg-mineshaft-600",
field.value === el.value && "border-primary-400 bg-mineshaft-600 opacity-100"
)}
onClick={() => field.onChange(el.value)}

View File

@@ -0,0 +1,34 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { ProjectPermissionCan } from "@app/components/permissions";
import { PageHeader } from "@app/components/v2";
import { ProjectPermissionSub } from "@app/context";
import { ProjectPermissionPamAccountActions } from "@app/context/ProjectPermissionContext/types";
import { PamAccountsSection } from "./components/PamAccountsSection";
export const PamAccountsPage = () => {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>{t("common.head-title", { title: "PAM" })}</title>
</Helmet>
<ProjectPermissionCan
renderGuardBanner
I={ProjectPermissionPamAccountActions.Read}
a={ProjectPermissionSub.PamAccounts}
>
<div className="h-full bg-bunker-800">
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<PageHeader title="Accounts" description="View, access, and manage accounts." />
<PamAccountsSection />
</div>
</div>
</div>
</ProjectPermissionCan>
</>
);
};

View File

@@ -0,0 +1,42 @@
import { Button } from "@app/components/v2";
export enum AccountView {
Flat = "flat",
Nested = "nested"
}
type Props = {
value: AccountView;
onChange: (value: AccountView) => void;
};
export const AccountViewToggle = ({ value, onChange }: Props) => {
return (
<div className="flex gap-0.5 rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
<Button
variant="outline_bg"
onClick={() => {
onChange(AccountView.Flat);
}}
size="xs"
className={`${
value === AccountView.Flat ? "bg-mineshaft-500" : "bg-transparent"
} min-w-[2.4rem] rounded border-none hover:bg-mineshaft-600`}
>
Hide Folders
</Button>
<Button
variant="outline_bg"
onClick={() => {
onChange(AccountView.Nested);
}}
size="xs"
className={`${
value === AccountView.Nested ? "bg-mineshaft-500" : "bg-transparent"
} min-w-[2.4rem] rounded border-none hover:bg-mineshaft-600`}
>
Show Folders
</Button>
</div>
);
};

View File

@@ -0,0 +1,83 @@
import { useMemo, useState } from "react";
import { faCopy } from "@fortawesome/free-regular-svg-icons";
import { faUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import ms from "ms";
import { createNotification } from "@app/components/notifications";
import { FormLabel, IconButton, Input, Modal, ModalContent } from "@app/components/v2";
import { PamResourceType, TPamAccount } from "@app/hooks/api/pam";
type Props = {
account?: TPamAccount;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
};
export const PamAccessAccountModal = ({ isOpen, onOpenChange, account }: Props) => {
const [duration, setDuration] = useState("4h");
const isDurationValid = useMemo(() => duration && ms(duration || "1s") > 0, [duration]);
const command = useMemo(
() =>
account && account.resource.resourceType === PamResourceType.Postgres
? `infisical pam db access-account ${account.id} --duration ${duration}`
: "",
[account, duration]
);
if (!account) return null;
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
className="max-w-2xl pb-2"
title="Access Account"
subTitle={`Access ${account.name} using a CLI command.`}
>
<FormLabel
label="Duration"
tooltipText="The maximum duration of your session. Ex: 1h, 3w, 30d"
/>
<Input
value={duration}
onChange={(e) => setDuration(e.target.value)}
placeholder="permanent"
isError={!isDurationValid}
/>
<FormLabel label="CLI Command" className="mt-4" />
<div className="flex gap-2">
<Input value={command} isDisabled className="opacity-50" />
<IconButton
ariaLabel="copy"
variant="outline_bg"
colorSchema="secondary"
onClick={() => {
navigator.clipboard.writeText(command);
createNotification({
text: "Command copied to clipboard",
type: "info"
});
onOpenChange(false);
}}
className="w-10"
>
<FontAwesomeIcon icon={faCopy} />
</IconButton>
</div>
<a
href="https://infisical.com/docs/cli/overview"
target="_blank"
className="mt-2 flex h-4 w-fit items-center gap-2 border-b border-mineshaft-400 text-sm text-mineshaft-400 transition-colors duration-100 hover:border-yellow-400 hover:text-yellow-400"
rel="noreferrer"
>
<span>Install the Infisical CLI</span>
<FontAwesomeIcon icon={faUpRightFromSquare} className="size-3" />
</a>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,49 @@
import { Controller, useFormContext } from "react-hook-form";
import { z } from "zod";
import { FormControl, Input, TextArea } from "@app/components/v2";
import { slugSchema } from "@app/lib/schemas";
export const genericAccountFieldsSchema = z.object({
name: slugSchema({ min: 1, max: 64, field: "Name" }),
description: z.string().max(512).nullable().optional()
});
export const GenericAccountFields = () => {
const {
formState: { errors },
control
} = useFormContext<{ name: string; description: string }>();
return (
<>
<Controller
name="name"
control={control}
render={({ field }) => (
<FormControl
helperText="Name must be slug-friendly"
errorText={errors.name?.message}
isError={Boolean(errors.name?.message)}
label="Name"
>
<Input autoFocus placeholder="my-account" {...field} />
</FormControl>
)}
/>
<Controller
name="description"
control={control}
render={({ field }) => (
<FormControl
errorText={errors.name?.message}
isError={Boolean(errors.name?.message)}
label="Description"
>
<TextArea {...field} />
</FormControl>
)}
/>
</>
);
};

View File

@@ -0,0 +1,146 @@
import { createNotification } from "@app/components/notifications";
import {
PamResourceType,
TPamAccount,
useCreatePamAccount,
useUpdatePamAccount
} from "@app/hooks/api/pam";
import { DiscriminativePick } from "@app/types";
import { PamAccountHeader } from "../PamAccountHeader";
import { PostgresAccountForm } from "./PostgresAccountForm";
type FormProps = {
onComplete: (account: TPamAccount) => void;
};
type CreateFormProps = FormProps & {
projectId: string;
resourceId: string;
resourceType: PamResourceType;
folderId?: string;
};
type UpdateFormProps = FormProps & {
account: TPamAccount;
};
const CreateForm = ({
onComplete,
projectId,
resourceId,
resourceType,
folderId
}: CreateFormProps) => {
const createPamAccount = useCreatePamAccount();
console.log({ folderId });
const onSubmit = async (
formData: DiscriminativePick<TPamAccount, "name" | "description" | "credentials">
) => {
try {
const account = await createPamAccount.mutateAsync({
...formData,
folderId,
resourceId,
resourceType,
projectId
});
createNotification({
text: "Successfully created account",
type: "success"
});
onComplete(account);
} catch (err: any) {
console.error(err);
createNotification({
title: "Failed to create account",
text: err.message,
type: "error"
});
}
};
switch (resourceType) {
case PamResourceType.Postgres:
return <PostgresAccountForm onSubmit={onSubmit} />;
default:
throw new Error(`Unhandled resource: ${resourceType}`);
}
};
const UpdateForm = ({ account, onComplete }: UpdateFormProps) => {
const updatePamAccount = useUpdatePamAccount();
const onSubmit = async (
formData: DiscriminativePick<TPamAccount, "name" | "description" | "credentials">
) => {
try {
const updatedAccount = await updatePamAccount.mutateAsync({
accountId: account.id,
resourceType: account.resource.resourceType,
...formData
});
createNotification({
text: "Successfully updated account",
type: "success"
});
onComplete(updatedAccount);
} catch (err: any) {
console.error(err);
createNotification({
title: "Failed to update account",
text: err.message,
type: "error"
});
}
};
switch (account.resource.resourceType) {
case PamResourceType.Postgres:
return <PostgresAccountForm account={account} onSubmit={onSubmit} />;
default:
throw new Error(`Unhandled resource: ${account.resource.resourceType}`);
}
};
type Props = {
onBack?: () => void;
projectId: string;
} & FormProps &
(
| {
account: TPamAccount;
resourceId?: undefined;
resourceName?: undefined;
resourceType?: undefined;
folderId?: undefined;
}
| {
account?: undefined;
resourceId: string;
resourceName: string;
resourceType: PamResourceType;
folderId?: string;
}
);
export const PamAccountForm = ({ onBack, projectId, ...props }: Props) => {
const { account, resourceName, resourceType } = props;
return (
<div>
<PamAccountHeader
resourceName={account ? account.resource.name : resourceName}
resourceType={account ? account.resource.resourceType : resourceType}
onBack={onBack}
/>
{account ? (
<UpdateForm {...props} account={account} />
) : (
<CreateForm {...props} projectId={projectId} />
)}
</div>
);
};

Some files were not shown because too many files have changed in this diff Show More