diff --git a/backend/src/db/migrations/20260417245211_pam-session-reason.ts b/backend/src/db/migrations/20260417245211_pam-session-reason.ts new file mode 100644 index 00000000000..57cad699b79 --- /dev/null +++ b/backend/src/db/migrations/20260417245211_pam-session-reason.ts @@ -0,0 +1,25 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + 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 { + 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"); + }); + } + } +} diff --git a/backend/src/db/schemas/pam-sessions.ts b/backend/src/db/schemas/pam-sessions.ts index f499da367db..a3fc0d4ad42 100644 --- a/backend/src/db/schemas/pam-sessions.ts +++ b/backend/src/db/schemas/pam-sessions.ts @@ -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; diff --git a/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts b/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts index 3df62afbb09..d8bfc139f43 100644 --- a/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts +++ b/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts @@ -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) @@ -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 ); @@ -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 } } }); @@ -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() }) @@ -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 @@ -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(), @@ -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"); diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index aae54811158..1197dcaedce 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -4933,6 +4933,7 @@ interface PamAccountAccessEvent { resourceName: string; accountName: string; duration?: string; + reason?: string; }; } diff --git a/backend/src/ee/services/pam-account-policy/pam-account-policy-constants.ts b/backend/src/ee/services/pam-account-policy/pam-account-policy-constants.ts index 1206b2c7c2b..c6f9069a834 100644 --- a/backend/src/ee/services/pam-account-policy/pam-account-policy-constants.ts +++ b/backend/src/ee/services/pam-account-policy/pam-account-policy-constants.ts @@ -4,7 +4,8 @@ import { PamAccountPolicyRuleType } from "./pam-account-policy-enums"; export const PAM_ACCOUNT_POLICY_RULE_SUPPORTED_RESOURCES: Record = { [PamAccountPolicyRuleType.CommandBlocking]: [PamResource.SSH], - [PamAccountPolicyRuleType.SessionLogMasking]: "all" + [PamAccountPolicyRuleType.SessionLogMasking]: "all", + [PamAccountPolicyRuleType.RequireReason]: "all" }; export const PAM_ACCOUNT_POLICY_RULE_METADATA: Record = @@ -16,5 +17,9 @@ export const PAM_ACCOUNT_POLICY_RULE_METADATA: Record { @@ -725,6 +736,8 @@ export const pamAccountServiceFactory = ({ }); } + const trimmedReason = reason?.trim() || null; + const fac = APPROVAL_POLICY_FACTORY_MAP[ApprovalPolicyType.PamAccess](ApprovalPolicyType.PamAccess); const inputs = { @@ -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` }); @@ -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 @@ -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) { @@ -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; } } } diff --git a/backend/src/ee/services/pam-account/pam-account-types.ts b/backend/src/ee/services/pam-account/pam-account-types.ts index 1d8e34d1edb..b61e83f9081 100644 --- a/backend/src/ee/services/pam-account/pam-account-types.ts +++ b/backend/src/ee/services/pam-account/pam-account-types.ts @@ -32,6 +32,7 @@ export type TAccessAccountDTO = { actorUserAgent: string; duration: number; mfaSessionId?: string; + reason?: string; }; export type TListAccountsDTO = { diff --git a/backend/src/ee/services/pam-resource/pam-resource-schemas.ts b/backend/src/ee/services/pam-resource/pam-resource-schemas.ts index 46eeca53661..0af9b9ef2c7 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-schemas.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-schemas.ts @@ -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({ diff --git a/backend/src/ee/services/pam-web-access/pam-web-access-service.ts b/backend/src/ee/services/pam-web-access/pam-web-access-service.ts index 6baa684c8b9..b148e7c57c6 100644 --- a/backend/src/ee/services/pam-web-access/pam-web-access-service.ts +++ b/backend/src/ee/services/pam-web-access/pam-web-access-service.ts @@ -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 { @@ -64,6 +67,7 @@ const SUPPORTED_WEB_ACCESS_RESOURCES = [PamResource.Postgres, PamResource.SSH, P type TPamWebAccessServiceFactoryDep = { pamAccountDAL: Pick; + pamAccountPolicyDAL: Pick; pamResourceDAL: Pick; permissionService: Pick; auditLogService: Pick; @@ -98,9 +102,11 @@ type THandleWebSocketConnectionDTO = { actorName: string; actorIp: string; actorUserAgent: string; + reason?: string | null; }; export const pamWebAccessServiceFactory = ({ pamAccountDAL, + pamAccountPolicyDAL, pamResourceDAL, permissionService, auditLogService, @@ -160,7 +166,8 @@ export const pamWebAccessServiceFactory = ({ actorEmail, actorName, auditLogInfo, - mfaSessionId + mfaSessionId, + reason }: TIssueWebSocketTicketDTO) => { const account = await pamAccountDAL.findById(accountId); @@ -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" }); } @@ -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); @@ -299,7 +321,8 @@ export const pamWebAccessServiceFactory = ({ accountName: account.name, actorEmail, actorName, - auditLogInfo + auditLogInfo, + reason: trimmedReason }) }); @@ -332,7 +355,8 @@ export const pamWebAccessServiceFactory = ({ actorEmail, actorName, actorIp, - actorUserAgent + actorUserAgent, + reason: accessReason }: THandleWebSocketConnectionDTO): Promise => { let session: { id: string } | null = null; let cleanedUp = false; @@ -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); @@ -586,7 +611,8 @@ export const pamWebAccessServiceFactory = ({ accountId, resourceName, accountName, - duration: expiresAt.toISOString() + duration: expiresAt.toISOString(), + reason: accessReason ?? undefined } } }); diff --git a/backend/src/ee/services/pam-web-access/pam-web-access-types.ts b/backend/src/ee/services/pam-web-access/pam-web-access-types.ts index 1263290f0a5..2a09937edfa 100644 --- a/backend/src/ee/services/pam-web-access/pam-web-access-types.ts +++ b/backend/src/ee/services/pam-web-access/pam-web-access-types.ts @@ -70,4 +70,5 @@ export type TIssueWebSocketTicketDTO = { actorName: string; auditLogInfo: AuditLogInfo; mfaSessionId?: string; + reason?: string; }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index ad69433edf3..a7d6c7f951c 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -2965,6 +2965,7 @@ export const registerRoutes = async ( const pamWebAccessService = pamWebAccessServiceFactory({ pamAccountDAL, + pamAccountPolicyDAL, pamResourceDAL, permissionService, auditLogService, diff --git a/frontend/src/hooks/api/pam/mutations.tsx b/frontend/src/hooks/api/pam/mutations.tsx index 6d9dd4db84a..64f8e9aba23 100644 --- a/frontend/src/hooks/api/pam/mutations.tsx +++ b/frontend/src/hooks/api/pam/mutations.tsx @@ -191,6 +191,7 @@ export type TAccessPamAccountDTO = { accountName: string; projectId: string; duration: string; + reason?: string; }; export type TAccessPamAccountResponse = { @@ -214,7 +215,8 @@ export const useAccessPamAccount = () => { resourceName, accountName, projectId, - duration + duration, + reason }: TAccessPamAccountDTO) => { const { data } = await apiRequest.post( "/api/v1/pam/accounts/access", @@ -223,7 +225,8 @@ export const useAccessPamAccount = () => { resourceName, accountName, projectId, - duration + duration, + reason } ); diff --git a/frontend/src/hooks/api/pam/queries.tsx b/frontend/src/hooks/api/pam/queries.tsx index a9ada119813..d0745d249a0 100644 --- a/frontend/src/hooks/api/pam/queries.tsx +++ b/frontend/src/hooks/api/pam/queries.tsx @@ -60,7 +60,6 @@ export const pamKeys = { projectId, { search } ], - getAccountPolicy: (policyId: string) => [...pamKeys.accountPolicy(), "get", policyId], getSession: (sessionId: string) => [...pamKeys.session(), "get", sessionId], getSessionLogs: (sessionId: string) => [...pamKeys.session(), "logs", sessionId], listSessions: (projectId: string) => [...pamKeys.session(), "list", projectId], diff --git a/frontend/src/hooks/api/pam/types/base-account.ts b/frontend/src/hooks/api/pam/types/base-account.ts index 8c44e00843d..0621019057a 100644 --- a/frontend/src/hooks/api/pam/types/base-account.ts +++ b/frontend/src/hooks/api/pam/types/base-account.ts @@ -22,6 +22,7 @@ export interface TBasePamAccount { description?: string | null; credentialsConfigured: boolean; requireMfa?: boolean | null; + requireReason?: boolean; lastRotatedAt?: string | null; lastRotationMessage?: string | null; rotationStatus?: string | null; diff --git a/frontend/src/hooks/api/pam/types/index.ts b/frontend/src/hooks/api/pam/types/index.ts index c2518b0174f..ca88f6117d9 100644 --- a/frontend/src/hooks/api/pam/types/index.ts +++ b/frontend/src/hooks/api/pam/types/index.ts @@ -128,6 +128,7 @@ export type TPamSession = { aiInsightsStatus?: string | null; aiInsightsError?: string | null; aiInsights?: TPamSessionAiInsights | null; + reason?: string | null; }; // Resource DTOs @@ -293,11 +294,12 @@ export type TPamSessionLogsPage = { // Account Policy types export enum PamAccountPolicyRuleType { CommandBlocking = "command-blocking", - SessionLogMasking = "session-log-masking" + SessionLogMasking = "session-log-masking", + RequireReason = "require-reason" } export type TPamAccountPolicyRuleConfig = { - patterns: string[]; + patterns?: string[]; }; export type TPamAccountPolicyRules = Partial< diff --git a/frontend/src/pages/pam/PamAccountAccessPage/PamAccountAccessPage.tsx b/frontend/src/pages/pam/PamAccountAccessPage/PamAccountAccessPage.tsx index 73dad528d0f..2dcd87c9f50 100644 --- a/frontend/src/pages/pam/PamAccountAccessPage/PamAccountAccessPage.tsx +++ b/frontend/src/pages/pam/PamAccountAccessPage/PamAccountAccessPage.tsx @@ -2,30 +2,33 @@ import { useState } from "react"; import { Helmet } from "react-helmet"; import { useParams } from "@tanstack/react-router"; -import { PamResourceType, useGetPamAccountById } from "@app/hooks/api/pam"; +import { PamResourceType, TPamAccount, useGetPamAccountById } from "@app/hooks/api/pam"; import { PamDataExplorerPage } from "@app/pages/pam/PamDataExplorerPage/PamDataExplorerPage"; +import { ReasonGate } from "./ReasonGate"; import { useWebAccessSession } from "./useWebAccessSession"; const TerminalContent = ({ - accountId, + account, projectId, - orgId + orgId, + reason }: { - accountId: string; + account: TPamAccount; projectId: string; orgId: string; + reason: string; }) => { - const { data: account, isPending } = useGetPamAccountById(accountId); const [sessionEnded, setSessionEnded] = useState(false); const { containerRef, isConnected, disconnect, reconnect } = useWebAccessSession({ - accountId, + accountId: account.id, projectId, orgId, resourceName: account?.resource?.name ?? "", accountName: account?.name ?? "", resourceType: account?.resource?.resourceType ?? "", + reason, onSessionEnd: () => setSessionEnded(true) }); @@ -34,22 +37,6 @@ const TerminalContent = ({ reconnect(); }; - if (isPending) { - return ( -
- Loading... -
- ); - } - - if (!account) { - return ( -
-

Could not find PAM Account with ID {accountId}

-
- ); - } - let statusLabel = "Connecting"; let statusDotClass = "bg-yellow-500"; if (isConnected) { @@ -129,11 +116,31 @@ const PageContent = () => { ); } - if (account?.resource?.resourceType === PamResourceType.Postgres) { - return ; + if (!account) { + return ( +
+

Could not find PAM Account with ID {accountId}

+
+ ); } - return ; + return ( + + {(reason) => { + if (account.resource?.resourceType === PamResourceType.Postgres) { + return ; + } + return ( + + ); + }} + + ); }; export const PamAccountAccessPage = () => { diff --git a/frontend/src/pages/pam/PamAccountAccessPage/ReasonGate.tsx b/frontend/src/pages/pam/PamAccountAccessPage/ReasonGate.tsx new file mode 100644 index 00000000000..90f2f425d74 --- /dev/null +++ b/frontend/src/pages/pam/PamAccountAccessPage/ReasonGate.tsx @@ -0,0 +1,73 @@ +import { ReactNode, useState } from "react"; +import { AlertTriangleIcon } from "lucide-react"; + +import { Button } from "@app/components/v3/generic/Button"; +import { TPamAccount } from "@app/hooks/api/pam"; + +type Props = { + account: TPamAccount; + children: (reason: string) => ReactNode; +}; + +export const ReasonGate = ({ account, children }: Props) => { + const [submittedReason, setSubmittedReason] = useState(null); + const [draft, setDraft] = useState(""); + const [touched, setTouched] = useState(false); + + if (submittedReason !== null) { + return <>{children(submittedReason)}; + } + + const isReasonRequired = Boolean(account.requireReason); + + const trimmed = draft.trim(); + const canContinue = !isReasonRequired || trimmed.length > 0; + const showError = touched && isReasonRequired && trimmed.length === 0; + + const handleSubmit = () => { + if (!canContinue) { + setTouched(true); + return; + } + setSubmittedReason(trimmed); + }; + + return ( +
+ {isReasonRequired ? : null} +

+ {isReasonRequired ? "Reason Required" : "Before You Continue"} +

+

+ {isReasonRequired + ? "This account's policy requires a reason for access. The reason will be stored with the session for audit purposes." + : "Optionally provide a reason for this session. The reason will be stored with the session for audit purposes."} +

+
+