summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMartin Polden <mpolden@mpolden.no>2022-08-23 13:16:15 +0200
committerMartin Polden <mpolden@mpolden.no>2022-08-24 11:17:29 +0200
commitcb7cb28a99dbf02afff112065612e4dccb0dc978 (patch)
tree9671ac0fac9090189d8a51dd8c9536d63ee4753b
parent26d0b997cc573bac2a1d7eda7a2494449452e121 (diff)
Add client to audit log entry
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/auditlog/AuditLog.java59
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/auditlog/AuditLogger.java21
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/AuditLogSerializer.java52
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/AuditLogResponse.java1
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/AuditLogSerializerTest.java31
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/controller/responses/auditlog.json2
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"