Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
415f987
feat: add PAM account credentials viewing with MFA support
saifsmailbox98 Apr 1, 2026
a085428
refactor: remove resource connection details from credentials view
saifsmailbox98 Apr 1, 2026
1a6844f
refactor: replace credentials dialog with inline sensitive credential…
saifsmailbox98 Apr 1, 2026
3229b86
fix: match private key pre styling to input and add thin scrollbar
saifsmailbox98 Apr 1, 2026
0de7d06
refactor: clean up credentials gate and defer MFA-only DB lookups
saifsmailbox98 Apr 1, 2026
6c7d759
refactor: remove unused useViewPamAccountCredentials hook
saifsmailbox98 Apr 1, 2026
7a247df
refactor: simplify credentials gate to button with inline loading states
saifsmailbox98 Apr 2, 2026
ee770d7
refactor: clean up credentials view rate limit and simplify sensitive…
saifsmailbox98 Apr 2, 2026
6f9432e
fix: hide credentials view button when account has no sensitive fields
saifsmailbox98 Apr 2, 2026
2afdac7
fix: replace inline error states with toast notifications and align K…
saifsmailbox98 Apr 2, 2026
d55172c
refactor: use inline spinner with text for MFA button states
saifsmailbox98 Apr 2, 2026
71fe7ae
fix: update credentials route description and fix children type in ga…
saifsmailbox98 Apr 2, 2026
1272818
fix: align audit log metadata with PAM conventions and add ABAC subje…
saifsmailbox98 Apr 3, 2026
f8711e0
fix: add re-entrancy guard to MFA polling interval
saifsmailbox98 Apr 3, 2026
8e1d483
feat: add PAM_ACCOUNT_READ_CREDENTIALS to frontend audit log types
saifsmailbox98 Apr 6, 2026
3743ac1
fix: remove PAM_ACCOUNT_READ_CREDENTIALS from audit log types
saifsmailbox98 Apr 6, 2026
e671cfe
Merge remote-tracking branch 'origin/main' into saif/pam-153-add-pass…
saifsmailbox98 Apr 7, 2026
beb15fc
fix: add resourceType to readCredentials permission subject
saifsmailbox98 Apr 7, 2026
a0fba20
fix: show error toast when viewing credentials for account with no pa…
saifsmailbox98 Apr 7, 2026
9e40860
fix: add resourceType to credentials gate CASL subject and await clip…
saifsmailbox98 Apr 7, 2026
69356e4
fix: show error toast when MFA popup is blocked
saifsmailbox98 Apr 7, 2026
e5dab20
fix: add try/catch to clipboard copy handlers in sensitive fields
saifsmailbox98 Apr 7, 2026
9523a62
fix: move credentials endpoint to POST and send empty body when no mf…
saifsmailbox98 Apr 7, 2026
9629bc8
fix: add ReadCredentials to rolePermission2Form deserialization for P…
saifsmailbox98 Apr 7, 2026
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
139 changes: 130 additions & 9 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 @@ -4,17 +4,44 @@ import { z } from "zod";
import { PamAccountDependenciesSchema } from "@app/db/schemas";
import { AuditLogInfo, EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-types";
import { PamAccountOrderBy, PamAccountView } from "@app/ee/services/pam-account/pam-account-enums";
import { SanitizedActiveDirectoryAccountWithResourceSchema } from "@app/ee/services/pam-resource/active-directory/active-directory-resource-schemas";
import { SanitizedAwsIamAccountWithResourceSchema } from "@app/ee/services/pam-resource/aws-iam/aws-iam-resource-schemas";
import { SanitizedKubernetesAccountWithResourceSchema } from "@app/ee/services/pam-resource/kubernetes/kubernetes-resource-schemas";
import { SanitizedMsSQLAccountWithResourceSchema } from "@app/ee/services/pam-resource/mssql/mssql-resource-schemas";
import { SanitizedMySQLAccountWithResourceSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas";
import {
ActiveDirectoryAccountCredentialsSchema,
SanitizedActiveDirectoryAccountWithResourceSchema
} from "@app/ee/services/pam-resource/active-directory/active-directory-resource-schemas";
import {
AwsIamAccountCredentialsSchema,
SanitizedAwsIamAccountWithResourceSchema
} from "@app/ee/services/pam-resource/aws-iam/aws-iam-resource-schemas";
import {
KubernetesAccountCredentialsSchema,
SanitizedKubernetesAccountWithResourceSchema
} from "@app/ee/services/pam-resource/kubernetes/kubernetes-resource-schemas";
import {
MsSQLAccountCredentialsSchema,
SanitizedMsSQLAccountWithResourceSchema
} from "@app/ee/services/pam-resource/mssql/mssql-resource-schemas";
import {
MySQLAccountCredentialsSchema,
SanitizedMySQLAccountWithResourceSchema
} from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas";
import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums";
import { GatewayAccessResponseSchema } from "@app/ee/services/pam-resource/pam-resource-schemas";
import { SanitizedPostgresAccountWithResourceSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
import { SanitizedRedisAccountWithResourceSchema } from "@app/ee/services/pam-resource/redis/redis-resource-schemas";
import { SanitizedSSHAccountWithResourceSchema } from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas";
import { SanitizedWindowsAccountWithResourceSchema } from "@app/ee/services/pam-resource/windows-server/windows-server-resource-schemas";
import {
PostgresAccountCredentialsSchema,
SanitizedPostgresAccountWithResourceSchema
} from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
import {
RedisAccountCredentialsSchema,
SanitizedRedisAccountWithResourceSchema
} from "@app/ee/services/pam-resource/redis/redis-resource-schemas";
import {
SanitizedSSHAccountWithResourceSchema,
SSHAccountCredentialsSchema
} from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas";
import {
SanitizedWindowsAccountWithResourceSchema,
WindowsAccountCredentialsSchema
} from "@app/ee/services/pam-resource/windows-server/windows-server-resource-schemas";
import { BadRequestError } from "@app/lib/errors";
import { logger } from "@app/lib/logger";
import { ms } from "@app/lib/ms";
Expand Down Expand Up @@ -48,6 +75,52 @@ const ListPamAccountsResponseSchema = z.object({
totalCount: z.number().default(0)
});

const AccountCredentialsBaseSchema = z.object({
accountId: z.string().uuid(),
accountName: z.string(),
resourceName: z.string(),
projectId: z.string().uuid()
});

const AccountCredentialsResponseSchema = z.discriminatedUnion("resourceType", [
AccountCredentialsBaseSchema.extend({
resourceType: z.literal(PamResource.Postgres),
credentials: PostgresAccountCredentialsSchema
}),
AccountCredentialsBaseSchema.extend({
resourceType: z.literal(PamResource.MySQL),
credentials: MySQLAccountCredentialsSchema
Comment thread
saifsmailbox98 marked this conversation as resolved.
}),
AccountCredentialsBaseSchema.extend({
resourceType: z.literal(PamResource.MsSQL),
credentials: MsSQLAccountCredentialsSchema
}),
AccountCredentialsBaseSchema.extend({
resourceType: z.literal(PamResource.SSH),
credentials: SSHAccountCredentialsSchema
}),
AccountCredentialsBaseSchema.extend({
resourceType: z.literal(PamResource.Redis),
credentials: RedisAccountCredentialsSchema
}),
AccountCredentialsBaseSchema.extend({
resourceType: z.literal(PamResource.Kubernetes),
credentials: KubernetesAccountCredentialsSchema
}),
AccountCredentialsBaseSchema.extend({
resourceType: z.literal(PamResource.AwsIam),
credentials: AwsIamAccountCredentialsSchema
}),
AccountCredentialsBaseSchema.extend({
resourceType: z.literal(PamResource.Windows),
credentials: WindowsAccountCredentialsSchema
}),
AccountCredentialsBaseSchema.extend({
resourceType: z.literal(PamResource.ActiveDirectory),
credentials: ActiveDirectoryAccountCredentialsSchema
})
]);

export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
server.route({
method: "GET",
Expand Down Expand Up @@ -225,6 +298,54 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
}
});

server.route({
method: "GET",
url: "/:accountId/credentials",
config: {
rateLimit: readLimit
},
schema: {
description: "View full (unsanitized) PAM account credentials",
params: z.object({
accountId: z.string().uuid()
}),
querystring: z.object({
mfaSessionId: z.string().optional()
}),
Comment thread
saifsmailbox98 marked this conversation as resolved.
response: {
200: AccountCredentialsResponseSchema
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const result = await server.services.pamAccount.viewCredentials({
accountId: req.params.accountId,
mfaSessionId: req.query.mfaSessionId,
actorId: req.permission.id,
actor: req.permission.type,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});

await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId: result.projectId,
event: {
type: EventType.PAM_ACCOUNT_READ_CREDENTIALS,
metadata: {
accountId: result.accountId,
accountName: result.accountName,
resourceName: result.resourceName,
resourceType: result.resourceType
}
}
});

return result as z.infer<typeof AccountCredentialsResponseSchema>;
}
});

server.route({
method: "GET",
url: "/",
Expand Down
12 changes: 12 additions & 0 deletions backend/src/ee/services/audit-log/audit-log-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,7 @@ export enum EventType {
PAM_ACCOUNT_DELETE = "pam-account-delete",
PAM_ACCOUNT_CREDENTIAL_ROTATION = "pam-account-credential-rotation",
PAM_ACCOUNT_CREDENTIAL_ROTATION_FAILED = "pam-account-credential-rotation-failed",
PAM_ACCOUNT_READ_CREDENTIALS = "pam-account-read-credentials",
Comment thread
saifsmailbox98 marked this conversation as resolved.
PAM_WEB_ACCESS_SESSION_TICKET_CREATED = "pam-web-access-session-ticket-created",
PAM_RESOURCE_LIST = "pam-resource-list",
PAM_RESOURCE_GET = "pam-resource-get",
Expand Down Expand Up @@ -4852,6 +4853,16 @@ interface PamAccountCredentialRotationFailedEvent {
};
}

interface PamAccountReadCredentialsEvent {
type: EventType.PAM_ACCOUNT_READ_CREDENTIALS;
metadata: {
accountId: string;
accountName: string;
resourceName: string;
resourceType: string;
};
}
Comment thread
saifsmailbox98 marked this conversation as resolved.

interface PamResourceListEvent {
type: EventType.PAM_RESOURCE_LIST;
metadata: {
Expand Down Expand Up @@ -6154,6 +6165,7 @@ export type Event =
| PamAccountDeleteEvent
| PamAccountCredentialRotationEvent
| PamAccountCredentialRotationFailedEvent
| PamAccountReadCredentialsEvent
| PamResourceListEvent
| PamResourceGetEvent
| PamResourceCreateEvent
Expand Down
14 changes: 14 additions & 0 deletions backend/src/ee/services/pam-account/pam-account-fns.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/kms/kms-types";

import { PamResource } from "../pam-resource/pam-resource-enums";
import { TPamAccountCredentials, TPamResourceInternalMetadata } from "../pam-resource/pam-resource-types";
import { SSHAuthMethod } from "../pam-resource/ssh/ssh-resource-enums";

Expand Down Expand Up @@ -67,6 +68,19 @@ export const decryptAccountMessage = async ({
return decryptedPlainTextBlob.toString();
};

// Returns false for account types where all credential fields are already visible in the sanitized view
export const hasSensitiveCredentials = (resourceType: string, credentials: TPamAccountCredentials): boolean => {
if (resourceType === PamResource.AwsIam) return false;
if (resourceType === PamResource.Kubernetes) return false;
if (
resourceType === PamResource.SSH &&
"authMethod" in credentials &&
credentials.authMethod === SSHAuthMethod.Certificate
)
return false;
return true;
};

const hasConfiguredCredentials = (credentials: TPamAccountCredentials): boolean => {
if ("password" in credentials && credentials.password) return true;
if ("privateKey" in credentials && credentials.privateKey) return true;
Expand Down
116 changes: 114 additions & 2 deletions backend/src/ee/services/pam-account/pam-account-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,19 @@ import { PamSessionStatus } from "../pam-session/pam-session-enums";
import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission";
import { TPamAccountDALFactory } from "./pam-account-dal";
import { PamAccountRotationStatus } from "./pam-account-enums";
import { decryptAccount, decryptAccountCredentials, encryptAccountCredentials } from "./pam-account-fns";
import {
decryptAccount,
decryptAccountCredentials,
encryptAccountCredentials,
hasSensitiveCredentials
} from "./pam-account-fns";
import {
TAccessAccountDTO,
TCreateAccountDTO,
TGetAccountByIdDTO,
TListAccountsDTO,
TUpdateAccountDTO
TUpdateAccountDTO,
TViewAccountCredentialsDTO
} from "./pam-account-types";

type TPamAccountServiceFactoryDep = {
Expand Down Expand Up @@ -1371,13 +1377,119 @@ export const pamAccountServiceFactory = ({
};
};

const viewCredentials = async ({
accountId,
mfaSessionId,
actor,
actorId,
actorAuthMethod,
actorOrgId
}: TViewAccountCredentialsDTO) => {
const accountWithResource = await pamAccountDAL.findByIdWithResourceDetails(accountId);
if (!accountWithResource) throw new NotFoundError({ message: `Account with ID '${accountId}' not found` });

const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: accountWithResource.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.PAM
});

const metadataByAccountId = await pamAccountDAL.findMetadataByAccountIds([accountWithResource.id]);
const accountMetadata = metadataByAccountId[accountWithResource.id] || [];

ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPamAccountActions.ReadCredentials,
subject(ProjectPermissionSub.PamAccounts, {
resourceName: accountWithResource.resource.name,
accountName: accountWithResource.name,
metadata: accountMetadata
})
);

// Decrypt early so we can check if there are sensitive fields before triggering MFA
const credentials = await decryptAccountCredentials({
encryptedCredentials: accountWithResource.encryptedCredentials,
kmsService,
projectId: accountWithResource.projectId
});

if (!hasSensitiveCredentials(accountWithResource.resource.resourceType, credentials)) {
throw new BadRequestError({ message: "This account has no sensitive credentials to view" });
}

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

const actorUser = await userDAL.findById(actorId);
if (!actorUser) throw new NotFoundError({ message: `User with ID '${actorId}' not found` });

const org = await orgDAL.findOrgById(project.orgId);
if (!org) throw new NotFoundError({ message: `Organization with ID '${project.orgId}' not found` });

const orgMfaMethod = org.enforceMfa ? (org.selectedMfaMethod as MfaMethod | null) : undefined;
const userMfaMethod = actorUser.isMfaEnabled ? (actorUser.selectedMfaMethod as MfaMethod | null) : undefined;
const mfaMethod = (orgMfaMethod ?? userMfaMethod ?? MfaMethod.EMAIL) as MfaMethod;

const newMfaSessionId = await mfaSessionService.createMfaSession(actorUser.id, accountWithResource.id, mfaMethod);

if (mfaMethod === MfaMethod.EMAIL && actorUser.email) {
await mfaSessionService.sendMfaCode(actorUser.id, actorUser.email);
}

throw new BadRequestError({
message: "MFA verification required to view PAM account credentials",
name: "SESSION_MFA_REQUIRED",
details: {
mfaSessionId: newMfaSessionId,
mfaMethod
}
});
}

if (mfaSessionId && accountWithResource.requireMfa) {
const mfaSession = await mfaSessionService.getMfaSession(mfaSessionId);
if (!mfaSession) {
throw new BadRequestError({ message: "MFA session not found or expired" });
}

if (mfaSession.userId !== actorId) {
throw new BadRequestError({ message: "MFA session does not belong to current user" });
}

if (mfaSession.resourceId !== accountWithResource.id) {
throw new BadRequestError({ message: "MFA session is for a different account" });
}

if (mfaSession.status !== MfaSessionStatus.ACTIVE) {
throw new BadRequestError({ message: "MFA session is not active. Please complete MFA verification first." });
}

await mfaSessionService.deleteMfaSession(mfaSessionId);
}
Comment thread
saifsmailbox98 marked this conversation as resolved.

return {
credentials,
resourceType: accountWithResource.resource.resourceType,
accountId: accountWithResource.id,
accountName: accountWithResource.name,
resourceName: accountWithResource.resource.name,
projectId: accountWithResource.projectId
};
};

return {
create,
updateById,
deleteById,
list,
getById,
access,
viewCredentials,
getSessionCredentials,
rotateAllDueAccounts,
triggerManualRotation
Expand Down
5 changes: 5 additions & 0 deletions backend/src/ee/services/pam-account/pam-account-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,8 @@ export type TListAccountsDTO = {
export type TGetAccountByIdDTO = {
accountId: string;
} & Omit<TProjectPermission, "projectId">;

export type TViewAccountCredentialsDTO = {
accountId: string;
mfaSessionId?: string;
} & Omit<TProjectPermission, "projectId">;
3 changes: 2 additions & 1 deletion backend/src/ee/services/permission/default-roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,8 @@ const buildAdminPermissionRules = () => {
ProjectPermissionPamAccountActions.Create,
ProjectPermissionPamAccountActions.Edit,
ProjectPermissionPamAccountActions.Delete,
ProjectPermissionPamAccountActions.TriggerRotation
ProjectPermissionPamAccountActions.TriggerRotation,
ProjectPermissionPamAccountActions.ReadCredentials
],
ProjectPermissionSub.PamAccounts
);
Expand Down
3 changes: 2 additions & 1 deletion backend/src/ee/services/permission/project-permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,8 @@ export enum ProjectPermissionPamAccountActions {
Create = "create",
Edit = "edit",
Delete = "delete",
TriggerRotation = "trigger-rotation"
TriggerRotation = "trigger-rotation",
ReadCredentials = "read-credentials"
}

export enum ProjectPermissionPamSessionActions {
Expand Down
Loading
Loading