feat: ssh pam draft

This commit is contained in:
Sheen Capadngan
2025-11-13 03:39:18 +08:00
parent 3f68ec8016
commit ace77b516e
27 changed files with 813 additions and 46 deletions
@@ -9,6 +9,11 @@ import {
SanitizedPostgresAccountWithResourceSchema,
UpdatePostgresAccountSchema
} from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
import {
CreateSSHAccountSchema,
SanitizedSSHAccountWithResourceSchema,
UpdateSSHAccountSchema
} from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas";
import { registerPamResourceEndpoints } from "./pam-account-endpoints";
@@ -30,5 +35,14 @@ export const PAM_ACCOUNT_REGISTER_ROUTER_MAP: Record<PamResource, (server: Fasti
createAccountSchema: CreateMySQLAccountSchema,
updateAccountSchema: UpdateMySQLAccountSchema
});
},
[PamResource.SSH]: async (server: FastifyZodProvider) => {
registerPamResourceEndpoints({
server,
resourceType: PamResource.SSH,
accountResponseSchema: SanitizedSSHAccountWithResourceSchema,
createAccountSchema: CreateSSHAccountSchema,
updateAccountSchema: UpdateSSHAccountSchema
});
}
};
@@ -5,6 +5,7 @@ import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { SanitizedMySQLAccountWithResourceSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas";
import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums";
import { SanitizedPostgresAccountWithResourceSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
import { SanitizedSSHAccountWithResourceSchema } from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas";
import { BadRequestError } from "@app/lib/errors";
import { ms } from "@app/lib/ms";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
@@ -12,6 +13,7 @@ import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
const SanitizedAccountSchema = z.union([
SanitizedSSHAccountWithResourceSchema, // ORDER MATTERS
SanitizedPostgresAccountWithResourceSchema,
SanitizedMySQLAccountWithResourceSchema
]);
@@ -93,7 +95,7 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
gatewayClientPrivateKey: z.string(),
gatewayServerCertificateChain: z.string(),
relayHost: z.string(),
metadata: z.record(z.string(), z.string()).optional()
metadata: z.record(z.string(), z.string().optional()).optional()
})
}
},
@@ -9,6 +9,11 @@ import {
SanitizedPostgresResourceSchema,
UpdatePostgresResourceSchema
} from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
import {
CreateSSHResourceSchema,
SanitizedSSHResourceSchema,
UpdateSSHResourceSchema
} from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas";
import { registerPamResourceEndpoints } from "./pam-resource-endpoints";
@@ -30,5 +35,14 @@ export const PAM_RESOURCE_REGISTER_ROUTER_MAP: Record<PamResource, (server: Fast
createResourceSchema: CreateMySQLResourceSchema,
updateResourceSchema: UpdateMySQLResourceSchema
});
},
[PamResource.SSH]: async (server: FastifyZodProvider) => {
registerPamResourceEndpoints({
server,
resourceType: PamResource.SSH,
resourceResponseSchema: SanitizedSSHResourceSchema,
createResourceSchema: CreateSSHResourceSchema,
updateResourceSchema: UpdateSSHResourceSchema
});
}
};
@@ -9,15 +9,24 @@ import {
PostgresResourceListItemSchema,
SanitizedPostgresResourceSchema
} from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
import {
SanitizedSSHResourceSchema,
SSHResourceListItemSchema
} from "@app/ee/services/pam-resource/ssh/ssh-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";
const SanitizedResourceSchema = z.union([SanitizedPostgresResourceSchema, SanitizedMySQLResourceSchema]);
const SanitizedResourceSchema = z.union([
SanitizedPostgresResourceSchema,
SanitizedMySQLResourceSchema,
SanitizedSSHResourceSchema
]);
const ResourceOptionsSchema = z.discriminatedUnion("resource", [
PostgresResourceListItemSchema,
MySQLResourceListItemSchema
MySQLResourceListItemSchema,
SSHResourceListItemSchema
]);
export const registerPamResourceRouter = async (server: FastifyZodProvider) => {
@@ -4,12 +4,17 @@ import { PamSessionsSchema } from "@app/db/schemas";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { MySQLSessionCredentialsSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas";
import { PostgresSessionCredentialsSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
import { SSHSessionCredentialsSchema } from "@app/ee/services/pam-resource/ssh/ssh-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";
const SessionCredentialsSchema = z.union([PostgresSessionCredentialsSchema, MySQLSessionCredentialsSchema]);
const SessionCredentialsSchema = z.union([
SSHSessionCredentialsSchema,
PostgresSessionCredentialsSchema,
MySQLSessionCredentialsSchema
]);
export const registerPamSessionRouter = async (server: FastifyZodProvider) => {
// Meant to be hit solely by gateway identities
@@ -26,7 +31,7 @@ export const registerPamSessionRouter = async (server: FastifyZodProvider) => {
}),
response: {
200: z.object({
credentials: SessionCredentialsSchema
credentials: z.any() // UNION DOES NOT WORK WITH ZOD SCHEMA
})
}
},
@@ -50,7 +55,7 @@ export const registerPamSessionRouter = async (server: FastifyZodProvider) => {
}
});
return { credentials };
return { credentials: credentials as z.infer<typeof SessionCredentialsSchema> };
}
});
@@ -24,9 +24,11 @@ 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 { TMySQLResourceConnectionDetails } from "../pam-resource/mysql/mysql-resource-types";
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 { TPostgresResourceConnectionDetails } from "../pam-resource/postgres/postgres-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";
@@ -251,17 +253,17 @@ export const pamAccountServiceFactory = ({
gatewayV2Service
);
// Logic to prevent overwriting unedited censored values
const finalCredentials = { ...credentials };
if (credentials.password === "__INFISICAL_UNCHANGED__") {
const decryptedCredentials = await decryptAccountCredentials({
encryptedCredentials: account.encryptedCredentials,
projectId: account.projectId,
kmsService
});
const decryptedCredentials = await decryptAccountCredentials({
encryptedCredentials: account.encryptedCredentials,
projectId: account.projectId,
kmsService
});
finalCredentials.password = decryptedCredentials.password;
}
// Logic to prevent overwriting unedited censored values
const finalCredentials = await factory.handleOverwritePreventionForCensoredValues(
credentials,
decryptedCredentials
);
const validatedCredentials = await factory.validateAccountCredentials(finalCredentials);
const encryptedCredentials = await encryptAccountCredentials({
@@ -486,11 +488,11 @@ export const pamAccountServiceFactory = ({
case PamResource.Postgres:
case PamResource.MySQL:
{
const connectionCredentials = await decryptResourceConnectionDetails({
const connectionCredentials = (await decryptResourceConnectionDetails({
encryptedConnectionDetails: resource.encryptedConnectionDetails,
kmsService,
projectId: account.projectId
});
})) as TMySQLResourceConnectionDetails | TPostgresResourceConnectionDetails;
const credentials = await decryptAccountCredentials({
encryptedCredentials: account.encryptedCredentials,
@@ -506,6 +508,21 @@ export const pamAccountServiceFactory = ({
};
}
break;
case PamResource.SSH:
{
const credentials = await decryptAccountCredentials({
encryptedCredentials: account.encryptedCredentials,
kmsService,
projectId: account.projectId
});
metadata = {
username: credentials.username,
accountName: account.name,
accountPath
};
}
break;
default:
break;
}
@@ -1,4 +1,5 @@
export enum PamResource {
Postgres = "postgres",
MySQL = "mysql"
MySQL = "mysql",
SSH = "ssh"
}
@@ -1,10 +1,12 @@
import { PamResource } from "./pam-resource-enums";
import { TPamAccountCredentials, TPamResourceConnectionDetails, TPamResourceFactory } from "./pam-resource-types";
import { sqlResourceFactory } from "./shared/sql/sql-resource-factory";
import { sshResourceFactory } from "./ssh/ssh-resource-factory";
type TPamResourceFactoryImplementation = TPamResourceFactory<TPamResourceConnectionDetails, TPamAccountCredentials>;
export const PAM_RESOURCE_FACTORY_MAP: Record<PamResource, TPamResourceFactoryImplementation> = {
[PamResource.Postgres]: sqlResourceFactory as TPamResourceFactoryImplementation,
[PamResource.MySQL]: sqlResourceFactory as TPamResourceFactoryImplementation
[PamResource.MySQL]: sqlResourceFactory as TPamResourceFactoryImplementation,
[PamResource.SSH]: sshResourceFactory as TPamResourceFactoryImplementation
};
@@ -192,19 +192,18 @@ export const pamResourceServiceFactory = ({
gatewayV2Service
);
// Logic to prevent overwriting unedited censored values
const finalCredentials = { ...rotationAccountCredentials };
if (
resource.encryptedRotationAccountCredentials &&
rotationAccountCredentials.password === "__INFISICAL_UNCHANGED__"
) {
let finalCredentials = { ...rotationAccountCredentials };
if (resource.encryptedRotationAccountCredentials) {
const decryptedCredentials = await decryptAccountCredentials({
encryptedCredentials: resource.encryptedRotationAccountCredentials,
projectId: resource.projectId,
kmsService
});
finalCredentials.password = decryptedCredentials.password;
finalCredentials = await factory.handleOverwritePreventionForCensoredValues(
rotationAccountCredentials,
decryptedCredentials
);
}
try {
@@ -12,15 +12,24 @@ import {
TPostgresResource,
TPostgresResourceConnectionDetails
} from "./postgres/postgres-resource-types";
import {
TSSHAccount,
TSSHAccountCredentials,
TSSHResource,
TSSHResourceConnectionDetails
} from "./ssh/ssh-resource-types";
// Resource types
export type TPamResource = TPostgresResource | TMySQLResource;
export type TPamResourceConnectionDetails = TPostgresResourceConnectionDetails | TMySQLResourceConnectionDetails;
export type TPamResource = TPostgresResource | TMySQLResource | TSSHResource;
export type TPamResourceConnectionDetails =
| TPostgresResourceConnectionDetails
| TMySQLResourceConnectionDetails
| TSSHResourceConnectionDetails;
// Account types
export type TPamAccount = TPostgresAccount | TMySQLAccount;
export type TPamAccount = TPostgresAccount | TMySQLAccount | TSSHAccount;
// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents
export type TPamAccountCredentials = TPostgresAccountCredentials | TMySQLAccountCredentials;
export type TPamAccountCredentials = TPostgresAccountCredentials | TMySQLAccountCredentials | TSSHAccountCredentials;
// Resource DTOs
export type TCreateResourceDTO = Pick<
@@ -51,4 +60,5 @@ export type TPamResourceFactory<T extends TPamResourceConnectionDetails, C exten
validateConnection: TPamResourceFactoryValidateConnection<T>;
validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials<C>;
rotateAccountCredentials: TPamResourceFactoryRotateAccountCredentials<C>;
handleOverwritePreventionForCensoredValues: (updatedAccountCredentials: C, currentCredentials: C) => Promise<C>;
};
@@ -337,9 +337,24 @@ export const sqlResourceFactory: TPamResourceFactory<TSqlResourceConnectionDetai
}
};
const handleOverwritePreventionForCensoredValues = async (
updatedAccountCredentials: TSqlAccountCredentials,
currentCredentials: TSqlAccountCredentials
) => {
if (updatedAccountCredentials.password === "__INFISICAL_UNCHANGED__") {
return {
...updatedAccountCredentials,
password: currentCredentials.password
};
}
return updatedAccountCredentials;
};
return {
validateConnection,
validateAccountCredentials,
rotateAccountCredentials
rotateAccountCredentials,
handleOverwritePreventionForCensoredValues
};
};
@@ -0,0 +1,5 @@
export enum SSHAuthMethod {
Password = "password",
PublicKey = "public-key",
Certificate = "certificate"
}
@@ -0,0 +1,73 @@
import {
TPamResourceFactory,
TPamResourceFactoryRotateAccountCredentials,
TPamResourceFactoryValidateAccountCredentials
} from "../pam-resource-types";
import { SSHAuthMethod } from "./ssh-resource-enums";
import { TSSHAccountCredentials, TSSHResourceConnectionDetails } from "./ssh-resource-types";
export const sshResourceFactory: TPamResourceFactory<TSSHResourceConnectionDetails, TSSHAccountCredentials> = (
resourceType,
connectionDetails,
gatewayId,
gatewayV2Service
) => {
const validateConnection = async () => {
return connectionDetails;
};
const validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials<TSSHAccountCredentials> = async (
credentials
) => {
return credentials;
};
const rotateAccountCredentials: TPamResourceFactoryRotateAccountCredentials<TSSHAccountCredentials> = async (
rotationAccountCredentials,
currentCredentials
) => {
return rotationAccountCredentials;
};
const handleOverwritePreventionForCensoredValues = async (
updatedAccountCredentials: TSSHAccountCredentials,
currentCredentials: TSSHAccountCredentials
) => {
if (updatedAccountCredentials.authMethod !== currentCredentials.authMethod) {
return updatedAccountCredentials;
}
if (
updatedAccountCredentials.authMethod === SSHAuthMethod.Password &&
currentCredentials.authMethod === SSHAuthMethod.Password
) {
if (updatedAccountCredentials.password === "__INFISICAL_UNCHANGED__") {
return {
...updatedAccountCredentials,
password: currentCredentials.password
};
}
}
if (
updatedAccountCredentials.authMethod === SSHAuthMethod.PublicKey &&
currentCredentials.authMethod === SSHAuthMethod.PublicKey
) {
if (updatedAccountCredentials.privateKey === "__INFISICAL_UNCHANGED__") {
return {
...updatedAccountCredentials,
privateKey: currentCredentials.privateKey
};
}
}
return updatedAccountCredentials;
};
return {
validateConnection,
validateAccountCredentials,
rotateAccountCredentials,
handleOverwritePreventionForCensoredValues
};
};
@@ -0,0 +1,117 @@
import { z } from "zod";
import { PamResource } from "../pam-resource-enums";
import {
BaseCreatePamAccountSchema,
BaseCreatePamResourceSchema,
BasePamAccountSchema,
BasePamAccountSchemaWithResource,
BasePamResourceSchema,
BaseUpdatePamAccountSchema,
BaseUpdatePamResourceSchema
} from "../pam-resource-schemas";
import { SSHAuthMethod } from "./ssh-resource-enums";
export const BaseSSHResourceSchema = BasePamResourceSchema.extend({ resourceType: z.literal(PamResource.SSH) });
export const SSHResourceListItemSchema = z.object({
name: z.literal("SSH"),
resource: z.literal(PamResource.SSH)
});
export const SSHResourceConnectionDetailsSchema = z.object({
host: z.string().trim(),
port: z.number()
});
export const SSHPasswordCredentialsSchema = z.object({
authMethod: z.literal(SSHAuthMethod.Password),
username: z.string().trim(),
password: z.string().trim()
});
export const SSHPublicKeyCredentialsSchema = z.object({
authMethod: z.literal(SSHAuthMethod.PublicKey),
username: z.string().trim(),
privateKey: z.string().trim()
});
export const SSHCertificateCredentialsSchema = z.object({
authMethod: z.literal(SSHAuthMethod.Certificate),
username: z.string().trim()
});
export const SSHAccountCredentialsSchema = z.discriminatedUnion("authMethod", [
SSHPasswordCredentialsSchema,
SSHPublicKeyCredentialsSchema,
SSHCertificateCredentialsSchema
]);
export const SSHResourceSchema = BaseSSHResourceSchema.extend({
connectionDetails: SSHResourceConnectionDetailsSchema,
rotationAccountCredentials: SSHAccountCredentialsSchema.nullable().optional()
});
export const SanitizedSSHResourceSchema = BaseSSHResourceSchema.extend({
connectionDetails: SSHResourceConnectionDetailsSchema,
rotationAccountCredentials: z
.discriminatedUnion("authMethod", [
z.object({
authMethod: z.literal(SSHAuthMethod.Password),
username: z.string()
}),
z.object({
authMethod: z.literal(SSHAuthMethod.PublicKey),
username: z.string()
}),
z.object({
authMethod: z.literal(SSHAuthMethod.Certificate),
username: z.string()
})
])
.nullable()
.optional()
});
export const CreateSSHResourceSchema = BaseCreatePamResourceSchema.extend({
connectionDetails: SSHResourceConnectionDetailsSchema,
rotationAccountCredentials: SSHAccountCredentialsSchema.nullable().optional()
});
export const UpdateSSHResourceSchema = BaseUpdatePamResourceSchema.extend({
connectionDetails: SSHResourceConnectionDetailsSchema.optional(),
rotationAccountCredentials: SSHAccountCredentialsSchema.nullable().optional()
});
// Accounts
export const SSHAccountSchema = BasePamAccountSchema.extend({
credentials: SSHAccountCredentialsSchema
});
export const CreateSSHAccountSchema = BaseCreatePamAccountSchema.extend({
credentials: SSHAccountCredentialsSchema
});
export const UpdateSSHAccountSchema = BaseUpdatePamAccountSchema.extend({
credentials: SSHAccountCredentialsSchema.optional()
});
export const SanitizedSSHAccountWithResourceSchema = BasePamAccountSchemaWithResource.extend({
credentials: z.discriminatedUnion("authMethod", [
z.object({
authMethod: z.literal(SSHAuthMethod.Password),
username: z.string()
}),
z.object({
authMethod: z.literal(SSHAuthMethod.PublicKey),
username: z.string()
}),
z.object({
authMethod: z.literal(SSHAuthMethod.Certificate),
username: z.string()
})
])
});
// Sessions
export const SSHSessionCredentialsSchema = SSHResourceConnectionDetailsSchema.and(SSHAccountCredentialsSchema);
@@ -0,0 +1,16 @@
import { z } from "zod";
import {
SSHAccountCredentialsSchema,
SSHAccountSchema,
SSHResourceConnectionDetailsSchema,
SSHResourceSchema
} from "./ssh-resource-schemas";
// Resources
export type TSSHResource = z.infer<typeof SSHResourceSchema>;
export type TSSHResourceConnectionDetails = z.infer<typeof SSHResourceConnectionDetailsSchema>;
// Accounts
export type TSSHAccount = z.infer<typeof SSHAccountSchema>;
export type TSSHAccountCredentials = z.infer<typeof SSHAccountCredentialsSchema>;