Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions backend/src/db/migrations/20260417245211_pam-session-reason.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Knex } from "knex";

import { TableName } from "../schemas";

export async function up(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.PamSession)) {
const hasCol = await knex.schema.hasColumn(TableName.PamSession, "reason");
if (!hasCol) {
await knex.schema.alterTable(TableName.PamSession, (t) => {
t.text("reason").nullable();
});
}
}
}

export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.PamSession)) {
const hasCol = await knex.schema.hasColumn(TableName.PamSession, "reason");
if (hasCol) {
await knex.schema.alterTable(TableName.PamSession, (t) => {
t.dropColumn("reason");
});
}
}
}
3 changes: 2 additions & 1 deletion backend/src/db/schemas/pam-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ export const PamSessionsSchema = z.object({
resourceId: z.string().uuid().nullable().optional(),
encryptedAiInsights: zodBuffer.nullable().optional(),
aiInsightsStatus: z.string().nullable().optional(),
aiInsightsError: z.string().nullable().optional()
aiInsightsError: z.string().nullable().optional(),
reason: z.string().nullable().optional()
});

export type TPamSessions = z.infer<typeof PamSessionsSchema>;
Expand Down
17 changes: 12 additions & 5 deletions backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,7 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
accountName: z.string().trim(),
projectId: z.string().uuid(),
mfaSessionId: z.string().optional(),
reason: z.string().trim().max(1000).optional(),
duration: z
.string()
.min(1)
Expand Down Expand Up @@ -583,7 +584,8 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
accountName: req.body.accountName,
projectId: req.body.projectId,
duration: req.body.duration,
mfaSessionId: req.body.mfaSessionId
mfaSessionId: req.body.mfaSessionId,
reason: req.body.reason
},
req.permission
);
Expand All @@ -598,7 +600,8 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
accountId: response.account.id,
resourceName: req.body.resourceName,
accountName: response.account.name,
duration: req.body.duration ? new Date(req.body.duration).toISOString() : undefined
duration: req.body.duration ? new Date(req.body.duration).toISOString() : undefined,
reason: req.body.reason
}
}
});
Expand Down Expand Up @@ -634,7 +637,8 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
}),
body: z.object({
projectId: z.string().uuid(),
mfaSessionId: z.string().optional()
mfaSessionId: z.string().optional(),
reason: z.string().trim().max(1000).optional()
}),
response: {
200: z.object({ ticket: z.string() })
Expand All @@ -655,7 +659,8 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
actorEmail: req.auth.user.email ?? "",
actorName: `${req.auth.user.firstName ?? ""} ${req.auth.user.lastName ?? ""}`.trim(),
auditLogInfo: req.auditLogInfo,
mfaSessionId: req.body.mfaSessionId
mfaSessionId: req.body.mfaSessionId,
reason: req.body.reason
});

await server.services.telemetry
Expand Down Expand Up @@ -724,6 +729,7 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
accountName: z.string(),
actorEmail: z.string(),
actorName: z.string(),
reason: z.string().nullable().optional(),
auditLogInfo: z.object({
ipAddress: z.string().optional(),
userAgent: z.string().optional(),
Expand Down Expand Up @@ -753,7 +759,8 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
auditLogInfo: payload.auditLogInfo as AuditLogInfo,
userId,
actorIp: req.realIp ?? "",
actorUserAgent: req.headers["user-agent"] ?? ""
actorUserAgent: req.headers["user-agent"] ?? "",
reason: payload.reason
});
} catch (err) {
logger.error(err, "WebSocket ticket validation failed");
Expand Down
1 change: 1 addition & 0 deletions backend/src/ee/services/audit-log/audit-log-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4933,6 +4933,7 @@ interface PamAccountAccessEvent {
resourceName: string;
accountName: string;
duration?: string;
reason?: string;
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { PamAccountPolicyRuleType } from "./pam-account-policy-enums";

export const PAM_ACCOUNT_POLICY_RULE_SUPPORTED_RESOURCES: Record<PamAccountPolicyRuleType, PamResource[] | "all"> = {
[PamAccountPolicyRuleType.CommandBlocking]: [PamResource.SSH],
[PamAccountPolicyRuleType.SessionLogMasking]: "all"
[PamAccountPolicyRuleType.SessionLogMasking]: "all",
[PamAccountPolicyRuleType.RequireReason]: "all"
};

export const PAM_ACCOUNT_POLICY_RULE_METADATA: Record<PamAccountPolicyRuleType, { name: string; description: string }> =
Expand All @@ -16,5 +17,9 @@ export const PAM_ACCOUNT_POLICY_RULE_METADATA: Record<PamAccountPolicyRuleType,
[PamAccountPolicyRuleType.SessionLogMasking]: {
name: "Session Log Masking",
description: "Mask sensitive data in session logs matching specified patterns"
},
[PamAccountPolicyRuleType.RequireReason]: {
name: "Require Access Reason",
description: "Require users to provide a reason before they can start a session"
}
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export enum PamAccountPolicyRuleType {
CommandBlocking = "command-blocking",
SessionLogMasking = "session-log-masking"
SessionLogMasking = "session-log-masking",
RequireReason = "require-reason"
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,12 @@ const RuleConfigSchema = z.object({
patterns: z.array(re2PatternSchema).min(1).max(20, "A rule can have at most 20 patterns")
});

const RequireReasonConfigSchema = z.object({});

export const PolicyRulesBaseSchema = z.object({
[PamAccountPolicyRuleType.CommandBlocking]: RuleConfigSchema.optional(),
[PamAccountPolicyRuleType.SessionLogMasking]: RuleConfigSchema.optional()
[PamAccountPolicyRuleType.SessionLogMasking]: RuleConfigSchema.optional(),
[PamAccountPolicyRuleType.RequireReason]: RequireReasonConfigSchema.optional()
});

export const PolicyRulesInputSchema = PolicyRulesBaseSchema.refine(
Expand Down
52 changes: 42 additions & 10 deletions backend/src/ee/services/pam-account/pam-account-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -682,13 +682,23 @@ export const pamAccountServiceFactory = ({

const decryptedAccount = await decryptAccount(accountWithParent, accountWithParent.projectId, kmsService);

// Resolve whether the policy enforces a reason at access time so the UI can
// gate the access flow without needing pam-account-policy:read permission.
let requireReason = false;
if (accountWithParent.policyId) {
const policy = await pamAccountPolicyDAL.findById(accountWithParent.policyId);
const policyRules = (policy?.rules ?? {}) as TPolicyRules;
requireReason = Boolean(policy?.isActive && policyRules[PamAccountPolicyRuleType.RequireReason]);
}

return {
...decryptedAccount,
...formatAccountParent({
resource: accountWithParent.resource,
domain: accountWithParent.domain
}),
metadata: accountMetadata
metadata: accountMetadata,
requireReason
};
};

Expand All @@ -702,7 +712,8 @@ export const pamAccountServiceFactory = ({
actorName,
actorUserAgent,
duration,
mfaSessionId
mfaSessionId,
reason
}: TAccessAccountDTO,
actor: OrgServiceActor
) => {
Expand All @@ -725,6 +736,8 @@ export const pamAccountServiceFactory = ({
});
}

const trimmedReason = reason?.trim() || null;

const fac = APPROVAL_POLICY_FACTORY_MAP[ApprovalPolicyType.PamAccess](ApprovalPolicyType.PamAccess);

const inputs = {
Expand Down Expand Up @@ -773,6 +786,19 @@ export const pamAccountServiceFactory = ({
);
}

// Reason check is intentionally placed after the approval/permission gates so
// its distinct error code does not leak policy configuration to unauthorized actors.
if (account.policyId) {
const policy = await pamAccountPolicyDAL.findById(account.policyId);
const policyRules = (policy?.rules ?? {}) as TPolicyRules;
if (policy?.isActive && policyRules[PamAccountPolicyRuleType.RequireReason] && !trimmedReason) {
throw new BadRequestError({
message: "A reason is required to access this account",
name: "PAM_REASON_REQUIRED"
});
}
}

const project = await projectDAL.findById(account.projectId);
if (!project) throw new NotFoundError({ message: `Project with ID '${account.projectId}' not found` });

Expand Down Expand Up @@ -885,7 +911,8 @@ export const pamAccountServiceFactory = ({
resourceId: resource.id,
userId: actor.id,
expiresAt,
startedAt: new Date()
startedAt: new Date(),
reason: trimmedReason
});

// Schedule session expiration job to run at expiresAt
Expand Down Expand Up @@ -919,7 +946,8 @@ export const pamAccountServiceFactory = ({
accountId: account.id,
resourceId: resource.id,
userId: actor.id,
expiresAt: new Date(Date.now() + duration)
expiresAt: new Date(Date.now() + duration),
reason: trimmedReason
});

if (!gatewayId) {
Expand Down Expand Up @@ -1111,13 +1139,17 @@ export const pamAccountServiceFactory = ({
const policy = await pamAccountPolicyDAL.findById(account.policyId);
if (policy && policy.isActive) {
const rules = (policy.rules ?? {}) as TPolicyRules;
for (const ruleType of Object.values(PamAccountPolicyRuleType)) {

const gatewayRuleTypes = [
PamAccountPolicyRuleType.CommandBlocking,
PamAccountPolicyRuleType.SessionLogMasking
] as const;
for (const ruleType of gatewayRuleTypes) {
const ruleConfig = rules[ruleType];
if (ruleConfig) {
const supported = PAM_ACCOUNT_POLICY_RULE_SUPPORTED_RESOURCES[ruleType];
if (supported === "all" || supported.includes(resource.resourceType as PamResource)) {
policyRules[ruleType] = ruleConfig;
}
const supported = PAM_ACCOUNT_POLICY_RULE_SUPPORTED_RESOURCES[ruleType];
const isSupported = supported === "all" || supported.includes(resource.resourceType as PamResource);
if (ruleConfig && isSupported) {
policyRules[ruleType] = ruleConfig;
}
}
}
Expand Down
1 change: 1 addition & 0 deletions backend/src/ee/services/pam-account/pam-account-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export type TAccessAccountDTO = {
actorUserAgent: string;
duration: number;
mfaSessionId?: string;
reason?: string;
};

export type TListAccountsDTO = {
Expand Down
3 changes: 2 additions & 1 deletion backend/src/ee/services/pam-resource/pam-resource-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@ export const BasePamAccountSchemaWithResource = BasePamAccountSchema.extend({
domain: PamDomainsSchema.pick({ id: true, name: true, domainType: true }).nullable().optional(),
policyName: z.string().nullable().optional(),
lastRotationMessage: z.string().nullable().optional(),
rotationStatus: z.string().nullable().optional()
rotationStatus: z.string().nullable().optional(),
requireReason: z.boolean().default(false)
});

export const BaseCreatePamAccountSchema = z.object({
Expand Down
36 changes: 31 additions & 5 deletions backend/src/ee/services/pam-web-access/pam-web-access-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ import { TUserDALFactory } from "@app/services/user/user-dal";

import { TPamAccountDALFactory } from "../pam-account/pam-account-dal";
import { decryptAccountCredentials } from "../pam-account/pam-account-fns";
import { TPamAccountPolicyDALFactory } from "../pam-account-policy/pam-account-policy-dal";
import { PamAccountPolicyRuleType } from "../pam-account-policy/pam-account-policy-enums";
import { TPolicyRules } from "../pam-account-policy/pam-account-policy-types";
import { TPamResourceDALFactory } from "../pam-resource/pam-resource-dal";
import { decryptResourceConnectionDetails } from "../pam-resource/pam-resource-fns";
import {
Expand Down Expand Up @@ -64,6 +67,7 @@ const SUPPORTED_WEB_ACCESS_RESOURCES = [PamResource.Postgres, PamResource.SSH, P

type TPamWebAccessServiceFactoryDep = {
pamAccountDAL: Pick<TPamAccountDALFactory, "findById" | "findMetadataByAccountIds">;
pamAccountPolicyDAL: Pick<TPamAccountPolicyDALFactory, "findById">;
pamResourceDAL: Pick<TPamResourceDALFactory, "findById">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
Expand Down Expand Up @@ -98,9 +102,11 @@ type THandleWebSocketConnectionDTO = {
actorName: string;
actorIp: string;
actorUserAgent: string;
reason?: string | null;
};
export const pamWebAccessServiceFactory = ({
pamAccountDAL,
pamAccountPolicyDAL,
pamResourceDAL,
permissionService,
auditLogService,
Expand Down Expand Up @@ -160,7 +166,8 @@ export const pamWebAccessServiceFactory = ({
actorEmail,
actorName,
auditLogInfo,
mfaSessionId
mfaSessionId,
reason
}: TIssueWebSocketTicketDTO) => {
const account = await pamAccountDAL.findById(accountId);

Expand All @@ -172,6 +179,8 @@ export const pamWebAccessServiceFactory = ({
throw new NotFoundError({ message: `Account with ID '${accountId}' not found` });
}

const trimmedReason = reason?.trim() || null;

if (!account.resourceId) {
throw new BadRequestError({ message: "Web access is only available for resource-backed accounts" });
}
Expand Down Expand Up @@ -241,6 +250,19 @@ export const pamWebAccessServiceFactory = ({
);
}

// Reason check is intentionally placed after the approval/permission gates so
// its distinct error code does not leak policy configuration to unauthorized actors.
if (account.policyId) {
const policy = await pamAccountPolicyDAL.findById(account.policyId);
const policyRules = (policy?.rules ?? {}) as TPolicyRules;
if (policy?.isActive && policyRules[PamAccountPolicyRuleType.RequireReason] && !trimmedReason) {
throw new BadRequestError({
message: "A reason is required to access this account",
name: "PAM_REASON_REQUIRED"
});
}
}

// MFA check
if (account.requireMfa && !mfaSessionId) {
const project = await projectDAL.findById(account.projectId);
Expand Down Expand Up @@ -299,7 +321,8 @@ export const pamWebAccessServiceFactory = ({
accountName: account.name,
actorEmail,
actorName,
auditLogInfo
auditLogInfo,
reason: trimmedReason
})
});

Expand Down Expand Up @@ -332,7 +355,8 @@ export const pamWebAccessServiceFactory = ({
actorEmail,
actorName,
actorIp,
actorUserAgent
actorUserAgent,
reason: accessReason
}: THandleWebSocketConnectionDTO): Promise<void> => {
let session: { id: string } | null = null;
let cleanedUp = false;
Expand Down Expand Up @@ -484,7 +508,8 @@ export const pamWebAccessServiceFactory = ({
resourceType: resource.resourceType,
accountId: account.id,
resourceId: resource.id,
userId
userId,
reason: accessReason?.trim() || null
});

await pamSessionExpirationService.scheduleSessionExpiration(session.id, expiresAt);
Expand Down Expand Up @@ -586,7 +611,8 @@ export const pamWebAccessServiceFactory = ({
accountId,
resourceName,
accountName,
duration: expiresAt.toISOString()
duration: expiresAt.toISOString(),
reason: accessReason ?? undefined
}
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,5 @@ export type TIssueWebSocketTicketDTO = {
actorName: string;
auditLogInfo: AuditLogInfo;
mfaSessionId?: string;
reason?: string;
};
1 change: 1 addition & 0 deletions backend/src/server/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2965,6 +2965,7 @@ export const registerRoutes = async (

const pamWebAccessService = pamWebAccessServiceFactory({
pamAccountDAL,
pamAccountPolicyDAL,
pamResourceDAL,
permissionService,
auditLogService,
Expand Down
Loading
Loading