diff options
author | Jon Marius Venstad <venstad@gmail.com> | 2019-04-04 11:02:13 +0200 |
---|---|---|
committer | Jon Marius Venstad <venstad@gmail.com> | 2019-04-04 11:02:13 +0200 |
commit | 7e453002c4957397f20e0bd9e31651da674ddf7c (patch) | |
tree | ff0f8830d4f519378b384b3d5649156e55378180 /controller-api | |
parent | 77433506fc9af7a0b17a43a0e14ad6b0f1ea8594 (diff) |
Move role package to controller-api
Diffstat (limited to 'controller-api')
13 files changed, 921 insertions, 0 deletions
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/Action.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/Action.java new file mode 100644 index 00000000000..2d9ef25d1f5 --- /dev/null +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/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.api.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-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/Context.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/Context.java new file mode 100644 index 00000000000..3ba0367a00c --- /dev/null +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/Context.java @@ -0,0 +1,84 @@ +// 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.api.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 whether this context is considered limited */ + public boolean limited() { + return tenant.isPresent() || application.isPresent(); + } + + /** Returns a context that has no restrictions on tenant or application in given system */ + public static Context unlimitedIn(SystemName system) { + return new Context(Optional.empty(), Optional.empty(), system); + } + + /** Returns a context that is limited to given tenant and system */ + public static Context limitedTo(TenantName tenant, SystemName system) { + return new Context(Optional.of(tenant), Optional.empty(), system); + } + + /** Returns a context that is limited to given tenant, application and system */ + public static Context limitedTo(TenantName tenant, ApplicationName application, SystemName system) { + return new Context(Optional.of(tenant), Optional.of(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-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/PathGroup.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/PathGroup.java new file mode 100644 index 00000000000..edf3f4e8711 --- /dev/null +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/PathGroup.java @@ -0,0 +1,176 @@ +// 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.api.role; + +import com.yahoo.restapi.Path; + +import java.net.URI; +import java.util.EnumSet; +import java.util.List; +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/{*}", + "/flags/v1/{*}", + "/nodes/v2/{*}", + "/orchestrator/v1/{*}", + "/os/v1/{*}", + "/provision/v2/{*}", + "/zone/v2/{*}"), + + /** Paths used for user management. */ + userManagement("/user/v1/{*}"), // TODO probably add tenant and application levels. + + /** Paths used for creating user tenants. */ + user("/application/v4/user"), + + /** Paths used for creating tenants with proper access control. */ + tenant(Matcher.tenant, + "/application/v4/tenant/{tenant}"), + + /** Paths used by tenant administrators. */ + tenantInfo(Matcher.tenant, + "/application/v4/tenant/{tenant}/application/"), + + /** Path for the base application resource. */ + application(Matcher.tenant, + Matcher.application, + "/application/v4/tenant/{tenant}/application/{application}"), + + /** Paths used by application administrators. */ + applicationInfo(Matcher.tenant, + Matcher.application, + "/application/v4/tenant/{tenant}/application/{application}/deploying/{*}", + "/application/v4/tenant/{tenant}/application/{application}/instance/{*}", + "/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}/logs", + "/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}/suspended", + "/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}/service/{*}", + "/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}/global-rotation/{*}"), + + /** Path used to restart application nodes. */ // TODO move to the above when everyone is on new pipeline. + applicationRestart(Matcher.tenant, + Matcher.application, + "/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{ignored}/restart"), + /** Paths used for development deployments. */ + developmentDeployment(Matcher.tenant, + Matcher.application, + "/application/v4/tenant/{tenant}/application/{application}/environment/dev/region/{region}/instance/{instance}", + "/application/v4/tenant/{tenant}/application/{application}/environment/dev/region/{region}/instance/{instance}/deploy", + "/application/v4/tenant/{tenant}/application/{application}/environment/perf/region/{region}/instance/{instance}", + "/application/v4/tenant/{tenant}/application/{application}/environment/perf/region/{region}/instance/{instance}/deploy"), + + /** Paths used for production deployments. */ + productionDeployment(Matcher.tenant, + Matcher.application, + "/application/v4/tenant/{tenant}/application/{application}/environment/prod/region/{region}/instance/{instance}", + "/application/v4/tenant/{tenant}/application/{application}/environment/prod/region/{region}/instance/{instance}/deploy", + "/application/v4/tenant/{tenant}/application/{application}/environment/test/region/{region}/instance/{instance}", + "/application/v4/tenant/{tenant}/application/{application}/environment/test/region/{region}/instance/{instance}/deploy", + "/application/v4/tenant/{tenant}/application/{application}/environment/staging/region/{region}/instance/{instance}", + "/application/v4/tenant/{tenant}/application/{application}/environment/staging/region/{region}/instance/{instance}/deploy"), + + /** Paths used for continuous deployment to production. */ + submission(Matcher.tenant, + Matcher.application, + "/application/v4/tenant/{tenant}/application/{application}/submit"), + + /** Paths used for other tasks by build services. */ // TODO: This will vanish. + buildService(Matcher.tenant, + Matcher.application, + "/application/v4/tenant/{tenant}/application/{application}/jobreport", + "/application/v4/tenant/{tenant}/application/{application}/promote", + "/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}/promote"), + + /** Paths which contain (not very strictly) classified information about, e.g., customers. */ + classifiedInfo("/athenz/v1/{*}", + "/cost/v1/{*}", + "/deployment/v1/{*}", + "/application/v4/", + "/application/v4/tenant/", + "/", + "/d/{*}", + "/statuspage/v1/{*}" + ), + + /** Paths providing public information. */ + publicInfo("/badge/v1/{*}", + "/zone/v1/{*}"); + + final List<String> pathSpecs; + final List<Matcher> matchers; + + PathGroup(String... pathSpecs) { + this(List.of(), List.of(pathSpecs)); + } + + PathGroup(Matcher first, String... pathSpecs) { + this(List.of(first), List.of(pathSpecs)); + } + + PathGroup(Matcher first, Matcher second, String... pathSpecs) { + this(List.of(first, second), List.of(pathSpecs)); + } + + /** Creates a new path group, if the given context matchers are each present exactly once in each of the given specs. */ + PathGroup(List<Matcher> matchers, List<String> pathSpecs) { + this.matchers = matchers; + this.pathSpecs = pathSpecs; + } + + /** Returns path if it matches any spec in this group, with match groups set by the match. */ + @SuppressWarnings("deprecation") + private Optional<Path> get(URI uri) { + Path matcher = new Path(uri); // TODO Get URI down here. + for (String spec : pathSpecs) // Iterate to be sure the Path's state is that of the match. + if (matcher.matches(spec)) return Optional.of(matcher); + return Optional.empty(); + } + + /** 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(URI uri, Context context) { + return get(uri).map(p -> { + boolean match = true; + String tenant = p.get(Matcher.tenant.name); + if (tenant != null && context.tenant().isPresent()) { + match = context.tenant().get().value().equals(tenant); + } + String application = p.get(Matcher.application.name); + if (application != null && context.application().isPresent()) { + match &= context.application().get().value().equals(application); + } + return match; + }).orElse(false); + } + + + /** Fragments used to match parts of a path to create a context. */ + enum Matcher { + + tenant("{tenant}"), + application("{application}"); + + final String pattern; + final String name; + + Matcher(String pattern) { + this.pattern = pattern; + this.name = pattern.substring(1, pattern.length() - 1); + } + + } + +} diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/Policy.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/Policy.java new file mode 100644 index 00000000000..970717b14a3 --- /dev/null +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/Policy.java @@ -0,0 +1,130 @@ +// 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.api.role; + +import com.yahoo.config.provision.ApplicationName; +import com.yahoo.config.provision.SystemName; +import com.yahoo.config.provision.TenantName; + +import java.net.URI; +import java.util.Set; + +/** + * Policies for REST APIs in the controller. A policy is only considered when defined in a {@link Role}. + * A policy describes a set of {@link Privilege}s, which are valid for a set of {@link SystemName}s. + * A policy is evaluated with a {@link Context}, which provides the {@link SystemName} the policy is + * evaluated in, and any limitations to a specific {@link TenantName} or {@link ApplicationName}. + * + * @author mpolden + */ +public enum Policy { + + /** Full access to everything. */ + operator(Privilege.grant(Action.all()) + .on(PathGroup.all()) + .in(SystemName.all())), + + /** Full access to user management in select systems. */ + manager(Privilege.grant(Action.all()) + .on(PathGroup.userManagement) + .in(SystemName.Public)), + + /** Access to create a user tenant in select systems. */ + userCreate(Privilege.grant(Action.update) + .on(PathGroup.user) + .in(SystemName.main, SystemName.cd, SystemName.dev)), + + /** Access to create a tenant in select systems. */ + tenantCreate(Privilege.grant(Action.create) + .on(PathGroup.tenant) + .in(SystemName.main, SystemName.cd, SystemName.dev)), // TODO SystemName.all() + + /** Full access to tenant information and settings. */ + tenantDelete(Privilege.grant(Action.delete) + .on(PathGroup.tenant) + .in(SystemName.all())), + + /** Full access to tenant information and settings. */ + tenantUpdate(Privilege.grant(Action.update) + .on(PathGroup.tenant) + .in(SystemName.all())), + + /** Read access to tenant information and settings. */ + tenantRead(Privilege.grant(Action.read) + .on(PathGroup.tenant, PathGroup.tenantInfo) + .in(SystemName.all())), + + /** Access to create application under a certain tenant. */ + applicationCreate(Privilege.grant(Action.create) + .on(PathGroup.application) + .in(SystemName.all())), + + /** Read access to application information and settings. */ + applicationRead(Privilege.grant(Action.read) + .on(PathGroup.application, PathGroup.applicationInfo) + .in(SystemName.all())), + + /** Read access to application information and settings. */ + applicationUpdate(Privilege.grant(Action.update) + .on(PathGroup.application, PathGroup.applicationInfo) + .in(SystemName.all())), + + /** Access to delete a certain application. */ + applicationDelete(Privilege.grant(Action.delete) + .on(PathGroup.application) + .in(SystemName.all())), + + /** Full access to application information and settings. */ + applicationOperations(Privilege.grant(Action.write()) + .on(PathGroup.applicationInfo, PathGroup.applicationRestart) + .in(SystemName.all())), + + /** Full access to application development deployments. */ + developmentDeployment(Privilege.grant(Action.all()) + .on(PathGroup.developmentDeployment) + .in(SystemName.all())), + + /** Full access to application production deployments. */ + productionDeployment(Privilege.grant(Action.all()) + .on(PathGroup.productionDeployment) + .in(SystemName.all())), + + /** Read access to all application deployments. */ + deploymentRead(Privilege.grant(Action.read) + .on(PathGroup.developmentDeployment, PathGroup.productionDeployment) + .in(SystemName.all())), + + /** Full access to submissions for continuous deployment. */ + submission(Privilege.grant(Action.all()) + .on(PathGroup.submission) + .in(SystemName.all())), + + /** Full access to the additional tasks needed for continuous deployment. */ + deploymentPipeline(Privilege.grant(Action.all()) // TODO remove when everyone is on new pipeline. + .on(PathGroup.buildService, PathGroup.applicationRestart) + .in(SystemName.all())), + + /** Read access to all information in select systems. */ + classifiedRead(Privilege.grant(Action.read) + .on(PathGroup.all()) + .in(SystemName.main, SystemName.cd, SystemName.dev)), + + /** Read access to public info. */ + publicRead(Privilege.grant(Action.read) + .on(PathGroup.publicInfo) + .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, URI uri, Context context) { + return privileges.stream().anyMatch(privilege -> privilege.actions().contains(action) && + privilege.systems().contains(context.system()) && + privilege.pathGroups().stream() + .anyMatch(pg -> pg.matches(uri, context))); + } + +} diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/Privilege.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/Privilege.java new file mode 100644 index 00000000000..a53717b25d6 --- /dev/null +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/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.api.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-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/Role.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/Role.java new file mode 100644 index 00000000000..e452464dcd4 --- /dev/null +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/Role.java @@ -0,0 +1,128 @@ +// 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.api.role; + +import java.net.URI; +import com.yahoo.config.provision.ApplicationName; +import com.yahoo.config.provision.SystemName; +import com.yahoo.config.provision.TenantName; + +import java.util.EnumSet; +import java.util.Map; +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. + * + * Optionally, some role definition also inherit all policies from a "lower ranking" role. Read the list of roles + * from {@code everyone} to {@code tenantAdmin}, in order, to see what policies these roles. + * + * @author mpolden + * @author jonmv + */ +public class Role implements RoleInSystem, RoleInSystemWithTenant, RoleInSystemWithTenantAndApplication { + + /** Deus ex machina. */ + public static final RoleInSystem hostedOperator = new Role(Policy.operator); + + /** Build service which may submit new applications for continuous deployment. */ + public static final RoleInSystemWithTenantAndApplication buildService = new Role(Policy.submission, + Policy.applicationRead); + + /** Base role which every user is part of. */ + public static final RoleInSystem everyone = new Role(Policy.classifiedRead, + Policy.publicRead, + Policy.userCreate, + Policy.tenantCreate); + + /** Application reader which can see all information about an application, its tenant and deployments. */ + public static final RoleInSystemWithTenantAndApplication applicationReader = new Role(everyone, + Policy.tenantRead, + Policy.applicationRead, + Policy.deploymentRead); + + /** Application developer with access to deploy to development zones. */ + public static final RoleInSystemWithTenantAndApplication applicationDeveloper = new Role(applicationReader, + Policy.developmentDeployment); + + /** Application operator with access to normal, operational tasks of an application. */ + public static final RoleInSystemWithTenantAndApplication applicationOperator = new Role(applicationDeveloper, + Policy.applicationOperations); + + /** Application administrator with full access to an already existing application, including emergency operations. */ + public static final RoleInSystemWithTenantAndApplication applicationAdmin = new Role(applicationOperator, + Policy.applicationUpdate, + Policy.productionDeployment, + Policy.submission); + + /** Application administrator with the additional ability to delete an application. */ + public static final RoleInSystemWithTenantAndApplication applicationOwner = new Role(applicationOperator, + Policy.applicationDelete); + + /** Tenant operator with admin access to all applications under the tenant, as well as the ability to create applications. */ + public static final RoleInSystemWithTenant tenantOperator = new Role(applicationAdmin, + Policy.applicationCreate); + + /** Tenant admin with full access to all tenant resources, except deleting the tenant. */ + public static final RoleInSystemWithTenant tenantAdmin = new Role(tenantOperator, + Policy.applicationDelete, + Policy.manager, + Policy.tenantUpdate); + + /** Tenant admin with full access to all tenant resources. */ + public static final RoleInSystemWithTenant tenantOwner = new Role(tenantAdmin, + Policy.tenantDelete); + + /** Build and continuous delivery service. */ // TODO replace with buildService, when everyone is on new pipeline. + public static final RoleInSystemWithTenantAndApplication tenantPipeline = new Role(everyone, + Policy.submission, + Policy.deploymentPipeline, + Policy.productionDeployment); + + /** Tenant administrator with full access to all child resources. */ + public static final RoleInSystemWithTenant athenzTenantAdmin = new Role(everyone, + Policy.tenantRead, + Policy.tenantUpdate, + Policy.tenantDelete, + Policy.applicationCreate, + Policy.applicationUpdate, + Policy.applicationDelete, + Policy.applicationOperations, + Policy.developmentDeployment); + + private final Set<Policy> policies; + + private Role(Policy... policies) { + this.policies = EnumSet.copyOf(Set.of(policies)); + } + + private Role(Object inherited, Policy... policies) { + this.policies = EnumSet.copyOf(Set.of(policies)); + this.policies.addAll(((Role) inherited).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 allows(Action action, URI uri, Context context) { + return policies.stream().anyMatch(policy -> policy.evaluate(action, uri, context)); + } + + @Override + public RoleMembership limitedTo(SystemName system) { + return new RoleMembership(Map.of(this, Set.of(Context.unlimitedIn(system)))); + } + + @Override + public RoleMembership limitedTo(TenantName tenant, SystemName system) { + return new RoleMembership(Map.of(this, Set.of(Context.limitedTo(tenant, system)))); + } + + @Override + public RoleMembership limitedTo(ApplicationName application, TenantName tenant, SystemName system) { + return new RoleMembership(Map.of(this, Set.of(Context.limitedTo(tenant, application, system)))); + } + +} + diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RoleInSystem.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RoleInSystem.java new file mode 100644 index 00000000000..144a02c4987 --- /dev/null +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RoleInSystem.java @@ -0,0 +1,8 @@ +package com.yahoo.vespa.hosted.controller.api.role; + +import com.yahoo.config.provision.SystemName; + +/** A role which requires only the context of a system. */ +public interface RoleInSystem { + RoleMembership limitedTo(SystemName system); +} diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RoleInSystemWithTenant.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RoleInSystemWithTenant.java new file mode 100644 index 00000000000..ab8aa29b7cc --- /dev/null +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RoleInSystemWithTenant.java @@ -0,0 +1,9 @@ +package com.yahoo.vespa.hosted.controller.api.role; + +import com.yahoo.config.provision.SystemName; +import com.yahoo.config.provision.TenantName; + +/** A role which requires the context of a system and a tenant. */ +public interface RoleInSystemWithTenant { + RoleMembership limitedTo(TenantName tenant, SystemName system); +} diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RoleInSystemWithTenantAndApplication.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RoleInSystemWithTenantAndApplication.java new file mode 100644 index 00000000000..31a903c998a --- /dev/null +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RoleInSystemWithTenantAndApplication.java @@ -0,0 +1,10 @@ +package com.yahoo.vespa.hosted.controller.api.role; + +import com.yahoo.config.provision.ApplicationName; +import com.yahoo.config.provision.SystemName; +import com.yahoo.config.provision.TenantName; + +/** A role which requires the context of a system, a tenant, and an application. */ +public interface RoleInSystemWithTenantAndApplication { + RoleMembership limitedTo(ApplicationName application, TenantName tenant, SystemName system); +} diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RoleMembership.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RoleMembership.java new file mode 100644 index 00000000000..f046b0d7203 --- /dev/null +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RoleMembership.java @@ -0,0 +1,73 @@ +// 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.api.role; + +import java.net.URI; +import java.util.Collections; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * 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 + * @author jonmv + */ +public class RoleMembership { + + private final Map<Role, Set<Context>> roles; + + RoleMembership(Map<Role, Set<Context>> roles) { + this.roles = roles.entrySet().stream() + .collect(Collectors.toUnmodifiableMap(entry -> entry.getKey(), + entry -> Set.copyOf(entry.getValue()))); + } + + public RoleMembership and(RoleMembership other) { + return new RoleMembership(Stream.concat(this.roles.entrySet().stream(), + other.roles.entrySet().stream()) + .collect(Collectors.toMap(Map.Entry::getKey, + Map.Entry::getValue, + (set1, set2) -> Stream.concat(set1.stream(), set2.stream()) + .collect(Collectors.toUnmodifiableSet())))); + } + + /** + * Returns whether any role in this allows action to take place in path + */ + public boolean allows(Action action, URI uri) { + return roles.entrySet().stream().anyMatch(kv -> { + Role role = kv.getKey(); + Set<Context> contexts = kv.getValue(); + return contexts.stream().anyMatch(context -> role.allows(action, uri, context)); + }); + } + + /** + * Returns the set of contexts for which the given role is valid. + */ + public Set<Context> contextsFor(Object role) { // TODO fix. + return roles.getOrDefault((Role) role, Collections.emptySet()); + } + + @Override + public String toString() { + return "roles " + roles; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if ( ! (o instanceof RoleMembership)) return false; + return Objects.equals(roles, ((RoleMembership) o).roles); + } + + @Override + public int hashCode() { + return Objects.hash(roles); + } + +} diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RolePrincipal.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RolePrincipal.java new file mode 100644 index 00000000000..4164dc80e70 --- /dev/null +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RolePrincipal.java @@ -0,0 +1,15 @@ +package com.yahoo.vespa.hosted.controller.api.role; + +import java.security.Principal; + +/** + * A {@link Principal} with a {@link RoleMembership}. + * + * @author jonmv + */ +public interface RolePrincipal extends Principal { + + /** Returns the roles with context this principal is a member of. */ + RoleMembership roles(); + +} diff --git a/controller-api/src/test/java/com/yahoo/vespa/hosted/controller/api/role/PathGroupTest.java b/controller-api/src/test/java/com/yahoo/vespa/hosted/controller/api/role/PathGroupTest.java new file mode 100644 index 00000000000..9d76d055877 --- /dev/null +++ b/controller-api/src/test/java/com/yahoo/vespa/hosted/controller/api/role/PathGroupTest.java @@ -0,0 +1,72 @@ +package com.yahoo.vespa.hosted.controller.api.role; + +import org.junit.Test; + +import java.util.HashSet; +import java.util.Set; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +/** + * @author jonmv + * @author mpolden + */ +public class PathGroupTest { + + @Test + public void uniqueMatches() { + // Ensure that each path group contains at most one match for any given path, to avoid undefined context extraction. + Set<String> checkedAgainstSelf = new HashSet<>(); + for (PathGroup group1 : PathGroup.values()) + for (PathGroup group2 : PathGroup.values()) + for (String path1 : group1.pathSpecs) + for (String path2 : group2.pathSpecs) { + if (path1.equals(path2)) { + if (checkedAgainstSelf.add(path1)) continue; + fail("Path '" + path1 + "' appears in both '" + group1 + "' and '" + group2 + "'."); + } + + String[] parts1 = path1.split("/"); + String[] parts2 = path2.split("/"); + + int end = Math.min(parts1.length, parts2.length); + // If one path has more parts than the other ... + // and the other doesn't end with a wildcard matcher ... + // and the longest one isn't just one wildcard longer ... + // then one is strictly longer than the other, and it's not a match. + if (end < parts1.length && (end == 0 || ! parts2[end - 1].equals("{*}")) && ! parts1[end].equals("{*}")) continue; + if (end < parts2.length && (end == 0 || ! parts1[end - 1].equals("{*}")) && ! parts2[end].equals("{*}")) continue; + + int i; + for (i = 0; i < end; i++) + if ( ! parts1[i].equals(parts2[i]) + && ! (parts1[i].startsWith("{") && parts1[i].endsWith("}")) + && ! (parts2[i].startsWith("{") && parts2[i].endsWith("}"))) break; + + if (i == end) fail("Paths '" + path1 + "' and '" + path2 + "' overlap."); + } + + assertEquals(PathGroup.all().stream().mapToInt(group -> group.pathSpecs.size()).sum(), + checkedAgainstSelf.size()); + } + + @Test + public void contextMatches() { + for (PathGroup group : PathGroup.values()) + for (String spec : group.pathSpecs) { + for (PathGroup.Matcher matcher : PathGroup.Matcher.values()) { + if (group.matchers.contains(matcher)) { + if ( ! spec.contains(matcher.pattern)) + fail("Spec '" + spec + "' in '" + group.name() + "' should contain matcher '" + matcher.pattern + "'."); + if (spec.replaceFirst(Pattern.quote(matcher.pattern), "").contains(matcher.pattern)) + fail("Spec '" + spec + "' in '" + group.name() + "' contains more than one instance of '" + matcher.pattern + "'."); + } + else if (spec.contains(matcher.pattern)) + fail("Spec '" + spec + "' in '" + group.name() + "' should not contain matcher '" + matcher.pattern + "'."); + } + } + } + +} diff --git a/controller-api/src/test/java/com/yahoo/vespa/hosted/controller/api/role/RoleMembershipTest.java b/controller-api/src/test/java/com/yahoo/vespa/hosted/controller/api/role/RoleMembershipTest.java new file mode 100644 index 00000000000..c2eeb17c367 --- /dev/null +++ b/controller-api/src/test/java/com/yahoo/vespa/hosted/controller/api/role/RoleMembershipTest.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.api.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.net.URI; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * @author mpolden + */ +public class RoleMembershipTest { + + @Test + public void operator_membership() { + RoleMembership roles = Role.hostedOperator.limitedTo(SystemName.main); + + // Operator actions + assertFalse(roles.allows(Action.create, URI.create("/not/explicitly/defined"))); + assertTrue(roles.allows(Action.create, URI.create("/controller/v1/foo"))); + assertTrue(roles.allows(Action.update, URI.create("/os/v1/bar"))); + assertTrue(roles.allows(Action.update, URI.create("/application/v4/tenant/t1/application/a1"))); + assertTrue(roles.allows(Action.update, URI.create("/application/v4/tenant/t2/application/a2"))); + } + + @Test + public void tenant_membership() { + RoleMembership roles = Role.athenzTenantAdmin.limitedTo(TenantName.from("t1"), SystemName.main); + assertFalse(roles.allows(Action.create, URI.create("/not/explicitly/defined"))); + assertFalse("Deny access to operator API", roles.allows(Action.create, URI.create("/controller/v1/foo"))); + assertFalse("Deny access to other tenant and app", roles.allows(Action.update, URI.create("/application/v4/tenant/t2/application/a2"))); + assertTrue(roles.allows(Action.update, URI.create("/application/v4/tenant/t1/application/a1"))); + + RoleMembership multiContext = Role.athenzTenantAdmin.limitedTo(TenantName.from("t1"), SystemName.main) + .and(Role.athenzTenantAdmin.limitedTo(TenantName.from("t2"), SystemName.main)); + assertFalse("Deny access to other tenant and app", multiContext.allows(Action.update, URI.create("/application/v4/tenant/t3/application/a3"))); + assertTrue(multiContext.allows(Action.update, URI.create("/application/v4/tenant/t2/application/a2"))); + assertTrue(multiContext.allows(Action.update, URI.create("/application/v4/tenant/t1/application/a1"))); + + RoleMembership publicSystem = Role.athenzTenantAdmin.limitedTo(TenantName.from("t1"), SystemName.vaas); + assertFalse(publicSystem.allows(Action.read, URI.create("/controller/v1/foo"))); + assertTrue(publicSystem.allows(Action.read, URI.create("/badge/v1/badge"))); + assertTrue(multiContext.allows(Action.update, URI.create("/application/v4/tenant/t1/application/a1"))); + } + + @Test + public void build_service_membership() { + RoleMembership roles = Role.tenantPipeline.limitedTo(ApplicationName.from("a1"), TenantName.from("t1"), SystemName.vaas); + assertFalse(roles.allows(Action.create, URI.create("/not/explicitly/defined"))); + assertFalse(roles.allows(Action.update, URI.create("/application/v4/tenant/t1/application/a1"))); + assertTrue(roles.allows(Action.create, URI.create("/application/v4/tenant/t1/application/a1/jobreport"))); + assertFalse("No global read access", roles.allows(Action.read, URI.create("/controller/v1/foo"))); + } + + @Test + public void multi_role_membership() { + RoleMembership roles = Role.athenzTenantAdmin.limitedTo(TenantName.from("t1"), SystemName.main) + .and(Role.tenantPipeline.limitedTo(ApplicationName.from("a1"), TenantName.from("t1"), SystemName.main)); + assertFalse(roles.allows(Action.create, URI.create("/not/explicitly/defined"))); + assertFalse(roles.allows(Action.create, URI.create("/controller/v1/foo"))); + assertTrue(roles.allows(Action.create, URI.create("/application/v4/tenant/t1/application/a1/jobreport"))); + assertTrue(roles.allows(Action.update, URI.create("/application/v4/tenant/t1/application/a1"))); + assertTrue("Global read access", roles.allows(Action.read, URI.create("/controller/v1/foo"))); + assertTrue("Dashboard read access", roles.allows(Action.read, URI.create("/"))); + assertTrue("Dashboard read access", roles.allows(Action.read, URI.create("/d/nodes"))); + assertTrue("Dashboard read access", roles.allows(Action.read, URI.create("/statuspage/v1/incidents"))); + } + +} |