19cdf8dc9f
The first cut spliced the timestamp on via a logback pattern:
{"ts":"%d{...}",%replace(%msg){'^\{',''}%n
Logback's variable substitution chokes on the literal braces
("All tokens consumed but was expecting }"), so the encoder failed to
configure. Caught by running the real jar and noticing logback had dumped its
internal status — which it only does when something failed to parse. The build
was green throughout: nothing asserted the audit trail was machine-readable.
AuditLog now emits the complete object including its own ISO-8601 "ts", and the
appender pattern is a bare %msg. Adds AuditLogTest, which parses each emitted
line with Jackson (so a malformed record fails the build) and pins that hostile
ids cannot escape their field to forge a second record.
311 tests green; logback now configures with zero internal errors.
73 lines
3.2 KiB
Java
73 lines
3.2 KiB
Java
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).
|
|
*
|
|
* <p>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.
|
|
*
|
|
* <p><strong>Message content is never recorded.</strong> 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 <em>who / what / against what /
|
|
* outcome</em> 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");
|
|
}
|
|
}
|