1
0
forked from baron/baron-sso

개요 페이지 클레임 변경 내용 표현

This commit is contained in:
2026-06-16 15:07:46 +09:00
parent 66556c9f03
commit 92ba779ff9
2 changed files with 346 additions and 2 deletions

View File

@@ -91,6 +91,117 @@ describe("recent client changes", () => {
]);
});
it("ignores audit object key order changes in update details", () => {
mockLocale("ko");
expect(
buildRecentClientChangeDetails("UPDATE_CLIENT", {
before: {
id_token_claims: [
{
key: "license",
namespace: "rp_claims",
nullable: true,
readPermission: "admin_only",
value: "",
valueType: "text",
writePermission: "admin_only",
},
{
key: "date",
namespace: "rp_claims",
nullable: true,
readPermission: "admin_only",
value: "",
valueType: "date",
writePermission: "admin_only",
},
],
},
after: {
id_token_claims: [
{
namespace: "rp_claims",
key: "license",
value: "",
valueType: "text",
nullable: true,
readPermission: "admin_only",
writePermission: "admin_only",
},
{
namespace: "rp_claims",
key: "date",
value: "",
valueType: "date",
nullable: true,
readPermission: "admin_only",
writePermission: "admin_only",
},
],
},
}),
).toEqual([]);
});
it("summarizes id_token_claims additions and removals", () => {
mockLocale("ko");
expect(
buildRecentClientChangeDetails("UPDATE_CLIENT", {
before: {
id_token_claims: [
{
namespace: "rp_claims",
key: "license",
value: "",
valueType: "text",
nullable: true,
readPermission: "admin_only",
writePermission: "admin_only",
},
{
namespace: "rp_claims",
key: "date",
value: "",
valueType: "date",
nullable: true,
readPermission: "admin_only",
writePermission: "admin_only",
},
],
},
after: {
id_token_claims: [
{
namespace: "rp_claims",
key: "license",
value: "",
valueType: "text",
nullable: true,
readPermission: "admin_only",
writePermission: "admin_only",
},
{
namespace: "rp_claims",
key: "test",
value: "",
valueType: "text",
nullable: true,
readPermission: "admin_only",
writePermission: "admin_only",
},
],
},
}),
).toEqual([
{
label: "커스텀 클레임",
value: "+ test (text), - date (date)",
},
]);
});
it("builds recent client changes with sorting, filtering, and detail slicing", () => {
mockLocale("ko");
@@ -192,6 +303,40 @@ describe("recent client changes", () => {
after: { name: "Ignored" },
},
),
makeAuditLog(
"evt-9",
"2026-05-27T15:00:00.000Z",
"UPDATE_CLIENT",
"client-a",
{
before: {
id_token_claims: [
{
key: "license",
namespace: "rp_claims",
nullable: true,
readPermission: "admin_only",
value: "",
valueType: "text",
writePermission: "admin_only",
},
],
},
after: {
id_token_claims: [
{
namespace: "rp_claims",
key: "license",
value: "",
valueType: "text",
nullable: true,
readPermission: "admin_only",
writePermission: "admin_only",
},
],
},
},
),
];
const changes = buildRecentClientChanges(auditLogs, clients);

View File

@@ -33,6 +33,27 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function normalizeAuditValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((item) => normalizeAuditValue(item));
}
if (isRecord(value)) {
return Object.keys(value)
.sort()
.reduce<Record<string, unknown>>((acc, key) => {
acc[key] = normalizeAuditValue(value[key]);
return acc;
}, {});
}
return value;
}
function auditValueSignature(value: unknown) {
return JSON.stringify(normalizeAuditValue(value));
}
export function getRecentClientActionLabel(action: string) {
switch (action) {
case "CREATE_CLIENT":
@@ -74,11 +95,169 @@ function getRecentClientFieldLabel(key: string) {
"ui.dev.clients.details.credentials.client_secret",
"클라이언트 시크릿",
);
case "id_token_claims":
return t(
"ui.dev.clients.general.id_token_claims.title",
"Custom Claims",
);
default:
return key;
}
}
function getIdTokenClaimIdentity(claim: Record<string, unknown>) {
const namespace =
typeof claim.namespace === "string" && claim.namespace
? claim.namespace
: null;
const key = typeof claim.key === "string" && claim.key ? claim.key : null;
if (!namespace || !key) {
return null;
}
return { namespace, key };
}
function formatIdTokenClaimDisplayName(claim: Record<string, unknown>) {
const identity = getIdTokenClaimIdentity(claim);
if (!identity) {
return "unknown";
}
if (identity.namespace === "rp_claims") {
return identity.key;
}
return `${identity.namespace}:${identity.key}`;
}
function isSimpleAuditScalar(value: unknown) {
return (
value === null ||
value === undefined ||
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
);
}
function formatIdTokenClaimChangeSummary(
beforeValue: unknown,
afterValue: unknown,
) {
if (!isRecord(beforeValue) || !isRecord(afterValue)) {
return null;
}
const beforeDisplayName = formatIdTokenClaimDisplayName(beforeValue);
const afterDisplayName = formatIdTokenClaimDisplayName(afterValue);
if (beforeDisplayName !== afterDisplayName) {
return `~ ${beforeDisplayName}${afterDisplayName}`;
}
const beforeValueType =
typeof beforeValue.valueType === "string" ? beforeValue.valueType : null;
const afterValueType =
typeof afterValue.valueType === "string" ? afterValue.valueType : null;
if (
beforeValueType &&
afterValueType &&
beforeValueType !== afterValueType
) {
return `~ ${beforeDisplayName}: ${beforeValueType}${afterValueType}`;
}
const beforeScalar = beforeValue.value;
const afterScalar = afterValue.value;
if (
isSimpleAuditScalar(beforeScalar) &&
isSimpleAuditScalar(afterScalar) &&
formatAuditValue(beforeScalar) !== formatAuditValue(afterScalar)
) {
return `~ ${beforeDisplayName}: ${formatAuditValue(beforeScalar)}${formatAuditValue(afterScalar)}`;
}
return `~ ${beforeDisplayName}`;
}
function summarizeIdTokenClaimArrayChange(
beforeValue: unknown,
afterValue: unknown,
) {
if (!Array.isArray(beforeValue) || !Array.isArray(afterValue)) {
return null;
}
const beforeClaims = beforeValue.filter(isRecord);
const afterClaims = afterValue.filter(isRecord);
const beforeByIdentity = new Map<string, Record<string, unknown>>();
const afterByIdentity = new Map<string, Record<string, unknown>>();
for (const claim of beforeClaims) {
const identity = getIdTokenClaimIdentity(claim);
if (identity) {
beforeByIdentity.set(`${identity.namespace}:${identity.key}`, claim);
}
}
for (const claim of afterClaims) {
const identity = getIdTokenClaimIdentity(claim);
if (identity) {
afterByIdentity.set(`${identity.namespace}:${identity.key}`, claim);
}
}
const additions: string[] = [];
const removals: string[] = [];
const updates: string[] = [];
for (const [identity, afterClaim] of afterByIdentity.entries()) {
const beforeClaim = beforeByIdentity.get(identity);
const displayName = formatIdTokenClaimDisplayName(afterClaim);
if (!beforeClaim) {
const valueType =
typeof afterClaim.valueType === "string" ? afterClaim.valueType : null;
additions.push(
valueType ? `+ ${displayName} (${valueType})` : `+ ${displayName}`,
);
continue;
}
if (auditValueSignature(beforeClaim) === auditValueSignature(afterClaim)) {
continue;
}
const summary = formatIdTokenClaimChangeSummary(beforeClaim, afterClaim);
if (summary) {
updates.push(summary);
}
}
for (const [identity, beforeClaim] of beforeByIdentity.entries()) {
if (afterByIdentity.has(identity)) {
continue;
}
const displayName = formatIdTokenClaimDisplayName(beforeClaim);
const valueType =
typeof beforeClaim.valueType === "string" ? beforeClaim.valueType : null;
removals.push(
valueType ? `- ${displayName} (${valueType})` : `- ${displayName}`,
);
}
const parts = [...additions, ...removals, ...updates].slice(0, 4);
if (parts.length === 0) {
return null;
}
if (additions.length + removals.length + updates.length > parts.length) {
parts.push("...");
}
return parts.join(", ");
}
export function buildRecentClientChangeDetails(
action: string,
details: AuditDetails,
@@ -126,8 +305,19 @@ export function buildRecentClientChangeDetails(
const beforeValue = before[key];
const afterValue = after[key];
if (key === "id_token_claims") {
const value = summarizeIdTokenClaimArrayChange(beforeValue, afterValue);
if (!value) {
return null;
}
return {
label: getRecentClientFieldLabel(key),
value,
};
}
if (action !== "CREATE_CLIENT" && action !== "DELETE_CLIENT") {
if (JSON.stringify(beforeValue) === JSON.stringify(afterValue)) {
if (auditValueSignature(beforeValue) === auditValueSignature(afterValue)) {
return null;
}
}
@@ -161,6 +351,10 @@ export function buildRecentClientChangeDetails(
})
.filter((item): item is { label: string; value: string } => Boolean(item));
if (changes.length === 0) {
return [];
}
return changes.slice(0, 3);
}
@@ -194,7 +388,12 @@ export function buildRecentClientChanges(
detailLabels: buildRecentClientChangeDetails(action, details),
} satisfies RecentClientChange;
})
.filter((item): item is RecentClientChange => Boolean(item))
.filter((item): item is RecentClientChange => {
if (!item) {
return false;
}
return item.detailLabels.length > 0;
})
.sort(
(left, right) =>
new Date(right.timestamp).getTime() -