diff options
author | Bjørn Christian Seime <bjorncs@oath.com> | 2018-02-14 18:43:26 +0100 |
---|---|---|
committer | Bjørn Christian Seime <bjorncs@oath.com> | 2018-02-14 18:43:26 +0100 |
commit | 05d19b2edc1bd5d484b1595a506946b6dcb97b44 (patch) | |
tree | db02b62d0288e010562a260611e25075646d0c3a /controller-server | |
parent | f2053b23249dfe7213f3401c9b37b693613e6cc2 (diff) |
Add global access control filter for hosted controller APIs
Diffstat (limited to 'controller-server')
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 |