aboutsummaryrefslogtreecommitdiffstats
path: root/controller-server
diff options
context:
space:
mode:
authorBjørn Christian Seime <bjorncs@oath.com>2018-02-14 18:43:26 +0100
committerBjørn Christian Seime <bjorncs@oath.com>2018-02-14 18:43:26 +0100
commit05d19b2edc1bd5d484b1595a506946b6dcb97b44 (patch)
treedb02b62d0288e010562a260611e25075646d0c3a /controller-server
parentf2053b23249dfe7213f3401c9b37b693613e6cc2 (diff)
Add global access control filter for hosted controller APIs
Diffstat (limited to 'controller-server')
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/Authorizer.java2
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/ControllerAuthorizationFilter.java218
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/ControllerTester.java28
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/filter/ControllerAuthorizationFilterTest.java193
4 files changed, 429 insertions, 12 deletions
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/Authorizer.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/Authorizer.java
index 9d45b9a6e09..6a268ce8fda 100644
--- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/Authorizer.java
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/Authorizer.java
@@ -95,7 +95,7 @@ public class Authorizer {
return new ForbiddenException(formattedMessage);
}
- private boolean isTenantAdmin(AthenzIdentity identity, Tenant tenant) {
+ public boolean isTenantAdmin(AthenzIdentity identity, Tenant tenant) {
switch (tenant.tenantType()) {
case ATHENS:
return isAthenzTenantAdmin(identity, tenant.getAthensDomain().get());
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/ControllerAuthorizationFilter.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/ControllerAuthorizationFilter.java
new file mode 100644
index 00000000000..4bdfef45f25
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/ControllerAuthorizationFilter.java
@@ -0,0 +1,218 @@
+// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi.filter;
+
+import com.google.inject.Inject;
+import com.yahoo.config.provision.ApplicationName;
+import com.yahoo.jdisc.handler.ResponseHandler;
+import com.yahoo.jdisc.http.HttpRequest.Method;
+import com.yahoo.jdisc.http.filter.DiscFilterRequest;
+import com.yahoo.jdisc.http.filter.SecurityRequestFilter;
+import com.yahoo.log.LogLevel;
+import com.yahoo.vespa.athenz.api.AthenzIdentity;
+import com.yahoo.vespa.athenz.api.AthenzPrincipal;
+import com.yahoo.vespa.hosted.controller.Controller;
+import com.yahoo.vespa.hosted.controller.api.identifiers.TenantId;
+import com.yahoo.vespa.hosted.controller.api.integration.athenz.AthenzClientFactory;
+import com.yahoo.vespa.hosted.controller.api.integration.entity.EntityService;
+import com.yahoo.vespa.hosted.controller.api.integration.zone.ZoneRegistry;
+import com.yahoo.vespa.hosted.controller.restapi.Path;
+import com.yahoo.vespa.hosted.controller.restapi.application.ApplicationInstanceAuthorizer;
+import com.yahoo.vespa.hosted.controller.restapi.application.Authorizer;
+import com.yahoo.yolean.chain.After;
+
+import javax.ws.rs.ForbiddenException;
+import javax.ws.rs.InternalServerErrorException;
+import javax.ws.rs.NotAuthorizedException;
+import javax.ws.rs.WebApplicationException;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+import java.util.logging.Logger;
+
+import static com.yahoo.jdisc.http.HttpRequest.Method.GET;
+import static com.yahoo.jdisc.http.HttpRequest.Method.HEAD;
+import static com.yahoo.jdisc.http.HttpRequest.Method.OPTIONS;
+import static com.yahoo.jdisc.http.HttpRequest.Method.POST;
+import static com.yahoo.jdisc.http.HttpRequest.Method.PUT;
+import static com.yahoo.vespa.hosted.controller.restapi.filter.SecurityFilterUtils.sendErrorResponse;
+
+/**
+ * A security filter protects all controller apis.
+ *
+ * @author bjorncs
+ */
+@After("com.yahoo.vespa.hosted.controller.athenz.filter.UserAuthWithAthenzPrincipalFilter")
+public class ControllerAuthorizationFilter implements SecurityRequestFilter {
+
+ private static final List<Method> WHITELISTED_METHODS = Arrays.asList(GET, OPTIONS, HEAD);
+
+ private final AthenzClientFactory clientFactory;
+ private final Controller controller;
+ private final Authorizer authorizer;
+ private final ApplicationInstanceAuthorizer applicationInstanceAuthorizer;
+ private final AuthorizationResponseHandler authorizationResponseHandler;
+
+ public interface AuthorizationResponseHandler {
+ void handle(ResponseHandler responseHandler, WebApplicationException verificationException);
+ }
+
+ @Inject
+ public ControllerAuthorizationFilter(AthenzClientFactory clientFactory,
+ Controller controller,
+ EntityService entityService,
+ ZoneRegistry zoneRegistry) {
+ this(clientFactory, controller, entityService, zoneRegistry, new LoggingAuthorizationResponseHandler());
+ }
+
+ ControllerAuthorizationFilter(AthenzClientFactory clientFactory,
+ Controller controller,
+ EntityService entityService,
+ ZoneRegistry zoneRegistry,
+ AuthorizationResponseHandler authorizationResponseHandler) {
+ this.clientFactory = clientFactory;
+ this.controller = controller;
+ this.authorizer = new Authorizer(controller, entityService, clientFactory);
+ this.applicationInstanceAuthorizer = new ApplicationInstanceAuthorizer(zoneRegistry, clientFactory);
+ this.authorizationResponseHandler = authorizationResponseHandler;
+ }
+
+ // NOTE: Be aware of the ordering of the path pattern matching. Semantics may change of the patterns are evaluated
+ // in different order.
+ @Override
+ public void filter(DiscFilterRequest request, ResponseHandler handler) {
+ Method method = getMethod(request);
+ if (isWhiteListedMethod(method)) return;
+
+ try {
+ Path path = new Path(request.getRequestURI());
+ AthenzPrincipal principal = getPrincipal(request);
+ if (isWhiteListedOperation(path, method)) {
+ // no authz check
+ } else if (isHostedOperatorOperation(path, method)) {
+ verifyIsHostedOperator(principal);
+ } else if (isTenantAdminOperation(path, method)) {
+ verifyIsTenantAdmin(principal, getTenantId(path));
+ } else if (isTenantPipelineOperation(path, method)) {
+ verifyIsTenantPipelineOperator(principal, getTenantId(path), getApplicationName(path));
+ } else {
+ throw new ForbiddenException("No access control is explicitly declared for this api.");
+ }
+ } catch (WebApplicationException e) {
+ authorizationResponseHandler.handle(handler, e);
+ }
+ }
+
+ private static boolean isWhiteListedMethod(Method method) {
+ return WHITELISTED_METHODS.contains(method);
+ }
+
+ private static boolean isWhiteListedOperation(Path path, Method method) {
+ return path.matches("/screwdriver/v1/jobsToRun") || // TODO EOL'ed API, remove this once api is gone
+ path.matches("/application/v4/user") && method == PUT || // Create user tenant
+ path.matches("/application/v4/tenant/{tenant}") && method == POST || // Create tenant
+ path.matches("/screwdriver/v1/jobreport"); // TODO To be migrated to application/v4
+ }
+
+ private static boolean isHostedOperatorOperation(Path path, Method method) {
+ if (isWhiteListedOperation(path, method)) return false;
+ return path.matches("/application/v4/tenant/{tenant}/application/{application}/deploying") ||
+ path.matches("/controller/v1/{*}") ||
+ path.matches("/provision/v2/{*}") ||
+ path.matches("/screwdriver/v1/trigger/tenant/{*}") ||
+ path.matches("/zone/v2/{*}");
+ }
+
+ private static boolean isTenantAdminOperation(Path path, Method method) {
+ if (isHostedOperatorOperation(path, method)) return false;
+ return path.matches("/application/v4/tenant/{tenant}") ||
+ path.matches("/application/v4/tenant/{tenant}/migrateTenantToAthens") ||
+ path.matches("/application/v4/tenant/{tenant}/application/{application}") ||
+ path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/dev/{*}") ||
+ path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/perf/{*}") ||
+ path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}/global-rotation/override");
+ }
+
+ private static boolean isTenantPipelineOperation(Path path, Method method) {
+ if (isTenantAdminOperation(path, method)) return false;
+ return path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/prod/{*}") ||
+ path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/test/{*}") ||
+ path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/staging/{*}");
+ }
+
+ private void verifyIsHostedOperator(AthenzPrincipal principal) {
+ if (!isHostedOperator(principal.getIdentity())) {
+ throw new ForbiddenException("Vespa operator role required");
+ }
+ }
+
+ private void verifyIsTenantAdmin(AthenzPrincipal principal, TenantId tenantId) {
+ if (!isHostedOperator(principal.getIdentity()) && !isTenantAdmin(principal.getIdentity(), tenantId)) {
+ throw new ForbiddenException("Tenant admin or Vespa operator role required");
+ }
+ }
+
+ private void verifyIsTenantPipelineOperator(AthenzPrincipal principal,
+ TenantId tenantId,
+ ApplicationName applicationName) {
+ if (isHostedOperator(principal.getIdentity())) return;
+ controller.tenants().tenant(tenantId)
+ .ifPresent(tenant -> applicationInstanceAuthorizer.throwIfUnauthorized(principal, tenant, applicationName));
+ }
+
+ private boolean isHostedOperator(AthenzIdentity identity) {
+ return clientFactory.createZmsClientWithServicePrincipal()
+ .hasHostedOperatorAccess(identity);
+ }
+
+ private boolean isTenantAdmin(AthenzIdentity identity, TenantId tenantId) {
+ return controller.tenants().tenant(tenantId)
+ .map(tenant -> authorizer.isTenantAdmin(identity, tenant))
+ .orElse(false);
+ }
+
+ private static TenantId getTenantId(Path path) {
+ if (!path.matches("/application/v4/tenant/{tenant}/{*}"))
+ throw new InternalServerErrorException("Unable to handle path: " + path.getPath());
+ return new TenantId(path.get("tenant"));
+ }
+
+ private static ApplicationName getApplicationName(Path path) {
+ if (!path.matches("/application/v4/tenant/{tenant}/application/{application}/{*}"))
+ throw new InternalServerErrorException("Unable to handle path: " + path.getPath());
+ return ApplicationName.from(path.get("application"));
+ }
+
+ private static Method getMethod(DiscFilterRequest request) {
+ return Method.valueOf(request.getMethod().toUpperCase());
+ }
+
+ private static AthenzPrincipal getPrincipal(DiscFilterRequest request) {
+ return Optional.ofNullable(request.getUserPrincipal())
+ .map(AthenzPrincipal.class::cast)
+ .orElseThrow(() -> new NotAuthorizedException("User not authenticated"));
+ }
+
+ private static class LoggingAuthorizationResponseHandler implements AuthorizationResponseHandler {
+
+ @SuppressWarnings("LoggerInitializedWithForeignClass")
+ private static final Logger log = Logger.getLogger(ControllerAuthorizationFilter.class.getName());
+
+ @Override
+ public void handle(ResponseHandler responseHandler, WebApplicationException exception) {
+ log.log(LogLevel.WARNING,
+ String.format("Access denied (%d): %s",
+ exception.getResponse().getStatus(), exception.getMessage()));
+ }
+ }
+
+ // TODO Use this as default once we are confident that the access control does not block legal operations
+ @SuppressWarnings("unused")
+ static class HttpRespondingAuthorizationResponseHandler implements AuthorizationResponseHandler {
+ @Override
+ public void handle(ResponseHandler responseHandler, WebApplicationException exception) {
+ sendErrorResponse(responseHandler, exception.getResponse().getStatus(), exception.getMessage());
+ }
+ }
+
+
+}
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/ControllerTester.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/ControllerTester.java
index ccc1798358d..691a5ef223d 100644
--- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/ControllerTester.java
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/ControllerTester.java
@@ -4,17 +4,15 @@ package com.yahoo.vespa.hosted.controller;
import com.yahoo.config.provision.ApplicationId;
import com.yahoo.config.provision.Environment;
import com.yahoo.config.provision.RegionName;
-import com.yahoo.vespa.hosted.controller.api.integration.deployment.ArtifactRepository;
-import com.yahoo.vespa.hosted.controller.api.integration.zone.ZoneId;
import com.yahoo.slime.Slime;
import com.yahoo.test.ManualClock;
+import com.yahoo.vespa.athenz.api.AthenzDomain;
import com.yahoo.vespa.curator.Lock;
import com.yahoo.vespa.curator.mock.MockCurator;
import com.yahoo.vespa.hosted.controller.api.Tenant;
import com.yahoo.vespa.hosted.controller.api.application.v4.model.DeployOptions;
import com.yahoo.vespa.hosted.controller.api.application.v4.model.GitRevision;
import com.yahoo.vespa.hosted.controller.api.application.v4.model.ScrewdriverBuildJob;
-import com.yahoo.vespa.athenz.api.AthenzDomain;
import com.yahoo.vespa.hosted.controller.api.identifiers.GitBranch;
import com.yahoo.vespa.hosted.controller.api.identifiers.GitCommit;
import com.yahoo.vespa.hosted.controller.api.identifiers.GitRepository;
@@ -23,11 +21,14 @@ import com.yahoo.vespa.hosted.controller.api.identifiers.PropertyId;
import com.yahoo.vespa.hosted.controller.api.identifiers.ScrewdriverId;
import com.yahoo.vespa.hosted.controller.api.identifiers.TenantId;
import com.yahoo.vespa.hosted.controller.api.integration.chef.ChefMock;
+import com.yahoo.vespa.hosted.controller.api.integration.deployment.ArtifactRepository;
import com.yahoo.vespa.hosted.controller.api.integration.dns.MemoryNameService;
+import com.yahoo.vespa.hosted.controller.api.integration.entity.EntityService;
import com.yahoo.vespa.hosted.controller.api.integration.entity.MemoryEntityService;
import com.yahoo.vespa.hosted.controller.api.integration.github.GitHubMock;
import com.yahoo.vespa.hosted.controller.api.integration.organization.MockOrganization;
import com.yahoo.vespa.hosted.controller.api.integration.routing.MemoryGlobalRoutingService;
+import com.yahoo.vespa.hosted.controller.api.integration.zone.ZoneId;
import com.yahoo.vespa.hosted.controller.application.ApplicationPackage;
import com.yahoo.vespa.hosted.controller.athenz.mock.AthenzClientFactoryMock;
import com.yahoo.vespa.hosted.controller.athenz.mock.AthenzDbMock;
@@ -63,31 +64,33 @@ public final class ControllerTester {
private final MemoryNameService nameService;
private final RotationsConfig rotationsConfig;
private final ArtifactRepositoryMock artifactRepository;
+ private final EntityService entityService;
private Controller controller;
public ControllerTester() {
this(new MemoryControllerDb(), new AthenzDbMock(), new ManualClock(), new ConfigServerClientMock(),
new ZoneRegistryMock(), new GitHubMock(), new MockCuratorDb(), defaultRotationsConfig(),
- new MemoryNameService(), new ArtifactRepositoryMock());
+ new MemoryNameService(), new ArtifactRepositoryMock(), new MemoryEntityService());
}
public ControllerTester(ManualClock clock) {
this(new MemoryControllerDb(), new AthenzDbMock(), clock, new ConfigServerClientMock(),
new ZoneRegistryMock(), new GitHubMock(), new MockCuratorDb(), defaultRotationsConfig(),
- new MemoryNameService(), new ArtifactRepositoryMock());
+ new MemoryNameService(), new ArtifactRepositoryMock(), new MemoryEntityService());
}
public ControllerTester(RotationsConfig rotationsConfig) {
this(new MemoryControllerDb(), new AthenzDbMock(), new ManualClock(), new ConfigServerClientMock(),
new ZoneRegistryMock(), new GitHubMock(), new MockCuratorDb(), rotationsConfig, new MemoryNameService(),
- new ArtifactRepositoryMock());
+ new ArtifactRepositoryMock(), new MemoryEntityService());
}
private ControllerTester(ControllerDb db, AthenzDbMock athenzDb, ManualClock clock,
ConfigServerClientMock configServer, ZoneRegistryMock zoneRegistry,
GitHubMock gitHub, CuratorDb curator, RotationsConfig rotationsConfig,
- MemoryNameService nameService, ArtifactRepositoryMock artifactRepository) {
+ MemoryNameService nameService, ArtifactRepositoryMock artifactRepository,
+ EntityService entityService) {
this.db = db;
this.athenzDb = athenzDb;
this.clock = clock;
@@ -98,8 +101,9 @@ public final class ControllerTester {
this.nameService = nameService;
this.rotationsConfig = rotationsConfig;
this.artifactRepository = artifactRepository;
+ this.entityService = entityService;
this.controller = createController(db, curator, rotationsConfig, configServer, clock, gitHub, zoneRegistry,
- athenzDb, nameService, artifactRepository);
+ athenzDb, nameService, artifactRepository, entityService);
}
public Controller controller() { return controller; }
@@ -120,10 +124,12 @@ public final class ControllerTester {
public ArtifactRepositoryMock artifactRepository() { return artifactRepository; }
+ public EntityService entityService() { return entityService; }
+
/** Create a new controller instance. Useful to verify that controller state is rebuilt from persistence */
public final void createNewController() {
controller = createController(db, curator, rotationsConfig, configServer, clock, gitHub, zoneRegistry, athenzDb,
- nameService, artifactRepository);
+ nameService, artifactRepository, entityService);
}
/** Creates the given tenant and application and deploys it */
@@ -233,12 +239,12 @@ public final class ControllerTester {
ConfigServerClientMock configServerClientMock, ManualClock clock,
GitHubMock gitHubClientMock, ZoneRegistryMock zoneRegistryMock,
AthenzDbMock athensDb, MemoryNameService nameService,
- ArtifactRepository artifactRepository) {
+ ArtifactRepository artifactRepository, EntityService entityService) {
Controller controller = new Controller(db,
curator,
rotationsConfig,
gitHubClientMock,
- new MemoryEntityService(),
+ entityService,
new MockOrganization(clock),
new MemoryGlobalRoutingService(),
zoneRegistryMock,
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/filter/ControllerAuthorizationFilterTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/filter/ControllerAuthorizationFilterTest.java
new file mode 100644
index 00000000000..87215a595a6
--- /dev/null
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/filter/ControllerAuthorizationFilterTest.java
@@ -0,0 +1,193 @@
+// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi.filter;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.yahoo.jdisc.http.HttpRequest.Method;
+import com.yahoo.jdisc.http.filter.DiscFilterRequest;
+import com.yahoo.vespa.athenz.api.AthenzDomain;
+import com.yahoo.vespa.athenz.api.AthenzIdentity;
+import com.yahoo.vespa.athenz.api.AthenzPrincipal;
+import com.yahoo.vespa.athenz.api.AthenzService;
+import com.yahoo.vespa.athenz.api.AthenzUser;
+import com.yahoo.vespa.hosted.controller.ControllerTester;
+import com.yahoo.vespa.hosted.controller.api.identifiers.ApplicationId;
+import com.yahoo.vespa.hosted.controller.api.identifiers.ScrewdriverId;
+import com.yahoo.vespa.hosted.controller.api.identifiers.TenantId;
+import com.yahoo.vespa.hosted.controller.api.integration.athenz.ApplicationAction;
+import com.yahoo.vespa.hosted.controller.api.integration.athenz.HostedAthenzIdentities;
+import com.yahoo.vespa.hosted.controller.athenz.mock.AthenzClientFactoryMock;
+import com.yahoo.vespa.hosted.controller.athenz.mock.AthenzDbMock;
+import com.yahoo.vespa.hosted.controller.restapi.filter.ControllerAuthorizationFilter.HttpRespondingAuthorizationResponseHandler;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.Optional;
+
+import static com.yahoo.container.jdisc.RequestHandlerTestDriver.MockResponseHandler;
+import static com.yahoo.jdisc.http.HttpRequest.Method.DELETE;
+import static com.yahoo.jdisc.http.HttpRequest.Method.POST;
+import static com.yahoo.jdisc.http.HttpRequest.Method.PUT;
+import static com.yahoo.jdisc.http.HttpResponse.Status.FORBIDDEN;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * @author bjorncs
+ */
+public class ControllerAuthorizationFilterTest {
+ private static final ObjectMapper mapper = new ObjectMapper();
+
+ private static final AthenzUser USER = user("john");
+ private static final AthenzUser HOSTED_OPERATOR = user("hosted-operator");
+ private static final AthenzDomain TENANT_DOMAIN = new AthenzDomain("tenantdomain");
+ private static final AthenzService TENANT_ADMIN = new AthenzService(TENANT_DOMAIN, "adminservice");
+ private static final AthenzService TENANT_PIPELINE = HostedAthenzIdentities.from(new ScrewdriverId("12345"));
+ private static final TenantId TENANT = new TenantId("mytenant");
+ private static final ApplicationId APPLICATION = new ApplicationId("myapp");
+
+ @Test
+ public void white_listed_operations_are_allowed() {
+ ControllerAuthorizationFilter filter = createFilter(new ControllerTester());
+ assertIsAllowed(invokeFilter(filter, createRequest(PUT, "/application/v4/user", USER)));
+ assertIsAllowed(invokeFilter(filter, createRequest(POST, "/application/v4/tenant/john", USER)));
+ assertIsAllowed(invokeFilter(filter, createRequest(DELETE, "/screwdriver/v1/jobsToRun", USER)));
+ assertIsAllowed(invokeFilter(filter, createRequest(DELETE, "/screwdriver/v1/jobreport", USER)));
+ }
+
+ @Test
+ public void only_hosted_operator_can_access_operator_apis() {
+ ControllerTester controllerTester = new ControllerTester();
+ controllerTester.athenzDb().hostedOperators.add(HOSTED_OPERATOR);
+
+ ControllerAuthorizationFilter filter = createFilter(controllerTester);
+ {
+ String path = "/application/v4/tenant/mytenant/application/myapp/deploying";
+ Method method = PUT;
+ assertIsAllowed(invokeFilter(filter, createRequest(method, path, HOSTED_OPERATOR)));
+ assertIsForbidden(invokeFilter(filter, createRequest(method, path, USER)));
+ }
+ {
+ String path = "/screwdriver/v1/trigger/tenant/mytenant/application/myapp/";
+ Method method = POST;
+ assertIsAllowed(invokeFilter(filter, createRequest(method, path, HOSTED_OPERATOR)));
+ assertIsForbidden(invokeFilter(filter, createRequest(method, path, USER)));
+ }
+ {
+ String path = "/provision/v2/provision/enqueue";
+ Method method = DELETE;
+ assertIsAllowed(invokeFilter(filter, createRequest(method, path, HOSTED_OPERATOR)));
+ assertIsForbidden(invokeFilter(filter, createRequest(method, path, USER)));
+ }
+ }
+
+ @Test
+ public void only_hosted_operator_or_tenant_admin_can_access_tenant_admin_apis() {
+ ControllerTester controllerTester = new ControllerTester();
+ controllerTester.athenzDb().hostedOperators.add(HOSTED_OPERATOR);
+ controllerTester.createTenant(TENANT.id(), TENANT_DOMAIN.getName(), null);
+ controllerTester.athenzDb().domains.get(TENANT_DOMAIN).admins.add(TENANT_ADMIN);
+
+ ControllerAuthorizationFilter filter = createFilter(controllerTester);
+ {
+ String path = "/application/v4/tenant/mytenant";
+ Method method = DELETE;
+ assertIsAllowed(invokeFilter(filter, createRequest(method, path, HOSTED_OPERATOR)));
+ assertIsAllowed(invokeFilter(filter, createRequest(method, path, TENANT_ADMIN)));
+ assertIsForbidden(invokeFilter(filter, createRequest(method, path, USER)));
+ }
+ {
+ String path = "/application/v4/tenant/mytenant/application/myapp/environment/perf/region/myregion/instance/default/deploy";
+ Method method = POST;
+ assertIsAllowed(invokeFilter(filter, createRequest(method, path, HOSTED_OPERATOR)));
+ assertIsAllowed(invokeFilter(filter, createRequest(method, path, TENANT_ADMIN)));
+ assertIsForbidden(invokeFilter(filter, createRequest(method, path, USER)));
+ }
+ {
+ String path = "/application/v4/tenant/mytenant/application/myapp/environment/prod/region/myregion/instance/default/global-rotation/override";
+ Method method = PUT;
+ assertIsAllowed(invokeFilter(filter, createRequest(method, path, HOSTED_OPERATOR)));
+ assertIsAllowed(invokeFilter(filter, createRequest(method, path, TENANT_ADMIN)));
+ assertIsForbidden(invokeFilter(filter, createRequest(method, path, USER)));
+ }
+ }
+
+ @Test
+ public void only_hosted_operator_and_screwdriver_project_with_deploy_role_can_access_tenant_pipeline_apis() {
+ ControllerTester controllerTester = new ControllerTester();
+ controllerTester.athenzDb().hostedOperators.add(HOSTED_OPERATOR);
+ controllerTester.createTenant(TENANT.id(), TENANT_DOMAIN.getName(), null);
+ controllerTester.createApplication(TENANT, APPLICATION.id(), "default", 12345);
+ AthenzDbMock.Domain domainMock = controllerTester.athenzDb().domains.get(TENANT_DOMAIN);
+ domainMock.admins.add(TENANT_ADMIN);
+ domainMock.applications.get(APPLICATION).addRoleMember(ApplicationAction.deploy, TENANT_PIPELINE);
+
+ ControllerAuthorizationFilter filter = createFilter(controllerTester);
+ {
+ String path = "/application/v4/tenant/mytenant/application/myapp/environment/prod/region/myregion/instance/default/deploy";
+ Method method = POST;
+ assertIsAllowed(invokeFilter(filter, createRequest(method, path, HOSTED_OPERATOR)));
+ assertIsAllowed(invokeFilter(filter, createRequest(method, path, TENANT_PIPELINE)));
+ assertIsForbidden(invokeFilter(filter, createRequest(method, path, TENANT_ADMIN)));
+ assertIsForbidden(invokeFilter(filter, createRequest(method, path, USER)));
+ }
+ }
+
+ private static void assertIsAllowed(Optional<AuthorizationResponse> response) {
+ assertFalse("Expected no response from filter", response.isPresent());
+ }
+
+ private static void assertIsForbidden(Optional<AuthorizationResponse> response) {
+ assertTrue("Expected a response from filter", response.isPresent());
+ assertEquals("Invalid status code", response.get().statusCode, FORBIDDEN);
+ }
+
+ private static ControllerAuthorizationFilter createFilter(ControllerTester controllerTester) {
+ return new ControllerAuthorizationFilter(new AthenzClientFactoryMock(controllerTester.athenzDb()),
+ controllerTester.controller(),
+ controllerTester.entityService(),
+ controllerTester.zoneRegistry(),
+ new HttpRespondingAuthorizationResponseHandler());
+ }
+
+ private static Optional<AuthorizationResponse> invokeFilter(ControllerAuthorizationFilter filter,
+ DiscFilterRequest request) {
+ MockResponseHandler responseHandlerMock = new MockResponseHandler();
+ filter.filter(request, responseHandlerMock);
+ return Optional.ofNullable(responseHandlerMock.getResponse())
+ .map(response -> new AuthorizationResponse(response.getStatus(), getErrorMessage(responseHandlerMock)));
+ }
+
+ private static DiscFilterRequest createRequest(Method method, String path, AthenzIdentity identity) {
+ DiscFilterRequest request = mock(DiscFilterRequest.class);
+ when(request.getMethod()).thenReturn(method.name());
+ when(request.getRequestURI()).thenReturn(path);
+ when(request.getUserPrincipal()).thenReturn(new AthenzPrincipal(identity));
+ return request;
+ }
+
+ private static String getErrorMessage(MockResponseHandler responseHandler) {
+ try {
+ return mapper.readTree(responseHandler.readAll()).get("message").asText();
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ private static AthenzUser user(String name) {
+ return new AthenzUser(name);
+ }
+
+ private static class AuthorizationResponse {
+ final int statusCode;
+ final String message;
+
+ AuthorizationResponse(int statusCode, String message) {
+ this.statusCode = statusCode;
+ this.message = message;
+ }
+ }
+} \ No newline at end of file