Skip to content

Commit f5a5773

Browse files
authored
Merge pull request #108 from runcycles/fix/audit-log-retention-ttl-v0.1.25.15
fix(audit): retention TTL on runtime-written audit rows (v0.1.25.15)
2 parents 20dfe45 + ce42282 commit f5a5773

7 files changed

Lines changed: 329 additions & 7 deletions

File tree

AUDIT.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Cycles Protocol v0.1.25 — Server Implementation Audit
22

3-
**Date:** 2026-04-18 (v0.1.25.14 — trace_id (W3C Trace Context) cross-surface correlation per cycles-protocol revision 2026-04-18; new `TraceContextFilter` extracts `traceparent` or `X-Cycles-Trace-Id` from inbound requests or generates a fresh 128-bit id, echoes `X-Cycles-Trace-Id` on every response, populates `trace_id` on `ErrorResponse` / `Event` / `WebhookDelivery` / `AuditLogEntry`),
3+
**Date:** 2026-04-18 (v0.1.25.15 — runtime audit-log retention TTL fix; `AuditRepository` now writes `audit:log:{id}` keys with `EX ttl` via the same Lua shape admin uses, configurable via `audit.retention.days` (default 400d), daily `@Scheduled` sweep prunes stale ZSET index pointers; closes a gap where runtime-written rows persisted indefinitely and did not participate in admin's authenticated-tier retention),
4+
2026-04-18 (v0.1.25.14 — trace_id (W3C Trace Context) cross-surface correlation per cycles-protocol revision 2026-04-18; new `TraceContextFilter` extracts `traceparent` or `X-Cycles-Trace-Id` from inbound requests or generates a fresh 128-bit id, echoes `X-Cycles-Trace-Id` on every response, populates `trace_id` on `ErrorResponse` / `Event` / `WebhookDelivery` / `AuditLogEntry`),
45
2026-04-16 (v0.1.25.13 — hydration cap + enum wire annotations on the sorted `GET /v1/reservations` path; `SORTED_HYDRATE_CAP=2000` guard on the in-memory sort hydration with WARN-on-cap, matches admin plane's v0.1.25.24 pattern; `@JsonValue`/`@JsonCreator fromWire` on `ReservationSortBy` + `SortDirection` to mirror admin's `SortSpec`/`SortDirection` contract),
56
2026-04-16 (v0.1.25.12 — `sort_by` + `sort_dir` on `GET /v1/reservations` per cycles-protocol spec revision 2026-04-16; 7-value sort enum, opaque cursor binds `(sort_by, sort_dir, filters)` tuple, legacy SCAN-cursor path preserved when both params omitted),
67
2026-04-14 (automated performance regression detection — nightly trend + release gate, no version bump),
@@ -20,6 +21,31 @@
2021

2122
---
2223

24+
### 2026-04-18 — v0.1.25.15: audit-log retention TTL (runtime-side fix)
25+
26+
Closes a gap surfaced by the post-v0.1.25.14 alignment audit: runtime's `AuditRepository.log()` was writing `audit:log:{id}` string keys with no `EX`, so runtime-written audit rows persisted indefinitely until Redis memory-eviction kicked in. This broke the 400-day retention story the admin plane tells operators — admin's `AuditRepository` already applies tiered TTL (400d authenticated / 30d unauthenticated) via a conditional Lua `SET … EX ttl`, but runtime-written rows were silently non-compliant with that contract. The audit dashboard reads from the shared index, so stale admin ZSETs would also accumulate pointers to long-expired runtime rows without a compensating sweep.
27+
28+
**Root cause:** the original v0.1.25.8 introduction of runtime-side audit writes copied admin's Lua shape from *before* admin added per-entry TTL in its v0.1.25.20. Admin's TTL work never back-propagated to runtime — not a regression, just an unnoticed drift.
29+
30+
**Scope decision — runtime-side fix over admin-side reconciliation:** the writer should own retention, not a downstream sweeper. Admin-side reconciliation would have required a reaper polling for TTL-less keys — heavier, fights the symptom, couples admin's cleanup cadence to runtime's write rate. Runtime-side adds ~100 LOC (Lua arg + config + sweeper mirror) with zero API surface change.
31+
32+
**Scope decision — single tier instead of admin's two tiers:** runtime only writes real-tenant audit rows. The `__admin__` (platform-plane) and `__unauth__` (pre-auth failure) sentinels are admin-plane concerns — runtime authenticates every request before the audit write, and runtime-plane operations like reservation release are always tenant-attributed. So one `audit.retention.days` knob (default 400 to match admin's authenticated tier) is sufficient. If a future runtime endpoint ever needs the 30-day short-tier behavior, this config can be extended without wire change.
33+
34+
**Lua change:** `LOG_AUDIT_LUA` now reads `ARGV[4]` as an optional TTL in seconds; conditional branch matches admin's script byte-for-byte (minus the sentinel logic). Atomic guarantee preserved — SET + 2×ZADD still run in one call, so the TTL addition cannot introduce orphaned index pointers on a mid-write crash.
35+
36+
**Sweeper:** mirrors admin's `sweepStaleIndexEntries()` — daily @Scheduled cron (default `0 0 3 * * *`), `ZREMRANGEBYSCORE` on `audit:logs:_all` plus SCAN over per-tenant indexes. Runtime deploys a sweeper of its own (rather than depending on admin's sweep hitting the shared Redis) so a runtime-only topology stays self-healing. Two sweepers scanning the same index is idempotent — `ZREMRANGEBYSCORE` on already-swept ranges is a no-op.
37+
38+
**Config:**
39+
40+
- `audit.retention.days` (env `AUDIT_RETENTION_DAYS`) — default `400`. `0` = indefinite retention; the sweeper is skipped explicitly in that mode (matching admin's `authenticatedRetentionDays=0` behavior).
41+
- `audit.sweep.cron` (env `AUDIT_SWEEP_CRON`) — default `0 0 3 * * *` (03:00 server time).
42+
43+
**Backward compatibility:** new writes get TTL; old runtime-written keys stay un-TTL'd until Redis memory pressure evicts them. No API change, no wire change, no admin-side reconciliation needed — admin's reader doesn't care whether the target key has a TTL, it only cares that a value is present or not (and already null-body-tolerant for TTL-expired pointers).
44+
45+
**Tests:** 7-case `AuditRepositoryTest` covering: (1) TTL passed as ARGV[4] = 400×86400, (2) `retentionDays=0` passes `"0"`, (3) `logId` + timestamp set on the entry, (4) Redis failure is non-fatal, (5) sweeper removes global and per-tenant pointers, (6) sweeper is a no-op when retention is 0, (7) sweep Redis failure is non-fatal. All green; full data module still 365 / 365; full api module still 152 / 152.
46+
47+
---
48+
2349
### 2026-04-18 — v0.1.25.14: trace_id cross-surface correlation (W3C Trace Context)
2450

2551
Implements the `CORRELATION AND TRACING` normative section added to `cycles-protocol-v0.yaml` in spec revision 2026-04-18 (commit `8d65959`). Introduces a third correlation identifier — `trace_id` — that is W3C Trace Context-compatible (OpenTelemetry-native) and links every HTTP request to its `ErrorResponse`, audit-log entry, emitted events, and outbound webhook deliveries under one logical-operation grain.

CHANGELOG.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,39 @@ changes to request/response bodies or Lua-script semantics would require a
1414
minor bump. "Internal signature changes" (e.g. Java method parameters) are
1515
called out but are not breaking to API clients.
1616

17+
## [0.1.25.15] — 2026-04-18
18+
19+
### Fixed
20+
21+
- Runtime-written audit-log entries now respect a configurable retention
22+
TTL (default 400 days). Previously, `AuditRepository.log()` wrote
23+
`audit:log:{id}` keys with no `EXPIRE`, so runtime-written rows
24+
persisted indefinitely until Redis eviction — silently failing to
25+
participate in the 400-day retention tier the admin plane applies to
26+
authenticated audit rows. Matches the authenticated-tier default on
27+
`cycles-server-admin`'s `AuditRepository` (admin's
28+
`audit.retention.authenticated.days=400`). Runtime never writes the
29+
admin-plane `__admin__` / `__unauth__` sentinels, so a single tier
30+
is sufficient.
31+
32+
### Added
33+
34+
- `audit.retention.days` config (default `400`, env `AUDIT_RETENTION_DAYS`).
35+
Set to `0` for indefinite retention (legal hold, HIPAA-adjacent
36+
deployments, or environments that offload audit to an archive store).
37+
- `audit.sweep.cron` config (default `0 0 3 * * *`, env `AUDIT_SWEEP_CRON`).
38+
Daily `@Scheduled` sweep prunes stale `audit:logs:{tenantId}` and
39+
`audit:logs:_all` ZSET pointers whose target `audit:log:{id}` key has
40+
TTL-expired. Self-contained — does not depend on admin's sweep running
41+
against the same Redis. Safe to run in parallel with admin's sweep
42+
(idempotent `ZREMRANGEBYSCORE`).
43+
44+
### Internal
45+
46+
- `AuditRepository.LOG_AUDIT_LUA` now reads ARGV[4] as an optional TTL
47+
in seconds (`0` or negative = no `EX`). Same shape as admin's script,
48+
minus the sentinel branching.
49+
1750
## [0.1.25.14] — 2026-04-18
1851

1952
### Added

OPERATIONS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,8 @@ don't fit.
414414
| `cycles.expiry.initial-delay-ms` | `5000` | Mostly a test knob. Leave. |
415415
| `cycles.tenant-config.cache-ttl-ms` | `60000` | Lower if admin tenant config changes need to take effect faster than 60s. |
416416
| `admin.api-key` | (empty) | Set to a fixed-length secret to enable the admin-on-behalf-of endpoint (v0.1.25.8+). Leave empty to disable. |
417+
| `audit.retention.days` | `400` | Retention for runtime-written audit rows (v0.1.25.15+). Default matches admin's `audit.retention.authenticated.days` — SOC2 Type II 12-month lookback + 1-month auditor-lag buffer. Set `0` for indefinite retention (legal hold, archive-store deployments). |
418+
| `audit.sweep.cron` | `0 0 3 * * *` | Daily cron for pruning stale ZSET index pointers (v0.1.25.15+). Lower cadence if audit write volume is very high; leave as-is otherwise. Skipped when `audit.retention.days=0`. |
417419
| `management.endpoints.web.exposure.include` | `health,info,prometheus` | Add more actuator endpoints if you need them, but `prometheus` is the one ops cares about. |
418420

419421
## Reservation list sorting (v0.1.25.12+)

cycles-protocol-service/cycles-protocol-service-api/src/main/resources/application.properties

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,9 @@ admin.api-key=${ADMIN_API_KEY:}
5858
# Event/delivery retention TTL
5959
events.retention.event-ttl-days=${EVENT_TTL_DAYS:90}
6060
events.retention.delivery-ttl-days=${DELIVERY_TTL_DAYS:14}
61+
62+
# Audit log retention (v0.1.25.15+). Matches admin's authenticated tier
63+
# default. Set to 0 for indefinite retention. audit.sweep.cron prunes
64+
# stale ZSET index pointers whose target key has TTL-expired.
65+
audit.retention.days=${AUDIT_RETENTION_DAYS:400}
66+
audit.sweep.cron=${AUDIT_SWEEP_CRON:0 0 3 * * *}

cycles-protocol-service/cycles-protocol-service-data/src/main/java/io/runcycles/protocol/data/repository/AuditRepository.java

Lines changed: 105 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@
55
import org.slf4j.Logger;
66
import org.slf4j.LoggerFactory;
77
import org.springframework.beans.factory.annotation.Autowired;
8+
import org.springframework.beans.factory.annotation.Value;
9+
import org.springframework.scheduling.annotation.Scheduled;
810
import org.springframework.stereotype.Repository;
911
import redis.clients.jedis.Jedis;
1012
import redis.clients.jedis.JedisPool;
13+
import redis.clients.jedis.params.ScanParams;
14+
import redis.clients.jedis.resps.ScanResult;
1115

1216
import java.time.Instant;
1317
import java.util.List;
@@ -25,10 +29,19 @@
2529
* 2026-04-13's NORMATIVE requirement that admin-driven releases
2630
* record an audit-log entry with {@code actor_type=admin_on_behalf_of}.
2731
*
32+
* <p>v0.1.25.15: audit entries now respect a configurable TTL
33+
* (default 400 days — SOC2 Type II 12-month lookback + buffer).
34+
* Matches the authenticated-tier retention admin's
35+
* {@code io.runcycles.admin.data.repository.AuditRepository} applies to
36+
* real-tenant rows. Runtime never writes the admin-plane sentinels
37+
* ({@code __admin__}, {@code __unauth__}) so a single tier suffices.
38+
* A daily sweep prunes stale index pointers whose target string key
39+
* has TTL-expired.
40+
*
2841
* <p>Key layout (MUST match {@code
2942
* io.runcycles.admin.data.repository.AuditRepository}):
3043
* <ul>
31-
* <li>{@code audit:log:<log_id>} — JSON-serialized entry</li>
44+
* <li>{@code audit:log:<log_id>} — JSON-serialized entry, EX = ttl</li>
3245
* <li>{@code audit:logs:<tenant_id>} — per-tenant ZSET index,
3346
* score = timestamp millis</li>
3447
* <li>{@code audit:logs:_all} — global ZSET index</li>
@@ -46,10 +59,40 @@
4659
public class AuditRepository {
4760
private static final Logger LOG = LoggerFactory.getLogger(AuditRepository.class);
4861

49-
// Same Lua script as cycles-server-admin's AuditRepository.
50-
// Identical key layout and atomicity guarantees.
62+
/**
63+
* Retention for runtime-written audit entries. Default 400 days =
64+
* SOC2 Type II 12-month lookback + 1-month buffer for post-period
65+
* auditor lag. Set to {@code 0} for indefinite retention (legal
66+
* hold, HIPAA-adjacent deployments, or environments that offload
67+
* to an archive store).
68+
*
69+
* <p>Matches {@code audit.retention.authenticated.days} on the
70+
* admin plane — runtime only writes real-tenant rows, so a single
71+
* tier is sufficient.
72+
*
73+
* @since 0.1.25.15
74+
*/
75+
@Value("${audit.retention.days:400}")
76+
private int retentionDays;
77+
78+
/**
79+
* Lua script for atomic audit-log creation with optional TTL via
80+
* ARGV[4] (seconds; 0 or negative = no expiry). Identical shape to
81+
* admin's script — keeps the wire-compatible storage layout the
82+
* admin dashboard reads against.
83+
* <pre>
84+
* SET audit:log:&#123;logId&#125; &lt;json&gt; [EX ttlSeconds]
85+
* ZADD audit:logs:&#123;tenantId&#125; &lt;score&gt; &lt;logId&gt;
86+
* ZADD audit:logs:_all &lt;score&gt; &lt;logId&gt;
87+
* </pre>
88+
*/
5189
private static final String LOG_AUDIT_LUA =
52-
"redis.call('SET', KEYS[1], ARGV[1])\n" +
90+
"local ttl = tonumber(ARGV[4])\n" +
91+
"if ttl and ttl > 0 then\n" +
92+
" redis.call('SET', KEYS[1], ARGV[1], 'EX', ttl)\n" +
93+
"else\n" +
94+
" redis.call('SET', KEYS[1], ARGV[1])\n" +
95+
"end\n" +
5396
"redis.call('ZADD', KEYS[2], ARGV[2], ARGV[3])\n" +
5497
"redis.call('ZADD', KEYS[3], ARGV[2], ARGV[3])\n" +
5598
"return 1\n";
@@ -64,15 +107,72 @@ public void log(AuditLogEntry entry) {
64107
entry.setTimestamp(Instant.now());
65108
String json = objectMapper.writeValueAsString(entry);
66109
String score = String.valueOf(entry.getTimestamp().toEpochMilli());
110+
long ttlSeconds = retentionDays > 0 ? (long) retentionDays * 86400L : 0L;
67111
jedis.eval(LOG_AUDIT_LUA,
68112
List.of("audit:log:" + logId,
69113
"audit:logs:" + entry.getTenantId(),
70114
"audit:logs:_all"),
71-
List.of(json, score, logId));
115+
List.of(json, score, logId, String.valueOf(ttlSeconds)));
72116
} catch (Exception e) {
73117
// Audit log failure must NOT break the business operation.
74118
LOG.error("Failed to write audit log (non-fatal): operation={} resource_id={}",
75119
entry.getOperation(), entry.getResourceId(), e);
76120
}
77121
}
122+
123+
/**
124+
* Daily sweep of the audit sorted-set indexes to remove pointers whose
125+
* target {@code audit:log:{id}} key has already expired. Without this
126+
* sweep the {@code audit:logs:_all} and per-tenant sorted sets would
127+
* grow unbounded even though the underlying log records are gone —
128+
* stale pointers still cost memory and lengthen any read-side scan.
129+
*
130+
* <p>Mirrors admin's {@code sweepStaleIndexEntries()} so runtime's
131+
* cleanup is self-contained (does not depend on admin being deployed
132+
* against the same Redis). Both sweeps scanning the shared index
133+
* is idempotent and safe — {@code ZREMRANGEBYSCORE} is a no-op on
134+
* already-swept ranges.
135+
*
136+
* <p>Runs at 03:00 server time by default (configurable via
137+
* {@code audit.sweep.cron}). Deployments with
138+
* {@code audit.retention.days=0} skip the sweep.
139+
*
140+
* <p>Best-effort: any exception is logged at ERROR but never
141+
* propagates. Skipped on the current tick if Redis is unavailable —
142+
* the next tick retries.
143+
*
144+
* @since 0.1.25.15
145+
*/
146+
@Scheduled(cron = "${audit.sweep.cron:0 0 3 * * *}")
147+
public void sweepStaleIndexEntries() {
148+
if (retentionDays <= 0) {
149+
LOG.debug("Audit index sweep skipped — retention is indefinite");
150+
return;
151+
}
152+
try (Jedis jedis = jedisPool.getResource()) {
153+
long cutoffMillis = Instant.now().toEpochMilli()
154+
- ((long) retentionDays * 86400L * 1000L);
155+
long removedGlobal = jedis.zremrangeByScore("audit:logs:_all",
156+
Double.NEGATIVE_INFINITY, cutoffMillis);
157+
long removedTenants = 0;
158+
ScanParams params = new ScanParams().match("audit:logs:*").count(100);
159+
String cursor = ScanParams.SCAN_POINTER_START;
160+
do {
161+
ScanResult<String> scan = jedis.scan(cursor, params);
162+
for (String indexKey : scan.getResult()) {
163+
if ("audit:logs:_all".equals(indexKey)) {
164+
continue;
165+
}
166+
removedTenants += jedis.zremrangeByScore(indexKey,
167+
Double.NEGATIVE_INFINITY, cutoffMillis);
168+
}
169+
cursor = scan.getCursor();
170+
} while (!ScanParams.SCAN_POINTER_START.equals(cursor));
171+
LOG.info("Audit index sweep completed — removed {} global + {} per-tenant stale pointers "
172+
+ "older than {} ms",
173+
removedGlobal, removedTenants, cutoffMillis);
174+
} catch (Exception e) {
175+
LOG.error("Audit index sweep failed (non-fatal — next tick will retry)", e);
176+
}
177+
}
78178
}

0 commit comments

Comments
 (0)