Lesson 03 / 10
Security Architecture
Counting trust boundaries as a graph: how many boundaries there are, which data crosses which boundary, checks per boundary and unchecked crossings, how many paths removing a boundary opens, and what a rule catches, misses, and false-alarms on in a known set of security incidents.
Contents
In the previous lesson security was a single score — the average of three leaves’ share relative to their threshold — and it did not say what changed when it rose. A security decision lands not on a number but on a place: the point where data crosses from one zone into another.
How authentication and authorization get implemented was measured in the Authentication and Authorization course, secret value management in the Server Security and Going to Production course; neither is repeated here. The question here sits at the architectural level: how many trust boundaries there are, which data crosses which boundary, how many checks stand at each boundary, which crossings pass through no check at all. And the course’s question continues — can this graph be turned into an executable rule.
The Trust Boundary Graph
The block below models the fictional regional library network’s trust zones, the boundaries between them, the checks each boundary carries, the checks each data type requires, and the crossings observed in one quarter. An unchecked crossing is one where at least one of the required checks is missing at the boundary it crosses.
// boundary.mjs — models the trust boundary graph and counts checks per boundary import { mkdirSync, writeFileSync } from "node:fs"; // QA10: the fictional regional library network's seven trust zones and the eight boundaries // between them. Each boundary carries the checks applied when crossing it. Model. const BOUNDARY = [ ["S1", "member device", "central service", ["identity", "encryption"]], ["S2", "branch edge unit", "branch network", ["identity"]], ["S3", "branch network", "central service", ["encryption", "logging"]], ["S4", "central service", "data layer", ["authorization", "encryption"]], ["S5", "central service", "municipal identity", ["identity", "encryption", "logging"]], ["S6", "central service", "external catalog", ["encryption"]], ["S7", "branch network", "data layer", []], ["S8", "member device", "external catalog", []], ]; // QA11: seven data types and the checks each one requires when it crosses a boundary. A // catalog record is open data and requires no check. Model. const REQUIRED = { "member identity": ["identity", "encryption"], "loan history": ["identity", "authorization", "masking"], "catalog record": [], "fee record": ["identity", "authorization", "logging"], "staff authorization": ["identity", "authorization", "logging"], "audit record": ["authorization", "logging"], "secret value": ["encryption", "logging"], }; // QA12: eighteen data crossings observed in one quarter [data type, source zone, target zone]. Model. const FLOW = [ ["member identity", "member device", "central service"], ["member identity", "central service", "municipal identity"], ["member identity", "branch edge unit", "branch network"], ["loan history", "data layer", "central service"], ["loan history", "central service", "member device"], ["loan history", "data layer", "branch network"], ["loan history", "branch network", "branch edge unit"], ["catalog record", "external catalog", "central service"], ["catalog record", "external catalog", "member device"], ["catalog record", "central service", "data layer"], ["catalog record", "branch network", "central service"], ["fee record", "central service", "data layer"], ["fee record", "branch edge unit", "branch network"], ["staff authorization", "municipal identity", "central service"], ["staff authorization", "central service", "branch network"], ["audit record", "central service", "data layer"], ["secret value", "central service", "external catalog"], ["secret value", "central service", "data layer"], ]; const ZONE = [...new Set(BOUNDARY.flatMap(([, a, b]) => [a, b]))]; const findBoundary = (a, b) => BOUNDARY.find(([, x, y]) => (x === a && y === b) || (x === b && y === a)); const missing = (type, s) => REQUIRED[type].filter((c) => !s[3].includes(c)); mkdirSync("graph", { recursive: true }); writeFileSync("graph/graph.mjs", `export const BOUNDARY = ${JSON.stringify(BOUNDARY)};\n` + `export const REQUIRED = ${JSON.stringify(REQUIRED)};\n` + `export const FLOW = ${JSON.stringify(FLOW)};\n`); console.log(`${ZONE.length} trust zones, ${BOUNDARY.length} boundaries, ${FLOW.length} data crossings, ` + `${Object.keys(REQUIRED).length} data types`); console.log(`\n${"boundary".padEnd(10)}${"zone pair".padEnd(38)}${"checks".padStart(8)}` + `${"crossings".padStart(10)}${"crossings missing a check".padStart(27)}`); for (const s of BOUNDARY) { const crossing = FLOW.filter(([, a, b]) => findBoundary(a, b) === s); console.log(`${s[0].padEnd(10)}${`${s[1]} - ${s[2]}`.padEnd(38)}${String(s[3].length).padStart(8)}` + `${String(crossing.length).padStart(10)}` + `${String(crossing.filter(([v]) => missing(v, s).length > 0).length).padStart(27)}`); } const flawed = FLOW.map((a) => [a, findBoundary(a[1], a[2])]) .map(([a, s]) => [a, s, missing(a[0], s)]).filter(([, , e]) => e.length > 0); console.log(`\ncrossing missing a check: ${flawed.length}/${FLOW.length} crossings miss at least one required check`); console.log(`${"boundary".padEnd(10)}${"data type".padEnd(21)}${"direction".padEnd(39)}missing check`); for (const [a, s, e] of flawed) console.log(`${s[0].padEnd(10)}${a[0].padEnd(21)}${`${a[1]} -> ${a[2]}`.padEnd(39)}${e.join(", ")}`); const noCheckAtAll = flawed.filter(([, s]) => s[3].length === 0); console.log(`\nboundary with no check at all: ${BOUNDARY.filter((s) => s[3].length === 0).map((s) => s[0]).join(", ")}; ` + `data crossings needing a check through these boundaries ${noCheckAtAll.length}`); const perType = Object.keys(REQUIRED).map((v) => [v, flawed.filter(([a]) => a[0] === v).length]).filter(([, n]) => n > 0); console.log(`crossings missing a check, per data type: ` + perType.map(([v, n]) => `${v} ${n}`).join(", "));
7 trust zones, 8 boundaries, 18 data crossings, 7 data types boundary zone pair checks crossings crossings missing a check S1 member device - central service 2 2 1 S2 branch edge unit - branch network 1 3 3 S3 branch network - central service 2 2 1 S4 central service - data layer 2 5 4 S5 central service - municipal identity 3 2 1 S6 central service - external catalog 1 2 1 S7 branch network - data layer 0 1 1 S8 member device - external catalog 0 1 0 crossing missing a check: 12/18 crossings miss at least one required check boundary data type direction missing check S2 member identity branch edge unit -> branch network encryption S4 loan history data layer -> central service identity, masking S1 loan history central service -> member device authorization, masking S7 loan history data layer -> branch network identity, authorization, masking S2 loan history branch network -> branch edge unit authorization, masking S4 fee record central service -> data layer identity, logging S2 fee record branch edge unit -> branch network authorization, logging S5 staff authorization municipal identity -> central service authorization S3 staff authorization central service -> branch network identity, authorization S4 audit record central service -> data layer logging S6 secret value central service -> external catalog logging S4 secret value central service -> data layer logging boundary with no check at all: S7, S8; data crossings needing a check through these boundaries 1 crossings missing a check, per data type: member identity 1, loan history 4, fee record 2, staff authorization 2, audit record 1, secret value 2
12 of eighteen crossings miss at least one required check. The distribution is not even boundary by boundary: the boundary facing the municipal identity zone carries three checks, the one between the branch network and the data layer carries none. But the two boundaries without checks are not the same thing: there is also no check between the member device and the external catalog, but only the catalog record — open data — crosses there. An unchecked boundary is not by itself a flaw; the flaw is data requiring a check crossing an unchecked boundary.
The busiest boundary is the one with the most missing checks: five crossings run between the central service and the data layer, four with a missing check. This is not a coincidence — as more data types cross a boundary, the required set of checks grows, but the boundary carries only one fixed set.
By data type, loan history leads with four missing checks: it crosses four separate boundaries, and one of its three required checks, masking, is not defined at any of the eight boundaries. One of the required checks does not exist anywhere in the system, and this only shows up once the required set and the existing set get placed side by side.
Removing a Boundary and the Rule’s Limit
The graph’s second question: what happens if a boundary is removed. Removing one means the two zones become a single trust zone, and data in either one crosses to the other unconditionally. The block below computes this for every boundary, then runs the rule against known incidents.
// removal.mjs — counts the paths a removed boundary opens and runs the check against a known set of incidents import { BOUNDARY, REQUIRED, FLOW } from "./graph/graph.mjs"; // QA13: the zone each data type is born in; reach is the transitive closure that starts at // the source and follows the flow arrows. Model. const SOURCE = { "member identity": "member device", "loan history": "data layer", "catalog record": "external catalog", "fee record": "branch edge unit", "staff authorization": "municipal identity", "audit record": "central service", "secret value": "central service", }; const DATA = Object.keys(REQUIRED); // When a boundary is removed, the two zones become a single trust zone: every data type gets // an unconditional crossing between the two ends. const reach = (type, removed) => { const reached = new Set([SOURCE[type]]); const merged = removed ? [removed[1], removed[2]] : []; for (let n = 0; n < BOUNDARY.length + 1; n += 1) for (const [v, a, b] of FLOW) { if (v === type && reached.has(a)) reached.add(b); if (merged.length && reached.has(merged[0])) reached.add(merged[1]); if (merged.length && reached.has(merged[1])) reached.add(merged[0]); } return reached; }; const baseline = Object.fromEntries(DATA.map((v) => [v, reach(v, null)])); const baselineTotal = DATA.reduce((s, v) => s + baseline[v].size, 0); console.log(`baseline reach: ${baselineTotal} (data type, zone) pairs, ` + `${DATA.length} data types x ${new Set(BOUNDARY.flatMap((s) => [s[1], s[2]])).size} zones`); console.log(`\n${"boundary".padEnd(10)}${"zones merged if removed".padEnd(40)}` + `${"paths opened".padStart(13)} data type opened the most`); const opened = BOUNDARY.map((s) => { const added = DATA.map((v) => [v, reach(v, s).size - baseline[v].size]); return [s, added.reduce((t, [, n]) => t + n, 0), added.sort((a, b) => b[1] - a[1])[0]]; }).sort((a, b) => b[1] - a[1]); for (const [s, total, [v, n]] of opened) console.log(`${s[0].padEnd(10)}${`${s[1]} + ${s[2]}`.padEnd(40)}${String(total).padStart(13)}` + ` ${n > 0 ? `${v} (${n})` : "-"}`); // Check: every crossing must pass through the checks its data type requires at the boundary it crosses. const findBoundary = (a, b) => BOUNDARY.find(([, x, y]) => (x === a && y === b) || (x === b && y === a)); const missing = (type, s) => (s ? REQUIRED[type].filter((c) => !s[3].includes(c)) : null); const warning = FLOW.map((a) => [a, findBoundary(a[1], a[2])]) .filter(([a, s]) => missing(a[0], s).length > 0); // QA14: known set of incidents — ten security incidents on record over the last two quarters // [data type, source, target, note]. Model. const INCIDENT = [ ["loan history", "data layer", "branch network", ""], ["fee record", "branch edge unit", "branch network", ""], ["secret value", "central service", "external catalog", ""], ["loan history", "branch network", "branch edge unit", ""], ["staff authorization", "central service", "branch network", ""], ["fee record", "central service", "data layer", ""], ["loan history", "data layer", "data layer", "in-zone backup copy"], ["secret value", "central service", "central service", "secret that fell into a log file"], ["member identity", "data layer", "external catalog", "path undefined in the graph"], ["member identity", "member device", "central service", "boundary with full checks"], ]; // QA15: three of the flagged crossings have their missing check met somewhere other than the boundary. const METELSEWHERE = [["audit record", "central service", "data layer", "data layer transaction log"], ["secret value", "central service", "data layer", "data layer transaction log"], ["staff authorization", "municipal identity", "central service", "authorization is signed at the source"]]; const matches = (a, b) => a[0] === b[0] && a[1] === b[1] && a[2] === b[2]; const caught = INCIDENT.filter((i) => warning.some(([a]) => matches(a, i))); const missed = INCIDENT.filter((i) => !caught.includes(i)); const falseAlarm = warning.filter(([a]) => METELSEWHERE.some((k) => matches(a, k))); const openGap = warning.length - caught.length - falseAlarm.length; console.log(`\n${warning.length} warnings, ${INCIDENT.length} known incidents: caught ${caught.length}, ` + `missed ${missed.length}, false alarm ${falseAlarm.length}, ` + `open gap with no incident yet ${openGap}`); for (const i of missed) { const s = findBoundary(i[1], i[2]); console.log(` missed: ${i[0].padEnd(20)} ${`${i[1]} -> ${i[2]}`.padEnd(36)} ` + `${i[3] || (s ? "boundary has no missing check" : "no such boundary")}`); } for (const [a] of falseAlarm) { const k = METELSEWHERE.find((n) => matches(a, n)); console.log(` false alarm: ${a[0].padEnd(20)} check met elsewhere: ${k[3]}`); } // QA16: the part that cannot be checked. The graph checks crossings at the boundaries it // knows about; it cannot check whether a boundary is in the right place, or a path that was // never reported. In its place: a review every quarter, 3 person-hours per boundary. Model. const HOURS = 3; const outsideGraph = missed.filter((i) => !findBoundary(i[1], i[2])).length; console.log(`\ncannot be checked: whether the boundary is in the right place, and an unreported path; ` + `${outsideGraph} of the ${missed.length} missed incidents are a crossing with no path in the graph`); console.log(`stands in its place: a review for ${BOUNDARY.length} boundaries, ` + `${BOUNDARY.length * HOURS} person-hours/quarter; the removal ranking gives the order ` + `(top of the list: ${opened[0][0][0]}, ${opened[0][1]} paths)`); const record = BOUNDARY.length + FLOW.length + DATA.length; console.log(`check cost: ${record} graph records held by hand (boundary, flow, data type); ` + `run ${FLOW.reduce((s, a) => s + REQUIRED[a[0]].length, 0)} check comparisons + ` + `${BOUNDARY.length} removal computations; if the graph goes stale, missed grows`);
baseline reach: 22 (data type, zone) pairs, 7 data types x 7 zones boundary zones merged if removed paths opened data type opened the most S3 branch network + central service 6 fee record (2) S7 branch network + data layer 5 catalog record (1) S5 central service + municipal identity 4 loan history (1) S6 central service + external catalog 4 member identity (1) S1 member device + central service 3 staff authorization (1) S8 member device + external catalog 3 member identity (1) S4 central service + data layer 2 member identity (1) S2 branch edge unit + branch network 1 staff authorization (1) 12 warnings, 10 known incidents: caught 6, missed 4, false alarm 3, open gap with no incident yet 3 missed: loan history data layer -> data layer in-zone backup copy missed: secret value central service -> central service secret that fell into a log file missed: member identity data layer -> external catalog path undefined in the graph missed: member identity member device -> central service boundary with full checks false alarm: staff authorization check met elsewhere: authorization is signed at the source false alarm: audit record check met elsewhere: data layer transaction log false alarm: secret value check met elsewhere: data layer transaction log cannot be checked: whether the boundary is in the right place, and an unreported path; 3 of the 4 missed incidents are a crossing with no path in the graph stands in its place: a review for 8 boundaries, 24 person-hours/quarter; the removal ranking gives the order (top of the list: S3, 6 paths) check cost: 33 graph records held by hand (boundary, flow, data type); run 36 check comparisons + 8 removal computations; if the graph goes stale, missed grows
Boundaries do not carry equal weight. Removing the boundary between the branch network and the central service opens six new paths; removing the one between the branch edge unit and the branch network opens one. The ranking does not track the number of checks at the boundary: a boundary carrying two checks opens more paths than one carrying none. A boundary’s value is measured not by the checks it carries but by how the zones it separates connect into the graph.
The rule produces twelve warnings, and the record holds ten incidents: six get caught, four are missed, three are false alarms. The remaining three warnings are neither — the missing check really is missing, it just has not produced an incident yet. A warning with no incident is not a false alarm; a false alarm is a warning where the check is actually met but looks missing. Without this distinction, half the warnings get written off as noise and the rule gets loosened.
The four missed incidents need to be read separately. Two stayed within a zone — the backup copy and the log file never crossed a boundary. One passed through a path undefined in the graph. The fourth is the most instructive: it happened at a boundary with a full set of checks. The graph sees whether a check exists, not whether it works correctly. This is where the part that cannot be checked comes from: whether a boundary is in the right place, and whether an unreported path exists, cannot be checked, and three of the missed incidents fall in that scope. In their place, a quarterly review is set for the eight boundaries, 24 person-hours; the removal analysis gives its order.
The weight of the cost is not in the run: the rule is thirty-six comparisons plus eight removal computations, but the thirty-three hand-held graph records must be updated with every architectural change. If the graph goes stale, the rule fails silently: every incident on a flow missing from the graph becomes a missed one.
Summary
- The trust boundary graph has seven zones, eight boundaries, eighteen crossings; twelve of them miss at least one required check.
- An unchecked boundary is not by itself a flaw: of the two unchecked boundaries, only one carries data that requires a check; the busiest boundary is also the one with the most missing checks.
- One required check (masking) is not defined at any of the eight boundaries; this only shows up once the required set and the existing set get placed side by side.
- Removing a boundary opens between one and six new paths; the ranking is set not by the check count but by how the zones connect into the graph.
- Twelve warnings, ten incidents: 6 caught, 4 missed, 3 false alarms, 3 open gaps; three of the missed incidents are a crossing with no path in the graph, one happened at a boundary with a full set of checks.
Next Step
The boundary graph said which zone is separated from which; it did not say how many copies of it run. Once the system grows, the real question is: what gets copied. The next lesson lines up three scale unit candidates — copying the whole system, copying individual services, copying per tenant — and for each candidate counts the copied component, the shared remaining resource, and the singleton that cannot be copied under any of them.
To keep your progress and take notes, Log in
My notes
Log in to take notes.