diff options
author | Andreas Eriksen <andreer@verizonmedia.com> | 2021-05-28 07:42:46 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-05-28 07:42:46 +0200 |
commit | e911924c48bf5301caf808bc091af12bbd2ab363 (patch) | |
tree | 053a407adb6951a67c61861b7dba85d9daefb368 /controller-server | |
parent | b7c3b4cd010073f70e5ff0bf9d877278ff4da8e1 (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')
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); } |