---
title: 'Audit Logs'
source: 'https://academia.sh/en/courses/cloud-governance/audit-logs'
course: 'Cloud Governance, Security and Cost'
language: en
updated: '2026-09-13T05:07:35+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Counts how many of four questions get answered in a set of events, and names the operation type the log does not cover'
    - "Measures the log's own surface: who can delete it, who can modify it, and the cost of immutability"
    - 'Scans retention period alongside cost and derives the marginal cost per question'
---

# Audit Logs

A set of events is dropped into the audit log, and how many of four questions it can answer is counted: 273 of 480 events answer all four, 88 in the default configuration. 3 of 8 investigations get answered. 11 subjects can delete the log; moving it to a separate account stops 6 legitimate jobs, and data-plane logging costs 772 times as much as management-plane logging.

The same sentence appeared repeatedly across the previous three lessons: "it all lands in the
audit log." Key use landed in the log, secret reads landed in the log, policy changes landed in
the log. That sentence has never been tested until now. This lesson tests it and reduces the
measure to a single form: a set of events is dropped into the audit log, and **how many questions
can be answered** is counted. There are four questions — who, when, which resource, from which
identity.

There is one operation type the log does not cover, and it has to be named: **data-plane reads and
writes.** Management-plane calls (policy changes, key use, resource creation) are logged by
default; object reads, object writes, and queue consumption are not. That logging is turned on
separately and **billed separately.** The second subject is the log itself: an audit log is also a
surface — who can delete it, where it is written, how immutability is achieved.

The measurement runs on the **regional measurement network**, is **fictional**, and is a **model**
written with `node`. The currency is fictional too: the "u" unit is made up, not a real price.

**CS19.** The event sample is 480 operations; daily volume is 2,400 events on the management plane
and 1,860,000 on the data plane (fictional).
**CS20.** Management-plane logging is on by default; data-plane logging is turned on separately and
billed separately.
**CS21.** An event's identity is one of four types: user, assumed-role user, workload identity,
automated service. Under role assumption, the single subject can only be read if a session tag is
present.
**CS22.** Batch operations do not name a resource individually; the resource field fills with the
container's name, and the "which resource" question goes unanswered.
**CS23.** For each destination the log can be written to, the model records the number of subjects
who can delete it, modify it, and read it, along with the immutability lock.
**CS24.** Cost is calculated in the fictional unit: ingesting a thousand events is 0.4 u, GB-month
storage is 12 u, event size is 1.4 KB. Sampled logging does not answer single-resource questions.
The generator is self-written; the seed is 20260722.

## How Many Questions Get Answered

```js
// event.mjs — the events that land in the audit log and the questions that can be answered. MODEL.
// The regional measurement network is FICTIONAL. The generator is self-written; the seed appears below.
export const SEED = 20260722;
const generator = (t) => { let s = t >>> 0;
  return () => (s = (s * 1664525 + 1013904223) >>> 0) / 4294967296; };

export const MANAGEMENT = ["policy-change", "key-use", "assume-role",
  "network-rule-change", "secret-read", "resource-create"];
export const DATA = ["object-read", "object-write", "object-delete", "queue-consume"];
export const DAILY = { management: 2400, data: 1860000 };  // daily event volume (fictional)

const r = generator(SEED);
export const EVENT = Array.from({ length: 480 }, () => {
  const isData = r() < 0.72;
  const op = isData ? DATA[Math.floor(r() * DATA.length)]
    : MANAGEMENT[Math.floor(r() * MANAGEMENT.length)];
  const u = r();
  const identity = u < 0.18 ? "user" : u < 0.52 ? "assumed-role"
    : u < 0.86 ? "workload" : "automated-service";
  return { plane: isData ? "data" : "management", op, identity,
    tag: identity === "assumed-role" ? r() < 0.38 : true,     // session tag gives the single subject
    detail: r() > (isData ? 0.34 : 0.12) };                   // batch operations do not name a resource
});

// Four questions: which of them does an event sitting in the log answer.
export const QUESTION = {
  "when": () => true,
  "from which identity": () => true,
  "which resource": (o) => o.detail,
  "who (single subject)": (o) => o.identity !== "assumed-role" || o.tag,
};
export const logged = (o, dataOn) => o.plane === "management" || dataOn;

// Investigation: a question is tied to an operation, a field, and a lookback period.
export const INVESTIGATION = [
  ["who changed the production policy document", "policy-change", "who (single subject)", 12],
  ["which objects were read from the archive", "object-read", "which resource", 5],
  ["which identity used the customer key", "key-use", "from which identity", 40],
  ["who deleted the measurement record", "object-delete", "who (single subject)", 3],
  ["when was the network rule loosened", "network-rule-change", "when", 210],
  ["which secret did the maintenance identity read", "secret-read", "which resource", 60],
  ["who read the partner token", "secret-read", "who (single subject)", 150],
  ["which resource did the backup write to", "object-write", "which resource", 400],
];
export const answer = (s, dataOn, retention) => {
  const [, op, field, days] = s;
  const l = EVENT.filter((o) => o.op === op && logged(o, dataOn) && QUESTION[field](o));
  if (days > retention) return ["no", `retention ${retention} days < ${days}`];
  if (l.length === 0) {
    const off = EVENT.some((o) => o.op === op) && DATA.includes(op) && !dataOn;
    return ["no", off ? "data-plane logging off" : `${field} field empty`];
  }
  return ["yes", `${l.length} events`];
};

const gen = (B) => (h) => h.map((v, j) => j ? String(v).padStart(B[j]) : String(v).padEnd(B[0]))
  .join("");
const row = gen([24, 12, 16, 18, 12]);

if (import.meta.url === `file://${process.argv[1]}`) {
  const m = EVENT.filter((o) => o.plane === "management").length;
  console.log(`model (seed ${SEED}): ${EVENT.length} event sample — ${m} management plane, ` +
    `${EVENT.length - m} data plane; daily volume ${DAILY.management} / ${DAILY.data} (fictional)`);
  console.log("\n" + row(["question", "default", "data plane too", "left unlogged", "share"]));
  for (const [name, f] of Object.entries(QUESTION)) {
    const v = EVENT.filter((o) => logged(o, false) && f(o)).length;
    const t = EVENT.filter((o) => logged(o, true) && f(o)).length;
    console.log(row([name, v, t, EVENT.length - t, `${Math.round(100 * t / EVENT.length)}%`]));
  }
  const allFour = (dataOn) => EVENT.filter((o) => logged(o, dataOn) &&
    Object.values(QUESTION).every((f) => f(o))).length;
  console.log(row(["all four", allFour(false), allFour(true), EVENT.length - allFour(true),
    `${Math.round(100 * allFour(true) / EVENT.length)}%`]));

  for (const [dataOn, retention, name] of [[false, 90, "default (management, 90 days)"],
    [true, 90, "data plane on (90 days)"], [true, 365, "data plane on (365 days)"]]) {
    const s = INVESTIGATION.map((x) => answer(x, dataOn, retention));
    console.log(`\n${name}: ${s.filter((x) => x[0] === "yes").length}/${INVESTIGATION.length} ` +
      `questions answered`);
    if (name.startsWith("default")) for (const [i, x] of INVESTIGATION.entries())
      console.log(`  ${x[0].padEnd(48)} ${s[i][0].padEnd(6)} ${s[i][1]}`);
  }
}
```

```
model (seed 20260722): 480 event sample — 132 management plane, 348 data plane; daily volume 2400 / 1860000 (fictional)

question                     default  data plane too     left unlogged       share
when                             132             480                 0        100%
from which identity              132             480                 0        100%
which resource                   119             352               128         73%
who (single subject)              99             374               106         78%
all four                          88             273               207         57%

default (management, 90 days): 3/8 questions answered
  who changed the production policy document       yes    16 events
  which objects were read from the archive         no     data-plane logging off
  which identity used the customer key             yes    17 events
  who deleted the measurement record               no     data-plane logging off
  when was the network rule loosened               no     retention 90 days < 210
  which secret did the maintenance identity read   yes    15 events
  who read the partner token                       no     retention 90 days < 150
  which resource did the backup write to           no     retention 90 days < 400

data plane on (90 days): 5/8 questions answered

data plane on (365 days): 7/8 questions answered
```
The first column is the default configuration's coverage, and 348 of the 480 events are not in that
column at all — data-plane logging is off. The "when" and "from which identity" questions are
answered for all of the remaining 132 events; the log always carries these two fields.

The other two do not. Once the data plane is also turned on, "which resource" is answered for 352
of the 480 events: the remaining 128 events are batch operations, and the resource field names the
container, not the individual objects. "Who" is answered for 374; the missing 106 events are
role-assumed calls that do not carry a session tag. The log has the role's name, not the name of the
person who assumed it — if the role is shared, the question stops there.

The number of events that answer all four questions at once is 273, or 57 percent. In the default
configuration, the same number is 88: 18 percent of the 480 events. This is the measured
counterpart of the sentence "it all lands in the audit log."

The investigation list delivers the same result with concrete questions: three of the eight get
answered. The failures fall into two classes, and each points to a separate decision. Two questions
go unanswered because data-plane logging is off, three because retention does not reach far enough.
Turning on the data plane brings it to 5/8, and pushing retention to 365 days on top of that brings
it to 7/8. Which comes first is a cost question, and the next section counts it.

## The Log's Own Surface and the Bill for Retention

```js
// record.mjs — the log's own surface (deletion, immutability) and a retention-cost scan.
// The currency is FICTIONAL: the "u" unit is made up, not a real price.
import { DAILY, INVESTIGATION, answer } from "./event.mjs";

const DESTINATION = [                                     // where the log is written. MODEL.
  ["same account, same bucket", 11, 11, 14, "none", 0, 0],
  ["same account, locked bucket", 3, 0, 14, "retention lock", 4, 2],
  ["separate account, locked bucket", 1, 0, 5, "retention lock", 9, 6],
  ["separate account + second copy", 0, 0, 5, "lock + copy", 14, 6],
].map(([name, del, mod, read, lock, extra, stopped]) => ({ name, del, mod, read, lock, extra, stopped }));

const EVENT_KB = 1.4, INGEST_PER_K = 0.4, STORAGE_PER_GB = 12;    // fictional unit (u)
const gb = (n, days) => (n * days * EVENT_KB) / (1024 * 1024);
const monthly = (n, days) => Math.round((n * 30 / 1000) * INGEST_PER_K + gb(n, days) * STORAGE_PER_GB);

const gen = (B) => (h) => h.map((v, j) => j ? String(v).padStart(B[j]) : String(v).padEnd(B[0]))
  .join("");
const rowDest = gen([33, 12, 16, 10, 19, 13, 14]);
const rowCost = gen([16, 12, 14, 14, 14, 13, 13]);

console.log(rowDest(["log destination", "can delete", "can modify", "can read", "immutability",
  "extra items", "stopped work"]));
for (const h of DESTINATION)
  console.log(rowDest([h.name, h.del, h.mod, h.read, h.lock, h.extra, h.stopped]));

console.log("\n" + rowCost(["retention(days)", "mgmt GB", "mgmt(u/mo)", "data GB", "data(u/mo)",
  "5% sample", "covered"]));
for (const days of [30, 90, 180, 365, 730]) {
  const t = INVESTIGATION.map((x) => answer(x, true, days)).filter((x) => x[0] === "yes").length;
  console.log(rowCost([days, gb(DAILY.management, days).toFixed(2), monthly(DAILY.management, days),
    Math.round(gb(DAILY.data, days)), monthly(DAILY.data, days),
    monthly(DAILY.data * 0.05, days), `${t}/${INVESTIGATION.length}`]));
}

const coverage = (dataOn, days) => INVESTIGATION.map((x) => answer(x, dataOn, days))
  .filter((x) => x[0] === "yes").length;
const spend = (dataOn, days) => monthly(DAILY.management, days) + (dataOn ? monthly(DAILY.data, days) : 0);
console.log(`\ncoverage and cost (fictional unit u): default ${coverage(false, 90)}/8 -> ` +
  `${spend(false, 90)} u/mo; data plane on 90 days ${coverage(true, 90)}/8 -> ${spend(true, 90)} u/mo; ` +
  `365 days ${coverage(true, 365)}/8 -> ${spend(true, 365)} u/mo`);
console.log(`turning on data-plane logging: +${spend(true, 90) - spend(false, 90)} u/mo and ` +
  `+${coverage(true, 90) - coverage(false, 90)} questions. Retention 90 -> 365 days: ` +
  `+${spend(true, 365) - spend(true, 90)} u/mo and +${coverage(true, 365) - coverage(true, 90)} questions`);
console.log(`two paths to the same coverage: management plane only, 365 days ${coverage(false, 365)}/8 -> ` +
  `${spend(false, 365)} u/mo; data plane on, 90 days ${coverage(true, 90)}/8 -> ${spend(true, 90)} u/mo`);
console.log(`data-plane logging costs ` +
  `${(monthly(DAILY.data, 365) / monthly(DAILY.management, 365)).toFixed(0)} times as much as ` +
  `management-plane logging; a 5% sample divides that gap by 20 but does not answer single-resource questions`);
```

```
log destination                    can delete      can modify  can read       immutability  extra items  stopped work
same account, same bucket                  11              11        14               none            0             0
same account, locked bucket                 3               0        14     retention lock            4             2
separate account, locked bucket             1               0         5     retention lock            9             6
separate account + second copy              0               0         5        lock + copy           14             6

retention(days)      mgmt GB    mgmt(u/mo)       data GB    data(u/mo)    5% sample      covered
30                      0.10            30            75         23214         1161          3/8
90                      0.29            32           224         25002         1250          5/8
180                     0.58            36           447         27684         1384          6/8
365                     1.17            43           906         33197         1660          7/8
730                     2.34            57          1813         44074         2204          8/8

coverage and cost (fictional unit u): default 3/8 -> 32 u/mo; data plane on 90 days 5/8 -> 25034 u/mo; 365 days 7/8 -> 33240 u/mo
turning on data-plane logging: +25002 u/mo and +2 questions. Retention 90 -> 365 days: +8206 u/mo and +2 questions
two paths to the same coverage: management plane only, 365 days 5/8 -> 43 u/mo; data plane on, 90 days 5/8 -> 25034 u/mo
data-plane logging costs 772 times as much as management-plane logging; a 5% sample divides that gap by 20 but does not answer single-resource questions
```
The top table opens with the lesson's most unsettling row. In the default placement, the audit log
sits inside the same account where the events it records took place, and **11 subjects can delete
it, 11 can modify it.** This is where the log's meaning comes from: if a subject who can perform an
event can also remove that event's record, the log is not evidence — it is a convenience. A
retention lock drops the modifiable count to zero and the deletable count to 3; the cost is 4
configuration items and **2 legitimate jobs stopping** — two jobs that operate directly on the
bucket do not coexist with the lock.

Moving to a separate account drops the deletable count to 1, but it also drops the number of
subjects who can read from 14 to 5, and **6 legitimate jobs stop**: teams that query the log
directly lose their access, and cross-account reads do not come back without being set up
separately. This is the cost of narrowing, and it comes with 9 configuration items. The last row
zeroes out the deletable count: lock and second copy, 14 items. Immutability is not a free feature —
it is a 14-item configuration and 6 stopped jobs.

The bottom table keeps retention and cost in the same row. Management-plane logging is cheap: 43
u/mo at 365 days, 1.17 GB. Data-plane logging over the same span is 33,197 u and 906 GB — **772
times as much.** "The operation type the log does not cover" is not left out because it is free; it is
left out because turning it on grows the bill by three digits. A five-percent sample divides that
gap by twenty, but it does not answer single-resource questions: "which objects were read from the
archive" goes unanswered under sampled logging.

The marginal accounting decides the order. Turning on the data plane adds 25,002 u a month and
gains 2 questions: 12,501 u per question. Extending retention from 90 to 365 days adds 8,206 u and
also gains 2 questions: 4,103 u per question. The last line settles the decision — keeping only
management-plane logging for 365 days answers 5/8 questions for 43 u/mo; turning on the data plane
and keeping it for 90 days asks 25,034 u for the same 5/8. A five-hundred-eighty-fold difference
comes down to a single configuration order.

## Summary

- 273 of 480 events answer all four questions at once (57%); in the default configuration that
  number is 88 (18%), because 348 events are on the data plane and never get logged.
- "When" and "from which identity" are answered in every logged event; "which resource" goes
  unanswered in 128 batch operations, and "who" goes unanswered in 106 role-assumption calls that
  do not carry a session tag.
- 3 of 8 investigations get answered in the default configuration; two questions fall through
  because data-plane logging is off, three because retention does not reach far enough.
- 11 subjects can delete the log in the default placement and 11 can modify it; a lock plus a
  separate account bring that to 0, at the cost of 14 configuration items and 6 legitimate jobs
  stopping.
- Data-plane logging costs 772 times as much as management-plane logging (365 days, fictional
  unit); a five-percent sample divides that gap by twenty but does not answer single-resource
  questions.
- The marginal cost sets the order: extending retention costs 4,103 u per question, turning on the
  data plane costs 12,501 u; keeping only management-plane logging for 365 days answers 5/8
  questions for 43 u/mo.

## Next Step

Once encryption, rotation, and logging are set up, a problem surfaces: all of it sits there as
configuration, and configuration drifts over time. A bucket lock gets removed, a log destination
gets changed, a leg's exception becomes permanent. The next lesson measures that drift with a rule
set: how many resources pass the compliance rules, how many do not, and how many legitimate
configurations break once automatic remediation kicks in.
