aboutsummaryrefslogtreecommitdiffstats
path: root/controller-api
diff options
context:
space:
mode:
authorJon Marius Venstad <venstad@gmail.com>2019-04-04 11:02:13 +0200
committerJon Marius Venstad <venstad@gmail.com>2019-04-04 11:02:13 +0200
commit7e453002c4957397f20e0bd9e31651da674ddf7c (patch)
treeff0f8830d4f519378b384b3d5649156e55378180 /controller-api
parent77433506fc9af7a0b17a43a0e14ad6b0f1ea8594 (diff)
Move role package to controller-api
Diffstat (limited to 'controller-api')
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/Action.java43
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/Context.java84
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/PathGroup.java176
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/Policy.java130
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/Privilege.java99
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/Role.java128
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RoleInSystem.java8
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RoleInSystemWithTenant.java9
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RoleInSystemWithTenantAndApplication.java10
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RoleMembership.java73
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RolePrincipal.java15
-rw-r--r--controller-api/src/test/java/com/yahoo/vespa/hosted/controller/api/role/PathGroupTest.java72
-rw-r--r--controller-api/src/test/java/com/yahoo/vespa/hosted/controller/api/role/RoleMembershipTest.java74
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")));
+ }
+
+}