Lesson 19 / 19
Security Configuration
Measuring authorization as a setting: counting the collections and records reachable in a deployment with authentication turned off, running the same five application identities under cluster-wide, database-level, and collection-level roles and comparing the totals of readable, writable, and personal-data-carrying records, naming the residue that least privilege cannot shrink, and pulling out the cost of transport and at-rest encryption as extra bytes and an extra processing step.
Contents
The previous lesson ran the same cluster under the same split across four settings and counted the classification as a property of the request’s path, not the store. In those runs, every write that reached the cluster was either accepted or rejected, every read either answered or left unanswered. Not once was it asked who the request came from.
This lesson asks that question, and its answer is a configuration too. Authentication mechanics are not covered here; the subject is the store’s own settings. Three things are measured: what a closed-off authorization setting is exposed to, how much narrowing role scope shrinks it, and which line item encryption adds cost to, and how much.
Exposed Surface Is a Countable Quantity
The adjectives used to describe a security configuration — strict, loose, adequate — carry no decision. Its measurable counterpart is the exposed surface: the total of records the identities defined in a configuration can read. Two more quantities stand beside it: writable records, and readable records that carry personal data. Its inputs are collection sizes, the identities the store recognizes, and the scope of the role assigned to each identity; the first two come from the deployment, the third is a decision.
The Mechanism
NS31 — the deployment has seven collections, 154,540 records, and five application identities; each identity’s job touches a specific handful of collections. Reason: staff and audit collections are added to the catalog, member, loan, and fine collections used throughout the course; a search service, a loan endpoint, a fine job, a report job, and a backup job are a typical breakdown. NS32 — in transport encryption, a frame carries at most 16,384 bytes and takes 29 bytes of overhead; connection setup is 5,200 bytes per connection. Reason: the overhead is a header, a sequence counter, and an authentication tag; what determines the result is not the number itself but that it is independent of response size. NS33 — at-rest encryption works on 4,096-byte blocks, keeps 28 bytes of metadata per block, and every block read is one extra decryption step.
The mechanism is a model: there is no real deployment, network, or identity provider. There is no measured number in the classes themselves; NS31–NS33 are assumptions, and every number in the output is a calculation derived from them.
// security/model.mjs — the store CONFIGURATION is the model: no real deployment, network, or // identity provider exists. Role scope and encryption cost are computed from the parameters. export const COLLECTION = [ // database, collection, records, bytes per record, carries personal data? ["library", "book", 20000, 368, false], ["library", "member", 12000, 214, true], ["library", "loan", 20000, 96, true], ["library", "fine", 4800, 72, true], ["library", "summary", 1560, 88, false], ["ops", "staff", 180, 260, true], ["ops", "audit", 96000, 128, false], ]; export const IDENTITY = ["catalog-search", "loan-endpoint", "fine-processing", "report", "backup"]; const grant = (db, coll, ...actions) => ({ db, coll, actions }); const ALL = [grant("*", "*", "read", "write")]; const sameForAll = (r) => Object.fromEntries(IDENTITY.map((k) => [k, r])); const DB_LEVEL = [grant("library", "*", "read", "write")]; const LEAST = { "catalog-search": [grant("library", "book", "read")], "loan-endpoint": [grant("library", "book", "read"), grant("library", "member", "read"), grant("library", "loan", "read", "write")], "fine-processing": [grant("library", "loan", "read"), grant("library", "fine", "read", "write")], report: [grant("library", "loan", "read"), grant("library", "fine", "read")], backup: [grant("*", "*", "read")], }; export const CONFIG = [ { name: "C0 authentication off", identity: "no", role: sameForAll(ALL) }, { name: "C1 cluster-wide single role", identity: "yes", role: sameForAll(ALL) }, { name: "C2 database-level role", identity: "yes", role: { ...sameForAll(DB_LEVEL), backup: LEAST.backup } }, { name: "C3 collection-level (least privilege)", identity: "yes", role: LEAST }, { name: "C4 collection-level + summary collection", identity: "yes", role: { ...LEAST, report: [grant("library", "summary", "read")] } }, ]; const matches = (i, d) => (i.db === "*" || i.db === d[0]) && (i.coll === "*" || i.coll === d[1]); export function surface(y) { const row = IDENTITY.map((k) => { let read = 0, write = 0, personal = 0; for (const d of COLLECTION) { const applicable = y.role[k].filter((i) => matches(i, d)); if (applicable.some((i) => i.actions.includes("read"))) { read += d[2]; if (d[4]) personal += d[2]; } if (applicable.some((i) => i.actions.includes("write"))) write += d[2]; } return { k, read, write, personal }; }); const total = (a) => row.reduce((s, r) => s + r[a], 0); const single = row.filter((r) => r.k !== "backup").map((r) => r.read); return { row, read: total("read"), write: total("write"), personal: total("personal"), widestSingle: Math.max(...single) }; } export const FRAME = 16384, FRAME_OVERHEAD = 29, HANDSHAKE = 5200, BLOCK = 4096, BLOCK_OVERHEAD = 28; export const frameCount = (b) => Math.ceil(b / FRAME); export const blockCount = (b) => Math.ceil(b / BLOCK); export const bytes = (d) => d[2] * d[3]; export const TOTAL_BYTES = COLLECTION.reduce((s, d) => s + bytes(d), 0); export const TOTAL_BLOCKS = COLLECTION.reduce((s, d) => s + blockCount(bytes(d)), 0); const SUMMARY_BYTES = bytes(COLLECTION.find((d) => d[1] === "summary")); export const WORKLOAD = [ // name, request count, bytes carried per request, blocks touched per request ["catalog-search point lookup", 180000, 1472, 1], ["loan-endpoint point lookup", 20000, 640, 1], ["fine-processing point lookup", 4800, 208, 1], ["report summary scan", 12, SUMMARY_BYTES, blockCount(SUMMARY_BYTES)], ["nightly backup", 1, TOTAL_BYTES, TOTAL_BLOCKS], ];
// security/measure.mjs — same collections, same five identities, five configurations; then the // same workload's transport and at-rest encryption cost import { COLLECTION, IDENTITY, CONFIG, WORKLOAD, surface, frameCount, blockCount, bytes, FRAME_OVERHEAD, HANDSHAKE, BLOCK_OVERHEAD, TOTAL_BYTES, TOTAL_BLOCKS } from "./model.mjs"; const n = (x) => String(x).replace(/\B(?=(\d{3})+(?!\d))/g, ","); const s = (x, w) => n(x).padStart(w); const pct = (x) => (x * 100).toFixed(2); const RECORDS = COLLECTION.reduce((a, d) => a + d[2], 0); console.log(`${COLLECTION.length} collections, ${n(RECORDS)} records, ${n(TOTAL_BYTES)} bytes, ` + `${IDENTITY.length} application identities; the record columns are totaled across five identities.\n`); console.log("configuration | identity | readable | widest single | writable | personal"); console.log("--------------------------------------------|----------|----------|----------------|----------|---------"); for (const cf of CONFIG) { const r = surface(cf); console.log(`${cf.name.padEnd(44)} | ${cf.identity.padEnd(8)} | ${s(r.read, 8)} | ` + `${s(r.widestSingle, 14)} | ${s(r.write, 8)} | ${s(r.personal, 7)}`); } const bk = CONFIG.map((cf) => surface(cf).row.find((r) => r.k === "backup")); console.log(`\nRecords the backup identity reads across the five configurations: ` + `${bk.map((r) => n(r.read)).join(", ")} (personal ${n(bk[4].personal)}).`); const requests = WORKLOAD.reduce((a, w) => a + w[1], 0); const load = WORKLOAD.reduce((a, w) => a + w[1] * w[2], 0); const frameEk = WORKLOAD.reduce((a, w) => a + w[1] * frameCount(w[2]) * FRAME_OVERHEAD, 0); console.log(`\nThe same day's workload: ${n(requests)} requests, ${n(load)} bytes carried.`); console.log("workload | requests | request bytes | frame overhead | ratio | decrypt steps"); console.log("------------------------------|----------|----------------|-----------------|--------|--------------"); for (const [name, count, b, bl] of WORKLOAD) { const ek = frameCount(b) * FRAME_OVERHEAD; console.log(`${name.padEnd(29)} | ${s(count, 8)} | ${s(b, 14)} | ${s(count * ek, 15)} | ` + `${("%" + pct(ek / b)).padStart(6)} | ${s(count * bl, 13)}`); } console.log(`${"total".padEnd(29)} | ${s(requests, 8)} | ${s(load, 14)} | ${s(frameEk, 15)} | ` + `${("%" + pct(frameEk / load)).padStart(6)} | ${s(WORKLOAD.reduce((a, w) => a + w[1] * w[3], 0), 13)}`); const POOL = 64, book = COLLECTION.find((d) => d[1] === "book"); console.log(`\nConnection setup ${n(HANDSHAKE)} bytes: with a ${POOL}-connection pool, ` + `${n(POOL * HANDSHAKE)} bytes (%${pct(POOL * HANDSHAKE / load)}); with one connection per ` + `request, ${n(requests * HANDSHAKE)} bytes (%${pct(requests * HANDSHAKE / load)}).`); console.log(`At-rest encryption: ${n(TOTAL_BLOCKS)} blocks x ${BLOCK_OVERHEAD} bytes = ` + `${n(TOTAL_BLOCKS * BLOCK_OVERHEAD)} bytes overhead (%${pct(TOTAL_BLOCKS * BLOCK_OVERHEAD / TOTAL_BYTES)}); ` + `a single point read decrypts 1 block, scanning the book collection decrypts ${n(blockCount(bytes(book)))}.`); console.log(`\nindependent of the run: the frame overhead is a fixed ${FRAME_OVERHEAD} bytes, its ratio` + ` depends on response size — %${pct(FRAME_OVERHEAD / 208)} on a 208-byte response, ` + `%${pct(FRAME_OVERHEAD / 16384)} on a full frame, ${(16384 / 208).toFixed(0)}x.`);
7 collections, 154,540 records, 24,665,680 bytes, 5 application identities; the record columns are totaled across five identities. configuration | identity | readable | widest single | writable | personal --------------------------------------------|----------|----------|----------------|----------|--------- C0 authentication off | no | 772,700 | 154,540 | 772,700 | 184,900 C1 cluster-wide single role | yes | 772,700 | 154,540 | 772,700 | 184,900 C2 database-level role | yes | 387,980 | 58,360 | 233,440 | 184,180 C3 collection-level (least privilege) | yes | 276,140 | 52,000 | 24,800 | 118,580 C4 collection-level + summary collection | yes | 252,900 | 52,000 | 24,800 | 93,780 Records the backup identity reads across the five configurations: 154,540, 154,540, 154,540, 154,540, 154,540 (personal 36,980). The same day's workload: 204,813 requests, 305,071,440 bytes carried. workload | requests | request bytes | frame overhead | ratio | decrypt steps ------------------------------|----------|----------------|-----------------|--------|-------------- catalog-search point lookup | 180,000 | 1,472 | 5,220,000 | %1.97 | 180,000 loan-endpoint point lookup | 20,000 | 640 | 580,000 | %4.53 | 20,000 fine-processing point lookup | 4,800 | 208 | 139,200 | %13.94 | 4,800 report summary scan | 12 | 137,280 | 3,132 | %0.19 | 408 nightly backup | 1 | 24,665,680 | 43,674 | %0.18 | 6,024 total | 204,813 | 305,071,440 | 5,986,006 | %1.96 | 211,232 Connection setup 5,200 bytes: with a 64-connection pool, 332,800 bytes (%0.11); with one connection per request, 1,065,027,600 bytes (%349.11). At-rest encryption: 6,024 blocks x 28 bytes = 168,672 bytes overhead (%0.68); a single point read decrypts 1 block, scanning the book collection decrypts 1,797. independent of the run: the frame overhead is a fixed 29 bytes, its ratio depends on response size — %13.94 on a 208-byte response, %0.18 on a full frame, 79x.
Authenticating Does Not Shrink the Surface, Scope Does
The first table’s first two rows are identical: 772,700 readable, 772,700 writable, 184,900 personal records. Turning authentication on did not shrink the exposed surface by a single record.
The table does not show the difference, because it holds identity count fixed. Under C0 that count is not five: every client reaching the store behaves like one of these five rows, deletion included. C0’s exposed surface is not 772,700 — it is the reachable-client count times 154,540. Authentication is not the setting that shrinks the surface; it is the setting that binds it to a finite set of identities. The shrinking is done by scope.
The three levels of scope give three numbers: the database level brings readable records down to 387,980, the collection level to 276,140. The real difference is in writes — 772,700 → 233,440 → 24,800, a 31.2x factor — because only two of the five identities need to write at all.
The widest single column shows what a single compromised identity would see: 154,540 → 58,360 →
52,000. But that column leaves the backup identity out; the row below it shows the backup’s read
count staying at 154,540 across all five configurations, 36,980 of it personal. Least privilege
did not change the worst case — it confined it to a single identity, one whose job is to read
everything.
C4 shows the final step: when the report job is given a summary collection carrying no personal fields, readable records drop to 252,900, personal records to 93,780. Where narrowing authorization ends, narrowing the data begins.
Encryption’s Cost Is Fixed, Its Ratio Is Not
The second table computes transport encryption over the same day’s workload: the overall ratio is 1.96 percent, 5,986,006 bytes against 305,071,440. The spread is wide — 13.94 percent on the 208-byte fine response, 0.18 percent on the 24,665,680-byte backup transfer. The frame overhead is fixed at 29 bytes; what sets the ratio is not the encryption but how small the response is, and the last line gives this independent of the run: 79x.
The real line item is below the table. Connection setup is 5,200 bytes per connection: with a 64-connection pool, 332,800 bytes a day, 0.11 percent of the load. The same workload opening a new connection per request runs to 1,065,027,600 bytes, 349 percent of the data carried. What makes transport encryption look expensive is usually how connections get established, not the encryption itself.
At-rest encryption is a separate line item: 6,024 blocks × 28 bytes = 168,672 bytes, 0.68 percent of the data, independent of data size. The cost at rest is small; the real cost is a processing step, and it is measured not by record count but by blocks touched: 1 for a point read, 1,797 for a scan of the book collection. The day’s 204,813 requests produce 211,232 decryption steps, 6,024 of them from a single nightly backup.
Summary
- Exposed surface is a countable quantity: the total records defined identities can read, alongside the totals of writable and personal records.
- Turning authentication on did not shrink the exposed surface at all (772,700 → 772,700); that setting binds the surface to a finite set of identities. With it off, the exposed surface is the number of reachable clients times 154,540, writes and deletes included.
- Narrowing scope from a cluster-wide role to the collection level brought readable records down to 276,140 and writable records down to 24,800 (a 31.2x factor).
- Least privilege did not change the worst case: the backup identity read all 154,540 records under every one of the five configurations. Where narrowing authorization ends, narrowing data begins — giving the report job a summary collection brought personal records from 118,580 to 93,780.
- Transport encryption’s frame overhead is fixed at 29 bytes: its ratio is 13.94 percent on a 208-byte response, 0.18 percent on a full frame. Connection setup without a pool adds 349 percent of the data carried.
- At-rest encryption costs 0.68 percent at rest; its processing cost is the number of blocks touched — 1 extra decryption step for a point read, 1,797 for a scan of the book collection.
Course Wrap-Up
This course asked one question across nineteen lessons: when the same work is run under two models or two decisions, what is the measured difference, and which guarantee was given up.
| Lesson | Two models/decisions compared | Measured difference | Guarantee given up |
|---|---|---|---|
| Key-Value Stores | key access — index | penalty query 1 — 20,000 round trips | a condition on the field |
| Document Databases | document — relational | nested record 1 — 4 round trips | write-time validation |
| Wide-Column Stores | row layout — column-family layout | circulation summary 1,867 — 46 pages | one layout serving two access patterns |
| Graph Databases | join — traversal | at the fifth degree, 14,190,839 — 217,544 | cost’s independence from degree |
| Time Series Stores | full timestamp — delta encoding | 9,331,200 — 329,013 bytes | the access unit becomes the segment |
| Model Selection Criteria | single pattern — mix | the winner changes with the mix | scoring accounting for writes |
| Document Data Types | explicit — short name — fixed schema | 7,357,008 — 6,082,574 — 3,139,138 bytes | the field name being stored |
| Embedding and Referencing | embedded — referenced schema | branch query 6,149,233 — 781,096 bytes | the book page’s single round trip |
| Query Operators | document-level — element-level conjunction | 6,033 — 3,340 documents | conditions being met within the same element |
| Aggregation Pipeline | filter first — group first | peak memory 2,418 — 40,276 | freedom of stage order |
| Indexing | intersection — element-level compound index | 6,033 — 3,340 documents read | maintenance 10,840 — 173,994 entries |
| Schema Validation | shallow — full rule, reject mode |
2,500 — 3,200 documents caught | ordinary writes going through |
| Transactions | single document — multi-document transaction | 0/20 — 12/20 violations, 18 aborts | abort-freedom, a 604,903-byte undo image |
| Replica Sets | three-/four-member — five-member cluster | 12 turns without writes, 9 — 6 rolled back | the durability of an accepted write |
| Sharding | borrow_day — hash(borrow_day) — member_id |
hot share 100% — 59.6% | balance preventing a hot shard |
| Read and Write Concerns | w=1 — w=majority — w=all |
ack turns 0/10/16, lost writes 3/0/0 | write availability |
| Consistency Models | sticky routing — session token | causal breaks 24 → 12 → 0 | stale reads staying at 47/108 |
| CAP and PACELC | w=majority·majority — w=1·local |
16 rejected/16 unanswered — 0 unanswered | the durability of an acceptance in the minority |
| Security Configuration | cluster-wide role — least privilege | writable 772,700 — 24,800 | a single identity handling every job |
The two right-hand columns are this course’s rule: it is not the family that gets chosen, it is the access pattern; every model buys a performance gain by giving up a guarantee. Picking a family by name is not a decision — the decision is writing down which work gets how many times cheaper and which guarantee drops. In none of the nineteen rows is a gain free.
These nineteen rows share one more assumption, never stated. The key-value store wrote its value to disk, the wide-column store flushed its in-memory table to a file, the replica set acknowledged only after the write reached disk, and this lesson’s at-rest encryption encrypted exactly that disk. Stores keeping all their data in memory, where durability itself turns into a setting, were never taken up. The next course — In-Memory Stores and Caching Systems — starts from there.
To keep your progress and take notes, Log in
My notes
Log in to take notes.