Skip to content

Commit 7f84ff4

Browse files
feat(pam): view sensitive credentials/password for PAM accounts (#5925)
* feat: add PAM account credentials viewing with MFA support Add a new endpoint and UI for viewing full (unsanitized) PAM account credentials and resource connection details behind a dedicated ReadCredentials permission, with optional MFA enforcement and audit logging. * refactor: remove resource connection details from credentials view Only show account credentials in the view credentials feature, not resource connection details, to avoid coupling the two entities. * refactor: replace credentials dialog with inline sensitive credentials gate Replace the modal dialog approach with an inline card-based UI. Public fields (username, host) are always visible, while sensitive fields (password, private key) are behind a gated area that handles MFA verification and permission checks in-place. * fix: match private key pre styling to input and add thin scrollbar * refactor: clean up credentials gate and defer MFA-only DB lookups Move project and user DB lookups inside the MFA-required block so they are skipped when MFA is not needed. Simplify credentials gate UI by consolidating loading/MFA-verifying states into the button, adding descriptive text for non-MFA mode, and removing unnecessary fragments and section comments. * refactor: remove unused useViewPamAccountCredentials hook * refactor: simplify credentials gate to button with inline loading states Replace the dashed-border gate box with a simple full-width button. Use v3 Button isPending for loading/MFA states with inline MFA status text. Remove MFA detail leaking to users without permission. * refactor: clean up credentials view rate limit and simplify sensitive field defs Use readLimit instead of writeLimit for the GET credentials endpoint. Simplify RESOURCE_FIELD_DEFS to only contain sensitive fields since non-sensitive fields are already rendered by CredentialsContent. * fix: hide credentials view button when account has no sensitive fields Accounts like SSH certificate auth and AWS IAM have no sensitive credentials beyond the sanitized view. The API now returns a 400 early (before MFA/audit log), and the frontend uses an account-aware function to decide whether to show the button. * fix: replace inline error states with toast notifications and align Kubernetes sensitive check - Remove error state from RevealState and SensitiveCredentialsGate; all errors now show a toast notification and reset to the initial button view - MFA timeout notification includes popup blocker hint - Remove redundant "Waiting for MFA..." button text (keep only the p tag) - Add Kubernetes to hasSensitiveCredentials exclusion list to match frontend - Remove unused onRetry prop from SensitiveCredentialsGate * refactor: use inline spinner with text for MFA button states - Replace Lottie isPending spinner with LoaderCircleIcon for all loading states - Loading state shows spinner only, MFA state shows spinner + "Waiting for MFA..." - Extract ButtonContent component to avoid nested ternary lint error - Change children prop to React.ReactElement to avoid useless fragment * fix: update credentials route description and fix children type in gate component * fix: align audit log metadata with PAM conventions and add ABAC subject conditions to credentials gate - Change ReadCredentials audit event metadata from resourceName to resourceId to match Delete/Rotation/RotationFailed event patterns - Pass accountName, resourceName, and metadata to SensitiveCredentialsGate so ProjectPermissionCan evaluates resource-scoped ReadCredentials policies * fix: add re-entrancy guard to MFA polling interval * feat: add PAM_ACCOUNT_READ_CREDENTIALS to frontend audit log types * fix: remove PAM_ACCOUNT_READ_CREDENTIALS from audit log types * fix: add resourceType to readCredentials permission subject * fix: show error toast when viewing credentials for account with no password * fix: add resourceType to credentials gate CASL subject and await clipboard writes * fix: show error toast when MFA popup is blocked * fix: add try/catch to clipboard copy handlers in sensitive fields * fix: move credentials endpoint to POST and send empty body when no mfaSessionId * fix: add ReadCredentials to rolePermission2Form deserialization for PAM accounts
1 parent b073607 commit 7f84ff4

15 files changed

Lines changed: 732 additions & 16 deletions

File tree

backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts

Lines changed: 130 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,44 @@ import { z } from "zod";
44
import { PamAccountDependenciesSchema } from "@app/db/schemas";
55
import { AuditLogInfo, EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-types";
66
import { PamAccountOrderBy, PamAccountView } from "@app/ee/services/pam-account/pam-account-enums";
7-
import { SanitizedActiveDirectoryAccountWithResourceSchema } from "@app/ee/services/pam-resource/active-directory/active-directory-resource-schemas";
8-
import { SanitizedAwsIamAccountWithResourceSchema } from "@app/ee/services/pam-resource/aws-iam/aws-iam-resource-schemas";
9-
import { SanitizedKubernetesAccountWithResourceSchema } from "@app/ee/services/pam-resource/kubernetes/kubernetes-resource-schemas";
10-
import { SanitizedMsSQLAccountWithResourceSchema } from "@app/ee/services/pam-resource/mssql/mssql-resource-schemas";
11-
import { SanitizedMySQLAccountWithResourceSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas";
7+
import {
8+
ActiveDirectoryAccountCredentialsSchema,
9+
SanitizedActiveDirectoryAccountWithResourceSchema
10+
} from "@app/ee/services/pam-resource/active-directory/active-directory-resource-schemas";
11+
import {
12+
AwsIamAccountCredentialsSchema,
13+
SanitizedAwsIamAccountWithResourceSchema
14+
} from "@app/ee/services/pam-resource/aws-iam/aws-iam-resource-schemas";
15+
import {
16+
KubernetesAccountCredentialsSchema,
17+
SanitizedKubernetesAccountWithResourceSchema
18+
} from "@app/ee/services/pam-resource/kubernetes/kubernetes-resource-schemas";
19+
import {
20+
MsSQLAccountCredentialsSchema,
21+
SanitizedMsSQLAccountWithResourceSchema
22+
} from "@app/ee/services/pam-resource/mssql/mssql-resource-schemas";
23+
import {
24+
MySQLAccountCredentialsSchema,
25+
SanitizedMySQLAccountWithResourceSchema
26+
} from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas";
1227
import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums";
1328
import { GatewayAccessResponseSchema } from "@app/ee/services/pam-resource/pam-resource-schemas";
14-
import { SanitizedPostgresAccountWithResourceSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
15-
import { SanitizedRedisAccountWithResourceSchema } from "@app/ee/services/pam-resource/redis/redis-resource-schemas";
16-
import { SanitizedSSHAccountWithResourceSchema } from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas";
17-
import { SanitizedWindowsAccountWithResourceSchema } from "@app/ee/services/pam-resource/windows-server/windows-server-resource-schemas";
29+
import {
30+
PostgresAccountCredentialsSchema,
31+
SanitizedPostgresAccountWithResourceSchema
32+
} from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
33+
import {
34+
RedisAccountCredentialsSchema,
35+
SanitizedRedisAccountWithResourceSchema
36+
} from "@app/ee/services/pam-resource/redis/redis-resource-schemas";
37+
import {
38+
SanitizedSSHAccountWithResourceSchema,
39+
SSHAccountCredentialsSchema
40+
} from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas";
41+
import {
42+
SanitizedWindowsAccountWithResourceSchema,
43+
WindowsAccountCredentialsSchema
44+
} from "@app/ee/services/pam-resource/windows-server/windows-server-resource-schemas";
1845
import { BadRequestError } from "@app/lib/errors";
1946
import { logger } from "@app/lib/logger";
2047
import { ms } from "@app/lib/ms";
@@ -50,6 +77,52 @@ const ListPamAccountsResponseSchema = z.object({
5077
totalCount: z.number().default(0)
5178
});
5279

80+
const AccountCredentialsBaseSchema = z.object({
81+
accountId: z.string().uuid(),
82+
accountName: z.string(),
83+
resourceName: z.string(),
84+
projectId: z.string().uuid()
85+
});
86+
87+
const AccountCredentialsResponseSchema = z.discriminatedUnion("resourceType", [
88+
AccountCredentialsBaseSchema.extend({
89+
resourceType: z.literal(PamResource.Postgres),
90+
credentials: PostgresAccountCredentialsSchema
91+
}),
92+
AccountCredentialsBaseSchema.extend({
93+
resourceType: z.literal(PamResource.MySQL),
94+
credentials: MySQLAccountCredentialsSchema
95+
}),
96+
AccountCredentialsBaseSchema.extend({
97+
resourceType: z.literal(PamResource.MsSQL),
98+
credentials: MsSQLAccountCredentialsSchema
99+
}),
100+
AccountCredentialsBaseSchema.extend({
101+
resourceType: z.literal(PamResource.SSH),
102+
credentials: SSHAccountCredentialsSchema
103+
}),
104+
AccountCredentialsBaseSchema.extend({
105+
resourceType: z.literal(PamResource.Redis),
106+
credentials: RedisAccountCredentialsSchema
107+
}),
108+
AccountCredentialsBaseSchema.extend({
109+
resourceType: z.literal(PamResource.Kubernetes),
110+
credentials: KubernetesAccountCredentialsSchema
111+
}),
112+
AccountCredentialsBaseSchema.extend({
113+
resourceType: z.literal(PamResource.AwsIam),
114+
credentials: AwsIamAccountCredentialsSchema
115+
}),
116+
AccountCredentialsBaseSchema.extend({
117+
resourceType: z.literal(PamResource.Windows),
118+
credentials: WindowsAccountCredentialsSchema
119+
}),
120+
AccountCredentialsBaseSchema.extend({
121+
resourceType: z.literal(PamResource.ActiveDirectory),
122+
credentials: ActiveDirectoryAccountCredentialsSchema
123+
})
124+
]);
125+
53126
export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
54127
server.route({
55128
method: "GET",
@@ -239,6 +312,54 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
239312
}
240313
});
241314

315+
server.route({
316+
method: "POST",
317+
url: "/:accountId/credentials",
318+
config: {
319+
rateLimit: writeLimit
320+
},
321+
schema: {
322+
description: "View full (unsanitized) PAM account credentials",
323+
params: z.object({
324+
accountId: z.string().uuid()
325+
}),
326+
body: z.object({
327+
mfaSessionId: z.string().optional()
328+
}),
329+
response: {
330+
200: AccountCredentialsResponseSchema
331+
}
332+
},
333+
onRequest: verifyAuth([AuthMode.JWT]),
334+
handler: async (req) => {
335+
const result = await server.services.pamAccount.viewCredentials({
336+
accountId: req.params.accountId,
337+
mfaSessionId: req.body.mfaSessionId,
338+
actorId: req.permission.id,
339+
actor: req.permission.type,
340+
actorAuthMethod: req.permission.authMethod,
341+
actorOrgId: req.permission.orgId
342+
});
343+
344+
await server.services.auditLog.createAuditLog({
345+
...req.auditLogInfo,
346+
orgId: req.permission.orgId,
347+
projectId: result.projectId,
348+
event: {
349+
type: EventType.PAM_ACCOUNT_READ_CREDENTIALS,
350+
metadata: {
351+
accountId: result.accountId,
352+
accountName: result.accountName,
353+
resourceId: result.resourceId,
354+
resourceType: result.resourceType
355+
}
356+
}
357+
});
358+
359+
return result as z.infer<typeof AccountCredentialsResponseSchema>;
360+
}
361+
});
362+
242363
server.route({
243364
method: "GET",
244365
url: "/",

backend/src/ee/services/audit-log/audit-log-types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,7 @@ export enum EventType {
607607
PAM_ACCOUNT_DELETE = "pam-account-delete",
608608
PAM_ACCOUNT_CREDENTIAL_ROTATION = "pam-account-credential-rotation",
609609
PAM_ACCOUNT_CREDENTIAL_ROTATION_FAILED = "pam-account-credential-rotation-failed",
610+
PAM_ACCOUNT_READ_CREDENTIALS = "pam-account-read-credentials",
610611
PAM_WEB_ACCESS_SESSION_TICKET_CREATED = "pam-web-access-session-ticket-created",
611612
PAM_RESOURCE_LIST = "pam-resource-list",
612613
PAM_RESOURCE_GET = "pam-resource-get",
@@ -4861,6 +4862,16 @@ interface PamAccountCredentialRotationFailedEvent {
48614862
};
48624863
}
48634864

4865+
interface PamAccountReadCredentialsEvent {
4866+
type: EventType.PAM_ACCOUNT_READ_CREDENTIALS;
4867+
metadata: {
4868+
accountId: string;
4869+
accountName: string;
4870+
resourceId: string;
4871+
resourceType: string;
4872+
};
4873+
}
4874+
48644875
interface PamResourceListEvent {
48654876
type: EventType.PAM_RESOURCE_LIST;
48664877
metadata: {
@@ -6164,6 +6175,7 @@ export type Event =
61646175
| PamAccountDeleteEvent
61656176
| PamAccountCredentialRotationEvent
61666177
| PamAccountCredentialRotationFailedEvent
6178+
| PamAccountReadCredentialsEvent
61676179
| PamResourceListEvent
61686180
| PamResourceGetEvent
61696181
| PamResourceCreateEvent

backend/src/ee/services/pam-account/pam-account-fns.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
22
import { KmsDataKey } from "@app/services/kms/kms-types";
33

4+
import { PamResource } from "../pam-resource/pam-resource-enums";
45
import { TPamAccountCredentials, TPamResourceInternalMetadata } from "../pam-resource/pam-resource-types";
56
import { SSHAuthMethod } from "../pam-resource/ssh/ssh-resource-enums";
67

@@ -67,6 +68,19 @@ export const decryptAccountMessage = async ({
6768
return decryptedPlainTextBlob.toString();
6869
};
6970

71+
// Returns false for account types where all credential fields are already visible in the sanitized view
72+
export const hasSensitiveCredentials = (resourceType: string, credentials: TPamAccountCredentials): boolean => {
73+
if (resourceType === PamResource.AwsIam) return false;
74+
if (resourceType === PamResource.Kubernetes) return false;
75+
if (
76+
resourceType === PamResource.SSH &&
77+
"authMethod" in credentials &&
78+
credentials.authMethod === SSHAuthMethod.Certificate
79+
)
80+
return false;
81+
return true;
82+
};
83+
7084
const hasConfiguredCredentials = (credentials: TPamAccountCredentials): boolean => {
7185
if ("password" in credentials && credentials.password) return true;
7286
if ("privateKey" in credentials && credentials.privateKey) return true;

backend/src/ee/services/pam-account/pam-account-service.ts

Lines changed: 116 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,19 @@ import { PamSessionStatus } from "../pam-session/pam-session-enums";
6464
import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission";
6565
import { TPamAccountDALFactory } from "./pam-account-dal";
6666
import { PamAccountRotationStatus } from "./pam-account-enums";
67-
import { decryptAccount, decryptAccountCredentials, encryptAccountCredentials } from "./pam-account-fns";
67+
import {
68+
decryptAccount,
69+
decryptAccountCredentials,
70+
encryptAccountCredentials,
71+
hasSensitiveCredentials
72+
} from "./pam-account-fns";
6873
import {
6974
TAccessAccountDTO,
7075
TCreateAccountDTO,
7176
TGetAccountByIdDTO,
7277
TListAccountsDTO,
73-
TUpdateAccountDTO
78+
TUpdateAccountDTO,
79+
TViewAccountCredentialsDTO
7480
} from "./pam-account-types";
7581

7682
type TPamAccountServiceFactoryDep = {
@@ -1379,13 +1385,121 @@ export const pamAccountServiceFactory = ({
13791385
};
13801386
};
13811387

1388+
const viewCredentials = async ({
1389+
accountId,
1390+
mfaSessionId,
1391+
actor,
1392+
actorId,
1393+
actorAuthMethod,
1394+
actorOrgId
1395+
}: TViewAccountCredentialsDTO) => {
1396+
const accountWithResource = await pamAccountDAL.findByIdWithResourceDetails(accountId);
1397+
if (!accountWithResource) throw new NotFoundError({ message: `Account with ID '${accountId}' not found` });
1398+
1399+
const { permission } = await permissionService.getProjectPermission({
1400+
actor,
1401+
actorId,
1402+
projectId: accountWithResource.projectId,
1403+
actorAuthMethod,
1404+
actorOrgId,
1405+
actionProjectType: ActionProjectType.PAM
1406+
});
1407+
1408+
const metadataByAccountId = await pamAccountDAL.findMetadataByAccountIds([accountWithResource.id]);
1409+
const accountMetadata = metadataByAccountId[accountWithResource.id] || [];
1410+
1411+
ForbiddenError.from(permission).throwUnlessCan(
1412+
ProjectPermissionPamAccountActions.ReadCredentials,
1413+
subject(ProjectPermissionSub.PamAccounts, {
1414+
resourceName: accountWithResource.resource.name,
1415+
resourceType: accountWithResource.resource.resourceType,
1416+
accountName: accountWithResource.name,
1417+
metadata: accountMetadata
1418+
})
1419+
);
1420+
1421+
// Decrypt early so we can check if there are sensitive fields before triggering MFA
1422+
const credentials = await decryptAccountCredentials({
1423+
encryptedCredentials: accountWithResource.encryptedCredentials,
1424+
kmsService,
1425+
projectId: accountWithResource.projectId
1426+
});
1427+
1428+
if (!hasSensitiveCredentials(accountWithResource.resource.resourceType, credentials)) {
1429+
throw new BadRequestError({ message: "This account has no sensitive credentials to view" });
1430+
}
1431+
1432+
if (!mfaSessionId && accountWithResource.requireMfa) {
1433+
const project = await projectDAL.findById(accountWithResource.projectId);
1434+
if (!project)
1435+
throw new NotFoundError({ message: `Project with ID '${accountWithResource.projectId}' not found` });
1436+
1437+
const actorUser = await userDAL.findById(actorId);
1438+
if (!actorUser) throw new NotFoundError({ message: `User with ID '${actorId}' not found` });
1439+
1440+
const org = await orgDAL.findOrgById(project.orgId);
1441+
if (!org) throw new NotFoundError({ message: `Organization with ID '${project.orgId}' not found` });
1442+
1443+
const orgMfaMethod = org.enforceMfa ? (org.selectedMfaMethod as MfaMethod | null) : undefined;
1444+
const userMfaMethod = actorUser.isMfaEnabled ? (actorUser.selectedMfaMethod as MfaMethod | null) : undefined;
1445+
const mfaMethod = (orgMfaMethod ?? userMfaMethod ?? MfaMethod.EMAIL) as MfaMethod;
1446+
1447+
const newMfaSessionId = await mfaSessionService.createMfaSession(actorUser.id, accountWithResource.id, mfaMethod);
1448+
1449+
if (mfaMethod === MfaMethod.EMAIL && actorUser.email) {
1450+
await mfaSessionService.sendMfaCode(actorUser.id, actorUser.email);
1451+
}
1452+
1453+
throw new BadRequestError({
1454+
message: "MFA verification required to view PAM account credentials",
1455+
name: "SESSION_MFA_REQUIRED",
1456+
details: {
1457+
mfaSessionId: newMfaSessionId,
1458+
mfaMethod
1459+
}
1460+
});
1461+
}
1462+
1463+
if (mfaSessionId && accountWithResource.requireMfa) {
1464+
const mfaSession = await mfaSessionService.getMfaSession(mfaSessionId);
1465+
if (!mfaSession) {
1466+
throw new BadRequestError({ message: "MFA session not found or expired" });
1467+
}
1468+
1469+
if (mfaSession.userId !== actorId) {
1470+
throw new BadRequestError({ message: "MFA session does not belong to current user" });
1471+
}
1472+
1473+
if (mfaSession.resourceId !== accountWithResource.id) {
1474+
throw new BadRequestError({ message: "MFA session is for a different account" });
1475+
}
1476+
1477+
if (mfaSession.status !== MfaSessionStatus.ACTIVE) {
1478+
throw new BadRequestError({ message: "MFA session is not active. Please complete MFA verification first." });
1479+
}
1480+
1481+
await mfaSessionService.deleteMfaSession(mfaSessionId);
1482+
}
1483+
1484+
return {
1485+
credentials,
1486+
resourceType: accountWithResource.resource.resourceType,
1487+
accountId: accountWithResource.id,
1488+
accountName: accountWithResource.name,
1489+
resourceId: accountWithResource.resource.id,
1490+
resourceName: accountWithResource.resource.name,
1491+
projectId: accountWithResource.projectId
1492+
};
1493+
};
1494+
13821495
return {
13831496
create,
13841497
updateById,
13851498
deleteById,
13861499
list,
13871500
getById,
13881501
access,
1502+
viewCredentials,
13891503
getSessionCredentials,
13901504
rotateAllDueAccounts,
13911505
triggerManualRotation

backend/src/ee/services/pam-account/pam-account-types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,8 @@ export type TListAccountsDTO = {
4545
export type TGetAccountByIdDTO = {
4646
accountId: string;
4747
} & Omit<TProjectPermission, "projectId">;
48+
49+
export type TViewAccountCredentialsDTO = {
50+
accountId: string;
51+
mfaSessionId?: string;
52+
} & Omit<TProjectPermission, "projectId">;

backend/src/ee/services/permission/default-roles.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,8 @@ const buildAdminPermissionRules = () => {
392392
ProjectPermissionPamAccountActions.Create,
393393
ProjectPermissionPamAccountActions.Edit,
394394
ProjectPermissionPamAccountActions.Delete,
395-
ProjectPermissionPamAccountActions.TriggerRotation
395+
ProjectPermissionPamAccountActions.TriggerRotation,
396+
ProjectPermissionPamAccountActions.ReadCredentials
396397
],
397398
ProjectPermissionSub.PamAccounts
398399
);

backend/src/ee/services/permission/project-permission.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,8 @@ export enum ProjectPermissionPamAccountActions {
254254
Create = "create",
255255
Edit = "edit",
256256
Delete = "delete",
257-
TriggerRotation = "trigger-rotation"
257+
TriggerRotation = "trigger-rotation",
258+
ReadCredentials = "read-credentials"
258259
}
259260

260261
export enum ProjectPermissionPamSessionActions {

0 commit comments

Comments
 (0)