Browse Source

Audit log bulk update of API keys (#88942)

This PR adds a new audit trail event for when API keys are updated in
bulk.

Relates: #88758
Nikolaj Volgushev 3 years ago
parent
commit
d01dd395bb

+ 2 - 0
x-pack/docs/en/rest-api/security.asciidoc

@@ -71,6 +71,7 @@ without requiring basic authentication:
 * <<security-api-grant-api-key,Grant API key>>
 * <<security-api-query-api-key,Query API key>>
 * <<security-api-update-api-key,Update API key>>
+* <<security-api-bulk-update-api-keys,Bulk update API keys>>
 
 [discrete]
 [[security-user-apis]]
@@ -190,6 +191,7 @@ include::security/oidc-authenticate-api.asciidoc[]
 include::security/oidc-logout-api.asciidoc[]
 include::security/query-api-key.asciidoc[]
 include::security/update-api-key.asciidoc[]
+include::security/bulk-update-api-keys.asciidoc[]
 include::security/saml-prepare-authentication-api.asciidoc[]
 include::security/saml-authenticate-api.asciidoc[]
 include::security/saml-logout-api.asciidoc[]

+ 5 - 0
x-pack/docs/en/rest-api/security/bulk-update-api-keys.asciidoc

@@ -0,0 +1,5 @@
+[role="xpack"]
+[[security-api-bulk-update-api-keys]]
+=== Bulk update API keys API
+
+coming::[8.5.0]

+ 34 - 2
x-pack/docs/en/security/auditing/event-types.asciidoc

@@ -258,6 +258,32 @@ event action.
 "tags":["dev","staging"]}}}}}
 ====
 
+[[event-change-apikeys]]
+`change_apikeys`::
+Logged when the <<security-api-bulk-update-api-keys,bulk update API keys>> API is
+invoked to update the attributes of multiple existing API keys.
++
+You must include the `security_config_change` event type to audit the related
+event action.
++
+.Example
+[%collapsible%open]
+====
+[source,js]
+{"type":"audit","timestamp":"2020-12-31T00:33:52,521+0200","node.id":
+"9clhpgjJRR-iKzOw20xBNQ","event.type":"security_config_change",
+"event.action":"change_apikeys","request.id":"9FteCmovTzWHVI-9Gpa_vQ",
+"change":{"apikeys":
+{"ids":["zcwN3YEBBmnjw-K-hW5_","j7c0WYIBqecB5CbVR6Oq"],"role_descriptors":
+[{"cluster":["monitor","manage_ilm"],"indices":[{"names":["index-a*"],"privileges":
+["read","maintenance"]},{"names":["in*","alias*"],"privileges":["read"],
+"field_security":{"grant":["field1*","@timestamp"],"except":["field11"]}}],
+"applications":[],"run_as":[]},{"cluster":["all"],"indices":[{"names":
+["index-b*"],"privileges":["all"]}],"applications":[],"run_as":[]}],
+"metadata":{"application":"my-application","environment":{"level":1,
+"tags":["dev","staging"]}}}}}
+====
+
 [[event-delete-privileges]]
 `delete_privileges`::
 Logged when the
@@ -563,7 +589,7 @@ the `event.action` attribute takes one of the following values:
 `put_user`, `change_password`, `put_role`, `put_role_mapping`,
 `change_enable_user`, `change_disable_user`, `put_privileges`, `create_apikey`,
 `delete_user`, `delete_role`, `delete_role_mapping`, `invalidate_apikeys`,
-`delete_privileges`, or `change_apikey`.
+`delete_privileges`, `change_apikey`, or `change_apikeys`.
 
 `request.id`      ::    A synthetic identifier that can be used to correlate the events
                         associated with a particular REST request.
@@ -653,7 +679,8 @@ ones):
 The events with the `event.type` attribute equal to `security_config_change` have one of the following
 `event.action` attribute values: `put_user`, `change_password`, `put_role`, `put_role_mapping`,
 `change_enable_user`, `change_disable_user`, `put_privileges`, `create_apikey`, `delete_user`,
-`delete_role`, `delete_role_mapping`, `invalidate_apikeys`, `delete_privileges`, or `change_apikey`.
+`delete_role`, `delete_role_mapping`, `invalidate_apikeys`, `delete_privileges`, `change_apikey`,
+or `change_apikeys`.
 
 These events also have *one* of the following extra attributes (in addition to the common
 ones), which is specific to the `event.type` attribute. The attribute's value is a nested JSON object:
@@ -789,6 +816,11 @@ a `name` or `expiration`.
 <boolean>, "user":{"name": <string>, "realm": <string>}}`
 ----
 // NOTCONSOLE
++
+The object for a bulk API key update will differ in that it will not
+include `name`, `owned_by_authenticated_user`, or `user`. Instead, it
+may include `metadata` and `role_descriptors`, which have the same
+schemas as the fields in the `apikey` config object above.
 
 `service_token`       ::   An object like:
 +

+ 35 - 6
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/audit/logfile/LoggingAuditTrail.java

@@ -43,6 +43,9 @@ import org.elasticsearch.xcontent.XContentBuilder;
 import org.elasticsearch.xcontent.json.JsonStringEncoder;
 import org.elasticsearch.xcontent.json.JsonXContent;
 import org.elasticsearch.xpack.core.security.action.Grant;
+import org.elasticsearch.xpack.core.security.action.apikey.BaseUpdateApiKeyRequest;
+import org.elasticsearch.xpack.core.security.action.apikey.BulkUpdateApiKeyAction;
+import org.elasticsearch.xpack.core.security.action.apikey.BulkUpdateApiKeyRequest;
 import org.elasticsearch.xpack.core.security.action.apikey.CreateApiKeyAction;
 import org.elasticsearch.xpack.core.security.action.apikey.CreateApiKeyRequest;
 import org.elasticsearch.xpack.core.security.action.apikey.GrantApiKeyAction;
@@ -290,7 +293,8 @@ public class LoggingAuditTrail implements AuditTrail, ClusterStateListener {
         ActivateProfileAction.NAME,
         UpdateProfileDataAction.NAME,
         SetProfileEnabledAction.NAME,
-        UpdateApiKeyAction.NAME
+        UpdateApiKeyAction.NAME,
+        BulkUpdateApiKeyAction.NAME
     );
     private static final String FILTER_POLICY_PREFIX = setting("audit.logfile.events.ignore_filters.");
     // because of the default wildcard value (*) for the field filter, a policy with
@@ -753,6 +757,9 @@ public class LoggingAuditTrail implements AuditTrail, ClusterStateListener {
                 } else if (msg instanceof final UpdateApiKeyRequest updateApiKeyRequest) {
                     assert UpdateApiKeyAction.NAME.equals(action);
                     securityChangeLogEntryBuilder(requestId).withRequestBody(updateApiKeyRequest).build();
+                } else if (msg instanceof final BulkUpdateApiKeyRequest bulkUpdateApiKeyRequest) {
+                    assert BulkUpdateApiKeyAction.NAME.equals(action);
+                    securityChangeLogEntryBuilder(requestId).withRequestBody(bulkUpdateApiKeyRequest).build();
                 } else {
                     throw new IllegalStateException(
                         "Unknown message class type ["
@@ -1231,6 +1238,16 @@ public class LoggingAuditTrail implements AuditTrail, ClusterStateListener {
             return this;
         }
 
+        LogEntryBuilder withRequestBody(final BulkUpdateApiKeyRequest bulkUpdateApiKeyRequest) throws IOException {
+            logEntry.with(EVENT_ACTION_FIELD_NAME, "change_apikeys");
+            XContentBuilder builder = JsonXContent.contentBuilder().humanReadable(true);
+            builder.startObject();
+            withRequestBody(builder, bulkUpdateApiKeyRequest);
+            builder.endObject();
+            logEntry.with(CHANGE_CONFIG_FIELD_NAME, Strings.toString(builder));
+            return this;
+        }
+
         private void withRequestBody(XContentBuilder builder, CreateApiKeyRequest createApiKeyRequest) throws IOException {
             TimeValue expiration = createApiKeyRequest.getExpiration();
             builder.startObject("apikey")
@@ -1250,19 +1267,31 @@ public class LoggingAuditTrail implements AuditTrail, ClusterStateListener {
 
         private void withRequestBody(final XContentBuilder builder, final UpdateApiKeyRequest updateApiKeyRequest) throws IOException {
             builder.startObject("apikey").field("id", updateApiKeyRequest.getId());
-            if (updateApiKeyRequest.getRoleDescriptors() != null) {
+            withBaseUpdateApiKeyFields(builder, updateApiKeyRequest);
+            builder.endObject();
+        }
+
+        private void withRequestBody(final XContentBuilder builder, final BulkUpdateApiKeyRequest bulkUpdateApiKeyRequest)
+            throws IOException {
+            builder.startObject("apikeys").stringListField("ids", bulkUpdateApiKeyRequest.getIds());
+            withBaseUpdateApiKeyFields(builder, bulkUpdateApiKeyRequest);
+            builder.endObject();
+        }
+
+        private void withBaseUpdateApiKeyFields(final XContentBuilder builder, final BaseUpdateApiKeyRequest baseUpdateApiKeyRequest)
+            throws IOException {
+            if (baseUpdateApiKeyRequest.getRoleDescriptors() != null) {
                 builder.startArray("role_descriptors");
-                for (RoleDescriptor roleDescriptor : updateApiKeyRequest.getRoleDescriptors()) {
+                for (RoleDescriptor roleDescriptor : baseUpdateApiKeyRequest.getRoleDescriptors()) {
                     withRoleDescriptor(builder, roleDescriptor);
                 }
                 builder.endArray();
             }
-            if (updateApiKeyRequest.getMetadata() != null) {
+            if (baseUpdateApiKeyRequest.getMetadata() != null) {
                 // Include in entry even if metadata is empty. It's meaningful to track an empty metadata request parameter
                 // because it replaces any metadata previously associated with the API key
-                builder.field("metadata", updateApiKeyRequest.getMetadata());
+                builder.field("metadata", baseUpdateApiKeyRequest.getMetadata());
             }
-            builder.endObject();
         }
 
         private void withRoleDescriptor(XContentBuilder builder, RoleDescriptor roleDescriptor) throws IOException {

+ 35 - 0
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/audit/logfile/LoggingAuditTrailTests.java

@@ -46,6 +46,8 @@ import org.elasticsearch.xcontent.NamedXContentRegistry;
 import org.elasticsearch.xcontent.XContentBuilder;
 import org.elasticsearch.xcontent.XContentType;
 import org.elasticsearch.xpack.core.XPackSettings;
+import org.elasticsearch.xpack.core.security.action.apikey.BulkUpdateApiKeyAction;
+import org.elasticsearch.xpack.core.security.action.apikey.BulkUpdateApiKeyRequest;
 import org.elasticsearch.xpack.core.security.action.apikey.CreateApiKeyAction;
 import org.elasticsearch.xpack.core.security.action.apikey.CreateApiKeyRequest;
 import org.elasticsearch.xpack.core.security.action.apikey.GrantApiKeyAction;
@@ -646,6 +648,39 @@ public class LoggingAuditTrailTests extends ESTestCase {
         // clear log
         CapturingLogger.output(logger.getName(), Level.INFO).clear();
 
+        final List<String> keyIds = randomList(1, 5, () -> randomAlphaOfLength(10));
+        final var bulkUpdateApiKeyRequest = new BulkUpdateApiKeyRequest(
+            keyIds,
+            randomBoolean() ? null : keyRoleDescriptors,
+            metadataWithSerialization.metadata()
+        );
+        auditTrail.accessGranted(requestId, authentication, BulkUpdateApiKeyAction.NAME, bulkUpdateApiKeyRequest, authorizationInfo);
+        final var expectedBulkUpdateKeyAuditEventString = """
+            "change":{"apikeys":{"ids":[%s]%s%s}}\
+            """.formatted(
+            bulkUpdateApiKeyRequest.getIds().stream().map("\"%s\""::formatted).collect(Collectors.joining(",")),
+            bulkUpdateApiKeyRequest.getRoleDescriptors() == null ? "" : "," + roleDescriptorsStringBuilder,
+            bulkUpdateApiKeyRequest.getMetadata() == null ? "" : ",\"metadata\":%s".formatted(metadataWithSerialization.serialization())
+        );
+        output = CapturingLogger.output(logger.getName(), Level.INFO);
+        assertThat(output.size(), is(2));
+        String generatedBulkUpdateKeyAuditEventString = output.get(1);
+        assertThat(generatedBulkUpdateKeyAuditEventString, containsString(expectedBulkUpdateKeyAuditEventString));
+        generatedBulkUpdateKeyAuditEventString = generatedBulkUpdateKeyAuditEventString.replace(
+            ", " + expectedBulkUpdateKeyAuditEventString,
+            ""
+        );
+        checkedFields = new MapBuilder<>(commonFields);
+        checkedFields.remove(LoggingAuditTrail.ORIGIN_ADDRESS_FIELD_NAME);
+        checkedFields.remove(LoggingAuditTrail.ORIGIN_TYPE_FIELD_NAME);
+        checkedFields.put("type", "audit")
+            .put(LoggingAuditTrail.EVENT_TYPE_FIELD_NAME, "security_config_change")
+            .put(LoggingAuditTrail.EVENT_ACTION_FIELD_NAME, "change_apikeys")
+            .put(LoggingAuditTrail.REQUEST_ID_FIELD_NAME, requestId);
+        assertMsg(generatedBulkUpdateKeyAuditEventString, checkedFields.map());
+        // clear log
+        CapturingLogger.output(logger.getName(), Level.INFO).clear();
+
         GrantApiKeyRequest grantApiKeyRequest = new GrantApiKeyRequest();
         grantApiKeyRequest.setRefreshPolicy(randomFrom(WriteRequest.RefreshPolicy.values()));
         grantApiKeyRequest.getGrant().setType(randomFrom(randomAlphaOfLength(8), null));