summaryrefslogtreecommitdiffstats
path: root/controller-server
diff options
context:
space:
mode:
authorAndreas Eriksen <andreer@verizonmedia.com>2021-05-28 07:42:46 +0200
committerGitHub <noreply@github.com>2021-05-28 07:42:46 +0200
commite911924c48bf5301caf808bc091af12bbd2ab363 (patch)
tree053a407adb6951a67c61861b7dba85d9daefb368 /controller-server
parentb7c3b4cd010073f70e5ff0bf9d877278ff4da8e1 (diff)
API for granting support access and registering granted certificates (#17987)
* WIP - api for granting support access and registering granted certificates * comment out WIP test * fix access, test api * rename package * copyright headers * always include notAfter/notBefore when serializing * make double sure lists remain unmodifiable * add minimal javadoc
Diffstat (limited to 'controller-server')
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/Controller.java7
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/CuratorDb.java24
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/SupportAccessSerializer.java110
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java28
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/support/access/SupportAccess.java126
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/support/access/SupportAccessChange.java53
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/support/access/SupportAccessControl.java90
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/support/access/SupportAccessGrant.java36
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/SupportAccessSerializerTest.java169
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiTest.java75
10 files changed, 717 insertions, 1 deletions
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/Controller.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/Controller.java
index e61a376730e..038c3ad65ab 100644
--- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/Controller.java
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/Controller.java
@@ -26,6 +26,7 @@ import com.yahoo.vespa.hosted.controller.notification.NotificationsDb;
import com.yahoo.vespa.hosted.controller.persistence.CuratorDb;
import com.yahoo.vespa.hosted.controller.persistence.JobControlFlags;
import com.yahoo.vespa.hosted.controller.security.AccessControl;
+import com.yahoo.vespa.hosted.controller.support.access.SupportAccessControl;
import com.yahoo.vespa.hosted.controller.versions.ControllerVersion;
import com.yahoo.vespa.hosted.controller.versions.OsVersion;
import com.yahoo.vespa.hosted.controller.versions.OsVersionStatus;
@@ -82,6 +83,7 @@ public class Controller extends AbstractComponent {
private final SecretStore secretStore;
private final CuratorArchiveBucketDb archiveBucketDb;
private final NotificationsDb notificationsDb;
+ private final SupportAccessControl supportAccessControl;
/**
* Creates a controller
@@ -120,6 +122,7 @@ public class Controller extends AbstractComponent {
notificationsDb = new NotificationsDb(this);
this.controllerConfig = controllerConfig;
this.secretStore = secretStore;
+ this.supportAccessControl = new SupportAccessControl(this);
// Record the version of this controller
curator().writeControllerVersion(this.hostname(), ControllerVersion.CURRENT);
@@ -306,4 +309,8 @@ public class Controller extends AbstractComponent {
return notificationsDb;
}
+ public SupportAccessControl supportAccess() {
+ return supportAccessControl;
+ }
+
}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/CuratorDb.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/CuratorDb.java
index fb004938572..3736d18a01c 100644
--- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/CuratorDb.java
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/CuratorDb.java
@@ -15,6 +15,7 @@ import com.yahoo.slime.SlimeUtils;
import com.yahoo.vespa.curator.Curator;
import com.yahoo.vespa.curator.Lock;
import com.yahoo.vespa.hosted.controller.Application;
+import com.yahoo.vespa.hosted.controller.api.identifiers.DeploymentId;
import com.yahoo.vespa.hosted.controller.api.integration.archive.ArchiveBucket;
import com.yahoo.vespa.hosted.controller.api.integration.certificates.EndpointCertificateMetadata;
import com.yahoo.vespa.hosted.controller.api.integration.deployment.JobType;
@@ -30,6 +31,7 @@ import com.yahoo.vespa.hosted.controller.routing.GlobalRouting;
import com.yahoo.vespa.hosted.controller.routing.RoutingPolicy;
import com.yahoo.vespa.hosted.controller.routing.RoutingPolicyId;
import com.yahoo.vespa.hosted.controller.routing.ZoneRoutingPolicy;
+import com.yahoo.vespa.hosted.controller.support.access.SupportAccess;
import com.yahoo.vespa.hosted.controller.tenant.Tenant;
import com.yahoo.vespa.hosted.controller.versions.ControllerVersion;
import com.yahoo.vespa.hosted.controller.versions.OsVersionStatus;
@@ -91,6 +93,7 @@ public class CuratorDb {
private static final Path archiveBucketsRoot = root.append("archiveBuckets");
private static final Path changeRequestsRoot = root.append("changeRequests");
private static final Path notificationsRoot = root.append("notifications");
+ private static final Path supportAccessRoot = root.append("supportAccess");
private final NodeVersionSerializer nodeVersionSerializer = new NodeVersionSerializer();
private final VersionStatusSerializer versionStatusSerializer = new VersionStatusSerializer(nodeVersionSerializer);
@@ -214,6 +217,10 @@ public class CuratorDb {
return curator.lock(lockRoot.append("notifications").append(tenantName.value()), defaultLockTimeout);
}
+ public Lock lockSupportAccess(DeploymentId deploymentId) {
+ return curator.lock(lockRoot.append("supportAccess").append(deploymentId.dottedString()), defaultLockTimeout);
+ }
+
// -------------- Helpers ------------------------------------------
/** Try locking with a low timeout, meaning it is OK to fail lock acquisition.
@@ -595,7 +602,7 @@ public class CuratorDb {
curator.delete(changeRequestPath(changeRequest.getId()));
}
- // -------------- Notifications ---------------------------------------------------
+ // -------------- Notifications -------------------------------------------
public List<Notification> readNotifications(TenantName tenantName) {
return readSlime(notificationsPath(tenantName))
@@ -617,6 +624,17 @@ public class CuratorDb {
curator.delete(notificationsPath(tenantName));
}
+ // -------------- Endpoint Support Access ---------------------------------
+
+ public SupportAccess readSupportAccess(DeploymentId deploymentId) {
+ return readSlime(supportAccessPath(deploymentId)).map(SupportAccessSerializer::fromSlime).orElse(SupportAccess.DISALLOWED_NO_HISTORY);
+ }
+
+ /** Take lock before reading before writing */
+ public void writeSupportAccess(DeploymentId deploymentId, SupportAccess supportAccess) {
+ curator.set(supportAccessPath(deploymentId), asJson(SupportAccessSerializer.toSlime(supportAccess, true, Optional.empty())));
+ }
+
// -------------- Paths ---------------------------------------------------
private Path lockPath(TenantName tenant) {
@@ -750,4 +768,8 @@ public class CuratorDb {
return notificationsRoot.append(tenantName.value());
}
+ private static Path supportAccessPath(DeploymentId deploymentId) {
+ return supportAccessRoot.append(deploymentId.dottedString());
+ }
+
}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/SupportAccessSerializer.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/SupportAccessSerializer.java
new file mode 100644
index 00000000000..74e2bfbb471
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/SupportAccessSerializer.java
@@ -0,0 +1,110 @@
+// Copyright 2021 Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.persistence;
+
+import com.yahoo.security.X509CertificateUtils;
+import com.yahoo.slime.Cursor;
+import com.yahoo.slime.Slime;
+import com.yahoo.slime.SlimeUtils;
+import com.yahoo.vespa.hosted.controller.support.access.SupportAccess;
+import com.yahoo.vespa.hosted.controller.support.access.SupportAccessChange;
+import com.yahoo.vespa.hosted.controller.support.access.SupportAccessGrant;
+
+import java.time.Instant;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+/**
+ * (de)serializes support access status and history
+ *
+ * @author andreer
+ */
+public class SupportAccessSerializer {
+
+ // WARNING: Since there are multiple servers in a ZooKeeper cluster and they upgrade one by one
+ // (and rewrite all nodes on startup), changes to the serialized format must be made
+ // such that what is serialized on version N+1 can be read by version N:
+ // - ADDING FIELDS: Always ok
+ // - REMOVING FIELDS: Stop reading the field first. Stop writing it on a later version.
+ // - CHANGING THE FORMAT OF A FIELD: Don't do it bro.
+
+ private static final String stateFieldName = "state";
+ private static final String supportAccessFieldName = "supportAccess";
+ private static final String untilFieldName = "until";
+ private static final String byFieldName = "by";
+ private static final String historyFieldName = "history";
+ private static final String allowedStateName = "allowed";
+ private static final String disallowedStateName = "disallowed";
+ private static final String atFieldName = "at";
+ private static final String grantFieldName = "grants";
+ private static final String requestorFieldName = "requestor";
+ private static final String notBeforeFieldName = "notBefore";
+ private static final String notAfterFieldName = "notAfter";
+ private static final String certificateFieldName = "certificate";
+
+
+ public static Slime toSlime(SupportAccess supportAccess, boolean includeCertificates, Optional<Instant> withCurrentState) {
+ Slime slime = new Slime();
+ Cursor root = slime.setObject();
+
+ withCurrentState.ifPresent(now -> {
+ Cursor status = root.setObject(stateFieldName);
+ SupportAccess.CurrentStatus currentState = supportAccess.currentStatus(now);
+ status.setString(supportAccessFieldName, currentState.state().name());
+ if (currentState.state() == SupportAccess.State.ALLOWED) {
+ status.setString(untilFieldName, serializeInstant(currentState.allowedUntil().orElseThrow()));
+ status.setString(byFieldName, currentState.allowedBy().orElseThrow());
+ }
+ }
+ );
+
+ Cursor history = root.setArray(historyFieldName);
+ for (SupportAccessChange change : supportAccess.changeHistory()) {
+ Cursor historyObject = history.addObject();
+ historyObject.setString(stateFieldName, change.accessAllowedUntil().isPresent() ? allowedStateName : disallowedStateName);
+ historyObject.setString(atFieldName, serializeInstant(change.changeTime()));
+ change.accessAllowedUntil().ifPresent(allowedUntil -> historyObject.setString(untilFieldName, serializeInstant(allowedUntil)));
+ historyObject.setString(byFieldName, change.madeBy());
+ }
+
+ Cursor grants = root.setArray(grantFieldName);
+ for (SupportAccessGrant grant : supportAccess.grantHistory()) {
+ Cursor grantObject = grants.addObject();
+ grantObject.setString(requestorFieldName, grant.requestor());
+ if (includeCertificates) {
+ grantObject.setString(certificateFieldName, X509CertificateUtils.toPem(grant.certificate()));
+ }
+ grantObject.setString(notBeforeFieldName, serializeInstant(grant.certificate().getNotBefore().toInstant()));
+ grantObject.setString(notAfterFieldName, serializeInstant(grant.certificate().getNotAfter().toInstant()));
+ }
+
+ return slime;
+ }
+
+ private static String serializeInstant(Instant i) {
+ return DateTimeFormatter.ISO_INSTANT.format(i.truncatedTo(ChronoUnit.SECONDS));
+ }
+
+ public static SupportAccess fromSlime(Slime slime) {
+ List<SupportAccessGrant> grantHistory = SlimeUtils.entriesStream(slime.get().field(grantFieldName))
+ .map(inspector ->
+ new SupportAccessGrant(
+ inspector.field(requestorFieldName).asString(),
+ X509CertificateUtils.fromPem(inspector.field(certificateFieldName).asString())
+ ))
+ .collect(Collectors.toUnmodifiableList());
+
+ List<SupportAccessChange> changeHistory = SlimeUtils.entriesStream(slime.get().field(historyFieldName))
+ .map(inspector ->
+ new SupportAccessChange(
+ SlimeUtils.optionalString(inspector.field(untilFieldName)).map(Instant::parse),
+ Instant.parse(inspector.field(atFieldName).asString()),
+ inspector.field(byFieldName).asString())
+ )
+ .collect(Collectors.toUnmodifiableList());
+
+ return new SupportAccess(changeHistory, grantHistory);
+ }
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java
index 7fcf7554452..7dff745bda5 100644
--- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java
@@ -90,12 +90,14 @@ import com.yahoo.vespa.hosted.controller.deployment.Run;
import com.yahoo.vespa.hosted.controller.deployment.TestConfigSerializer;
import com.yahoo.vespa.hosted.controller.notification.Notification;
import com.yahoo.vespa.hosted.controller.notification.NotificationSource;
+import com.yahoo.vespa.hosted.controller.persistence.SupportAccessSerializer;
import com.yahoo.vespa.hosted.controller.rotation.RotationId;
import com.yahoo.vespa.hosted.controller.rotation.RotationState;
import com.yahoo.vespa.hosted.controller.rotation.RotationStatus;
import com.yahoo.vespa.hosted.controller.routing.GlobalRouting;
import com.yahoo.vespa.hosted.controller.security.AccessControlRequests;
import com.yahoo.vespa.hosted.controller.security.Credentials;
+import com.yahoo.vespa.hosted.controller.support.access.SupportAccess;
import com.yahoo.vespa.hosted.controller.tenant.AthenzTenant;
import com.yahoo.vespa.hosted.controller.tenant.CloudTenant;
import com.yahoo.vespa.hosted.controller.tenant.LastLoginInfo;
@@ -122,6 +124,7 @@ import java.security.PublicKey;
import java.time.DayOfWeek;
import java.time.Duration;
import java.time.Instant;
+import java.time.temporal.ChronoUnit;
import java.util.Arrays;
import java.util.Base64;
import java.util.Comparator;
@@ -252,6 +255,7 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler {
if (path.matches("/application/v4/tenant/{tenant}/application/{application}/instance/{instance}/environment/{environment}/region/{region}/clusters")) return clusters(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"));
if (path.matches("/application/v4/tenant/{tenant}/application/{application}/instance/{instance}/environment/{environment}/region/{region}/content/{*}")) return content(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"), path.getRest(), request);
if (path.matches("/application/v4/tenant/{tenant}/application/{application}/instance/{instance}/environment/{environment}/region/{region}/logs")) return logs(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"), request.propertyMap());
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}/instance/{instance}/environment/{environment}/region/{region}/access/support")) return supportAccess(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"), request.propertyMap());
if (path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}/metrics")) return metrics(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"));
if (path.matches("/application/v4/tenant/{tenant}/application/{application}/instance/{instance}/environment/{environment}/region/{region}/global-rotation")) return rotationStatus(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"), Optional.ofNullable(request.getProperty("endpointId")));
if (path.matches("/application/v4/tenant/{tenant}/application/{application}/instance/{instance}/environment/{environment}/region/{region}/global-rotation/override")) return getGlobalRotationOverride(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"));
@@ -301,6 +305,7 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler {
if (path.matches("/application/v4/tenant/{tenant}/application/{application}/instance/{instance}/environment/{environment}/region/{region}/reindexing")) return enableReindexing(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"), request);
if (path.matches("/application/v4/tenant/{tenant}/application/{application}/instance/{instance}/environment/{environment}/region/{region}/restart")) return restart(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"), request);
if (path.matches("/application/v4/tenant/{tenant}/application/{application}/instance/{instance}/environment/{environment}/region/{region}/suspend")) return suspend(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"), true);
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}/instance/{instance}/environment/{environment}/region/{region}/access/support")) return allowSupportAccess(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"), request);
if (path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}")) return deploy(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"), request);
if (path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}/deploy")) return deploy(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"), request); // legacy synonym of the above
if (path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}/restart")) return restart(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"), request);
@@ -332,6 +337,7 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler {
if (path.matches("/application/v4/tenant/{tenant}/application/{application}/instance/{instance}/environment/{environment}/region/{region}/reindexing")) return disableReindexing(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"), request);
if (path.matches("/application/v4/tenant/{tenant}/application/{application}/instance/{instance}/environment/{environment}/region/{region}/suspend")) return suspend(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"), false);
if (path.matches("/application/v4/tenant/{tenant}/application/{application}/instance/{instance}/environment/{environment}/region/{region}/global-rotation/override")) return setGlobalRotationOverride(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"), true, request);
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}/instance/{instance}/environment/{environment}/region/{region}/access/support")) return disallowSupportAccess(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"), request);
if (path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}")) return deactivate(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"), request);
if (path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}/global-rotation/override")) return setGlobalRotationOverride(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"), true, request);
return ErrorResponse.notFoundError("Nothing at " + path);
@@ -967,6 +973,28 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler {
};
}
+ private HttpResponse supportAccess(String tenantName, String applicationName, String instanceName, String environment, String region, Map<String, String> queryParameters) {
+ DeploymentId deployment = new DeploymentId(ApplicationId.from(tenantName, applicationName, instanceName), requireZone(environment, region));
+ SupportAccess supportAccess = controller.supportAccess().forDeployment(deployment);
+ return new SlimeJsonResponse(SupportAccessSerializer.toSlime(supportAccess, false, Optional.ofNullable(controller.clock().instant())));
+ }
+
+ // TODO support access: only let tenants (not operators!) allow access
+ // TODO support access: configurable period of access?
+ private HttpResponse allowSupportAccess(String tenantName, String applicationName, String instanceName, String environment, String region, HttpRequest request) {
+ DeploymentId deployment = new DeploymentId(ApplicationId.from(tenantName, applicationName, instanceName), requireZone(environment, region));
+ Principal principal = requireUserPrincipal(request);
+ SupportAccess allowed = controller.supportAccess().allow(deployment, Instant.now().plus(7, ChronoUnit.DAYS), principal.getName());
+ return new SlimeJsonResponse(SupportAccessSerializer.toSlime(allowed, false, Optional.ofNullable(controller.clock().instant())));
+ }
+
+ private HttpResponse disallowSupportAccess(String tenantName, String applicationName, String instanceName, String environment, String region, HttpRequest request) {
+ DeploymentId deployment = new DeploymentId(ApplicationId.from(tenantName, applicationName, instanceName), requireZone(environment, region));
+ Principal principal = requireUserPrincipal(request);
+ SupportAccess disallowed = controller.supportAccess().disallow(deployment, principal.getName());
+ return new SlimeJsonResponse(SupportAccessSerializer.toSlime(disallowed, false, Optional.ofNullable(controller.clock().instant())));
+ }
+
private HttpResponse metrics(String tenantName, String applicationName, String instanceName, String environment, String region) {
ApplicationId application = ApplicationId.from(tenantName, applicationName, instanceName);
ZoneId zone = requireZone(environment, region);
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/support/access/SupportAccess.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/support/access/SupportAccess.java
new file mode 100644
index 00000000000..a8024a2ced3
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/support/access/SupportAccess.java
@@ -0,0 +1,126 @@
+// Copyright 2021 Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.support.access;
+
+import java.time.Instant;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/** Immutable state of support access, keeping history of all changes/grants. */
+public class SupportAccess {
+
+ public static final SupportAccess DISALLOWED_NO_HISTORY = new SupportAccess(List.of(), List.of());
+
+ private final List<SupportAccessChange> changeHistory;
+ private final List<SupportAccessGrant> grantHistory;
+
+ /** public for serializer - do not use */
+ public SupportAccess(List<SupportAccessChange> changeHistory, List<SupportAccessGrant> grantHistory) {
+ this.changeHistory = Collections.unmodifiableList(changeHistory);
+ this.grantHistory = Collections.unmodifiableList(grantHistory);
+ }
+
+ public List<SupportAccessChange> changeHistory() {
+ return changeHistory;
+ }
+
+ public List<SupportAccessGrant> grantHistory() {
+ return grantHistory;
+ }
+
+ public CurrentStatus currentStatus(Instant now) {
+ Optional<SupportAccessChange> latestChange = changeHistory.stream().findFirst();
+
+ if (latestChange.isEmpty() || latestChange.get().accessAllowedUntil().isEmpty() || now.isAfter(latestChange.get().accessAllowedUntil().get()))
+ return new CurrentStatus(State.NOT_ALLOWED, Optional.empty(), Optional.empty());
+
+ return new CurrentStatus(State.ALLOWED, latestChange.get().accessAllowedUntil(), Optional.of(latestChange.get().madeBy()));
+ }
+
+ public SupportAccess withAllowedUntil(Instant until, String changedBy, Instant changeTime) {
+ if (!until.isAfter(changeTime))
+ throw new IllegalArgumentException("Support access cannot be allowed for the past");
+
+ verifyChangeOrdering(changeTime);
+ return new SupportAccess(
+ prepend(new SupportAccessChange(Optional.of(until), changeTime, changedBy), changeHistory),
+ grantHistory);
+ }
+
+ public SupportAccess withDisallowed(String changedBy, Instant changeTime) {
+ verifyChangeOrdering(changeTime);
+ return new SupportAccess(
+ prepend(new SupportAccessChange(Optional.empty(), changeTime, changedBy), changeHistory),
+ grantHistory);
+ }
+
+ public SupportAccess withGrant(SupportAccessGrant supportAccessGrant) {
+ return new SupportAccess(changeHistory, prepend(supportAccessGrant, grantHistory));
+ }
+
+ private void verifyChangeOrdering(Instant changeTime) {
+ changeHistory.stream().findFirst().ifPresent(lastChange -> {
+ if (changeTime.isBefore(lastChange.changeTime())) {
+ throw new IllegalArgumentException("Support access change cannot be dated before previous change");
+ }
+ });
+ }
+
+ private <T> List<T> prepend(T newEntry, List<T> existingEntries) {
+ return Stream.concat(Stream.of(newEntry), existingEntries.stream()) // latest change first
+ .collect(Collectors.toUnmodifiableList());
+ }
+
+ public static class CurrentStatus {
+ private final State state;
+ private final Optional<Instant> allowedUntil;
+ private final Optional<String> allowedBy;
+
+ private CurrentStatus(State state, Optional<Instant> allowedUntil, Optional<String> allowedBy) {
+ this.state = state;
+ this.allowedUntil = allowedUntil;
+ this.allowedBy = allowedBy;
+ }
+
+ public State state() {
+ return state;
+ }
+
+ public Optional<Instant> allowedUntil() {
+ return allowedUntil;
+ }
+
+ public Optional<String> allowedBy() {
+ return allowedBy;
+ }
+ }
+
+ public enum State {
+ NOT_ALLOWED,
+ ALLOWED
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ SupportAccess that = (SupportAccess) o;
+ return changeHistory.equals(that.changeHistory) && grantHistory.equals(that.grantHistory);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(changeHistory, grantHistory);
+ }
+
+ @Override
+ public String toString() {
+ return "SupportAccess{" +
+ "changeHistory=" + changeHistory +
+ ", grantHistory=" + grantHistory +
+ '}';
+ }
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/support/access/SupportAccessChange.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/support/access/SupportAccessChange.java
new file mode 100644
index 00000000000..8cb502db6ab
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/support/access/SupportAccessChange.java
@@ -0,0 +1,53 @@
+// Copyright 2021 Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.support.access;
+
+import java.time.Instant;
+import java.util.Objects;
+import java.util.Optional;
+
+/** An (immutable) change in support access, recording what change was made, when, and by whom. */
+public class SupportAccessChange {
+ private final Instant madeAt;
+ private final Optional<Instant> accessAllowedUntil;
+ private final String changedBy;
+
+ public SupportAccessChange(Optional<Instant> accessAllowedUntil, Instant changeTime, String changedBy) {
+ this.madeAt = changeTime;
+ this.accessAllowedUntil = accessAllowedUntil;
+ this.changedBy = changedBy;
+ }
+
+ public Instant changeTime() {
+ return madeAt;
+ }
+
+ public Optional<Instant> accessAllowedUntil() {
+ return accessAllowedUntil;
+ }
+
+ public String madeBy() {
+ return changedBy;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ SupportAccessChange that = (SupportAccessChange) o;
+ return madeAt.equals(that.madeAt) && accessAllowedUntil.equals(that.accessAllowedUntil) && changedBy.equals(that.changedBy);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(madeAt, accessAllowedUntil, changedBy);
+ }
+
+ @Override
+ public String toString() {
+ return "SupportAccessChange{" +
+ "madeAt=" + madeAt +
+ ", accessAllowedUntil=" + accessAllowedUntil +
+ ", changedBy='" + changedBy + '\'' +
+ '}';
+ }
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/support/access/SupportAccessControl.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/support/access/SupportAccessControl.java
new file mode 100644
index 00000000000..4a550ad3379
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/support/access/SupportAccessControl.java
@@ -0,0 +1,90 @@
+// Copyright 2021 Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.support.access;
+
+import com.yahoo.vespa.curator.Lock;
+import com.yahoo.vespa.hosted.controller.Controller;
+import com.yahoo.vespa.hosted.controller.api.identifiers.DeploymentId;
+
+import java.security.cert.X509Certificate;
+import java.time.Instant;
+import java.time.Period;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static com.yahoo.vespa.hosted.controller.support.access.SupportAccess.State.NOT_ALLOWED;
+
+/**
+ * Which application endpoints should Vespa support be allowed to access for debugging?
+ *
+ * @author andreer
+ */
+public class SupportAccessControl {
+
+ private final Controller controller;
+
+ private final java.time.Period MAX_SUPPORT_ACCESS_TIME = Period.ofDays(10);
+
+ public SupportAccessControl(Controller controller) {
+ this.controller = controller;
+ }
+
+ public SupportAccess forDeployment(DeploymentId deploymentId) {
+ return controller.curator().readSupportAccess(deploymentId);
+ }
+
+ public SupportAccess disallow(DeploymentId deployment, String by) {
+ try (Lock lock = controller.curator().lockSupportAccess(deployment)) {
+ var now = controller.clock().instant();
+ SupportAccess supportAccess = forDeployment(deployment);
+ if (supportAccess.currentStatus(now).state() == NOT_ALLOWED) {
+ throw new IllegalArgumentException("Support access is no longer allowed");
+ } else {
+ var disallowed = supportAccess.withDisallowed(by, now);
+ controller.curator().writeSupportAccess(deployment, disallowed);
+ return disallowed;
+ }
+ }
+ }
+
+ public SupportAccess allow(DeploymentId deployment, Instant until, String by) {
+ try (Lock lock = controller.curator().lockSupportAccess(deployment)) {
+ var now = controller.clock().instant();
+ if (until.isAfter(now.plus(MAX_SUPPORT_ACCESS_TIME))) {
+ throw new IllegalArgumentException("Support access cannot be allowed for more than 10 days");
+ }
+ SupportAccess allowed = forDeployment(deployment).withAllowedUntil(until, by, now);
+ controller.curator().writeSupportAccess(deployment, allowed);
+ return allowed;
+ }
+ }
+
+ public SupportAccess registerGrant(DeploymentId deployment, String by, X509Certificate certificate) {
+ try (Lock lock = controller.curator().lockSupportAccess(deployment)) {
+ var now = controller.clock().instant();
+ SupportAccess supportAccess = forDeployment(deployment);
+ if (certificate.getNotAfter().toInstant().isBefore(now)) {
+ throw new IllegalArgumentException("Support access certificate has already expired!");
+ }
+ if (certificate.getNotAfter().toInstant().isAfter(now.plus(MAX_SUPPORT_ACCESS_TIME))) {
+ throw new IllegalArgumentException("Support access certificate validity time is limited to " + MAX_SUPPORT_ACCESS_TIME);
+ }
+ if (supportAccess.currentStatus(now).state() == NOT_ALLOWED) {
+ throw new IllegalArgumentException("Support access is not currently allowed by " + deployment.toUserFriendlyString());
+ }
+ SupportAccess granted = supportAccess.withGrant(new SupportAccessGrant(by, certificate));
+ controller.curator().writeSupportAccess(deployment, granted);
+ return granted;
+ }
+ }
+
+ public List<SupportAccessGrant> activeGrantsFor(DeploymentId deployment) {
+ var now = controller.clock().instant();
+ SupportAccess supportAccess = forDeployment(deployment);
+ if (supportAccess.currentStatus(now).state() == NOT_ALLOWED) return List.of();
+
+ return supportAccess.grantHistory().stream()
+ .filter(grant -> !grant.certificate().getNotBefore().toInstant().isBefore(now))
+ .filter(grant -> !grant.certificate().getNotAfter().toInstant().isAfter(now))
+ .collect(Collectors.toUnmodifiableList());
+ }
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/support/access/SupportAccessGrant.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/support/access/SupportAccessGrant.java
new file mode 100644
index 00000000000..cdbb58675a5
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/support/access/SupportAccessGrant.java
@@ -0,0 +1,36 @@
+// Copyright 2021 Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.support.access;
+
+import java.security.cert.X509Certificate;
+import java.util.Objects;
+
+public class SupportAccessGrant {
+ private final String requestor;
+ private final X509Certificate certificate;
+
+ public SupportAccessGrant(String requestor, X509Certificate certificate) {
+ this.requestor = Objects.requireNonNull(requestor);
+ this.certificate = Objects.requireNonNull(certificate);
+ }
+
+ public String requestor() {
+ return requestor;
+ }
+
+ public X509Certificate certificate() {
+ return certificate;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ SupportAccessGrant that = (SupportAccessGrant) o;
+ return requestor.equals(that.requestor) && certificate.equals(that.certificate);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(requestor, certificate);
+ }
+}
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/SupportAccessSerializerTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/SupportAccessSerializerTest.java
new file mode 100644
index 00000000000..2a43f8cc4f3
--- /dev/null
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/SupportAccessSerializerTest.java
@@ -0,0 +1,169 @@
+// Copyright 2021 Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.persistence;
+
+import com.yahoo.security.KeyAlgorithm;
+import com.yahoo.security.KeyUtils;
+import com.yahoo.security.SignatureAlgorithm;
+import com.yahoo.security.X509CertificateBuilder;
+import com.yahoo.security.X509CertificateUtils;
+import com.yahoo.slime.JsonFormat;
+import com.yahoo.slime.SlimeUtils;
+import com.yahoo.vespa.hosted.controller.support.access.SupportAccess;
+import com.yahoo.vespa.hosted.controller.support.access.SupportAccessGrant;
+import org.intellij.lang.annotations.Language;
+import org.junit.Test;
+
+import javax.security.auth.x500.X500Principal;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.math.BigInteger;
+import java.security.cert.X509Certificate;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Optional;
+
+import static org.junit.Assert.*;
+
+public class SupportAccessSerializerTest {
+
+ private final X509Certificate cert_3_to_4 = grantCertificate(hour(3), hour(4));
+ private final X509Certificate cert_7_to_19 = grantCertificate(hour(7), hour(19));
+
+ SupportAccess supportAccessExample = SupportAccess.DISALLOWED_NO_HISTORY
+ .withAllowedUntil(hour(24), "andreer", hour(2))
+ .withGrant(new SupportAccessGrant("mortent", cert_3_to_4))
+ .withGrant(new SupportAccessGrant("mortent", cert_7_to_19))
+ .withDisallowed("andreer", hour(22))
+ .withAllowedUntil(hour(36), "andreer", hour(30));
+
+ @Language("JSON")
+ private final String expectedWithCertificates = "{\n" +
+ " \"history\": [\n" +
+ " {\n" +
+ " \"state\": \"allowed\",\n" +
+ " \"at\": \"1970-01-02T06:00:00Z\",\n" +
+ " \"until\": \"1970-01-02T12:00:00Z\",\n" +
+ " \"by\": \"andreer\"\n" +
+ " },\n" +
+ " {\n" +
+ " \"state\": \"disallowed\",\n" +
+ " \"at\": \"1970-01-01T22:00:00Z\",\n" +
+ " \"by\": \"andreer\"\n" +
+ " },\n" +
+ " {\n" +
+ " \"state\": \"allowed\",\n" +
+ " \"at\": \"1970-01-01T02:00:00Z\",\n" +
+ " \"until\": \"1970-01-02T00:00:00Z\",\n" +
+ " \"by\": \"andreer\"\n" +
+ " }\n" +
+ " ],\n" +
+ " \"grants\": [\n" +
+ " {\n" +
+ " \"requestor\": \"mortent\",\n" +
+ " \"certificate\": \"" + toPem(cert_7_to_19) + "\",\n" +
+ " \"notBefore\": \"1970-01-01T07:00:00Z\",\n" +
+ " \"notAfter\": \"1970-01-01T19:00:00Z\"\n" +
+ " },\n" +
+ " {\n" +
+ " \"requestor\": \"mortent\",\n" +
+ " \"certificate\": \"" + toPem(cert_3_to_4) + "\",\n" +
+ " \"notBefore\": \"1970-01-01T03:00:00Z\",\n" +
+ " \"notAfter\": \"1970-01-01T04:00:00Z\"\n" +
+ " }\n" +
+ " ]\n" +
+ "}\n";
+
+ public String toPem(X509Certificate cert) {
+ return X509CertificateUtils.toPem(cert).replace("\n", "\\n");
+ }
+
+ @Test
+ public void serialize_default() {
+ assertSerialized(SupportAccess.DISALLOWED_NO_HISTORY, true, Instant.EPOCH, "{\n" +
+ " \"state\": {\n" +
+ " \"supportAccess\": \"NOT_ALLOWED\"\n" +
+ " },\n" +
+ " \"history\": [\n" +
+ " ],\n" +
+ " \"grants\": [\n" +
+ " ]\n" +
+ "}\n");
+ }
+
+ @Test
+ public void serialize_with_certificates() {
+ assertSerialized(supportAccessExample, true, null, expectedWithCertificates);
+ }
+
+ @Test
+ public void serialize_with_status() {
+ assertSerialized(supportAccessExample, false, hour(32),
+ "{\n" +
+ " \"state\": {\n" +
+ " \"supportAccess\": \"ALLOWED\",\n" +
+ " \"until\": \"1970-01-02T12:00:00Z\",\n" +
+ " \"by\": \"andreer\"\n" +
+ " },\n" +
+ " \"history\": [\n" +
+ " {\n" +
+ " \"state\": \"allowed\",\n" +
+ " \"at\": \"1970-01-02T06:00:00Z\",\n" +
+ " \"until\": \"1970-01-02T12:00:00Z\",\n" +
+ " \"by\": \"andreer\"\n" +
+ " },\n" +
+ " {\n" +
+ " \"state\": \"disallowed\",\n" +
+ " \"at\": \"1970-01-01T22:00:00Z\",\n" +
+ " \"by\": \"andreer\"\n" +
+ " },\n" +
+ " {\n" +
+ " \"state\": \"allowed\",\n" +
+ " \"at\": \"1970-01-01T02:00:00Z\",\n" +
+ " \"until\": \"1970-01-02T00:00:00Z\",\n" +
+ " \"by\": \"andreer\"\n" +
+ " }\n" +
+ " ],\n" +
+ " \"grants\": [\n" +
+ " {\n" +
+ " \"requestor\": \"mortent\",\n" +
+ " \"notBefore\": \"1970-01-01T07:00:00Z\",\n" +
+ " \"notAfter\": \"1970-01-01T19:00:00Z\"\n" +
+ " },\n" +
+ " {\n" +
+ " \"requestor\": \"mortent\",\n" +
+ " \"notBefore\": \"1970-01-01T03:00:00Z\",\n" +
+ " \"notAfter\": \"1970-01-01T04:00:00Z\"\n" +
+ " }\n" +
+ " ]\n" +
+ "}\n");
+ }
+
+ @Test
+ public void deserialize() {
+ assertEquals(supportAccessExample, SupportAccessSerializer.fromSlime(SlimeUtils.jsonToSlime(expectedWithCertificates)));
+ }
+
+ private Instant hour(long h) {
+ return Instant.EPOCH.plus(h, ChronoUnit.HOURS);
+ }
+
+ private void assertSerialized(SupportAccess supportAccess, boolean includeCertificates, Instant now, String expected) {
+ var slime = SupportAccessSerializer.toSlime(supportAccess, includeCertificates, Optional.ofNullable(now));
+
+ try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+ new JsonFormat(false).encode(out, slime);
+ assertEquals(expected, out.toString());
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ static X509Certificate grantCertificate(Instant notBefore, Instant notAfter) {
+ return X509CertificateBuilder
+ .fromKeypair(
+ KeyUtils.generateKeypair(KeyAlgorithm.EC, 256), new X500Principal("CN=mysubject"),
+ notBefore, notAfter, SignatureAlgorithm.SHA256_WITH_ECDSA, BigInteger.valueOf(1))
+ .build();
+ }
+} \ No newline at end of file
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiTest.java
index 10e398ad133..47aa3e6b9d4 100644
--- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiTest.java
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiTest.java
@@ -14,6 +14,10 @@ import com.yahoo.config.provision.RegionName;
import com.yahoo.config.provision.TenantName;
import com.yahoo.config.provision.zone.RoutingMethod;
import com.yahoo.config.provision.zone.ZoneId;
+import com.yahoo.security.KeyAlgorithm;
+import com.yahoo.security.KeyUtils;
+import com.yahoo.security.SignatureAlgorithm;
+import com.yahoo.security.X509CertificateBuilder;
import com.yahoo.vespa.athenz.api.AthenzDomain;
import com.yahoo.vespa.athenz.api.AthenzIdentity;
import com.yahoo.vespa.athenz.api.AthenzPrincipal;
@@ -73,9 +77,13 @@ import com.yahoo.yolean.Exceptions;
import org.junit.Before;
import org.junit.Test;
+import javax.security.auth.x500.X500Principal;
import java.io.File;
+import java.math.BigInteger;
import java.net.URI;
+import java.security.cert.X509Certificate;
import java.time.Instant;
+import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Base64;
@@ -1487,6 +1495,73 @@ public class ApplicationApiTest extends ControllerContainerTest {
new File("deployment-without-shared-endpoints.json"));
}
+ @Test
+ public void support_access() {
+ var app = deploymentTester.newDeploymentContext(createTenantAndApplication());
+ var zone = ZoneId.from(Environment.prod, RegionName.from("us-west-1"));
+ deploymentTester.controllerTester().zoneRegistry().setRoutingMethod(ZoneApiMock.from(zone),
+ List.of(RoutingMethod.exclusive, RoutingMethod.shared));
+ ApplicationPackage applicationPackage = new ApplicationPackageBuilder()
+ .athenzIdentity(com.yahoo.config.provision.AthenzDomain.from("domain"), AthenzService.from("service"))
+ .compileVersion(RoutingController.DIRECT_ROUTING_MIN_VERSION)
+ .instances("instance1")
+ .region(zone.region().value())
+ .build();
+ app.submit(applicationPackage).deploy();
+
+ // GET support access status returns no history
+ tester.assertResponse(request("/application/v4/tenant/tenant1/application/application1/instance/instance1/environment/prod/region/us-west-1/access/support", GET)
+ .userIdentity(USER_ID),
+ "{\"state\":{\"supportAccess\":\"NOT_ALLOWED\"},\"history\":[],\"grants\":[]}", 200
+ );
+
+ // POST allowing support access adds to history
+ Instant now = tester.controller().clock().instant().truncatedTo(ChronoUnit.SECONDS);
+ String allowedResponse = "{\"state\":{\"supportAccess\":\"ALLOWED\",\"until\":\"" + serializeInstant(now.plus(7, ChronoUnit.DAYS))
+ + "\",\"by\":\"user.myuser\"},\"history\":[{\"state\":\"allowed\",\"at\":\"" + serializeInstant(now)
+ + "\",\"until\":\"" + serializeInstant(now.plus(7, ChronoUnit.DAYS))
+ + "\",\"by\":\"user.myuser\"}],\"grants\":[]}";
+ tester.assertResponse(request("/application/v4/tenant/tenant1/application/application1/instance/instance1/environment/prod/region/us-west-1/access/support", POST)
+ .userIdentity(USER_ID),
+ allowedResponse, 200
+ );
+
+ // Grant access to support user
+ X509Certificate support_cert = grantCertificate(now, now.plusSeconds(3600));
+ tester.controller().supportAccess().registerGrant(app.deploymentIdIn(zone), "user.andreer", support_cert);
+
+ // GET shows grant
+ String grantResponse = allowedResponse.replaceAll("\"grants\":\\[]",
+ "\"grants\":[{\"requestor\":\"user.andreer\",\"notBefore\":\"" + serializeInstant(now) + "\",\"notAfter\":\"" + serializeInstant(now.plusSeconds(3600)) + "\"}]");
+ tester.assertResponse(request("/application/v4/tenant/tenant1/application/application1/instance/instance1/environment/prod/region/us-west-1/access/support", GET)
+ .userIdentity(USER_ID),
+ grantResponse, 200
+ );
+
+ // DELETE removes access
+ System.out.println("grantresponse:\n"+grantResponse+"\n");
+ String disallowedResponse = grantResponse
+ .replaceAll("ALLOWED\".*?}", "NOT_ALLOWED\"}")
+ .replace("history\":[", "history\":[{\"state\":\"disallowed\",\"at\":\""+ serializeInstant(now) +"\",\"by\":\"user.myuser\"},");
+ System.out.println("disallowedResponse:\n"+disallowedResponse+"\n");
+ tester.assertResponse(request("/application/v4/tenant/tenant1/application/application1/instance/instance1/environment/prod/region/us-west-1/access/support", DELETE)
+ .userIdentity(USER_ID),
+ disallowedResponse, 200
+ );
+ }
+
+ private static String serializeInstant(Instant i) {
+ return DateTimeFormatter.ISO_INSTANT.format(i.truncatedTo(ChronoUnit.SECONDS));
+ }
+
+ static X509Certificate grantCertificate(Instant notBefore, Instant notAfter) {
+ return X509CertificateBuilder
+ .fromKeypair(
+ KeyUtils.generateKeypair(KeyAlgorithm.EC, 256), new X500Principal("CN=mysubject"),
+ notBefore, notAfter, SignatureAlgorithm.SHA256_WITH_ECDSA, BigInteger.valueOf(1))
+ .build();
+ }
+
private MultiPartStreamer createApplicationDeployData(ApplicationPackage applicationPackage, boolean deployDirectly) {
return createApplicationDeployData(Optional.of(applicationPackage), deployDirectly);
}