diff options
author | Martin Polden <mpolden@mpolden.no> | 2022-08-23 13:16:15 +0200 |
---|---|---|
committer | Martin Polden <mpolden@mpolden.no> | 2022-08-24 11:17:29 +0200 |
commit | cb7cb28a99dbf02afff112065612e4dccb0dc978 (patch) | |
tree | 9671ac0fac9090189d8a51dd8c9536d63ee4753b | |
parent | 26d0b997cc573bac2a1d7eda7a2494449452e121 (diff) |
Add client to audit log entry
6 files changed, 109 insertions, 57 deletions
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/auditlog/AuditLog.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/auditlog/AuditLog.java index 6d011438b10..aa6e3b0c44d 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/auditlog/AuditLog.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/auditlog/AuditLog.java @@ -1,8 +1,6 @@ // Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. package com.yahoo.vespa.hosted.controller.auditlog; -import com.google.common.collect.Ordering; - import java.time.Instant; import java.util.ArrayList; import java.util.Comparator; @@ -14,17 +12,17 @@ import java.util.Optional; * This represents the audit log of a hosted Vespa system. The audit log contains manual actions performed through * operator APIs served by the controller. * + * Entries of the audit log are sorted by their timestamp, in descending order. + * * @author mpolden */ -public class AuditLog { +public record AuditLog(List<Entry> entries) { public static final AuditLog empty = new AuditLog(List.of()); - private final List<Entry> entries; - /** DO NOT USE. Public for serialization purposes */ public AuditLog(List<Entry> entries) { - this.entries = Ordering.natural().immutableSortedCopy(entries); + this.entries = Objects.requireNonNull(entries).stream().sorted().toList(); } /** Returns a new audit log without entries older than given instant */ @@ -34,7 +32,7 @@ public class AuditLog { return new AuditLog(entries); } - /** Returns an new audit log with given entry added */ + /** Returns copy of this with given entry added */ public AuditLog with(Entry entry) { List<Entry> entries = new ArrayList<>(this.entries); entries.add(entry); @@ -47,29 +45,20 @@ public class AuditLog { return new AuditLog(entries.subList(0, n)); } - /** Returns all entries in this. Entries are sorted descendingly by their timestamp */ - public List<Entry> entries() { - return entries; - } - /** An entry in the audit log. This describes an HTTP request */ - public static class Entry implements Comparable<Entry> { + public record Entry(Instant at, String principal, Method method, String resource, Optional<String> data, + Client client) implements Comparable<Entry> { private final static int maxDataLength = 1024; private final static Comparator<Entry> comparator = Comparator.comparing(Entry::at).reversed(); - private final Instant at; - private final String principal; - private final Method method; - private final String resource; - private final Optional<String> data; - - public Entry(Instant at, String principal, Method method, String resource, byte[] data) { - this.at = Objects.requireNonNull(at, "at must be non-null"); - this.principal = Objects.requireNonNull(principal, "principal must be non-null"); - this.method = Objects.requireNonNull(method, "method must be non-null"); - this.resource = Objects.requireNonNull(resource, "resource must be non-null"); - this.data = sanitize(data); + public Entry(Instant at, Client client, String principal, Method method, String resource, byte[] data) { + this(Objects.requireNonNull(at, "at must be non-null"), + Objects.requireNonNull(principal, "principal must be non-null"), + Objects.requireNonNull(method, "method must be non-null"), + Objects.requireNonNull(resource, "resource must be non-null"), + sanitize(data), + Objects.requireNonNull(client, "client must be non-null")); } /** Time of the request */ @@ -77,6 +66,14 @@ public class AuditLog { return at; } + /** + * The client that performed this request. This may be based on user-controlled input, e.g. User-Agent header + * and is thus not guaranteed to be accurate. + */ + public Client client() { + return client; + } + /** The principal performing the request */ public String principal() { return principal; @@ -110,6 +107,18 @@ public class AuditLog { DELETE } + /** Known clients of the audit log */ + public enum Client { + /** The Vespa Cloud Console */ + console, + /** Vespa CLI */ + cli, + /** Operator tools */ + hv, + /** Other clients, e.g. curl */ + other, + } + private static Optional<String> sanitize(byte[] data) { StringBuilder sb = new StringBuilder(); for (byte b : data) { diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/auditlog/AuditLogger.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/auditlog/AuditLogger.java index b6782767386..033cd0a52c9 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/auditlog/AuditLogger.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/auditlog/AuditLogger.java @@ -2,6 +2,7 @@ package com.yahoo.vespa.hosted.controller.auditlog; import com.yahoo.container.jdisc.HttpRequest; +import com.yahoo.jdisc.http.HttpHeaders; import com.yahoo.transaction.Mutex; import com.yahoo.vespa.hosted.controller.persistence.CuratorDb; @@ -66,9 +67,10 @@ public class AuditLogger { throw new UncheckedIOException(e); } + AuditLog.Entry.Client client = parseClient(request); Instant now = clock.instant(); - AuditLog.Entry entry = new AuditLog.Entry(now, principal.getName(), method.get(), pathAndQueryOf(request.getUri()), - data); + AuditLog.Entry entry = new AuditLog.Entry(now, client, principal.getName(), method.get(), + pathAndQueryOf(request.getUri()), data); try (Mutex lock = db.lockAuditLog()) { AuditLog auditLog = db.readAuditLog() .pruneBefore(now.minus(entryTtl)) @@ -81,6 +83,21 @@ public class AuditLogger { return new HttpRequest(request.getJDiscRequest(), new ByteArrayInputStream(data), request.propertyMap()); } + private static AuditLog.Entry.Client parseClient(HttpRequest request) { + String userAgent = request.getHeader(HttpHeaders.Names.USER_AGENT); + if (userAgent != null) { + if (userAgent.startsWith("Vespa CLI/")) { + return AuditLog.Entry.Client.cli; + } else if (userAgent.startsWith("Vespa Hosted Client ")) { + return AuditLog.Entry.Client.hv; + } + } + if (request.getPort() == 443) { + return AuditLog.Entry.Client.console; + } + return AuditLog.Entry.Client.other; + } + /** Returns the auditable method of given request, if any */ private static Optional<AuditLog.Entry.Method> auditableMethod(HttpRequest request) { try { diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/AuditLogSerializer.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/AuditLogSerializer.java index 38b9d994a6d..92be728afc8 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/AuditLogSerializer.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/AuditLogSerializer.java @@ -32,6 +32,7 @@ public class AuditLogSerializer { private static final String methodField = "method"; private static final String resourceField = "resource"; private static final String dataField = "data"; + private static final String clientField = "client"; public Slime toSlime(AuditLog log) { Slime slime = new Slime(); @@ -40,6 +41,7 @@ public class AuditLogSerializer { log.entries().forEach(entry -> { Cursor entryObject = entryArray.addObject(); entryObject.setLong(atField, entry.at().toEpochMilli()); + entryObject.setString(clientField, asString(entry.client())); entryObject.setString(principalField, entry.principal()); entryObject.setString(methodField, asString(entry.method())); entryObject.setString(resourceField, entry.resource()); @@ -47,13 +49,15 @@ public class AuditLogSerializer { }); return slime; } - public AuditLog fromSlime(Slime slime) { List<AuditLog.Entry> entries = new ArrayList<>(); Cursor root = slime.get(); root.field(entriesField).traverse((ArrayTraverser) (i, entryObject) -> { entries.add(new AuditLog.Entry( SlimeUtils.instant(entryObject.field(atField)), + SlimeUtils.optionalString(entryObject.field(clientField)) + .map(AuditLogSerializer::clientFrom) + .orElse(AuditLog.Entry.Client.other), entryObject.field(principalField).asString(), methodFrom(entryObject.field(methodField)), entryObject.field(resourceField).asString(), @@ -66,23 +70,41 @@ public class AuditLogSerializer { } private static String asString(AuditLog.Entry.Method method) { - switch (method) { - case POST: return "POST"; - case PATCH: return "PATCH"; - case PUT: return "PUT"; - case DELETE: return "DELETE"; - default: throw new IllegalArgumentException("No serialization defined for method " + method); - } + return switch (method) { + case POST -> "POST"; + case PATCH -> "PATCH"; + case PUT -> "PUT"; + case DELETE -> "DELETE"; + }; } private static AuditLog.Entry.Method methodFrom(Inspector field) { - switch (field.asString()) { - case "POST": return AuditLog.Entry.Method.POST; - case "PATCH": return AuditLog.Entry.Method.PATCH; - case "PUT": return AuditLog.Entry.Method.PUT; - case "DELETE": return AuditLog.Entry.Method.DELETE; - default: throw new IllegalArgumentException("Unknown serialized value '" + field.asString() + "'"); - } + return switch (field.asString()) { + case "POST" -> AuditLog.Entry.Method.POST; + case "PATCH" -> AuditLog.Entry.Method.PATCH; + case "PUT" -> AuditLog.Entry.Method.PUT; + case "DELETE" -> AuditLog.Entry.Method.DELETE; + default -> throw new IllegalArgumentException("Unknown serialized value '" + field.asString() + "'"); + }; + } + + private static String asString(AuditLog.Entry.Client client) { + return switch (client) { + case console -> "console"; + case cli -> "cli"; + case hv -> "hv"; + case other -> "other"; + }; + } + + private static AuditLog.Entry.Client clientFrom(String s) { + return switch (s) { + case "console" -> AuditLog.Entry.Client.console; + case "cli" -> AuditLog.Entry.Client.cli; + case "hv" -> AuditLog.Entry.Client.hv; + case "other" -> AuditLog.Entry.Client.other; + default -> throw new IllegalArgumentException("Unknown serialized value '" + s + "'"); + }; } } diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/AuditLogResponse.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/AuditLogResponse.java index c3155406194..f46806743e9 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/AuditLogResponse.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/AuditLogResponse.java @@ -22,6 +22,7 @@ public class AuditLogResponse extends SlimeJsonResponse { log.entries().forEach(entry -> { Cursor entryObject = entryArray.addObject(); entryObject.setString("time", entry.at().toString()); + entryObject.setString("client", entry.client().name()); entryObject.setString("user", entry.principal()); entryObject.setString("method", entry.method().name()); entryObject.setString("resource", entry.resource()); diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/AuditLogSerializerTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/AuditLogSerializerTest.java index 4508372738f..50e12c43829 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/AuditLogSerializerTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/AuditLogSerializerTest.java @@ -26,21 +26,21 @@ public class AuditLogSerializerTest { Instant i4 = i1.minus(Duration.ofHours(3)); AuditLog log = new AuditLog(List.of( - new AuditLog.Entry(i1, "bar", AuditLog.Entry.Method.POST, - "/bar/baz/", - "0".repeat(2048).getBytes(StandardCharsets.UTF_8)), - new AuditLog.Entry(i2, "foo", AuditLog.Entry.Method.POST, - "/foo/bar/", - "{\"foo\":\"bar\"}".getBytes(StandardCharsets.UTF_8)), - new AuditLog.Entry(i3, "baz", AuditLog.Entry.Method.POST, - "/foo/baz/", - new byte[0]), - new AuditLog.Entry(i4, "baz", AuditLog.Entry.Method.POST, - "/foo/baz/", - "000\ufdff\ufeff\uffff000".getBytes(StandardCharsets.UTF_8)), // non-ascii - new AuditLog.Entry(i4, "quux", AuditLog.Entry.Method.POST, - "/foo/quux/", - new byte[]{(byte) 0xDE, (byte) 0xAD, (byte) 0xBE, (byte) 0xEF}) // garbage + new AuditLog.Entry(i1, AuditLog.Entry.Client.other, "bar", AuditLog.Entry.Method.POST, + "/bar/baz/", + "0".repeat(2048).getBytes(StandardCharsets.UTF_8)), + new AuditLog.Entry(i2, AuditLog.Entry.Client.other, "foo", AuditLog.Entry.Method.POST, + "/foo/bar/", + "{\"foo\":\"bar\"}".getBytes(StandardCharsets.UTF_8)), + new AuditLog.Entry(i3, AuditLog.Entry.Client.hv, "baz", AuditLog.Entry.Method.POST, + "/foo/baz/", + new byte[0]), + new AuditLog.Entry(i4, AuditLog.Entry.Client.console, "baz", AuditLog.Entry.Method.POST, + "/foo/baz/", + "000\ufdff\ufeff\uffff000".getBytes(StandardCharsets.UTF_8)), // non-ascii + new AuditLog.Entry(i4, AuditLog.Entry.Client.cli, "quux", AuditLog.Entry.Method.POST, + "/foo/quux/", + new byte[]{(byte) 0xDE, (byte) 0xAD, (byte) 0xBE, (byte) 0xEF}) // garbage )); AuditLogSerializer serializer = new AuditLogSerializer(); @@ -52,6 +52,7 @@ public class AuditLogSerializerTest { AuditLog.Entry serializedEntry = serialized.entries().get(i); assertEquals(entry.at().truncatedTo(MILLIS), serializedEntry.at()); + assertEquals(entry.client(), serializedEntry.client()); assertEquals(entry.principal(), serializedEntry.principal()); assertEquals(entry.method(), serializedEntry.method()); assertEquals(entry.resource(), serializedEntry.resource()); diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/controller/responses/auditlog.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/controller/responses/auditlog.json index ab5bda78db0..b04e34daaa9 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/controller/responses/auditlog.json +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/controller/responses/auditlog.json @@ -2,6 +2,7 @@ "entries": [ { "time": "2019-03-01T14:13:14Z", + "client": "other", "user": "operator2", "method": "POST", "resource": "/controller/v1/jobs/upgrader/confidence/6.42", @@ -9,6 +10,7 @@ }, { "time": "2019-03-01T12:13:14Z", + "client": "other", "user": "operator1", "method": "POST", "resource": "/controller/v1/maintenance/inactive/DeploymentExpirer" |