diff options
author | Martin Polden <mpolden@mpolden.no> | 2019-03-22 10:33:40 +0100 |
---|---|---|
committer | Martin Polden <mpolden@mpolden.no> | 2019-03-22 10:57:00 +0100 |
commit | 4e17ebaea7a20506d5edd4d26baa68767a0c59b7 (patch) | |
tree | 26292a68cd00b691a6bd5332a09a0a30f268539a | |
parent | 519d59334ca8c3e314e71f83de618e375a7c2d6c (diff) |
Define roles and policies
10 files changed, 556 insertions, 1 deletions
diff --git a/config-provisioning/abi-spec.json b/config-provisioning/abi-spec.json index 80680a4d095..6e110b708cf 100644 --- a/config-provisioning/abi-spec.json +++ b/config-provisioning/abi-spec.json @@ -707,7 +707,8 @@ "public static com.yahoo.config.provision.SystemName[] values()", "public static com.yahoo.config.provision.SystemName valueOf(java.lang.String)", "public static com.yahoo.config.provision.SystemName defaultSystem()", - "public static com.yahoo.config.provision.SystemName from(java.lang.String)" + "public static com.yahoo.config.provision.SystemName from(java.lang.String)", + "public static java.util.Set all()" ], "fields": [ "public static final enum com.yahoo.config.provision.SystemName dev", diff --git a/config-provisioning/src/main/java/com/yahoo/config/provision/SystemName.java b/config-provisioning/src/main/java/com/yahoo/config/provision/SystemName.java index 891c213e9aa..2de11be08f2 100644 --- a/config-provisioning/src/main/java/com/yahoo/config/provision/SystemName.java +++ b/config-provisioning/src/main/java/com/yahoo/config/provision/SystemName.java @@ -1,6 +1,9 @@ // Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. package com.yahoo.config.provision; +import java.util.EnumSet; +import java.util.Set; + /** * Systems in hosted Vespa * @@ -39,4 +42,8 @@ public enum SystemName { } } + public static Set<SystemName> all() { + return EnumSet.allOf(SystemName.class); + } + } diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/role/Action.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/role/Action.java new file mode 100644 index 00000000000..533c28905a9 --- /dev/null +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/role/Action.java @@ -0,0 +1,43 @@ +// Copyright 2019 Oath Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.controller.role; + +import com.yahoo.jdisc.http.HttpRequest; + +import java.util.EnumSet; + +/** + * Action defines an operation, typically a HTTP method, that may be performed on an entity in the controller + * (e.g. tenant or application). + * + * @author mpolden + */ +public enum Action { + + create, + read, + update, + delete; + + public static EnumSet<Action> all() { + return EnumSet.allOf(Action.class); + } + + public static EnumSet<Action> write() { + return EnumSet.of(create, update, delete); + } + + /** Returns the appropriate action for given HTTP method */ + public static Action from(HttpRequest.Method method) { + switch (method) { + case POST: return Action.create; + case GET: + case OPTIONS: + case HEAD: return Action.read; + case PUT: + case PATCH: return Action.update; + case DELETE: return Action.delete; + default: throw new IllegalArgumentException("No action defined for method " + method); + } + } + +} diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/role/Context.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/role/Context.java new file mode 100644 index 00000000000..71948540667 --- /dev/null +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/role/Context.java @@ -0,0 +1,74 @@ +// Copyright 2019 Oath Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.controller.role; + +import com.yahoo.config.provision.ApplicationName; +import com.yahoo.config.provision.SystemName; +import com.yahoo.config.provision.TenantName; + +import java.util.Objects; +import java.util.Optional; + +/** + * The context in which a role is valid. + * + * @author mpolden + */ +public class Context { + + private final Optional<TenantName> tenant; + private final Optional<ApplicationName> application; + private final SystemName system; + + private Context(Optional<TenantName> tenant, Optional<ApplicationName> application, SystemName system) { + this.tenant = Objects.requireNonNull(tenant, "tenant must be non-null"); + this.application = Objects.requireNonNull(application, "application must be non-null"); + this.system = Objects.requireNonNull(system, "system must be non-null"); + } + + /** A specific tenant this is valid for, if any */ + public Optional<TenantName> tenant() { + return tenant; + } + + /** A specific application this is valid for, if any */ + public Optional<ApplicationName> application() { + return application; + } + + /** System in which this is valid */ + public SystemName system() { + return system; + } + + /** Returns an empty context in given system */ + public static Context empty(SystemName system) { + return new Context(Optional.empty(), Optional.empty(), system); + } + + /** Returns context for a tenant and an optional application in system */ + public static Context of(TenantName tenant, Optional<ApplicationName> application, SystemName system) { + return new Context(Optional.of(tenant), application, system); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Context context = (Context) o; + return tenant.equals(context.tenant) && + application.equals(context.application) && + system == context.system; + } + + @Override + public int hashCode() { + return Objects.hash(tenant, application, system); + } + + @Override + public String toString() { + return "tenant " + tenant.map(TenantName::value).orElse("[none]") + ", application " + + application.map(ApplicationName::value).orElse("[none]") + ", system " + system; + } + +} diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/role/PathGroup.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/role/PathGroup.java new file mode 100644 index 00000000000..6ccc2112010 --- /dev/null +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/role/PathGroup.java @@ -0,0 +1,100 @@ +// Copyright 2019 Oath Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.controller.role; + +import com.yahoo.restapi.Path; + +import java.util.EnumSet; +import java.util.Optional; +import java.util.Set; + +/** + * This declares and groups all known REST API paths in the controller. + * + * When creating a new API, its paths must be added here and a policy must be declared in {@link Policy}. + * + * @author mpolden + */ +public enum PathGroup { + + /** Paths used for system management by operators */ + operator("/controller/v1/{*}", + "/provision/v2/{*}", + "/flags/v1/{*}", + "/os/v1/{*}", + "/cost/v1/{*}", + "/zone/v2/{*}", + "/nodes/v2/{*}", + "/orchestrator/v1/{*}"), + + /** Paths used when onboarding and creating a new tenants */ + onboardingUser("/application/v4/user"), + + onboardingTenant("/application/v4/tenant/{ignored}"), + + + /** Paths used by tenant/application administrators */ + tenant("/athenz/v1/", + "/athenz/v1/domains", + "/application/v4/", + "/application/v4/athensDomain/", + "/application/v4/property/", + "/application/v4/tenant/", + "/application/v4/tenant-pipeline/", + "/application/v4/tenant/{tenant}", + "/application/v4/tenant/tenant1/application/", + "/application/v4/tenant/{tenant}/application/{application}", + "/application/v4/tenant/{tenant}/application/{application}/deploying/{*}", + "/application/v4/tenant/{tenant}/application/{application}/instance/{instance}/job/{job}/{*}", + "/application/v4/tenant/{tenant}/application/{application}/environment/dev/{*}", + "/application/v4/tenant/{tenant}/application/{application}/environment/perf/{*}", + "/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}/global-rotation/override"), + + /** + * Paths used for deployments by build service(s). Note that context is ignored in these paths as build service + * roles are not granted in specific contexts. + */ + buildService("/zone/v1/{*}", + "/application/v4/tenant/{ignored}/application/{ignored2}/jobreport", + "/application/v4/tenant/{ignored}/application/{ignored2}/submit", + "/application/v4/tenant/{ignored}/application/{ignored2}/promote", + "/application/v4/tenant/{ignored}/application/{ignored2}/environment/prod/{*}", + "/application/v4/tenant/{ignored}/application/{ignored2}/environment/test/{*}", + "/application/v4/tenant/{ignored}/application/{ignored2}/environment/staging/{*}"), + + /** Paths providing information about deployment status */ + deployment("/badge/v1/{*}", + "/deployment/v1/{*}"); + + private final Set<String> pathSpecs; + + PathGroup(String... pathSpecs) { + this.pathSpecs = Set.of(pathSpecs); + } + + /** Returns path if it matches any spec in this group */ + private Optional<Path> get(String path) { + return Optional.of(path).map(Path::new).filter(p -> pathSpecs.stream().anyMatch(p::matches)); + } + + /** All known path groups */ + public static Set<PathGroup> all() { + return EnumSet.allOf(PathGroup.class); + } + + /** Returns whether this group matches path in given context */ + public boolean matches(String path, Context context) { + return get(path).filter(p -> { + boolean match = true; + String tenant = p.get("tenant"); + if (tenant != null && context.tenant().isPresent()) { + match = context.tenant().get().value().equals(tenant); + } + String application = p.get("application"); + if (application != null && context.application().isPresent()) { + match &= context.application().get().value().equals(application); + } + return match; + }).isPresent(); + } + +} diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/role/Policy.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/role/Policy.java new file mode 100644 index 00000000000..968f6e39797 --- /dev/null +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/role/Policy.java @@ -0,0 +1,64 @@ +// Copyright 2019 Oath Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.controller.role; + +import com.yahoo.config.provision.SystemName; + +import java.util.Set; + +/** + * Policies for REST APIs in the controller. A policy is only considered when defined in a {@link Role}. + * + * @author mpolden + */ +public enum Policy { + + /** Operator policy allows access to everything in all systems */ + operator(Privilege.grant(Action.all()) + .on(PathGroup.all()) + .in(SystemName.all())), + + /** + * Tenant policy allows tenants to access their own tenant, in all systems, and allows global read access in + * selected systems + */ + tenant(Privilege.grant(Action.all()) + .on(PathGroup.tenant) + .in(SystemName.all()), + Privilege.grant(Action.read) + .on(PathGroup.all()) + .in(SystemName.main, SystemName.cd, SystemName.dev)), + + /** Build service policy only allows access relevant for build service(s) */ + buildService(Privilege.grant(Action.all()) + .on(PathGroup.buildService) + .in(SystemName.all())), + + /** Unauthorized policy allows creation of tenants and read of everything in selected systems */ + unauthorized(Privilege.grant(Action.update) + .on(PathGroup.onboardingUser) + .in(SystemName.main, SystemName.cd, SystemName.dev), + Privilege.grant(Action.create) + .on(PathGroup.onboardingTenant) + .in(SystemName.main, SystemName.cd, SystemName.dev), + Privilege.grant(Action.read) + .on(PathGroup.all()) + .in(SystemName.main, SystemName.cd, SystemName.dev), + Privilege.grant(Action.read) + .on(PathGroup.deployment) + .in(SystemName.all())); + + private final Set<Privilege> privileges; + + Policy(Privilege... privileges) { + this.privileges = Set.of(privileges); + } + + /** Returns whether action is allowed on path in given context */ + public boolean evaluate(Action action, String path, Context context) { + return privileges.stream().anyMatch(privilege -> privilege.actions().contains(action) && + privilege.systems().contains(context.system()) && + privilege.pathGroups().stream() + .anyMatch(pg -> pg.matches(path, context))); + } + +} diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/role/Privilege.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/role/Privilege.java new file mode 100644 index 00000000000..4c5ad136f56 --- /dev/null +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/role/Privilege.java @@ -0,0 +1,99 @@ +// Copyright 2019 Oath Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.controller.role; + +import com.yahoo.config.provision.SystemName; + +import java.util.EnumSet; +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Set; + +/** + * This describes a privilege in the controller. A privilege expresses the actions (e.g. create or read) granted + * for a particular group of REST API paths. A privilege is valid in one or more systems. + * + * @author mpolden + */ +public class Privilege { + + private final Set<SystemName> systems; + private final Set<Action> actions; + private final Set<PathGroup> pathGroups; + + private Privilege(Set<SystemName> systems, Set<Action> actions, Set<PathGroup> pathGroups) { + this.systems = EnumSet.copyOf(Objects.requireNonNull(systems, "system must be non-null")); + this.actions = EnumSet.copyOf(Objects.requireNonNull(actions, "actions must be non-null")); + this.pathGroups = EnumSet.copyOf(Objects.requireNonNull(pathGroups, "pathGroups must be non-null")); + if (systems.isEmpty()) { + throw new IllegalArgumentException("systems must be non-empty"); + } + } + + /** Systems where this applies */ + public Set<SystemName> systems() { + return systems; + } + + /** Actions allowed by this */ + public Set<Action> actions() { + return actions; + } + + /** Path groups where this applies */ + public Set<PathGroup> pathGroups() { + return pathGroups; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Privilege privilege = (Privilege) o; + return systems.equals(privilege.systems) && + actions.equals(privilege.actions) && + pathGroups.equals(privilege.pathGroups); + } + + @Override + public int hashCode() { + return Objects.hash(systems, actions, pathGroups); + } + + public static PrivilegeBuilder grant(Action... actions) { + return grant(Set.of(actions)); + } + + public static PrivilegeBuilder grant(Set<Action> actions) { + return new PrivilegeBuilder(actions); + } + + public static class PrivilegeBuilder { + + private Set<Action> actions; + private Set<PathGroup> pathGroups; + + private PrivilegeBuilder(Set<Action> actions) { + this.actions = EnumSet.copyOf(actions); + this.pathGroups = new LinkedHashSet<>(); + } + + public PrivilegeBuilder on(PathGroup... pathGroups) { + return on(Set.of(pathGroups)); + } + + public PrivilegeBuilder on(Set<PathGroup> pathGroups) { + this.pathGroups.addAll(pathGroups); + return this; + } + + public Privilege in(SystemName... systems) { + return in(Set.of(systems)); + } + + public Privilege in(Set<SystemName> systems) { + return new Privilege(systems, actions, pathGroups); + } + + } + +} diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/role/Role.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/role/Role.java new file mode 100644 index 00000000000..6d1f8287405 --- /dev/null +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/role/Role.java @@ -0,0 +1,35 @@ +// Copyright 2019 Oath Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.controller.role; + +import java.util.EnumSet; +import java.util.Set; + +/** + * This declares all tenant roles known to the controller. A role contains one or more {@link Policy}'s which decide + * what actions a member of a role can perform. + * + * @author mpolden + */ +public enum Role { + + hostedOperator(Policy.operator), + tenantAdmin(Policy.tenant), + tenantPipelineOperator(Policy.buildService), + everyone(Policy.unauthorized); + + private final Set<Policy> policies; + + Role(Policy... policies) { + this.policies = EnumSet.copyOf(Set.of(policies)); + } + + /** + * Returns whether this role is allowed to perform action in given role context. Action is allowed if at least one + * policy evaluates to true. + */ + public boolean allow(Action action, String path, Context context) { + return policies.stream().anyMatch(policy -> policy.evaluate(action, path, context)); + } + +} + diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/role/RoleMembership.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/role/RoleMembership.java new file mode 100644 index 00000000000..167c1128dbe --- /dev/null +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/role/RoleMembership.java @@ -0,0 +1,43 @@ +// Copyright 2019 Oath Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.controller.role; + +import java.util.Map; +import java.util.Set; + +/** + * A list of roles and their associated contexts. This defines the role membership of a tenant, and in which contexts + * (see {@link Context}) those roles apply. + * + * @author mpolden + */ +public class RoleMembership { + + private final Map<Role, Set<Context>> roles; + + public RoleMembership(Map<Role, Set<Context>> roles) { + this.roles = Map.copyOf(roles); + } + + /** Returns whether any role in this allows action to take place in path */ + public boolean allow(Action action, String path) { + return roles.entrySet().stream().anyMatch(kv -> { + Role role = kv.getKey(); + Set<Context> contexts = kv.getValue(); + return contexts.stream().anyMatch(context -> role.allow(action, path, context)); + }); + } + + @Override + public String toString() { + return "roles " + roles; + } + + /** + * A role resolver. Identity providers can implement this to translate their internal representation of role + * membership to a {@link RoleMembership}. + */ + public interface Resolver { + RoleMembership membership(); + } + +} diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/role/RoleMembershipTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/role/RoleMembershipTest.java new file mode 100644 index 00000000000..051860331ee --- /dev/null +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/role/RoleMembershipTest.java @@ -0,0 +1,89 @@ +// Copyright 2019 Oath Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.controller.role; + +import com.yahoo.config.provision.ApplicationName; +import com.yahoo.config.provision.SystemName; +import com.yahoo.config.provision.TenantName; +import org.junit.Test; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * @author mpolden + */ +public class RoleMembershipTest { + + @Test + public void operator_membership() { + RoleMembership roles = new RoleMembership(Map.of(Role.hostedOperator, Set.of(Context.empty(SystemName.main)))); + + // Operator actions + assertFalse(roles.allow(Action.create, "/not/explictly/defined")); + assertTrue(roles.allow(Action.create, "/controller/v1/foo")); + assertTrue(roles.allow(Action.update, "/os/v1/bar")); + assertTrue(roles.allow(Action.update, "/application/v4/tenant/t1/application/a1")); + assertTrue(roles.allow(Action.update, "/application/v4/tenant/t2/application/a2")); + } + + @Test + public void tenant_membership() { + RoleMembership roles = new RoleMembership(Map.of(Role.tenantAdmin, + Set.of(Context.of(TenantName.from("t1"), + Optional.of(ApplicationName.from("a1")), + SystemName.main)))); + + assertFalse(roles.allow(Action.create, "/not/explictly/defined")); + assertFalse("Deny access to operator API", roles.allow(Action.create, "/controller/v1/foo")); + assertFalse("Deny access to other tenant and app", roles.allow(Action.update, "/application/v4/tenant/t2/application/a2")); + assertFalse("Deny access to other app", roles.allow(Action.update, "/application/v4/tenant/t1/application/a2")); + assertTrue(roles.allow(Action.update, "/application/v4/tenant/t1/application/a1")); + assertTrue("Global read access", roles.allow(Action.read, "/controller/v1/foo")); + + RoleMembership multiContext = new RoleMembership(Map.of(Role.tenantAdmin, + Set.of(Context.of(TenantName.from("t1"), + Optional.of(ApplicationName.from("a1")), + SystemName.main), + Context.of(TenantName.from("t2"), + Optional.of(ApplicationName.from("a2")), + SystemName.main)))); + assertFalse("Deny access to other tenant and app", multiContext.allow(Action.update, "/application/v4/tenant/t3/application/a3")); + assertTrue(multiContext.allow(Action.update, "/application/v4/tenant/t2/application/a2")); + assertTrue(multiContext.allow(Action.update, "/application/v4/tenant/t1/application/a1")); + assertTrue("Global read access", roles.allow(Action.read, "/controller/v1/foo")); + + RoleMembership publicSystem = new RoleMembership(Map.of(Role.tenantAdmin, + Set.of(Context.of(TenantName.from("t1"), + Optional.of(ApplicationName.from("a1")), + SystemName.vaas)))); + assertFalse(publicSystem.allow(Action.read, "/controller/v1/foo")); + assertTrue(multiContext.allow(Action.update, "/application/v4/tenant/t1/application/a1")); + } + + @Test + public void build_service_membership() { + RoleMembership roles = new RoleMembership(Map.of(Role.tenantPipelineOperator, Set.of(Context.empty(SystemName.main)))); + assertFalse(roles.allow(Action.create, "/not/explictly/defined")); + assertFalse(roles.allow(Action.update, "/application/v4/tenant/t1/application/a1")); + assertTrue(roles.allow(Action.create, "/application/v4/tenant/t1/application/a1/jobreport")); + assertFalse("No global read access", roles.allow(Action.read, "/controller/v1/foo")); + } + + @Test + public void multi_role_membership() { + RoleMembership roles = new RoleMembership(Map.of(Role.tenantAdmin, Set.of(Context.of(TenantName.from("t1"), + Optional.of(ApplicationName.from("a1")), + SystemName.main)), + Role.tenantPipelineOperator, Set.of(Context.empty(SystemName.main)))); + assertFalse(roles.allow(Action.create, "/not/explictly/defined")); + assertFalse(roles.allow(Action.create,"/controller/v1/foo")); + assertTrue(roles.allow(Action.create, "/application/v4/tenant/t1/application/a1/jobreport")); + assertTrue(roles.allow(Action.update, "/application/v4/tenant/t1/application/a1")); + assertTrue("Global read access", roles.allow(Action.read, "/controller/v1/foo")); + } + +} |