diff options
author | Morten Tokle <mortent@verizonmedia.com> | 2021-06-03 11:49:26 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-06-03 11:49:26 +0200 |
commit | c4abcf758d42eea13745edcbfdee543d61b79568 (patch) | |
tree | f59e9427b5e6fff9e748fce75effb0ad33d8d096 /controller-server | |
parent | 5bf38bbdda250c5fdac68a1f8d989fb303702715 (diff) | |
parent | d4a0b5486f49a24f2c002a813cdeba3674ce21ad (diff) |
Merge pull request #18089 from vespa-engine/mortent/request-dp-operator-access
Request dataplane operator access
Diffstat (limited to 'controller-server')
8 files changed, 160 insertions, 7 deletions
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/ApplicationController.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/ApplicationController.java index 0f9188d1f65..cb3c84f5bd1 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/ApplicationController.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/ApplicationController.java @@ -63,6 +63,7 @@ import com.yahoo.vespa.hosted.controller.notification.NotificationSource; import com.yahoo.vespa.hosted.controller.persistence.CuratorDb; import com.yahoo.vespa.hosted.controller.security.AccessControl; import com.yahoo.vespa.hosted.controller.security.Credentials; +import com.yahoo.vespa.hosted.controller.support.access.SupportAccessGrant; import com.yahoo.vespa.hosted.controller.tenant.AthenzTenant; import com.yahoo.vespa.hosted.controller.tenant.CloudTenant; import com.yahoo.vespa.hosted.controller.tenant.Tenant; @@ -70,6 +71,7 @@ import com.yahoo.vespa.hosted.controller.versions.VespaVersion; import com.yahoo.yolean.Exceptions; import java.security.Principal; +import java.security.cert.X509Certificate; import java.time.Clock; import java.time.Duration; import java.time.Instant; @@ -88,6 +90,7 @@ import java.util.function.Consumer; import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Collectors; +import java.util.stream.Stream; import static com.yahoo.vespa.hosted.controller.api.integration.configserver.Node.State.active; import static com.yahoo.vespa.hosted.controller.api.integration.configserver.Node.State.reserved; @@ -501,11 +504,14 @@ public class ApplicationController { .filter(tenant-> tenant instanceof CloudTenant) .map(tenant -> ((CloudTenant) tenant).tenantSecretStores()) .orElse(List.of()); + List<X509Certificate> operatorCertificates = controller.supportAccess().activeGrantsFor(new DeploymentId(application, zone)).stream() + .map(SupportAccessGrant::certificate) + .collect(toList()); ConfigServer.PreparedApplication preparedApplication = configServer.deploy(new DeploymentData(application, zone, applicationPackage.zippedContent(), platform, endpoints, endpointCertificateMetadata, dockerImageRepo, domain, - tenantRoles, deploymentQuota, tenantSecretStores)); + tenantRoles, deploymentQuota, tenantSecretStores, operatorCertificates)); return new ActivateResult(new RevisionId(applicationPackage.hash()), preparedApplication.prepareResponse(), applicationPackage.zippedContent().length); 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 b72a6d2f820..d7b805c0949 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 @@ -31,6 +31,7 @@ import com.yahoo.restapi.Path; import com.yahoo.restapi.ResourceResponse; import com.yahoo.restapi.SlimeJsonResponse; import com.yahoo.security.KeyUtils; +import com.yahoo.security.X509CertificateUtils; import com.yahoo.slime.Cursor; import com.yahoo.slime.Inspector; import com.yahoo.slime.JsonParseException; @@ -122,6 +123,7 @@ import java.net.URISyntaxException; import java.security.DigestInputStream; import java.security.Principal; import java.security.PublicKey; +import java.security.cert.X509Certificate; import java.time.DayOfWeek; import java.time.Duration; import java.time.Instant; diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/AccessRequestResponse.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/AccessRequestResponse.java new file mode 100644 index 00000000000..e17421764e5 --- /dev/null +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/AccessRequestResponse.java @@ -0,0 +1,28 @@ +// Copyright Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. + +package com.yahoo.vespa.hosted.controller.restapi.controller; + +import com.yahoo.restapi.SlimeJsonResponse; +import com.yahoo.slime.Cursor; +import com.yahoo.slime.Slime; +import com.yahoo.vespa.athenz.api.AthenzIdentity; +import com.yahoo.vespa.athenz.api.AthenzUser; + +import java.util.Collection; + +public class AccessRequestResponse extends SlimeJsonResponse { + + public AccessRequestResponse(Collection<AthenzUser> members) { + super(toSlime(members)); + } + + private static Slime toSlime(Collection<AthenzUser> members) { + Slime slime = new Slime(); + Cursor root = slime.setObject(); + Cursor array = root.setArray("members"); + members.stream() + .map(AthenzIdentity::getFullName) + .forEach(array::addString); + return slime; + } +} diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/ControllerApiHandler.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/ControllerApiHandler.java index 98a9ade1b16..cba89fe39cf 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/ControllerApiHandler.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/ControllerApiHandler.java @@ -2,27 +2,41 @@ package com.yahoo.vespa.hosted.controller.restapi.controller; import com.yahoo.component.Version; +import com.yahoo.config.provision.ApplicationId; +import com.yahoo.config.provision.Zone; +import com.yahoo.config.provision.zone.ZoneId; import com.yahoo.container.jdisc.HttpRequest; import com.yahoo.container.jdisc.HttpResponse; import com.yahoo.container.jdisc.LoggingRequestHandler; import com.yahoo.io.IOUtils; import com.yahoo.restapi.ErrorResponse; +import com.yahoo.restapi.MessageResponse; import com.yahoo.restapi.Path; import com.yahoo.restapi.ResourceResponse; +import com.yahoo.security.X509CertificateUtils; import com.yahoo.slime.Inspector; import com.yahoo.slime.SlimeUtils; +import com.yahoo.vespa.athenz.api.AthenzUser; import com.yahoo.vespa.hosted.controller.Controller; +import com.yahoo.vespa.hosted.controller.api.identifiers.DeploymentId; +import com.yahoo.vespa.hosted.controller.api.integration.deployment.JobType; import com.yahoo.vespa.hosted.controller.auditlog.AuditLoggingRequestHandler; import com.yahoo.vespa.hosted.controller.maintenance.ControllerMaintenance; import com.yahoo.vespa.hosted.controller.maintenance.Upgrader; +import com.yahoo.vespa.hosted.controller.support.access.SupportAccess; import com.yahoo.vespa.hosted.controller.versions.VespaVersion.Confidence; import com.yahoo.yolean.Exceptions; +import javax.ws.rs.InternalServerErrorException; import java.io.IOException; import java.io.InputStream; import java.io.UncheckedIOException; +import java.security.Principal; +import java.security.cert.X509Certificate; +import java.time.Instant; import java.util.Optional; import java.util.Scanner; +import java.util.function.Function; import java.util.logging.Level; /** @@ -77,9 +91,53 @@ public class ControllerApiHandler extends AuditLoggingRequestHandler { private HttpResponse post(HttpRequest request) { Path path = new Path(request.getUri()); if (path.matches("/controller/v1/jobs/upgrader/confidence/{version}")) return overrideConfidence(request, path.get("version")); + if (path.matches("/controller/v1/access/requests/{user}")) return approveMembership(request, path.get("user")); + if (path.matches("/controller/v1/access/grants/{user}")) return grantAccess(request, path.get("user")); return notFound(path); } + private HttpResponse approveMembership(HttpRequest request, String user) { + AthenzUser athenzUser = AthenzUser.fromUserId(user); + byte[] jsonBytes = toJsonBytes(request.getData()); + Inspector inspector = SlimeUtils.jsonToSlime(jsonBytes).get(); + ApplicationId applicationId = requireField(inspector, "applicationId", ApplicationId::fromSerializedForm); + ZoneId zone = requireField(inspector, "zone", ZoneId::from); + if(controller.supportAccess().allowDataplaneMembership(athenzUser, new DeploymentId(applicationId, zone))) { + return new AccessRequestResponse(controller.serviceRegistry().accessControlService().listMembers()); + } else { + return new MessageResponse(400, "Unable to approve membership request"); + } + } + + private HttpResponse grantAccess(HttpRequest request, String user) { + Principal principal = requireUserPrincipal(request); + Instant now = controller.clock().instant(); + + byte[] jsonBytes = toJsonBytes(request.getData()); + Inspector requestObject = SlimeUtils.jsonToSlime(jsonBytes).get(); + X509Certificate certificate = requireField(requestObject, "certificate", X509CertificateUtils::fromPem); + ApplicationId applicationId = requireField(requestObject, "applicationId", ApplicationId::fromSerializedForm); + ZoneId zone = requireField(requestObject, "zone", ZoneId::from); + DeploymentId deployment = new DeploymentId(applicationId, zone); + + // Register grant + SupportAccess supportAccess = controller.supportAccess().registerGrant(deployment, principal.getName(), certificate); + + // Trigger deployment to include operator cert + JobType jobType = JobType.from(controller.system(), deployment.zoneId()) + .orElseThrow(() -> new IllegalStateException("No job found to trigger for " + deployment.toUserFriendlyString())); + + String jobName = controller.applications().deploymentTrigger() + .reTrigger(deployment.applicationId(), jobType).type().jobName(); + return new MessageResponse(String.format("Operator %s granted access and job %s triggered", principal.getName(), jobName)); + } + + private <T> T requireField(Inspector inspector, String field, Function<String, T> mapper) { + return SlimeUtils.optionalString(inspector.field(field)) + .map(mapper::apply) + .orElseThrow(() -> new IllegalArgumentException("Expected field \"" + field + "\" in request")); + } + private HttpResponse delete(HttpRequest request) { Path path = new Path(request.getUri()); if (path.matches("/controller/v1/jobs/upgrader/confidence/{version}")) return removeConfidenceOverride(path.get("version")); @@ -145,4 +203,9 @@ public class ControllerApiHandler extends AuditLoggingRequestHandler { } } + private static Principal requireUserPrincipal(HttpRequest request) { + Principal principal = request.getJDiscRequest().getUserPrincipal(); + if (principal == null) throw new InternalServerErrorException("Expected a user principal"); + return principal; + } } 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 index 4a550ad3379..ccee1b4af43 100644 --- 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 @@ -1,16 +1,20 @@ // 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.athenz.api.AthenzIdentity; +import com.yahoo.vespa.athenz.api.AthenzUser; 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.Principal; 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.ALLOWED; import static com.yahoo.vespa.hosted.controller.support.access.SupportAccess.State.NOT_ALLOWED; /** @@ -87,4 +91,15 @@ public class SupportAccessControl { .filter(grant -> !grant.certificate().getNotAfter().toInstant().isAfter(now)) .collect(Collectors.toUnmodifiableList()); } + + public boolean allowDataplaneMembership(AthenzUser identity, DeploymentId deploymentId) { + Instant instant = controller.clock().instant(); + SupportAccess supportAccess = forDeployment(deploymentId); + SupportAccess.CurrentStatus currentStatus = supportAccess.currentStatus(instant); + if(currentStatus.state() == ALLOWED) { + return controller.serviceRegistry().accessControlService().approveDataPlaneAccess(identity, currentStatus.allowedUntil().orElse(instant.plus(MAX_SUPPORT_ACCESS_TIME))); + } else { + return false; + } + } } diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/integration/ServiceRegistryMock.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/integration/ServiceRegistryMock.java index 702ce83d116..4a068681a50 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/integration/ServiceRegistryMock.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/integration/ServiceRegistryMock.java @@ -9,11 +9,12 @@ import com.yahoo.test.ManualClock; import com.yahoo.vespa.hosted.controller.api.integration.ServiceRegistry; import com.yahoo.vespa.hosted.controller.api.integration.archive.ArchiveService; import com.yahoo.vespa.hosted.controller.api.integration.archive.MockArchiveService; +import com.yahoo.vespa.hosted.controller.api.integration.athenz.AccessControlService; +import com.yahoo.vespa.hosted.controller.api.integration.athenz.MockAccessControlService; import com.yahoo.vespa.hosted.controller.api.integration.aws.MockRoleService; import com.yahoo.vespa.hosted.controller.api.integration.aws.RoleService; import com.yahoo.vespa.hosted.controller.api.integration.aws.MockAwsEventFetcher; import com.yahoo.vespa.hosted.controller.api.integration.aws.MockResourceTagger; -import com.yahoo.vespa.hosted.controller.api.integration.aws.NoopRoleService; import com.yahoo.vespa.hosted.controller.api.integration.aws.ResourceTagger; import com.yahoo.vespa.hosted.controller.api.integration.billing.BillingController; import com.yahoo.vespa.hosted.controller.api.integration.billing.MockBillingController; @@ -35,7 +36,6 @@ import com.yahoo.vespa.hosted.controller.api.integration.stubs.MockMailer; import com.yahoo.vespa.hosted.controller.api.integration.stubs.MockMeteringClient; import com.yahoo.vespa.hosted.controller.api.integration.stubs.MockRunDataStore; import com.yahoo.vespa.hosted.controller.api.integration.stubs.MockTesterCloud; -import com.yahoo.vespa.hosted.controller.api.integration.vcmr.ChangeRequestClient; import com.yahoo.vespa.hosted.controller.api.integration.vcmr.MockChangeRequestClient; /** @@ -73,6 +73,7 @@ public class ServiceRegistryMock extends AbstractComponent implements ServiceReg private final NoopTenantSecretService tenantSecretService = new NoopTenantSecretService(); private final ArchiveService archiveService = new MockArchiveService(); private final MockChangeRequestClient changeRequestClient = new MockChangeRequestClient(); + private final AccessControlService accessControlService = new MockAccessControlService(); public ServiceRegistryMock(SystemName system) { this.zoneRegistryMock = new ZoneRegistryMock(system); @@ -229,6 +230,11 @@ public class ServiceRegistryMock extends AbstractComponent implements ServiceReg return changeRequestClient; } + @Override + public AccessControlService accessControlService() { + return accessControlService; + } + public ConfigServerMock configServerMock() { return configServerMock; } 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 47aa3e6b9d4..ce7b4a6123b 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 @@ -18,6 +18,7 @@ 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.vespa.athenz.api.AthenzDomain; import com.yahoo.vespa.athenz.api.AthenzIdentity; import com.yahoo.vespa.athenz.api.AthenzPrincipal; @@ -82,6 +83,7 @@ import java.io.File; import java.math.BigInteger; import java.net.URI; import java.security.cert.X509Certificate; +import java.time.Duration; import java.time.Instant; import java.time.format.DateTimeFormatter; import java.time.temporal.ChronoUnit; @@ -1501,6 +1503,7 @@ public class ApplicationApiTest extends ControllerContainerTest { var zone = ZoneId.from(Environment.prod, RegionName.from("us-west-1")); deploymentTester.controllerTester().zoneRegistry().setRoutingMethod(ZoneApiMock.from(zone), List.of(RoutingMethod.exclusive, RoutingMethod.shared)); + addUserToHostedOperatorRole(HostedAthenzIdentities.from(HOSTED_VESPA_OPERATOR)); ApplicationPackage applicationPackage = new ApplicationPackageBuilder() .athenzIdentity(com.yahoo.config.provision.AthenzDomain.from("domain"), AthenzService.from("service")) .compileVersion(RoutingController.DIRECT_ROUTING_MIN_VERSION) @@ -1528,22 +1531,28 @@ public class ApplicationApiTest extends ControllerContainerTest { // Grant access to support user X509Certificate support_cert = grantCertificate(now, now.plusSeconds(3600)); - tester.controller().supportAccess().registerGrant(app.deploymentIdIn(zone), "user.andreer", support_cert); + String grantPayload= "{\n" + + " \"applicationId\": \"tenant1:application1:instance1\",\n" + + " \"zone\": \"prod.us-west-1\",\n" + + " \"certificate\":\""+X509CertificateUtils.toPem(support_cert)+ "\"\n" + + "}"; + tester.assertResponse(request("/controller/v1/access/grants/"+HOSTED_VESPA_OPERATOR.id(), POST) + .data(grantPayload) + .userIdentity(HOSTED_VESPA_OPERATOR), + "{\"message\":\"Operator user.johnoperator granted access and job production-us-west-1 triggered\"}"); // GET shows grant String grantResponse = allowedResponse.replaceAll("\"grants\":\\[]", - "\"grants\":[{\"requestor\":\"user.andreer\",\"notBefore\":\"" + serializeInstant(now) + "\",\"notAfter\":\"" + serializeInstant(now.plusSeconds(3600)) + "\"}]"); + "\"grants\":[{\"requestor\":\"user.johnoperator\",\"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 diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/controller/ControllerApiTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/controller/ControllerApiTest.java index 8f6988dbc27..fc83c58cc67 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/controller/ControllerApiTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/controller/ControllerApiTest.java @@ -6,8 +6,11 @@ import com.yahoo.config.provision.ApplicationId; import com.yahoo.config.provision.zone.ZoneId; import com.yahoo.container.jdisc.HttpRequest; import com.yahoo.test.ManualClock; +import com.yahoo.vespa.athenz.api.AthenzUser; import com.yahoo.vespa.flags.InMemoryFlagSource; import com.yahoo.vespa.flags.PermanentFlags; +import com.yahoo.vespa.hosted.controller.api.identifiers.DeploymentId; +import com.yahoo.vespa.hosted.controller.api.integration.athenz.MockAccessControlService; import com.yahoo.vespa.hosted.controller.api.integration.configserver.Application; import com.yahoo.vespa.hosted.controller.api.integration.resource.ResourceSnapshot; import com.yahoo.vespa.hosted.controller.auditlog.AuditLogger; @@ -165,4 +168,25 @@ public class ControllerApiTest extends ControllerContainerTest { ); } + @Test + public void testApproveMembership() { + ApplicationId applicationId = ApplicationId.from("tenant", "app", "instance"); + DeploymentId deployment = new DeploymentId(applicationId, ZoneId.defaultId()); + String requestBody = "{\n" + + " \"applicationId\": \"" + deployment.applicationId().serializedForm() + "\",\n" + + " \"zone\": \"" + deployment.zoneId().value() + "\"\n" + + "}"; + + MockAccessControlService accessControlService = (MockAccessControlService) tester.serviceRegistry().accessControlService(); + tester.assertResponse(operatorRequest("http://localhost:8080/controller/v1/access/requests/"+hostedOperator.getName(), requestBody, Request.Method.POST), + "{\"message\":\"Unable to approve membership request\"}", 400); + + accessControlService.addPendingMember(hostedOperator); + tester.assertResponse(operatorRequest("http://localhost:8080/controller/v1/access/requests/"+hostedOperator.getName(), requestBody, Request.Method.POST), + "{\"message\":\"Unable to approve membership request\"}", 400); + + tester.controller().supportAccess().allow(deployment, Instant.now().plus(Duration.ofHours(1)), "tenantx"); + tester.assertResponse(operatorRequest("http://localhost:8080/controller/v1/access/requests/"+hostedOperator.getName(), requestBody, Request.Method.POST), + "{\"members\":[\"user.alice\"]}"); + } } |