Skip to content
academia.sh

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_dayhash(borrow_day)member_id hot share 100% — 59.6% balance preventing a hot shard
Read and Write Concerns w=1w=majorityw=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.

Start typing to search.

↑↓ Esc navigate · open · close