802 lines
29 KiB
JavaScript
802 lines
29 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import zlib from "node:zlib";
|
|
|
|
const DEFAULT_OUTPUT_DIR = "docs/architecture";
|
|
const libraryInput = process.argv[2] || process.env.OCI_DRAWIO_LIBRARY;
|
|
if (!libraryInput) {
|
|
throw new Error(
|
|
"OCI Draw.io library path is required as the first argument or OCI_DRAWIO_LIBRARY",
|
|
);
|
|
}
|
|
const libraryPath = path.resolve(libraryInput);
|
|
const outputDir = path.resolve(process.argv[3] || DEFAULT_OUTPUT_DIR);
|
|
|
|
const COLORS = {
|
|
ink: "#312D2A",
|
|
muted: "#5B5652",
|
|
oracleRed: "#C74634",
|
|
oracleOrange: "#AE562C",
|
|
regionFill: "#F5F4F2",
|
|
regionStroke: "#9E9892",
|
|
panel: "#FFFFFF",
|
|
panelSoft: "#FCFBFA",
|
|
thirdParty: "#6B7280",
|
|
success: "#2F6B4F",
|
|
blue: "#2F6F8F",
|
|
};
|
|
|
|
const REQUIRED_ICONS = [
|
|
"Identity and Security - User",
|
|
"Compute - Virtual Machine VM",
|
|
"Database - Autonomous DB",
|
|
"Analytics and AI - Artificial Intelligence",
|
|
"Storage - Object Storage",
|
|
"Identity and Security - Vault",
|
|
];
|
|
|
|
function escapeXml(value) {
|
|
return String(value)
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """);
|
|
}
|
|
|
|
function readLibrary(file) {
|
|
const raw = fs.readFileSync(file, "utf8").trim();
|
|
const prefix = "<mxlibrary>";
|
|
const suffix = "</mxlibrary>";
|
|
if (!raw.startsWith(prefix) || !raw.endsWith(suffix)) {
|
|
throw new Error(`Invalid Draw.io library: ${file}`);
|
|
}
|
|
const entries = JSON.parse(raw.slice(prefix.length, -suffix.length));
|
|
const byTitle = new Map(entries.map((entry) => [entry.title, entry]));
|
|
const missing = REQUIRED_ICONS.filter((title) => !byTitle.has(title));
|
|
if (missing.length) {
|
|
throw new Error(`Required OCI library shapes not found: ${missing.join(", ")}`);
|
|
}
|
|
return byTitle;
|
|
}
|
|
|
|
function decodeLibraryEntry(entry) {
|
|
const encoded = Buffer.from(entry.xml, "base64");
|
|
return decodeURIComponent(zlib.inflateRawSync(encoded).toString("utf8"));
|
|
}
|
|
|
|
function style(parts) {
|
|
return Object.entries(parts)
|
|
.filter(([, value]) => value !== undefined && value !== null)
|
|
.map(([key, value]) => `${key}=${value}`)
|
|
.join(";") + ";";
|
|
}
|
|
|
|
function createPage(name, id, width, height) {
|
|
let sequence = 1;
|
|
const cells = [
|
|
`<mxCell id="${id}_background" value="" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=none;locked=1;" vertex="1" connectable="0" parent="1">`
|
|
+ `<mxGeometry x="0" y="0" width="${width}" height="${height}" as="geometry"/>`
|
|
+ `</mxCell>`,
|
|
];
|
|
const nextId = (prefix = "c") => `${id}_${prefix}_${sequence++}`;
|
|
|
|
function vertex({
|
|
x,
|
|
y,
|
|
w,
|
|
h,
|
|
value = "",
|
|
cellStyle,
|
|
parent = "1",
|
|
cellId = nextId("v"),
|
|
connectable,
|
|
}) {
|
|
const connectableAttr = connectable === false ? ' connectable="0"' : "";
|
|
cells.push(
|
|
`<mxCell id="${cellId}" value="${escapeXml(value)}" style="${escapeXml(cellStyle)}" vertex="1" parent="${parent}"${connectableAttr}>`
|
|
+ `<mxGeometry x="${x}" y="${y}" width="${w}" height="${h}" as="geometry"/>`
|
|
+ `</mxCell>`,
|
|
);
|
|
return cellId;
|
|
}
|
|
|
|
function edge({
|
|
source,
|
|
target,
|
|
value = "",
|
|
cellStyle,
|
|
points = [],
|
|
cellId = nextId("e"),
|
|
}) {
|
|
const pointXml = points.length
|
|
? `<Array as="points">${points.map(([x, y]) => `<mxPoint x="${x}" y="${y}"/>`).join("")}</Array>`
|
|
: "";
|
|
cells.push(
|
|
`<mxCell id="${cellId}" value="${escapeXml(value)}" style="${escapeXml(cellStyle)}" edge="1" parent="1" source="${source}" target="${target}">`
|
|
+ `<mxGeometry relative="1" as="geometry">${pointXml}</mxGeometry>`
|
|
+ `</mxCell>`,
|
|
);
|
|
return cellId;
|
|
}
|
|
|
|
function officialIcon(library, title, x, y, parent = "1") {
|
|
const entry = library.get(title);
|
|
const decoded = decodeLibraryEntry(entry);
|
|
const sourceCells = [
|
|
...decoded.matchAll(/<mxCell\b[^>]*\/>|<mxCell\b[^>]*>[\s\S]*?<\/mxCell>/g),
|
|
].map((match) => match[0]);
|
|
const parsed = sourceCells
|
|
.map((xml) => ({
|
|
xml,
|
|
id: xml.match(/\bid="([^"]+)"/)?.[1],
|
|
parent: xml.match(/\bparent="([^"]+)"/)?.[1],
|
|
value: xml.match(/\bvalue="([^"]*)"/)?.[1] ?? "",
|
|
}))
|
|
.filter((cell) => cell.id && !["0", "1"].includes(cell.id));
|
|
const included = parsed.filter((cell) => !cell.value.trim());
|
|
const wrapperId = nextId("oci");
|
|
cells.push(
|
|
`<mxCell id="${wrapperId}" value="" style="group" vertex="1" connectable="0" parent="${parent}">`
|
|
+ `<mxGeometry x="${x}" y="${y}" width="${entry.w}" height="90" as="geometry"/>`
|
|
+ `</mxCell>`,
|
|
);
|
|
const idMap = new Map(included.map((cell) => [cell.id, nextId("ociPart")]));
|
|
for (const cell of included) {
|
|
let xml = cell.xml;
|
|
xml = xml.replace(/\bid="([^"]+)"/, `id="${idMap.get(cell.id)}"`);
|
|
xml = xml.replace(/\bparent="([^"]+)"/, (_match, oldParent) => {
|
|
if (oldParent === "1") {
|
|
return `parent="${wrapperId}"`;
|
|
}
|
|
return `parent="${idMap.get(oldParent) || wrapperId}"`;
|
|
});
|
|
xml = xml.replace(/\b(source|target)="([^"]+)"/g, (_match, attr, oldId) =>
|
|
`${attr}="${idMap.get(oldId) || oldId}"`);
|
|
cells.push(xml);
|
|
}
|
|
return wrapperId;
|
|
}
|
|
|
|
function result() {
|
|
const model = [
|
|
`<mxGraphModel dx="1600" dy="1000" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="${width}" pageHeight="${height}" background="#FFFFFF" math="0" shadow="0">`,
|
|
"<root>",
|
|
'<mxCell id="0"/>',
|
|
'<mxCell id="1" parent="0"/>',
|
|
...cells,
|
|
"</root>",
|
|
"</mxGraphModel>",
|
|
].join("");
|
|
return `<diagram id="${id}" name="${escapeXml(name)}">${model}</diagram>`;
|
|
}
|
|
|
|
return { vertex, edge, officialIcon, result };
|
|
}
|
|
|
|
const labelStyle = (options = {}) => style({
|
|
rounded: 0,
|
|
whiteSpace: "wrap",
|
|
html: 1,
|
|
strokeColor: "none",
|
|
fillColor: "none",
|
|
align: options.align || "left",
|
|
verticalAlign: options.verticalAlign || "middle",
|
|
fontFamily: "Oracle Sans",
|
|
fontColor: options.fontColor || COLORS.ink,
|
|
fontSize: options.fontSize || 12,
|
|
fontStyle: options.bold ? 1 : 0,
|
|
spacingLeft: options.spacingLeft || 0,
|
|
spacingRight: options.spacingRight || 0,
|
|
});
|
|
|
|
const panelStyle = (options = {}) => style({
|
|
rounded: 1,
|
|
arcSize: options.arcSize || 8,
|
|
whiteSpace: "wrap",
|
|
html: 1,
|
|
fillColor: options.fillColor || COLORS.panel,
|
|
strokeColor: options.strokeColor || COLORS.regionStroke,
|
|
strokeWidth: options.strokeWidth || 1,
|
|
dashed: options.dashed ? 1 : 0,
|
|
fontFamily: "Oracle Sans",
|
|
fontColor: options.fontColor || COLORS.ink,
|
|
fontSize: options.fontSize || 12,
|
|
align: options.align || "center",
|
|
verticalAlign: options.verticalAlign || "middle",
|
|
spacing: options.spacing || 6,
|
|
spacingLeft: options.spacingLeft,
|
|
spacingRight: options.spacingRight,
|
|
});
|
|
|
|
const edgeStyle = (options = {}) => style({
|
|
edgeStyle: "orthogonalEdgeStyle",
|
|
rounded: 1,
|
|
orthogonalLoop: 1,
|
|
jettySize: "auto",
|
|
html: 1,
|
|
strokeColor: options.strokeColor || COLORS.ink,
|
|
strokeWidth: options.strokeWidth || 2,
|
|
dashed: options.dashed ? 1 : 0,
|
|
endArrow: options.endArrow || "block",
|
|
endFill: options.endFill === false ? 0 : 1,
|
|
fontFamily: "Oracle Sans",
|
|
fontColor: options.fontColor || COLORS.ink,
|
|
fontSize: options.fontSize || 11,
|
|
labelBackgroundColor: "#FFFFFF",
|
|
exitX: options.exitX,
|
|
exitY: options.exitY,
|
|
entryX: options.entryX,
|
|
entryY: options.entryY,
|
|
});
|
|
|
|
function title(page, heading, subtitle, width) {
|
|
page.vertex({
|
|
x: 36,
|
|
y: 22,
|
|
w: width - 72,
|
|
h: 38,
|
|
value: heading,
|
|
cellStyle: labelStyle({ fontSize: 25, bold: true }),
|
|
});
|
|
page.vertex({
|
|
x: 38,
|
|
y: 60,
|
|
w: width - 76,
|
|
h: 28,
|
|
value: subtitle,
|
|
cellStyle: labelStyle({ fontSize: 12, fontColor: COLORS.muted }),
|
|
});
|
|
page.vertex({
|
|
x: 36,
|
|
y: 92,
|
|
w: width - 72,
|
|
h: 3,
|
|
cellStyle: panelStyle({
|
|
fillColor: COLORS.oracleRed,
|
|
strokeColor: COLORS.oracleRed,
|
|
arcSize: 0,
|
|
}),
|
|
});
|
|
}
|
|
|
|
function boundary(page, x, y, w, h, heading, options = {}) {
|
|
const box = page.vertex({
|
|
x,
|
|
y,
|
|
w,
|
|
h,
|
|
value: "",
|
|
cellStyle: panelStyle({
|
|
fillColor: options.fillColor || COLORS.regionFill,
|
|
strokeColor: options.strokeColor || COLORS.regionStroke,
|
|
strokeWidth: options.strokeWidth || 1,
|
|
dashed: options.dashed,
|
|
arcSize: options.arcSize || 7,
|
|
}),
|
|
});
|
|
page.vertex({
|
|
x: x + 14,
|
|
y: y + 8,
|
|
w: w - 28,
|
|
h: 26,
|
|
value: `<b>${heading}</b>`,
|
|
cellStyle: labelStyle({
|
|
fontSize: options.fontSize || 13,
|
|
fontColor: options.headingColor || COLORS.ink,
|
|
}),
|
|
});
|
|
return box;
|
|
}
|
|
|
|
function addOverviewPage(library) {
|
|
const page = createPage("01 · 전체 구성", "hmm-overview", 1800, 1080);
|
|
title(
|
|
page,
|
|
"HMM AI 데이터 접근 아키텍처",
|
|
"사용자 Bearer MCP · Select AI · VPD · HR 정책 벡터 검색 · AWS RDS PostgreSQL Federation",
|
|
1800,
|
|
);
|
|
|
|
boundary(page, 30, 120, 190, 790, "HMM 사용자", {
|
|
fillColor: COLORS.panelSoft,
|
|
});
|
|
page.officialIcon(library, "Identity and Security - User", 80, 165);
|
|
const users = page.vertex({
|
|
x: 54,
|
|
y: 270,
|
|
w: 142,
|
|
h: 150,
|
|
value: "<b>팀장 · E1001</b><br>팀원 · E1002~E1007<br>HR 관리자<br><br><font color=\"#5B5652\">같은 질문이라도 역할과 보고 관계에 따라 다른 행을 조회</font>",
|
|
cellStyle: panelStyle({ fillColor: "#FFFFFF", strokeColor: "#D4CFCA" }),
|
|
});
|
|
const externalAgent = page.vertex({
|
|
x: 54,
|
|
y: 465,
|
|
w: 142,
|
|
h: 115,
|
|
value: "<b>외부 Agent</b><br>AI Database Private<br>Agent Factory<br><br><font color=\"#5B5652\">MCP client</font>",
|
|
cellStyle: panelStyle({ fillColor: "#FFFFFF", strokeColor: "#D4CFCA" }),
|
|
});
|
|
page.vertex({
|
|
x: 54,
|
|
y: 625,
|
|
w: 142,
|
|
h: 210,
|
|
value: "<b>공개 HTTPS</b><br><br>hmm.cloud-handson.com<br><br>hmm-backoffice.cloud-handson.com/mcp<br><br><font color=\"#AE562C\">사용자 Bearer Token</font><br><font color=\"#5B5652\">TLS · HttpOnly portal cookie</font>",
|
|
cellStyle: panelStyle({ fillColor: "#FFFFFF", strokeColor: COLORS.oracleOrange }),
|
|
});
|
|
|
|
boundary(page, 250, 120, 1260, 900, "Oracle Cloud Infrastructure", {
|
|
fillColor: "#FBFAF9",
|
|
});
|
|
boundary(page, 280, 165, 930, 815, "OCI Region · ap-seoul-1", {
|
|
fillColor: COLORS.regionFill,
|
|
});
|
|
boundary(page, 315, 210, 480, 320, "VCN · Public application subnet", {
|
|
fillColor: "#FFFFFF",
|
|
strokeColor: COLORS.oracleOrange,
|
|
dashed: true,
|
|
headingColor: COLORS.oracleOrange,
|
|
});
|
|
|
|
page.officialIcon(library, "Compute - Virtual Machine VM", 340, 265);
|
|
const compute = page.vertex({
|
|
x: 445,
|
|
y: 260,
|
|
w: 320,
|
|
h: 235,
|
|
value: "",
|
|
cellStyle: panelStyle({ fillColor: "#FCFBFA", strokeColor: "#D4CFCA" }),
|
|
});
|
|
page.vertex({
|
|
x: 462,
|
|
y: 274,
|
|
w: 286,
|
|
h: 34,
|
|
value: "<b>Compute VM · 132.226.232.69</b>",
|
|
cellStyle: labelStyle({ fontSize: 13, bold: true }),
|
|
});
|
|
const nginx = page.vertex({
|
|
x: 470,
|
|
y: 320,
|
|
w: 270,
|
|
h: 42,
|
|
value: "<b>Nginx</b> · TLS / reverse proxy",
|
|
cellStyle: panelStyle({ fillColor: "#FFFFFF", strokeColor: "#D4CFCA" }),
|
|
});
|
|
const consoleApp = page.vertex({
|
|
x: 470,
|
|
y: 376,
|
|
w: 270,
|
|
h: 46,
|
|
value: "<b>AI Web Agent Console</b><br>Streamlit · 질문/근거/감사 UI",
|
|
cellStyle: panelStyle({ fillColor: "#FFFFFF", strokeColor: COLORS.blue }),
|
|
});
|
|
const backoffice = page.vertex({
|
|
x: 470,
|
|
y: 436,
|
|
w: 270,
|
|
h: 46,
|
|
value: "<b>VPD Backoffice</b><br>Spring Boot · `/mcp` · 권한 관리",
|
|
cellStyle: panelStyle({ fillColor: "#FFFFFF", strokeColor: COLORS.oracleRed }),
|
|
});
|
|
|
|
page.officialIcon(library, "Identity and Security - Vault", 330, 635);
|
|
const vault = page.vertex({
|
|
x: 420,
|
|
y: 642,
|
|
w: 160,
|
|
h: 76,
|
|
value: "<b>Vault / Secrets</b><br>DB·OCI 자격증명<br><font color=\"#5B5652\">Git 외부 관리</font>",
|
|
cellStyle: panelStyle({ fillColor: "#FFFFFF", strokeColor: "#D4CFCA" }),
|
|
});
|
|
|
|
page.officialIcon(library, "Storage - Object Storage", 330, 800);
|
|
const objectStorage = page.vertex({
|
|
x: 445,
|
|
y: 804,
|
|
w: 180,
|
|
h: 82,
|
|
value: "<b>Object Storage</b><br>HR 규정 PDF 원본<br>파일명·생성일 메타데이터",
|
|
cellStyle: panelStyle({ fillColor: "#FFFFFF", strokeColor: "#D4CFCA" }),
|
|
});
|
|
|
|
page.officialIcon(library, "Database - Autonomous DB", 805, 225);
|
|
const adb = page.vertex({
|
|
x: 905,
|
|
y: 220,
|
|
w: 270,
|
|
h: 690,
|
|
value: "",
|
|
cellStyle: panelStyle({ fillColor: "#FFFFFF", strokeColor: COLORS.oracleRed, strokeWidth: 2 }),
|
|
});
|
|
page.vertex({
|
|
x: 920,
|
|
y: 238,
|
|
w: 238,
|
|
h: 40,
|
|
value: "<b>Autonomous Database · HMMAIPOC</b>",
|
|
cellStyle: labelStyle({ fontSize: 14, bold: true }),
|
|
});
|
|
const startupGate = page.vertex({
|
|
x: 925,
|
|
y: 294,
|
|
w: 230,
|
|
h: 72,
|
|
value: "<b>MCP Tool 계약</b><br>BACKOFFICE_MCP_TOOLS<br><font color=\"#AE562C\">시작 시 USER_AI_AGENT_TOOLS.ENABLED 검증</font>",
|
|
cellStyle: panelStyle({ fillColor: "#FFF7F2", strokeColor: COLORS.oracleOrange }),
|
|
});
|
|
const agentTools = page.vertex({
|
|
x: 925,
|
|
y: 382,
|
|
w: 230,
|
|
h: 78,
|
|
value: "<b>ADB Agent Tools</b><br>resolve_hr_term<br>search_hr_policy<br><font color=\"#5B5652\">DBMS_CLOUD_AI_AGENT.RUN_TOOL</font>",
|
|
cellStyle: panelStyle({ fillColor: "#FCFBFA", strokeColor: "#D4CFCA" }),
|
|
});
|
|
const selectAi = page.vertex({
|
|
x: 925,
|
|
y: 475,
|
|
w: 230,
|
|
h: 80,
|
|
value: "<b>search_hr_data · SELECT_AI</b><br>ADMIN: SHOWSQL 생성/검증<br>CB_ORDS: 읽기 전용 SQL 실행",
|
|
cellStyle: panelStyle({ fillColor: "#F4F8FA", strokeColor: COLORS.blue }),
|
|
});
|
|
const vpd = page.vertex({
|
|
x: 925,
|
|
y: 570,
|
|
w: 230,
|
|
h: 92,
|
|
value: "<b>Bearer → Secure Context → VPD</b><br>HMM_ACCESS_CTX<br>HMM_LEAVE_SCOPE_POLICY<br><font color=\"#C74634\">SELF · MANAGED_TEAM · ALL</font>",
|
|
cellStyle: panelStyle({ fillColor: "#FFF7F2", strokeColor: COLORS.oracleRed }),
|
|
});
|
|
const hrData = page.vertex({
|
|
x: 925,
|
|
y: 678,
|
|
w: 230,
|
|
h: 72,
|
|
value: "<b>HMM HR 정형 데이터</b><br>직원·팀·휴가·근태<br>역할·그룹·권한·Token hash",
|
|
cellStyle: panelStyle({ fillColor: "#FCFBFA", strokeColor: "#D4CFCA" }),
|
|
});
|
|
const vectorData = page.vertex({
|
|
x: 925,
|
|
y: 765,
|
|
w: 230,
|
|
h: 62,
|
|
value: "<b>정책 지식</b><br>Abstract · Tag · Chunk · Embed 4 Vector",
|
|
cellStyle: panelStyle({ fillColor: "#FCFBFA", strokeColor: "#D4CFCA" }),
|
|
});
|
|
const federation = page.vertex({
|
|
x: 925,
|
|
y: 842,
|
|
w: 230,
|
|
h: 52,
|
|
value: "<b>Federation View</b><br>HMM_RDS_*_V · 선사 배정 View",
|
|
cellStyle: panelStyle({ fillColor: "#FCFBFA", strokeColor: "#D4CFCA" }),
|
|
});
|
|
|
|
boundary(page, 1235, 165, 240, 300, "OCI Region · us-chicago-1", {
|
|
fillColor: COLORS.regionFill,
|
|
});
|
|
page.officialIcon(library, "Analytics and AI - Artificial Intelligence", 1260, 220);
|
|
const genai = page.vertex({
|
|
x: 1260,
|
|
y: 320,
|
|
w: 190,
|
|
h: 110,
|
|
value: "<b>OCI Generative AI</b><br><br>GPT-5.4 Mini<br><font color=\"#5B5652\">질문 계획 · SQL 생성 · 답변</font><br><br>Cohere Embed 4<br><font color=\"#5B5652\">용어·문서 Vector</font>",
|
|
cellStyle: panelStyle({ fillColor: "#FFFFFF", strokeColor: COLORS.oracleOrange }),
|
|
});
|
|
|
|
boundary(page, 1535, 120, 235, 900, "3rd Party Cloud · AWS", {
|
|
fillColor: "#FFFFFF",
|
|
strokeColor: COLORS.thirdParty,
|
|
dashed: true,
|
|
headingColor: COLORS.thirdParty,
|
|
});
|
|
const rds = page.vertex({
|
|
x: 1570,
|
|
y: 530,
|
|
w: 165,
|
|
h: 180,
|
|
value: "<b>AWS RDS PostgreSQL</b><br><br>hmm_demo.carriers<br>carrier_monthly_performance<br><br><font color=\"#5B5652\">가상 선사 8개<br>월간 실적 144행</font>",
|
|
cellStyle: style({
|
|
shape: "cylinder3",
|
|
boundedLbl: 1,
|
|
backgroundOutline: 1,
|
|
size: 15,
|
|
whiteSpace: "wrap",
|
|
html: 1,
|
|
fillColor: "#F9FAFB",
|
|
strokeColor: COLORS.thirdParty,
|
|
fontFamily: "Oracle Sans",
|
|
fontColor: COLORS.ink,
|
|
fontSize: 12,
|
|
spacingTop: 12,
|
|
}),
|
|
});
|
|
|
|
page.edge({
|
|
source: users,
|
|
target: nginx,
|
|
value: "HTTPS · 질문/관리",
|
|
cellStyle: edgeStyle({ strokeColor: COLORS.blue, exitX: 1, exitY: 0.35, entryX: 0, entryY: 0.5 }),
|
|
});
|
|
page.edge({
|
|
source: externalAgent,
|
|
target: backoffice,
|
|
value: "MCP initialize · tools/list · tools/call",
|
|
cellStyle: edgeStyle({ strokeColor: COLORS.oracleRed, exitX: 1, exitY: 0.5, entryX: 0, entryY: 0.5 }),
|
|
});
|
|
page.edge({
|
|
source: nginx,
|
|
target: consoleApp,
|
|
value: "",
|
|
cellStyle: edgeStyle({ strokeColor: COLORS.blue, exitX: 0.5, exitY: 1, entryX: 0.5, entryY: 0 }),
|
|
});
|
|
page.edge({
|
|
source: consoleApp,
|
|
target: backoffice,
|
|
value: "MCP",
|
|
cellStyle: edgeStyle({ strokeColor: COLORS.oracleRed, exitX: 1, exitY: 0.5, entryX: 1, entryY: 0.5 }),
|
|
points: [[780, 399], [780, 459]],
|
|
});
|
|
page.edge({
|
|
source: backoffice,
|
|
target: startupGate,
|
|
value: "JDBC · Tool 호출",
|
|
cellStyle: edgeStyle({ strokeColor: COLORS.oracleRed, exitX: 1, exitY: 0.5, entryX: 0, entryY: 0.5 }),
|
|
points: [[805, 459], [885, 459], [885, 330]],
|
|
});
|
|
page.edge({
|
|
source: startupGate,
|
|
target: agentTools,
|
|
value: "ENABLED",
|
|
cellStyle: edgeStyle({ strokeColor: COLORS.success, exitX: 0.5, exitY: 1, entryX: 0.5, entryY: 0 }),
|
|
});
|
|
page.edge({
|
|
source: startupGate,
|
|
target: selectAi,
|
|
value: "Java Tool",
|
|
cellStyle: edgeStyle({ strokeColor: COLORS.blue, exitX: 1, exitY: 0.5, entryX: 1, entryY: 0.5 }),
|
|
points: [[1170, 330], [1170, 515]],
|
|
});
|
|
page.edge({
|
|
source: selectAi,
|
|
target: vpd,
|
|
value: "동일 CB_ORDS session",
|
|
cellStyle: edgeStyle({ strokeColor: COLORS.oracleRed, exitX: 0.5, exitY: 1, entryX: 0.5, entryY: 0 }),
|
|
});
|
|
page.edge({
|
|
source: vpd,
|
|
target: hrData,
|
|
value: "predicate 자동 적용",
|
|
cellStyle: edgeStyle({ strokeColor: COLORS.oracleRed, exitX: 0.5, exitY: 1, entryX: 0.5, entryY: 0 }),
|
|
});
|
|
page.edge({
|
|
source: objectStorage,
|
|
target: vectorData,
|
|
value: "PDF ingest · 청킹",
|
|
cellStyle: edgeStyle({ dashed: true, strokeColor: COLORS.oracleOrange, exitX: 1, exitY: 0.5, entryX: 0, entryY: 0.5 }),
|
|
});
|
|
page.edge({
|
|
source: vault,
|
|
target: compute,
|
|
value: "runtime secret",
|
|
cellStyle: edgeStyle({ dashed: true, strokeColor: COLORS.regionStroke, exitX: 0.5, exitY: 0, entryX: 0, entryY: 1 }),
|
|
});
|
|
page.edge({
|
|
source: adb,
|
|
target: genai,
|
|
value: "모델 호출",
|
|
cellStyle: edgeStyle({ dashed: true, strokeColor: COLORS.oracleOrange, exitX: 1, exitY: 0.28, entryX: 0, entryY: 0.55 }),
|
|
});
|
|
page.edge({
|
|
source: federation,
|
|
target: rds,
|
|
value: "HMM_RDS_PG_LINK · TLS 5432",
|
|
cellStyle: edgeStyle({ strokeColor: COLORS.thirdParty, exitX: 1, exitY: 0.5, entryX: 0, entryY: 0.5 }),
|
|
});
|
|
|
|
page.vertex({
|
|
x: 1545,
|
|
y: 830,
|
|
w: 215,
|
|
h: 105,
|
|
value: "<b>읽기 책임 분리</b><br>RDS: 선사 실적 원장<br>ADB: 직원·배정·권한·VPD<br>MCP: 허용된 View만 자연어 조회",
|
|
cellStyle: panelStyle({ fillColor: "#F9FAFB", strokeColor: COLORS.thirdParty }),
|
|
});
|
|
page.vertex({
|
|
x: 280,
|
|
y: 985,
|
|
w: 1195,
|
|
h: 24,
|
|
value: "※ hmm-mcp.cloud-handson.com은 공용 호환 게이트웨이입니다. 사용자별 VPD MCP의 기준 주소는 hmm-backoffice.cloud-handson.com/mcp입니다.",
|
|
cellStyle: labelStyle({ fontSize: 11, fontColor: COLORS.muted }),
|
|
});
|
|
return page.result();
|
|
}
|
|
|
|
function numberedBox(page, number, x, y, w, h, heading, body, options = {}) {
|
|
const box = page.vertex({
|
|
x,
|
|
y,
|
|
w,
|
|
h,
|
|
value: `<b>${heading}</b><br>${body}`,
|
|
cellStyle: panelStyle({
|
|
fillColor: options.fillColor || "#FFFFFF",
|
|
strokeColor: options.strokeColor || "#D4CFCA",
|
|
align: "left",
|
|
verticalAlign: "middle",
|
|
spacing: 10,
|
|
spacingLeft: 22,
|
|
spacingRight: 8,
|
|
fontSize: options.fontSize || 11,
|
|
}),
|
|
});
|
|
page.vertex({
|
|
x: x - 12,
|
|
y: y + 10,
|
|
w: 30,
|
|
h: 30,
|
|
value: `<b><font color=\"#FFFFFF\">${number}</font></b>`,
|
|
cellStyle: style({
|
|
ellipse: 1,
|
|
shape: "ellipse",
|
|
whiteSpace: "wrap",
|
|
html: 1,
|
|
fillColor: options.badgeColor || COLORS.oracleRed,
|
|
strokeColor: options.badgeColor || COLORS.oracleRed,
|
|
fontFamily: "Oracle Sans",
|
|
fontSize: 13,
|
|
align: "center",
|
|
verticalAlign: "middle",
|
|
}),
|
|
});
|
|
return box;
|
|
}
|
|
|
|
function addSecurityFlowPage(library) {
|
|
const page = createPage("02 · MCP 요청과 VPD", "hmm-security-flow", 1800, 1050);
|
|
title(
|
|
page,
|
|
"MCP Tool Discovery와 사용자별 VPD 실행 흐름",
|
|
"광고 계약 검증은 시작 시 1회 · 사용자 인증과 Context 설정·SQL 실행·정리는 요청마다 같은 세션에서 수행",
|
|
1800,
|
|
);
|
|
|
|
boundary(page, 35, 120, 1730, 185, "서버 시작 · Tool 광고 전 Fail-Closed Gate", {
|
|
fillColor: "#FFF7F2",
|
|
strokeColor: COLORS.oracleOrange,
|
|
headingColor: COLORS.oracleOrange,
|
|
});
|
|
const config = numberedBox(page, "A", 95, 185, 300, 78, "BACKOFFICE_MCP_TOOLS", "공개 이름 · 설명 · 입력 schema<br>AGENT_TOOL targetName", {
|
|
strokeColor: COLORS.oracleOrange,
|
|
badgeColor: COLORS.oracleOrange,
|
|
});
|
|
const validator = numberedBox(page, "B", 510, 185, 330, 78, "McpAgentToolStartupValidator", "AGENT_TOOL targetName 중복 제거<br>SELECT_AI는 DB Tool 검증에서 제외", {
|
|
strokeColor: COLORS.oracleOrange,
|
|
badgeColor: COLORS.oracleOrange,
|
|
});
|
|
const metadata = numberedBox(page, "C", 955, 185, 330, 78, "USER_AI_AGENT_TOOLS", "Tool 존재 + STATUS=ENABLED 확인", {
|
|
strokeColor: COLORS.oracleOrange,
|
|
badgeColor: COLORS.oracleOrange,
|
|
});
|
|
const ready = numberedBox(page, "D", 1400, 185, 285, 78, "Endpoint Ready", "모두 정상일 때만 tools/list 광고<br><font color=\"#C74634\">누락·비활성·조회 오류는 기동 실패</font>", {
|
|
strokeColor: COLORS.success,
|
|
badgeColor: COLORS.success,
|
|
});
|
|
page.edge({ source: config, target: validator, value: "구성 로드", cellStyle: edgeStyle({ strokeColor: COLORS.oracleOrange }) });
|
|
page.edge({ source: validator, target: metadata, value: "시작 시 1회 조회", cellStyle: edgeStyle({ strokeColor: COLORS.oracleOrange }) });
|
|
page.edge({ source: metadata, target: ready, value: "모두 ENABLED", cellStyle: edgeStyle({ strokeColor: COLORS.success }) });
|
|
|
|
boundary(page, 35, 330, 225, 665, "MCP Client", { fillColor: "#F9FAFB" });
|
|
page.officialIcon(library, "Identity and Security - User", 95, 385);
|
|
const client = numberedBox(page, 1, 70, 500, 160, 100, "Agent 요청", "initialize<br>tools/list<br>tools/call", {
|
|
strokeColor: COLORS.blue,
|
|
badgeColor: COLORS.blue,
|
|
});
|
|
const response = numberedBox(page, 10, 70, 800, 160, 115, "필터링 결과", "허용 행 + 근거만 반환<br>Token·내부 SQL은 미노출", {
|
|
strokeColor: COLORS.success,
|
|
badgeColor: COLORS.success,
|
|
});
|
|
|
|
boundary(page, 285, 330, 650, 665, "VPD Backoffice · Spring Boot", {
|
|
fillColor: "#FCFBFA",
|
|
});
|
|
const auth = numberedBox(page, 2, 330, 400, 250, 110, "Bearer 인증", "Token SHA-256 hash<br>만료·회수·재직 상태 검증<br><font color=\"#C74634\">실패: HTTP 401</font>", {
|
|
strokeColor: COLORS.oracleRed,
|
|
});
|
|
const router = numberedBox(page, 3, 650, 400, 235, 110, "MCP Tool Router", "catalog에서 이름·인자 검증<br>AGENT_TOOL / SELECT_AI 분기", {
|
|
strokeColor: COLORS.oracleRed,
|
|
});
|
|
const agentTool = numberedBox(page, 4, 350, 590, 250, 130, "ADB AGENT_TOOL", "resolve_hr_term · search_hr_policy<br>동일 기본 JDBC session<br>SET_USER_BY_BEARER<br>→ RUN_TOOL → CLEAR_USER", {
|
|
strokeColor: COLORS.oracleOrange,
|
|
badgeColor: COLORS.oracleOrange,
|
|
fontSize: 10,
|
|
});
|
|
const showSql = numberedBox(page, 5, 650, 590, 235, 125, "search_hr_data", "ADMIN 생성 세션<br>DBMS_CLOUD_AI.GENERATE<br>action=showsql<br>SELECT/WITH + 승인 객체 검증", {
|
|
strokeColor: COLORS.blue,
|
|
badgeColor: COLORS.blue,
|
|
});
|
|
page.edge({ source: client, target: auth, value: "Authorization: Bearer …", cellStyle: edgeStyle({ strokeColor: COLORS.oracleRed }) });
|
|
page.edge({ source: auth, target: router, value: "인증된 employee", cellStyle: edgeStyle({ strokeColor: COLORS.oracleRed }) });
|
|
page.edge({ source: router, target: agentTool, value: "AGENT_TOOL", cellStyle: edgeStyle({ strokeColor: COLORS.oracleOrange, exitX: 0.25, exitY: 1, entryX: 0.5, entryY: 0 }) });
|
|
page.edge({ source: router, target: showSql, value: "SELECT_AI", cellStyle: edgeStyle({ strokeColor: COLORS.blue, exitX: 0.75, exitY: 1, entryX: 0.5, entryY: 0 }) });
|
|
|
|
boundary(page, 960, 330, 805, 665, "Autonomous Database · 보안 실행 경계", {
|
|
fillColor: COLORS.regionFill,
|
|
strokeColor: COLORS.oracleRed,
|
|
});
|
|
page.officialIcon(library, "Database - Autonomous DB", 995, 370);
|
|
const setContext = numberedBox(page, 6, 1100, 390, 285, 110, "CB_ORDS 동일 JDBC 세션", "CB_ORDS_HANDLER_PKG<br>SET_VPD_CONTEXT<br>Runtime principal·context 일치 검증", {
|
|
strokeColor: COLORS.oracleRed,
|
|
});
|
|
const context = numberedBox(page, 7, 1445, 390, 270, 110, "Secure Application Context", "HMM_ACCESS_CTX_PKG<br>SET_USER_BY_BEARER<br>EMPLOYEE_ID · CODE · TEAM_ID", {
|
|
strokeColor: COLORS.oracleRed,
|
|
});
|
|
const execute = numberedBox(page, 8, 1100, 585, 285, 125, "읽기 전용 SQL 실행", "CB_ORDS · SET TRANSACTION READ ONLY<br>EXEMPT ACCESS POLICY 없음<br>승인된 HR Table/View만 조회", {
|
|
strokeColor: COLORS.blue,
|
|
badgeColor: COLORS.blue,
|
|
});
|
|
const policy = numberedBox(page, "8a", 1445, 585, 270, 125, "HMM_LEAVE_SCOPE_POLICY", "SYS_CONTEXT 기반 predicate 자동 적용<br><b>VIEWER → SELF</b><br><b>MANAGER → MANAGED_TEAM</b><br><b>ADMIN role → ALL</b>", {
|
|
strokeColor: COLORS.oracleRed,
|
|
});
|
|
const cleanup = numberedBox(page, 9, 1100, 805, 615, 95, "항상 정리", "결과 읽기 → ROLLBACK → CB_ORDS_HANDLER_PKG · CLEAR_VPD_CONTEXT<br><font color=\"#5B5652\">성공·실패와 무관하게 session context와 client identifier 제거</font>", {
|
|
strokeColor: COLORS.success,
|
|
badgeColor: COLORS.success,
|
|
});
|
|
page.edge({ source: showSql, target: setContext, value: "검증된 SQL + 원문 Bearer", cellStyle: edgeStyle({ strokeColor: COLORS.oracleRed }) });
|
|
page.edge({
|
|
source: agentTool,
|
|
target: response,
|
|
value: "Agent Tool 결과 · CLEAR_USER",
|
|
cellStyle: edgeStyle({ dashed: true, strokeColor: COLORS.success, exitX: 1, exitY: 0.5, entryX: 1, entryY: 0.5 }),
|
|
points: [[635, 655], [635, 760], [280, 760], [280, 855]],
|
|
});
|
|
page.edge({ source: setContext, target: context, value: "SET_USER_BY_BEARER", cellStyle: edgeStyle({ strokeColor: COLORS.oracleRed }) });
|
|
page.edge({ source: setContext, target: execute, value: "context 확인 후 실행", cellStyle: edgeStyle({ strokeColor: COLORS.blue, exitX: 0.5, exitY: 1, entryX: 0.5, entryY: 0 }) });
|
|
page.edge({ source: context, target: policy, value: "SYS_CONTEXT", cellStyle: edgeStyle({ strokeColor: COLORS.oracleRed, exitX: 0.5, exitY: 1, entryX: 0.5, entryY: 0 }) });
|
|
page.edge({ source: execute, target: policy, value: "SELECT → predicate 주입", cellStyle: edgeStyle({ strokeColor: COLORS.oracleRed }) });
|
|
page.edge({ source: policy, target: cleanup, value: "허용 행", cellStyle: edgeStyle({ strokeColor: COLORS.success, exitX: 0.5, exitY: 1, entryX: 0.8, entryY: 0 }) });
|
|
page.edge({ source: execute, target: cleanup, value: "result set", cellStyle: edgeStyle({ strokeColor: COLORS.success, exitX: 0.5, exitY: 1, entryX: 0.2, entryY: 0 }) });
|
|
page.edge({
|
|
source: cleanup,
|
|
target: response,
|
|
value: "정제된 Tool 결과",
|
|
cellStyle: edgeStyle({ strokeColor: COLORS.success, exitX: 0, exitY: 0.5, entryX: 1, entryY: 0.5 }),
|
|
points: [[950, 945], [270, 945]],
|
|
});
|
|
|
|
page.vertex({
|
|
x: 330,
|
|
y: 790,
|
|
w: 555,
|
|
h: 110,
|
|
value: "<b>보안 불변조건</b><br>• Token 원문은 DB·로그·응답·Git에 저장하지 않음<br>• SQL 생성 ADMIN과 실행 CB_ORDS를 분리<br>• 컨텍스트 설정과 보호 객체 SELECT는 반드시 같은 connection<br>• 권한 또는 메타데이터가 불완전하면 결과를 축소하는 대신 요청/기동 실패",
|
|
cellStyle: panelStyle({ fillColor: "#FFF7F2", strokeColor: COLORS.oracleRed, align: "left", verticalAlign: "middle", spacing: 10 }),
|
|
});
|
|
return page.result();
|
|
}
|
|
|
|
function writeDrawio(library) {
|
|
const diagrams = [addOverviewPage(library), addSecurityFlowPage(library)];
|
|
const mxfile = [
|
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
'<mxfile host="app.diagrams.net" modified="2026-08-04T00:00:00.000Z" agent="cloud-handson" version="24.2" type="device" compressed="false">',
|
|
...diagrams,
|
|
"</mxfile>",
|
|
].join("\n");
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
const output = path.join(outputDir, "hmm-ai-data-access-architecture.drawio");
|
|
fs.writeFileSync(output, mxfile, "utf8");
|
|
return output;
|
|
}
|
|
|
|
const library = readLibrary(libraryPath);
|
|
const output = writeDrawio(library);
|
|
console.log(output);
|