Merge Fix 05 (HIGH A-5): approval payload preview with profile-edit diff + cert-issuance preview

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
shankar0123
2026-05-11 11:17:14 +00:00
3 changed files with 549 additions and 59 deletions
+18
View File
@@ -105,6 +105,24 @@
runbooks for multi-tenant IdPs in `docs/operator/oidc-runbooks/` already
documented the field; the GUI now matches.
- **Approval payload preview (Audit 2026-05-11 A-5).**
The MED-10 closure claim ("PARTIAL: raw JSON preview; diff library
deferred") was inaccurate — `ApprovalsPage.tsx` rendered no payload
at all, so approvers were clicking Approve / Reject without seeing
the change they were authorizing. That defeats the entire four-eyes
primitive: an approver who can't see what they're approving is
rubber-stamping. Each row now carries a Preview toggle that expands
an inline panel dispatching by kind: `profile_edit` shows a
field-level before/after diff (changed-only rows, red/green cells,
`(unset)` sentinel for added/removed fields); `cert_issuance` shows
a definition list of CN / SANs / profile / key algo / must-staple /
validity (catches the wildcard-against-corp-internal-profile attack
at review time); unknown kinds render a generic JSON preview for
forward-compat with future approval kinds. The base64-encoded JSON
payload is decoded via the new `decodePayload` helper; malformed
inputs render an explicit decode-error fallback — silent failure on
the payload preview is what produced this bug in the first place.
- **Pre-login cookie Path widened from `/auth/oidc/` to `/` (Audit MED-14
follow-on).** Required to satisfy the `__Host-` prefix's `Path=/` rule. The
cookie lifetime is unchanged (10 minutes) and only the callback handler
+242
View File
@@ -141,4 +141,246 @@ describe('ApprovalsPage', () => {
renderWithProviders(<ApprovalsPage />);
await waitFor(() => expect(screen.getByTestId('approvals-empty')).toBeTruthy());
});
// =============================================================================
// Audit 2026-05-11 A-5 — payload preview.
// =============================================================================
// b64 of '{"before":{"must_staple":false,"max_validity_days":397},"after":{"must_staple":true,"max_validity_days":90}}'
const profileEditPayload = btoa(JSON.stringify({
before: { must_staple: false, max_validity_days: 397, requires_approval: true },
after: { must_staple: true, max_validity_days: 90, requires_approval: true },
}));
const profileEditNoChangesPayload = btoa(JSON.stringify({
before: { must_staple: false },
after: { must_staple: false },
}));
const certIssuancePayload = btoa(JSON.stringify({
subject_common_name: 'api.acme.com',
sans: ['api.acme.com', 'www.acme.com'],
profile_id: 'p-corp-public',
key_algorithm: 'ECDSA-P256',
must_staple: true,
validity_days: 90,
}));
it('A-5 Preview button toggles the payload panel', async () => {
vi.mocked(client.listApprovals).mockResolvedValue({
data: [{
...samplePending[1],
payload: certIssuancePayload,
}],
total: 1,
page: 1,
per_page: 50,
} as never);
vi.mocked(client.authMe).mockResolvedValue(aliceMe);
renderWithProviders(<ApprovalsPage />);
await waitFor(() => screen.getByTestId('approvals-preview-toggle-ar-2'));
// Panel hidden by default.
expect(screen.queryByTestId('approvals-payload-preview-ar-2')).toBeNull();
fireEvent.click(screen.getByTestId('approvals-preview-toggle-ar-2'));
await waitFor(() => screen.getByTestId('approvals-payload-preview-ar-2'));
expect(screen.getByTestId('approval-cert-issuance-preview')).toBeTruthy();
// Toggle off.
fireEvent.click(screen.getByTestId('approvals-preview-toggle-ar-2'));
await waitFor(() => {
expect(screen.queryByTestId('approvals-payload-preview-ar-2')).toBeNull();
});
});
it('A-5 ProfileEdit kind renders field diff with changed-only rows', async () => {
vi.mocked(client.listApprovals).mockResolvedValue({
data: [{
...samplePending[0],
requested_by: 'bob', // not self — Preview button is enabled regardless
payload: profileEditPayload,
}],
total: 1,
page: 1,
per_page: 50,
} as never);
vi.mocked(client.authMe).mockResolvedValue(aliceMe);
renderWithProviders(<ApprovalsPage />);
await waitFor(() => screen.getByTestId('approvals-preview-toggle-ar-1'));
fireEvent.click(screen.getByTestId('approvals-preview-toggle-ar-1'));
await waitFor(() => screen.getByTestId('approval-profile-edit-diff'));
// Only changed fields render rows.
expect(screen.getByTestId('approval-profile-edit-row-must_staple')).toBeTruthy();
expect(screen.getByTestId('approval-profile-edit-row-max_validity_days')).toBeTruthy();
// Unchanged requires_approval should NOT render a row.
expect(screen.queryByTestId('approval-profile-edit-row-requires_approval')).toBeNull();
});
it('A-5 ProfileEdit before/after values are visible in the diff cells', async () => {
vi.mocked(client.listApprovals).mockResolvedValue({
data: [{
...samplePending[0],
requested_by: 'bob',
payload: profileEditPayload,
}],
total: 1,
page: 1,
per_page: 50,
} as never);
vi.mocked(client.authMe).mockResolvedValue(aliceMe);
renderWithProviders(<ApprovalsPage />);
await waitFor(() => screen.getByTestId('approvals-preview-toggle-ar-1'));
fireEvent.click(screen.getByTestId('approvals-preview-toggle-ar-1'));
await waitFor(() => screen.getByTestId('approval-profile-edit-diff'));
const row = screen.getByTestId('approval-profile-edit-row-must_staple');
expect(row.textContent).toContain('false');
expect(row.textContent).toContain('true');
});
it('A-5 ProfileEdit with no changes renders empty-state', async () => {
vi.mocked(client.listApprovals).mockResolvedValue({
data: [{
...samplePending[0],
requested_by: 'bob',
payload: profileEditNoChangesPayload,
}],
total: 1,
page: 1,
per_page: 50,
} as never);
vi.mocked(client.authMe).mockResolvedValue(aliceMe);
renderWithProviders(<ApprovalsPage />);
await waitFor(() => screen.getByTestId('approvals-preview-toggle-ar-1'));
fireEvent.click(screen.getByTestId('approvals-preview-toggle-ar-1'));
await waitFor(() => screen.getByTestId('approval-profile-edit-no-changes'));
expect(screen.queryByTestId('approval-profile-edit-diff')).toBeNull();
});
it('A-5 CertIssuance renders definition list with SANs + profile + key algo', async () => {
vi.mocked(client.listApprovals).mockResolvedValue({
data: [{
...samplePending[1],
payload: certIssuancePayload,
}],
total: 1,
page: 1,
per_page: 50,
} as never);
vi.mocked(client.authMe).mockResolvedValue(aliceMe);
renderWithProviders(<ApprovalsPage />);
await waitFor(() => screen.getByTestId('approvals-preview-toggle-ar-2'));
fireEvent.click(screen.getByTestId('approvals-preview-toggle-ar-2'));
const dl = await waitFor(() => screen.getByTestId('approval-cert-issuance-preview'));
expect(dl.textContent).toContain('api.acme.com');
expect(dl.textContent).toContain('www.acme.com');
expect(dl.textContent).toContain('p-corp-public');
expect(dl.textContent).toContain('ECDSA-P256');
});
it('A-5 Unknown kind falls back to generic JSON pre block', async () => {
const unknownKindPayload = btoa(JSON.stringify({ some_future_field: 42, nested: { ok: true } }));
vi.mocked(client.listApprovals).mockResolvedValue({
data: [{
id: 'ar-3',
kind: 'future_kind_v3' as never, // not in current enum
profile_id: 'prof-x',
requested_by: 'bob',
state: 'pending' as const,
payload: unknownKindPayload,
created_at: '2026-05-09T20:02:00Z',
updated_at: '2026-05-09T20:02:00Z',
}],
total: 1,
page: 1,
per_page: 50,
} as never);
vi.mocked(client.authMe).mockResolvedValue(aliceMe);
renderWithProviders(<ApprovalsPage />);
await waitFor(() => screen.getByTestId('approvals-preview-toggle-ar-3'));
fireEvent.click(screen.getByTestId('approvals-preview-toggle-ar-3'));
const pre = await waitFor(() => screen.getByTestId('approval-payload-generic-json'));
expect(pre.textContent).toContain('some_future_field');
expect(pre.textContent).toContain('42');
});
it('A-5 Empty payload renders the "No payload attached" sentinel', async () => {
vi.mocked(client.listApprovals).mockResolvedValue({
data: [{
...samplePending[1],
payload: undefined,
}],
total: 1,
page: 1,
per_page: 50,
} as never);
vi.mocked(client.authMe).mockResolvedValue(aliceMe);
renderWithProviders(<ApprovalsPage />);
await waitFor(() => screen.getByTestId('approvals-preview-toggle-ar-2'));
fireEvent.click(screen.getByTestId('approvals-preview-toggle-ar-2'));
await waitFor(() => screen.getByTestId('approval-payload-empty'));
});
it('A-5 Malformed base64 payload renders the decode-error fallback', async () => {
vi.mocked(client.listApprovals).mockResolvedValue({
data: [{
...samplePending[1],
payload: '!!!not-valid-base64!!!',
}],
total: 1,
page: 1,
per_page: 50,
} as never);
vi.mocked(client.authMe).mockResolvedValue(aliceMe);
renderWithProviders(<ApprovalsPage />);
await waitFor(() => screen.getByTestId('approvals-preview-toggle-ar-2'));
fireEvent.click(screen.getByTestId('approvals-preview-toggle-ar-2'));
await waitFor(() => screen.getByTestId('approval-payload-decode-error'));
});
});
// =============================================================================
// Pure-function tests on decodePayload (Audit 2026-05-11 A-5).
// =============================================================================
describe('decodePayload', () => {
it('returns null for undefined input', async () => {
const { decodePayload } = await import('./ApprovalsPage');
expect(decodePayload(undefined)).toBeNull();
});
it('returns null for empty string', async () => {
const { decodePayload } = await import('./ApprovalsPage');
expect(decodePayload('')).toBeNull();
});
it('round-trips base64-encoded JSON', async () => {
const { decodePayload } = await import('./ApprovalsPage');
const original = { foo: 'bar', n: 42, arr: [1, 2, 3] };
const encoded = btoa(JSON.stringify(original));
expect(decodePayload(encoded)).toEqual(original);
});
it('returns null on malformed base64', async () => {
const { decodePayload } = await import('./ApprovalsPage');
expect(decodePayload('!!!not-base64!!!')).toBeNull();
});
it('returns null on valid base64 of non-JSON content', async () => {
const { decodePayload } = await import('./ApprovalsPage');
expect(decodePayload(btoa('not a json document'))).toBeNull();
});
});
+233 -3
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { Fragment, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import {
listApprovals,
@@ -25,14 +25,212 @@ import ErrorState from '../../components/ErrorState';
// are both populated; metadata.common_name surfaces.
// - profile_edit — Bundle 1 Phase 9 closure; cert + job are empty,
// payload carries the pending profile diff (rendered as a
// collapsible JSON preview).
// field-level before/after diff via PayloadPreview).
//
// Same-actor self-approve is rejected server-side with HTTP 403; the
// page surfaces the error inline. Approve / reject actions are HIDDEN
// when the caller's actor_id equals requested_by, so the operator
// can't even click the wrong button.
//
// Audit 2026-05-11 A-5 — payload preview. The MED-10 closure claim
// said the GUI rendered a "raw JSON preview" but the verifier found
// zero payload rendering — approvers had to click Approve blind. This
// page now renders an inline expandable panel per row that dispatches
// to one of three components by `kind`:
//
// - kind=profile_edit → ProfileEditDiff: field-level before/after
// table. The whole point of the four-eyes principle is "the
// approver sees what's changing"; rendering this as a flat field
// diff is materially more useful than a unified line-diff for the
// small flat-object profile shape.
// - kind=cert_issuance → IssuanceRequestPreview: CN / SANs /
// profile / key algo / must-staple / validity. Catches the
// wildcard-against-corp-internal-profile attack at review time.
// - any other kind → generic JSON <pre>. Forward-compat for
// future approval kinds added to migration 000033's enum.
//
// The payload arrives as a base64-encoded JSON string (Go json-encodes
// []byte to base64 by default; see internal/domain/approval.go:41).
// decodePayload() handles the decode + parse + null/error guard.
// =============================================================================
// decodePayload base64-decodes the wire payload and JSON-parses the
// result. Returns the parsed shape or null on any failure (empty
// payload, malformed base64, malformed JSON). The component layer
// renders the generic fallback in that case so the approver still
// sees *something* — silent failure on the payload preview defeats
// the entire fix.
//
// Exported for test reach.
export function decodePayload(payload: string | undefined): unknown {
if (!payload) return null;
try {
// atob throws on invalid base64; the surrounding try/catch falls
// through to null which the caller renders as "Unable to decode
// payload" in the generic branch.
const decoded = atob(payload);
return JSON.parse(decoded);
} catch {
return null;
}
}
// =============================================================================
// PayloadPreview — kind dispatch.
// =============================================================================
function PayloadPreview({ kind, payload }: { kind: string; payload: string | undefined }) {
const decoded = decodePayload(payload);
if (decoded === null && payload) {
return (
<div
className="text-xs text-red-700"
data-testid="approval-payload-decode-error"
>
Unable to decode payload (base64 / JSON parse failed). Raw value:{' '}
<code className="break-all">{payload}</code>
</div>
);
}
if (decoded === null) {
return (
<div
className="text-xs text-ink-muted italic"
data-testid="approval-payload-empty"
>
No payload attached.
</div>
);
}
if (kind === 'profile_edit') {
return <ProfileEditDiff payload={decoded} />;
}
if (kind === 'cert_issuance') {
return <IssuanceRequestPreview payload={decoded} />;
}
// Forward-compat fallback for future kinds.
return (
<pre
className="text-xs bg-surface-muted p-3 rounded overflow-x-auto"
data-testid="approval-payload-generic-json"
>
{JSON.stringify(decoded, null, 2)}
</pre>
);
}
// =============================================================================
// ProfileEditDiff — field-level before/after table.
// =============================================================================
function ProfileEditDiff({ payload }: { payload: unknown }) {
const envelope = payload as { before?: Record<string, unknown>; after?: Record<string, unknown> };
const before = envelope?.before ?? {};
const after = envelope?.after ?? {};
const allKeys = Array.from(new Set([...Object.keys(before), ...Object.keys(after)])).sort();
const changedKeys = allKeys.filter(k => JSON.stringify(before[k]) !== JSON.stringify(after[k]));
if (changedKeys.length === 0) {
return (
<div
className="text-xs text-ink-muted italic"
data-testid="approval-profile-edit-no-changes"
>
No field changes detected.
</div>
);
}
return (
<table
className="text-xs w-full border border-surface-border"
data-testid="approval-profile-edit-diff"
>
<thead className="bg-surface-muted">
<tr>
<th className="text-left px-2 py-1 font-medium">Field</th>
<th className="text-left px-2 py-1 font-medium">Before</th>
<th className="text-left px-2 py-1 font-medium">After</th>
</tr>
</thead>
<tbody>
{changedKeys.map(k => (
<tr
key={k}
className="border-t border-surface-border"
data-testid={`approval-profile-edit-row-${k}`}
>
<td className="px-2 py-1 font-mono"><code>{k}</code></td>
<td className="px-2 py-1 font-mono break-all bg-red-50">
{renderValue(before[k])}
</td>
<td className="px-2 py-1 font-mono break-all bg-green-50">
{renderValue(after[k])}
</td>
</tr>
))}
</tbody>
</table>
);
}
// renderValue stringifies a payload value for the diff cells. Renders
// undefined as a visually distinct "(unset)" sentinel so the approver
// can tell a field was added (before=unset) vs flipped (before=value).
function renderValue(v: unknown) {
if (v === undefined) {
return <span className="text-ink-muted italic">(unset)</span>;
}
return JSON.stringify(v);
}
// =============================================================================
// IssuanceRequestPreview — definition list of the load-bearing fields.
// =============================================================================
function IssuanceRequestPreview({ payload }: { payload: unknown }) {
const p = payload as {
subject_common_name?: string;
common_name?: string;
sans?: string[];
profile_id?: string;
key_algorithm?: string;
must_staple?: boolean;
validity_days?: number;
requester_actor_id?: string;
};
// The certificate-service issuance request uses `subject_common_name`
// on some paths and `common_name` on others; surface either.
const cn = p.subject_common_name ?? p.common_name ?? '—';
return (
<dl
className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs"
data-testid="approval-cert-issuance-preview"
>
<dt className="text-ink-muted">Common Name</dt>
<dd className="font-mono">{cn}</dd>
<dt className="text-ink-muted">SANs</dt>
<dd className="font-mono">{(p.sans ?? []).join(', ') || '—'}</dd>
<dt className="text-ink-muted">Profile</dt>
<dd className="font-mono">{p.profile_id ?? '—'}</dd>
<dt className="text-ink-muted">Key algorithm</dt>
<dd className="font-mono">{p.key_algorithm ?? '—'}</dd>
<dt className="text-ink-muted">Must-staple</dt>
<dd>{p.must_staple === undefined ? '—' : p.must_staple ? 'yes' : 'no'}</dd>
<dt className="text-ink-muted">Validity (days)</dt>
<dd>{p.validity_days ?? '—'}</dd>
{p.requester_actor_id && (
<>
<dt className="text-ink-muted">Requester (payload-claimed)</dt>
<dd className="font-mono">{p.requester_actor_id}</dd>
</>
)}
</dl>
);
}
export default function ApprovalsPage() {
const me = useAuthMe();
const qc = useQueryClient();
@@ -47,6 +245,11 @@ export default function ApprovalsPage() {
const [actionError, setActionError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
// Audit 2026-05-11 A-5 — per-row payload preview expansion.
// Single-string state (rather than a Set) because most operators
// only inspect one approval at a time; widening to multi-select
// is a trivial future change if the workflow demands it.
const [expandedID, setExpandedID] = useState<string | null>(null);
const handleApprove = async (req: ApprovalRequest) => {
const note = window.prompt('Approval note (optional):') ?? '';
@@ -137,6 +340,7 @@ export default function ApprovalsPage() {
<th className="text-left px-3 py-2">Profile</th>
<th className="text-left px-3 py-2">Requested by</th>
<th className="text-left px-3 py-2">Created</th>
<th className="px-3 py-2 w-24">Payload</th>
<th className="px-3 py-2 w-44"></th>
</tr>
</thead>
@@ -144,9 +348,10 @@ export default function ApprovalsPage() {
{items.map(req => {
const isMine = req.requested_by === myID;
const isPending = req.state === 'pending';
const isExpanded = expandedID === req.id;
return (
<Fragment key={req.id}>
<tr
key={req.id}
className="border-t border-surface-border align-top"
data-testid={`approvals-row-${req.id}`}
>
@@ -171,6 +376,20 @@ export default function ApprovalsPage() {
<td className="px-3 py-2 text-xs text-ink-muted">
{new Date(req.created_at).toLocaleString()}
</td>
<td className="px-3 py-2">
{/* Audit 2026-05-11 A-5 — payload preview toggle.
Always rendered (even when payload is empty)
so the approver can verify there ISN'T a
payload they might have missed. */}
<button
className="btn btn-ghost text-xs"
onClick={() => setExpandedID(isExpanded ? null : req.id)}
data-testid={`approvals-preview-toggle-${req.id}`}
aria-expanded={isExpanded}
>
{isExpanded ? 'Hide' : 'Preview'}
</button>
</td>
<td className="px-3 py-2 text-right">
{isPending && !isMine && (
<div className="flex gap-1 justify-end">
@@ -205,6 +424,17 @@ export default function ApprovalsPage() {
)}
</td>
</tr>
{isExpanded && (
<tr
className="border-t border-surface-border bg-surface-muted/40"
data-testid={`approvals-payload-preview-${req.id}`}
>
<td colSpan={7} className="px-3 py-3">
<PayloadPreview kind={req.kind} payload={req.payload} />
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>