package dev.ltms.bridged.auth; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.time.Instant; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; /** * Append-only record of privileged actions (CB-505). * *
Writes JSON lines to a dedicated {@code audit} logger — its own appender, separate from the * chatty app log — so the trail stays greppable and can later be shipped without dragging debug * noise along. * *
Message content is never recorded. This bridge carries the user's source * code, diffs, and prompts; an audit trail that quietly accumulated them would be a transcript * archive wearing a security control's clothing. Records carry who / what / against what / * outcome and correlation ids only. */ public final class AuditLog { private static final Logger AUDIT = LoggerFactory.getLogger("audit"); private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSXXX").withZone(ZoneOffset.UTC); private AuditLog() { } /** Record an allowed action. */ public static void allowed(Principal caller, Authz.Action action, String target) { write(caller, action, target, "allowed", null); } /** Record a refused action and why. */ public static void denied(Principal caller, Authz.Action action, String target, String reason) { write(caller, action, target, "denied", reason); } /** Record an action that was authorized but then failed downstream (guard, timeout, herdr). */ public static void failed(Principal caller, Authz.Action action, String target, String reason) { write(caller, action, target, "failed", reason); } private static void write(Principal caller, Authz.Action action, String target, String outcome, String reason) { Principal c = caller != null ? caller : Principal.anonymous(); StringBuilder sb = new StringBuilder(200); // The timestamp is built here rather than by the appender pattern: a pattern that wrapped // literal braces around the message collides with logback's own variable substitution. sb.append("{\"ts\":\"").append(TS.format(Instant.now())).append('"') .append(",\"role\":\"").append(c.role()).append('"') .append(",\"actor\":\"").append(esc(c.describe())).append('"') .append(",\"pid\":").append(c.pid()) .append(",\"action\":\"").append(action).append('"') .append(",\"target\":").append(target == null ? "null" : '"' + esc(target) + '"') .append(",\"outcome\":\"").append(outcome).append('"'); if (reason != null) { sb.append(",\"reason\":\"").append(esc(reason)).append('"'); } sb.append('}'); // The appender supplies the timestamp, so it cannot disagree with the app log's clock. AUDIT.info(sb.toString()); } /** Minimal JSON string escaping — these values are ids and short reasons, never free text. */ private static String esc(String s) { return s.replace("\\", "\\\\").replace("\"", "\\\"") .replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t"); } }