diff --git a/docs/access-control.md b/docs/access-control.md index ad428ebe..ae73307e 100644 --- a/docs/access-control.md +++ b/docs/access-control.md @@ -2,8 +2,8 @@ TESK API integrates with [Elixir AAI](https://www.elixir-europe.org/services/compute/aai) via [OpenID Connect](http://openid.net/connect/) protocol to authenticate and authorise requests. Authentication and authorisation are optional and can be switched off completely. When authentication is on, in order to perform any operation on tasks (get and/or create tasks) TESK API user will have to include a valid access token issued by Elixir AAI in the request header. To obtain such a token, a user will have to: * have a valid account in Elixir AAI -* belong to a specific Elixir group -* have access to a registered Elixir client application, that was assigned - among others - `groupNames` scope. +* belong to a specific Elixir group (or, with [authorisation without groups](#authorisation-without-groups) on, have one of the configured values in a claim) +* have access to a registered Elixir client application, that was assigned - among others - `groupNames` scope (with authorisation without groups, the scope that releases the configured claim). ## Switching on/off authentication and authorisation Authentication and authorisation can be globally switched on and off for TESK API using [Spring Profiles](https://docs.spring.io/spring-boot/docs/current/reference/html/boot-features-profiles.html). When the default `noauth` profile is active, all endpoints are unauthenticated (so the setting for `GET /service-info` below has no effect) and all (also previously) created TES tasks are visible to any user. New tasks are labelled as created by `anonymousUser` and no team ownership is assigned to new tasks. With `auth` profile activated, all the rules described below apply. Set the profile with `SPRING_PROFILES_ACTIVE` (see [Configuration](configuration.md#settings)). @@ -38,7 +38,7 @@ TESK API validates the token by hitting Elixir AAI `user_info` endpoint on each We have not tested TESK API integration with a different OIDC provider than Elixir AAI, but since standard Spring OAuth library is used, it should be possible (just point `security.oauth2.resource.user-info-uri`, or `SECURITY_OAUTH2_RESOURCE_USER_INFO_URI`, to a proper URL), provided that the other Authorization Server provides information about the user in the same format that Elixir does. Otherwise changes to [GroupNamesSecurityExtractor](https://github.com/elixir-cloud-aai/tesk-api/blob/dev/src/main/java/uk/ac/ebi/tsc/tesk/config/security/GroupNamesSecurityExtractor.java) and [ElixirPrincipalExtractor](https://github.com/elixir-cloud-aai/tesk-api/blob/dev/src/main/java/uk/ac/ebi/tsc/tesk/config/security/ElixirPrincipalExtractor.java) will be necessary. ## Authorisation -TESK API uses Elixir AAI group membership to authorise users. Elixir recognises group managers and members, but only includes information about the latter in `user_info` endpoint response. Therefore a nested structure of groups is used by us to reflect different roles of users. +TESK API uses Elixir AAI group membership to authorise users (and optionally a claim, see [Authorisation without groups](#authorisation-without-groups)). Elixir recognises group managers and members, but only includes information about the latter in `user_info` endpoint response. Therefore a nested structure of groups is used by us to reflect different roles of users. ### Group structure Group level | Example (from our own environments) | Meaning | Multiplicity ------------ |---| -------------|--- @@ -86,3 +86,64 @@ Environment variable | Meaning | Default `TESK_API_AUTHORISATION_ENV_SUBGROUP` | Name of the subgroup representing environment (aka Environment Group) | `EBI` (Full group name: `elixir:GA4GH:GA4GH-CAP:EBI`) `TESK_API_AUTHORISATION_ADMIN_SUBGROUP` | Name of the subgroup representing admins (super admins or team admins) | `ADMIN` (Full group name of super-admins: `elixir:GA4GH:GA4GH-CAP:EBI:ADMIN`) +## Authorisation without groups + +Some OpenID Connect providers (e.g. LS Login) give no group memberships that +fit the [group structure](#group-structure). For them, TESK API can authorise +users by a claim of the user info instead. The setting is off by default; with +it off, nothing changes. It needs the `auth` profile. + +Environment variable | Meaning | Default +--- | --- | --- +`TESK_API_AUTHORISATION_CLAIM_ENABLED` | `true`: users with one of the values below in the claim below are authorised without groups. | `false` +`TESK_API_AUTHORISATION_CLAIM_NAME` | Name of the claim in the user info, e.g. `eduperson_entitlement` or `eduperson_assurance`. Required if the setting is on. | none +`TESK_API_AUTHORISATION_CLAIM_VALUES` | Comma-separated values of the claim; a user needs at least one of them. The claim can be a string or a list of strings; values are compared exactly (also in case). Required if the setting is on. | none + +If the setting is on without a claim name or without a non-blank value, TESK +API doesn't start. + +Users get their tokens from a client that the provider must allow to release +the claim (e.g. the scope `eduperson_entitlement` or `eduperson_assurance`); +without it, the user info lacks the claim and every request is refused with +`403 Forbidden`. + +With the setting on, a user with one of the values (a "claim user"): + +* may create tasks. The tasks belong to the user only, without a team: a + task with `tags['GROUP_NAME']` is refused (`403 Forbidden`), unless the + user may create tasks for that team by the group rules. +* may list, get and cancel their own tasks without a team, and no others. + +Group rules are unchanged and come first: a team member, team admin or super +admin keeps the rights described [above](#endpoint-permissions), whether or +not they have the claim. Super admins (the Super Admin Group) see all tasks, +including those of claim users; team admins don't see them, as they belong to +no team. + +Listing follows the group rules first, too: a user who is a team member or +team admin and has the claim lists the tasks of their teams, but not their +own tasks without a team, although they may still get and cancel these by +ID. This happens to tasks that a user created with the claim only before +joining a team, and to tasks of team admins who aren't team members (these +are created without a team). A team member who loses the membership but has +the claim lists only their tasks without a team. + +### Security implications + +* Everyone who gets one of the values from your provider can run any + container image with any command on your cluster, within the resources + that tasks may request. Choose a claim and values that your provider sets + per user and that users can't set themselves, e.g. an entitlement that an + administrator of your community grants, or an assurance level that your + provider checks. A value that every account has (e.g. a low assurance + level, or the provider's default entitlement) opens TESK to all of its + users. +* Values are compared exactly, so list every value that you accept: with + `eduperson_assurance`, a higher level doesn't imply a lower one. +* TESK API reads the claim from the user info endpoint on each request, so a + value revoked at the provider takes effect at the next request. Tasks that + are already running keep running. +* Users are identified by `sub` (with `@` replaced by `_`), as for group + members: tasks are labelled with it. As for all users, Kubernetes allows + label values of up to 63 characters, so users with longer subjects can't + create tasks. diff --git a/docs/api.md b/docs/api.md index 7a97c617..e49734bf 100644 --- a/docs/api.md +++ b/docs/api.md @@ -132,6 +132,6 @@ Status code | Meaning 200 | OK: successful request 400 | Bad request: validation error, or cancellation of a task that doesn't run 401 | Unauthorized: no access token in the request, or an invalid token -403 | Forbidden: the user may not do this (usually, not in the required group) +403 | Forbidden: the user may not do this (usually, not in the required group, or without the required claim) 404 | Not found: no task with this ID 500 | Something else went wrong (possibly the configuration) diff --git a/docs/configuration.md b/docs/configuration.md index 867193c5..d44a9213 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -30,6 +30,7 @@ Environment variable | Meaning `TESK_API_SERVICE_INFO_PUBLIC` | With the `auth` profile: `true` (default) lets `GET` and `HEAD /service-info` answer without a token; `false` makes them need a valid token (no group membership). No effect with `noauth`, where all endpoints are open. Since 1.2.0. `SECURITY_OAUTH2_RESOURCE_USER_INFO_URI` | User info endpoint of the OpenID Connect provider, which validates tokens; see [Access control](access-control.md#token-validation). Default: ELIXIR AAI. `TESK_API_AUTHORISATION_*` | Authorisation by group membership; see [Access control](access-control.md#configuration). +`TESK_API_AUTHORISATION_CLAIM_*` | Authorisation without groups, by a claim of the user info; off by default. Turning it on lets everyone with one of the configured values run tasks; see [Access control](access-control.md#authorisation-without-groups) for the settings and their security implications. `TESK_API_SWAGGER_UI_ENABLED` | `true`: serves the [Swagger UI](api.md#swagger-ui) at `/swagger-ui/index.html` (the root path `/` redirects there) with its static resources and its configuration (`/swagger-resources`). `false` (default): serves none of them. The API description (`/v2/api-docs`) is served either way. Other values stop the app at startup. Turning it on serves the Swagger UI and its static resources publicly, also with the `auth` profile: turn it on only where it is needed. Since 1.2.0; before, the Swagger UI was always on. `TESK_API_SWAGGER_OAUTH_*` | The OpenID Connect client of the Swagger UI; see [Access control](access-control.md#built-in-client). `TESK_API_SWAGGER_OAUTH_CLIENT_SECRET` | Deprecated since 1.2.0 and ignored: the Swagger UI is a public client and gets no client secret. TESK API logs a warning at startup if it is set. Remove it from your deployment; if a client secret was configured while the Swagger UI was on (always the case before 1.2.0, with the `auth` profile), rotate that secret at your identity provider (see [Upgrading](upgrading.md#to-120)). diff --git a/docs/index.md b/docs/index.md index 5470f412..61e874fc 100644 --- a/docs/index.md +++ b/docs/index.md @@ -15,7 +15,7 @@ These pages cover: - [API](api.md): the endpoints, service info and HTTP status codes; - [Configuration](configuration.md): the settings of TESK API; - [Access control](access-control.md): authentication with OpenID Connect and - authorisation by group membership; + authorisation by group membership (or optionally by a claim); - [Deployment](deployment.md): running TESK API locally and in Kubernetes; - [Upgrading](upgrading.md): what changes between versions; - [Development](development.md): building, testing and releasing. diff --git a/docs/upgrading.md b/docs/upgrading.md index 85d794da..bb10f520 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -68,6 +68,14 @@ Changes since 1.2.0 that clients or deployments may notice: before; numbers with more digits or decimals are refused with `400 Bad Request`. The API description (`/v2/api-docs`) now gives `cpu_cores` as a number instead of an integer. +- **Authorisation without groups.** With the `auth` profile, TESK API can + authorise users by a claim of the user info instead of group membership + (`TESK_API_AUTHORISATION_CLAIM_ENABLED`, `TESK_API_AUTHORISATION_CLAIM_NAME` + and `TESK_API_AUTHORISATION_CLAIM_VALUES`; see [Authorisation without + groups](access-control.md#authorisation-without-groups)). It is off by + default, so nothing changes unless you turn it on; read its security + implications first. The Helm chart of TESK doesn't set these yet; set them + as environment variables of the deployment. - **Non-root image.** The image runs as the user `tesk` (UID 1000, group 0) instead of `root`, with the home directory `/home/tesk` (see [Deployment](deployment.md#image)). The Helm chart of TESK already runs it diff --git a/src/integration-test/java/uk/ac/ebi/tsc/tesk/AuthIT.java b/src/integration-test/java/uk/ac/ebi/tsc/tesk/AuthIT.java index 3f901dfd..ef3ddcea 100644 --- a/src/integration-test/java/uk/ac/ebi/tsc/tesk/AuthIT.java +++ b/src/integration-test/java/uk/ac/ebi/tsc/tesk/AuthIT.java @@ -655,6 +655,47 @@ public void nonmember_getList() throws Exception { .andExpect(status().isForbidden()); } + /** + * Without tesk.api.authorisation.claim.enabled (the default), a claim doesn't authorise a user + * without groups (issue #78) + */ + @Test + public void claimWithoutGroups_settingOff() throws Exception { + + mockElixir.givenThat( + WireMock.get("/") + .willReturn( + okJson( + "{\"sub\" : \"123\", \"eduperson_assurance\" : [\"https://refeds.org/assurance/IAP/medium\"]}"))); + + this.mvc + .perform( + post(TASK_URL) + .content( + getFileContentFromResources( + "fromTesToK8s_minimal/task.json")) + .header("Authorization", "Bearer BAR") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()); + this.mvc + .perform(get(TASK_URL).header("Authorization", "Bearer BAR")) + .andExpect(status().isForbidden()); + + // nor get or cancel the own task without a group + MockUtil.mockUngroupedTaskKubernetesResponses(this.mockKubernetes, "123"); + this.mvc + .perform(get(TASK_URL + "/{id}", "task-123").header("Authorization", "Bearer BAR")) + .andExpect(status().isForbidden()); + this.mvc + .perform( + post(TASK_URL + "/{id}:cancel", "task-123") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer BAR")) + .andExpect(status().isForbidden()); + } + @Test public void anybody_can_see_serviceInfo() throws Exception { this.mvc.perform(get(SERVICE_INFO_URL)).andExpect(status().isOk()); diff --git a/src/integration-test/java/uk/ac/ebi/tsc/tesk/ClaimAuthIT.java b/src/integration-test/java/uk/ac/ebi/tsc/tesk/ClaimAuthIT.java new file mode 100644 index 00000000..cb3256a0 --- /dev/null +++ b/src/integration-test/java/uk/ac/ebi/tsc/tesk/ClaimAuthIT.java @@ -0,0 +1,391 @@ +package uk.ac.ebi.tsc.tesk; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static uk.ac.ebi.tsc.tesk.TestUtils.getFileContentFromResources; +import static uk.ac.ebi.tsc.tesk.UrlConstants.TASK_URL; + +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.junit.WireMockRule; +import io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.util.Config; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +/** + * Integration testing of authorisation without groups (issue #78): with {@code + * tesk.api.authorisation.claim.enabled}, users with one of the required values of the configured + * claim may create tasks without a group and see only their own tasks; group members and admins + * keep their rights. Kubernetes API and OIDC userInfo endpoint are WireMocked. + */ +@RunWith(SpringRunner.class) +@SpringBootTest +@AutoConfigureMockMvc +@TestPropertySource( + locations = {"classpath:application.properties"}, + properties = { + "security.oauth2.resource.user-info-uri = http://localhost:8090", + "spring.profiles.active=auth", + "tesk.api.authorisation.claim.enabled=true", + "tesk.api.authorisation.claim.name=eduperson_assurance", + "tesk.api.authorisation.claim.values=" + + "https://refeds.org/assurance/IAP/medium,https://refeds.org/assurance/IAP/high" + }) +public class ClaimAuthIT { + + private static final String GROUP_PREFIX = "urn:geant:elixir-europe.org:group:elixir:"; + private static final String GROUP_SUFFIX = "#perun.elixir-czech.cz"; + + @Autowired private MockMvc mvc; + + @Rule public WireMockRule mockElixir = new WireMockRule(8090); + + @Rule + public WireMockRule mockKubernetes = + new WireMockRule( + wireMockConfig() + .port(9000) + .usingFilesUnderDirectory("src/integration-test/resources")); + + @TestConfiguration + static class KubernetesClientMock { + @Bean + @Primary + public ApiClient kubernetesApiClient() { + + return Config.fromUrl("http://localhost:9000", false); + } + } + + /** User info of the user {@code sub} with the claim's value (or none) and groups */ + private void userInfo(String sub, String assurance, String... groups) { + StringBuilder json = new StringBuilder("{\"sub\" : \"" + sub + "\""); + if (assurance != null) { + json.append(", \"eduperson_assurance\" : [\"https://refeds.org/assurance/IAP/low\", \"") + .append(assurance) + .append("\"]"); + } + json.append(", \"eduperson_entitlement\" : ["); + for (int i = 0; i < groups.length; i++) { + json.append(i > 0 ? ", " : "") + .append("\"") + .append(GROUP_PREFIX) + .append(groups[i]) + .append(GROUP_SUFFIX) + .append("\""); + } + json.append("]}"); + mockElixir.givenThat(WireMock.get("/").willReturn(okJson(json.toString()))); + } + + private ResultActions createTask(String path) throws Exception { + return this.mvc.perform( + post(TASK_URL) + .content(getFileContentFromResources(path)) + .header("Authorization", "Bearer BAR") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)); + } + + private ResultActions listTasks() throws Exception { + return this.mvc.perform(get(TASK_URL).header("Authorization", "Bearer BAR")); + } + + private ResultActions getTask() throws Exception { + return this.mvc.perform( + get(TASK_URL + "/{id}", "task-123").header("Authorization", "Bearer BAR")); + } + + private ResultActions cancelTask() throws Exception { + return this.mvc.perform( + post(TASK_URL + "/{id}:cancel", "task-123") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer BAR")); + } + + private void mockUngroupedTask(String userId) { + MockUtil.mockUngroupedTaskKubernetesResponses(this.mockKubernetes, userId); + } + + private void mockCreateTask(String groupLabel) { + mockKubernetes.givenThat( + WireMock.post("/apis/batch/v1/namespaces/default/jobs") + .withRequestBody( + groupLabel == null + ? matchingJsonPath( + "$.metadata.labels['creator-group-name']", absent()) + : matchingJsonPath( + "$.metadata.labels[?(@.creator-group-name == '" + + groupLabel + + "')]")) + .willReturn(okJson("{\"metadata\":{\"name\":\"task-fe99716a\"}}"))); + mockKubernetes.givenThat( + WireMock.post("/api/v1/namespaces/default/configmaps") + .willReturn(okJson("{\"metadata\":{\"name\":\"task-fe99716a\"}}"))); + } + + @Test + public void claimUser_createTask_withoutGroup() throws Exception { + userInfo("user@example.org", "https://refeds.org/assurance/IAP/medium"); + mockKubernetes.givenThat( + WireMock.post("/apis/batch/v1/namespaces/default/jobs") + .withRequestBody( + matchingJsonPath( + "$.metadata.labels['creator-group-name']", absent())) + .withRequestBody( + matchingJsonPath( + "$.metadata.labels[?(@.creator-user-id == 'user_example.org')]")) + .willReturn(okJson("{\"metadata\":{\"name\":\"task-fe99716a\"}}"))); + mockKubernetes.givenThat( + WireMock.post("/api/v1/namespaces/default/configmaps") + .willReturn(okJson("{\"metadata\":{\"name\":\"task-fe99716a\"}}"))); + + createTask("fromTesToK8s_minimal/task.json").andExpect(status().isOk()); + } + + /** Any one of the required values is enough */ + @Test + public void claimUserOtherValue_createTask() throws Exception { + userInfo("user@example.org", "https://refeds.org/assurance/IAP/high"); + mockKubernetes.givenThat( + WireMock.post("/apis/batch/v1/namespaces/default/jobs") + .willReturn(okJson("{\"metadata\":{\"name\":\"task-fe99716a\"}}"))); + mockKubernetes.givenThat( + WireMock.post("/api/v1/namespaces/default/configmaps") + .willReturn(okJson("{\"metadata\":{\"name\":\"task-fe99716a\"}}"))); + + createTask("fromTesToK8s_minimal/task.json").andExpect(status().isOk()); + } + + /** A user authorised by the claim only can't create tasks for a group */ + @Test + public void claimUser_createTask_forGroup() throws Exception { + userInfo("user@example.org", "https://refeds.org/assurance/IAP/medium"); + + createTask("fromTesToK8s/task.json").andExpect(status().isForbidden()); + } + + @Test + public void claimAbsent_createTask() throws Exception { + userInfo("user@example.org", null); + + createTask("fromTesToK8s_minimal/task.json").andExpect(status().isForbidden()); + } + + @Test + public void claimWrongValue_createTask() throws Exception { + userInfo("user@example.org", "https://refeds.org/assurance/IAP/medium/other"); + + createTask("fromTesToK8s_minimal/task.json").andExpect(status().isForbidden()); + } + + @Test + public void claimAbsent_getList() throws Exception { + userInfo("user@example.org", null); + + listTasks().andExpect(status().isForbidden()); + } + + @Test + public void claimWrongValue_getList() throws Exception { + userInfo("user@example.org", "https://refeds.org/assurance/IAP/low"); + + listTasks().andExpect(status().isForbidden()); + } + + /** A user authorised by the claim lists only their own tasks without a group */ + @Test + public void claimUser_getList() throws Exception { + userInfo("user@example.org", "https://refeds.org/assurance/IAP/medium"); + mockKubernetes.givenThat( + WireMock.get( + "/apis/batch/v1/namespaces/default/jobs?labelSelector=job-type%3Dtaskmaster" + + "%2Ccreator-user-id%3Duser_example.org%2C%21creator-group-name") + .willReturn(aResponse().withBodyFile("list/taskmasters.json"))); + MockUtil.mockListTaskKubernetesResponses(this.mockKubernetes); + + listTasks().andExpect(status().isOk()).andExpect(jsonPath("$.tasks.length()").value(4)); + } + + /** Admins still list all tasks, with or without the claim */ + @Test + public void superAdmin_getList() throws Exception { + for (String assurance : new String[] {null, "https://refeds.org/assurance/IAP/medium"}) { + userInfo("123", assurance, "GA4GH:GA4GH-CAP:EBI:ADMIN"); + mockKubernetes.givenThat( + WireMock.get( + "/apis/batch/v1/namespaces/default/jobs?labelSelector=job-type%3Dtaskmaster") + .willReturn(aResponse().withBodyFile("list/taskmasters.json"))); + MockUtil.mockListTaskKubernetesResponses(this.mockKubernetes); + + listTasks().andExpect(status().isOk()).andExpect(jsonPath("$.tasks.length()").value(4)); + } + } + + /** Group members keep their rules, also with the claim */ + @Test + public void member_getList() throws Exception { + for (String assurance : new String[] {null, "https://refeds.org/assurance/IAP/medium"}) { + userInfo("123", assurance, "GA4GH:GA4GH-CAP:EBI", "GA4GH:GA4GH-CAP:EBI:TEST"); + mockKubernetes.givenThat( + WireMock.get( + "/apis/batch/v1/namespaces/default/jobs?labelSelector=job-type%3Dtaskmaster" + + "%2Ccreator-group-name%20in%20%28TEST%29%2Ccreator-user-id%3D123") + .willReturn(aResponse().withBodyFile("list/taskmasters.json"))); + MockUtil.mockListTaskKubernetesResponses(this.mockKubernetes); + + listTasks().andExpect(status().isOk()).andExpect(jsonPath("$.tasks.length()").value(4)); + } + } + + @Test + public void claimUser_getOwnTask() throws Exception { + userInfo("user@example.org", "https://refeds.org/assurance/IAP/medium"); + mockUngroupedTask("user_example.org"); + + getTask().andExpect(status().isOk()); + } + + @Test + public void claimUser_getOthersTask() throws Exception { + userInfo("user@example.org", "https://refeds.org/assurance/IAP/medium"); + mockUngroupedTask("someone_else"); + + getTask().andExpect(status().isForbidden()); + } + + /** A task of a group stays hidden from its creator once only the claim authorises them */ + @Test + public void claimUser_getOwnGroupTask() throws Exception { + userInfo("123", "https://refeds.org/assurance/IAP/medium"); + MockUtil.mockGetTaskKubernetesResponses(this.mockKubernetes); + + getTask().andExpect(status().isForbidden()); + } + + @Test + public void claimAbsent_getOwnTask() throws Exception { + userInfo("user@example.org", null); + mockUngroupedTask("user_example.org"); + + getTask().andExpect(status().isForbidden()); + } + + @Test + public void superAdmin_getUngroupedTask() throws Exception { + userInfo("123", null, "GA4GH:GA4GH-CAP:EBI:ADMIN"); + mockUngroupedTask("user_example.org"); + + getTask().andExpect(status().isOk()); + } + + /** Authorised to cancel the own task; it isn't running, hence 400 */ + @Test + public void claimUser_cancelOwnTask() throws Exception { + userInfo("user@example.org", "https://refeds.org/assurance/IAP/medium"); + mockUngroupedTask("user_example.org"); + + cancelTask() + .andExpect(status().isBadRequest()) + .andExpect( + jsonPath("$.message") + .value("Job with ID=task-123 has no pods in RUNNING status.")); + } + + /** Group members with the claim create tasks for their group, as without it */ + @Test + public void memberWithClaim_createTask() throws Exception { + userInfo( + "123", + "https://refeds.org/assurance/IAP/medium", + "GA4GH:GA4GH-CAP:EBI", + "GA4GH:GA4GH-CAP:EBI:TEST"); + mockCreateTask("TEST"); + + createTask("fromTesToK8s_minimal/task.json").andExpect(status().isOk()); + } + + /** A claim user who is a member of the chosen group may create tasks for it */ + @Test + public void memberWithClaim_createTask_forOwnGroup() throws Exception { + userInfo( + "123", + "https://refeds.org/assurance/IAP/medium", + "GA4GH:GA4GH-CAP:EBI", + "GA4GH:GA4GH-CAP:EBI:ABC"); + mockCreateTask("ABC"); + // the full task asks for memory, so the limits are read + mockKubernetes.givenThat( + WireMock.get("/api/v1/namespaces/default/limitranges") + .willReturn(okJson("{\"metadata\":{\"name\":\"core-resource-limits\"}}"))); + + createTask("fromTesToK8s/task.json").andExpect(status().isOk()); + } + + /** Super admins create tasks without a group, with or without the claim */ + @Test + public void superAdmin_createTask() throws Exception { + for (String assurance : new String[] {null, "https://refeds.org/assurance/IAP/medium"}) { + userInfo("123", assurance, "GA4GH:GA4GH-CAP:EBI", "GA4GH:GA4GH-CAP:EBI:ADMIN"); + mockCreateTask(null); + + createTask("fromTesToK8s_minimal/task.json").andExpect(status().isOk()); + } + } + + @Test + public void claimWrongValue_getOwnTask() throws Exception { + userInfo("user@example.org", "https://refeds.org/assurance/IAP/low"); + mockUngroupedTask("user_example.org"); + + getTask().andExpect(status().isForbidden()); + } + + @Test + public void claimAbsentOrWrong_cancelOwnTask() throws Exception { + for (String assurance : new String[] {null, "https://refeds.org/assurance/IAP/low"}) { + userInfo("user@example.org", assurance); + mockUngroupedTask("user_example.org"); + + cancelTask().andExpect(status().isForbidden()); + } + } + + /** Super admins may cancel tasks of claim users (it isn't running, hence 400) */ + @Test + public void superAdmin_cancelUngroupedTask() throws Exception { + userInfo("123", null, "GA4GH:GA4GH-CAP:EBI", "GA4GH:GA4GH-CAP:EBI:ADMIN"); + mockUngroupedTask("user_example.org"); + + cancelTask() + .andExpect(status().isBadRequest()) + .andExpect( + jsonPath("$.message") + .value("Job with ID=task-123 has no pods in RUNNING status.")); + } + + @Test + public void claimUser_cancelOthersTask() throws Exception { + userInfo("user@example.org", "https://refeds.org/assurance/IAP/medium"); + mockUngroupedTask("someone_else"); + + cancelTask().andExpect(status().isForbidden()); + } +} diff --git a/src/integration-test/java/uk/ac/ebi/tsc/tesk/MockUtil.java b/src/integration-test/java/uk/ac/ebi/tsc/tesk/MockUtil.java index 988632a8..fcf9e631 100644 --- a/src/integration-test/java/uk/ac/ebi/tsc/tesk/MockUtil.java +++ b/src/integration-test/java/uk/ac/ebi/tsc/tesk/MockUtil.java @@ -36,6 +36,28 @@ static void mockGetTaskKubernetesResponses(WireMockRule mockKubernetes) { .willReturn(ok("hello executor!"))); } + /** + * Lets task-123 be a task of the user {@code userId} without a group (replaces the taskmaster + * of {@link #mockGetTaskKubernetesResponses}, as WireMock prefers the stub added last) + */ + static void mockUngroupedTaskKubernetesResponses(WireMockRule mockKubernetes, String userId) { + mockGetTaskKubernetesResponses(mockKubernetes); + mockKubernetes.givenThat( + WireMock.get("/apis/batch/v1/namespaces/default/jobs/task-123") + .willReturn( + aResponse() + .withHeader("Content-Type", "application/json") + .withBody( + "{\"metadata\": {\"labels\": {\"creator-user-id\": \"" + + userId + + "\", \"job-type\": \"taskmaster\"}," + + " \"name\": \"task-123\"}," + + " \"spec\": {\"selector\": {\"matchLabels\":" + + " {\"controller-uid\":" + + " \"24a0504a-4a2b-11e8-a06f-fa163ecf0042\"}}}," + + " \"status\": {\"succeeded\": 1}}"))); + } + static void mockListTaskKubernetesResponses(WireMockRule mockKubernetes) { mockKubernetes.givenThat( WireMock.get( diff --git a/src/main/java/uk/ac/ebi/tsc/tesk/config/security/AuthorisationProperties.java b/src/main/java/uk/ac/ebi/tsc/tesk/config/security/AuthorisationProperties.java index 2a8a35e0..d9d7273e 100644 --- a/src/main/java/uk/ac/ebi/tsc/tesk/config/security/AuthorisationProperties.java +++ b/src/main/java/uk/ac/ebi/tsc/tesk/config/security/AuthorisationProperties.java @@ -1,10 +1,13 @@ package uk.ac.ebi.tsc.tesk.config.security; +import java.util.ArrayList; +import java.util.List; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Configuration; /** - * Configuration of group based authorisation + * Configuration of group based authorisation, and of the optional authorisation by a claim without + * groups ({@link Claim}) * * @author Ania Niewielska */ @@ -32,6 +35,51 @@ public class AuthorisationProperties { /** Full name of a group representing admins of a single installation of TESK */ private String adminGroupFull; + /** Authorisation without groups, by a claim of the user info (off by default) */ + private final Claim claim = new Claim(); + + /** + * Authorisation without groups (issue #78): if enabled, users whose user info has one of the + * values in the claim may create tasks without a group, and see, get and cancel their own tasks + * without a group. Group members and admins keep their rights. + */ + public static class Claim { + /** Whether the claim authorises users without groups */ + private boolean enabled = false; + /** Name of the claim in the user info, e.g. eduperson_assurance */ + private String name; + /** Values of the claim, one of which a user needs (compared exactly) */ + private List values = new ArrayList<>(); + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public List getValues() { + return values; + } + + public void setValues(List values) { + this.values = values; + } + } + + public Claim getClaim() { + return claim; + } + public String getGroupsClaim() { return groupsClaim; } diff --git a/src/main/java/uk/ac/ebi/tsc/tesk/config/security/ElixirPrincipalExtractor.java b/src/main/java/uk/ac/ebi/tsc/tesk/config/security/ElixirPrincipalExtractor.java index 54696740..9ddaec28 100644 --- a/src/main/java/uk/ac/ebi/tsc/tesk/config/security/ElixirPrincipalExtractor.java +++ b/src/main/java/uk/ac/ebi/tsc/tesk/config/security/ElixirPrincipalExtractor.java @@ -1,5 +1,7 @@ package uk.ac.ebi.tsc.tesk.config.security; +import java.util.Collection; +import java.util.Collections; import java.util.Map; import java.util.Optional; import java.util.Set; @@ -9,6 +11,7 @@ import org.springframework.boot.autoconfigure.security.oauth2.resource.AuthoritiesExtractor; import org.springframework.boot.autoconfigure.security.oauth2.resource.PrincipalExtractor; import org.springframework.security.core.GrantedAuthority; +import org.springframework.util.StringUtils; /** * Custom principal extractor (puts all of the info from userinfo to custom {@link User} object) @@ -21,6 +24,10 @@ public class ElixirPrincipalExtractor implements PrincipalExtractor { private final AuthorisationProperties authorisationProperties; private final Pattern MEMBERED_GROUP; private final Pattern MANAGED_GROUP; + /** Name of the claim that authorises users without groups; null if that setting is off */ + private final String claimName; + /** Values of that claim, one of which authorises a user */ + private final Set claimValues; ElixirPrincipalExtractor( AuthoritiesExtractor groupsExtractor, AuthorisationProperties authorisationProperties) { @@ -39,6 +46,43 @@ public class ElixirPrincipalExtractor implements PrincipalExtractor { + "([^:]+):" + authorisationProperties.getAdminSubgroup() + authorisationProperties.getGroupSuffix()); + AuthorisationProperties.Claim claim = authorisationProperties.getClaim(); + if (claim.isEnabled()) { + Set values = + Optional.ofNullable(claim.getValues()).orElse(Collections.emptyList()).stream() + .filter(StringUtils::hasText) + .map(String::trim) + .collect(Collectors.toSet()); + if (!StringUtils.hasText(claim.getName()) || values.isEmpty()) { + // fail at startup rather than authorising nobody, or (with a blank value) anybody + throw new IllegalStateException( + "tesk.api.authorisation.claim.enabled needs" + + " tesk.api.authorisation.claim.name and at least one non-blank" + + " value in tesk.api.authorisation.claim.values"); + } + this.claimName = claim.getName().trim(); + this.claimValues = Collections.unmodifiableSet(values); + } else { + this.claimName = null; + this.claimValues = Collections.emptySet(); + } + } + + /** + * Whether the user info has one of the required values in the claim (a string, or a list of + * them, as JSON gives them), compared exactly; always false if the setting is off + */ + private boolean isClaimAuthorised(Map map) { + if (this.claimName == null) { + return false; + } + Object claim = map.get(this.claimName); + Collection values = + claim instanceof Collection + ? (Collection) claim + : Collections.singletonList(claim); + return values.stream() + .anyMatch(value -> value instanceof String && this.claimValues.contains(value)); } @Override @@ -107,6 +151,7 @@ public Object extractPrincipal(Map map) { .teskMemberedGroups(memberedGroups) .teskManagedGroups(managedGroups) .teskAdmin(isAdmin) + .claimAuthorised(isClaimAuthorised(map)) .build(); } } diff --git a/src/main/java/uk/ac/ebi/tsc/tesk/config/security/User.java b/src/main/java/uk/ac/ebi/tsc/tesk/config/security/User.java index 9caf3c32..59df2276 100644 --- a/src/main/java/uk/ac/ebi/tsc/tesk/config/security/User.java +++ b/src/main/java/uk/ac/ebi/tsc/tesk/config/security/User.java @@ -32,6 +32,12 @@ public class User implements Serializable, UserDetails, Principal { /** Is the user a TESK installation admin (belongs to a group with that meaning) */ private boolean teskAdmin = false; + /** + * Is the user authorised without groups, by a value of the claim configured in + * tesk.api.authorisation.claim (always false if that setting is off) + */ + private boolean claimAuthorised = false; + /** * List of 'organisational' groups, to which the user belongs (last part of Elixir group names * only (see {@link ElixirPrincipalExtractor} to see, how that information is retrieved from @@ -58,6 +64,10 @@ public boolean isTeskAdmin() { return teskAdmin; } + public boolean isClaimAuthorised() { + return claimAuthorised; + } + public boolean isGroupMember(String groupName) { return teskMemberedGroups != null && teskMemberedGroups.contains(groupName); } @@ -91,6 +101,13 @@ public boolean isMemberInNonManagedGroups() { return false; } + /** + * Label selector of the taskmaster jobs that the user may list, in addition to the job type: + * none for admins (all tasks); the groups (and, unless a manager of one, the own tasks in them) + * for group members and managers; the own tasks without a group for users authorised by the + * claim only (tesk.api.authorisation.claim). Null also for users without any of these rights, + * who may not list tasks at all (see {@code TesService#listTasks}). + */ public String getLabelSelector() { Set allTeskGroups = new LinkedHashSet<>(); if (this.teskMemberedGroups != null) { @@ -113,6 +130,15 @@ public String getLabelSelector() { } return sb.toString(); } + if (isClaimAuthorised()) { + // authorised without groups: only the own tasks without a group (admins returned + // above, so that they keep seeing all tasks) + return Constants.LABEL_USERID_KEY + + "=" + + getUsername() + + ",!" + + Constants.LABEL_GROUPNAME_KEY; + } return null; } @@ -126,7 +152,8 @@ public String getLabelSelector() { Set allGroups, Set teskMemberedGroups, Set teskManagedGroups, - boolean teskAdmin) { + boolean teskAdmin, + boolean claimAuthorised) { this.userId = userId; this.preferredUsername = preferredUsername; this.name = name; @@ -137,6 +164,7 @@ public String getLabelSelector() { this.teskMemberedGroups = teskMemberedGroups; this.teskManagedGroups = teskManagedGroups; this.teskAdmin = teskAdmin; + this.claimAuthorised = claimAuthorised; } @Override @@ -211,6 +239,8 @@ public String toString() { + ")" + ", iasAdmin=" + isTeskAdmin() + + ", claimAuthorised=" + + isClaimAuthorised() + "}"; } @@ -228,6 +258,9 @@ public boolean equals(Object o) { if (teskAdmin != user.teskAdmin) { return false; } + if (claimAuthorised != user.claimAuthorised) { + return false; + } if (userId != null ? !userId.equals(user.userId) : user.userId != null) { return false; } @@ -271,6 +304,7 @@ public int hashCode() { result = 31 * result + (email != null ? email.hashCode() : 0); result = 31 * result + (allGroups != null ? allGroups.hashCode() : 0); result = 31 * result + (teskAdmin ? 1 : 0); + result = 31 * result + (claimAuthorised ? 1 : 0); result = 31 * result + (teskMemberedGroups != null ? teskMemberedGroups.hashCode() : 0); result = 31 * result + (teskManagedGroups != null ? teskManagedGroups.hashCode() : 0); return result; @@ -287,6 +321,7 @@ public static class UserBuilder { private Set teskMemberedGroups; private Set teskManagedGroups; private boolean teskAdmin = false; + private boolean claimAuthorised = false; UserBuilder(String userId) { this.userId = userId; @@ -341,6 +376,11 @@ public UserBuilder teskAdmin(boolean teskAdmin) { return this; } + public UserBuilder claimAuthorised(boolean claimAuthorised) { + this.claimAuthorised = claimAuthorised; + return this; + } + public User build() { return new User( userId, @@ -352,7 +392,8 @@ public User build() { allGroups, teskMemberedGroups, teskManagedGroups, - teskAdmin); + teskAdmin, + claimAuthorised); } } } diff --git a/src/main/java/uk/ac/ebi/tsc/tesk/tes/service/TesService.java b/src/main/java/uk/ac/ebi/tsc/tesk/tes/service/TesService.java index aa956180..e49ca811 100644 --- a/src/main/java/uk/ac/ebi/tsc/tesk/tes/service/TesService.java +++ b/src/main/java/uk/ac/ebi/tsc/tesk/tes/service/TesService.java @@ -18,12 +18,14 @@ public interface TesService { /** * Creates new TES task, by converting input and calling create method. In case of detecting - * duplicated task ID, retries with new generated ID (up to a limit of retries) + * duplicated task ID, retries with new generated ID (up to a limit of retries). Users + * authorised by the claim only (tesk.api.authorisation.claim) create tasks without a group. */ @PreAuthorize( - "hasRole(@authorisationProperties.baseGroupFull) AND" + "(hasRole(@authorisationProperties.baseGroupFull) AND" + "(#user.member OR #user.teskAdmin) AND" - + "(#task.tags?.get('GROUP_NAME') == null OR #user.isGroupMember(#task.tags['GROUP_NAME']))") + + "(#task.tags?.get('GROUP_NAME') == null OR #user.isGroupMember(#task.tags['GROUP_NAME'])))" + + " OR (#user.claimAuthorised AND #task.tags?.get('GROUP_NAME') == null)") TesCreateTaskResponse createTask(TesTask task, User user); /** @@ -37,7 +39,8 @@ public interface TesService { @PostAuthorize( "(#user.username == returnObject.logs[0].metadata['USER_ID'] AND returnObject.logs[0].metadata['GROUP_NAME'] != null AND #user.isGroupMember(returnObject.logs[0].metadata['GROUP_NAME']))" + " OR (#user.teskAdmin)" - + " OR (returnObject.logs[0].metadata['GROUP_NAME'] != null AND #user.isGroupManager(returnObject.logs[0].metadata['GROUP_NAME']))") + + " OR (returnObject.logs[0].metadata['GROUP_NAME'] != null AND #user.isGroupManager(returnObject.logs[0].metadata['GROUP_NAME']))" + + " OR (#user.claimAuthorised AND #user.username == returnObject.logs[0].metadata['USER_ID'] AND returnObject.logs[0].metadata['GROUP_NAME'] == null)") TesTask getTask(String taskId, TaskView view, User user); /** @@ -53,17 +56,20 @@ public interface TesService { * resulting task * @return - the page of tasks, with the continue token of the next page, if there is one */ - @PreAuthorize("#user.teskAdmin OR #user.manager OR #user.member") + @PreAuthorize("#user.teskAdmin OR #user.manager OR #user.member OR #user.claimAuthorised") TesListTasksResponse listTasks( String namePrefix, Long pageSize, String pageToken, TaskView view, User user); /** * Cancels a task with a given id, by setting a label to taskmaster Job and Pod object. If not * task with a given Id - throws TaskNotFoundException If task completed, throws - * CancelNotRunningTask + * CancelNotRunningTask. The controller gets the task for the user first, so that users can only + * cancel the tasks they may get. * * @param taskId - TES task ID (==taskmaster's job name) */ - @PreAuthorize("hasRole(@authorisationProperties.baseGroupFull)") + @PreAuthorize( + "hasRole(@authorisationProperties.baseGroupFull)" + + " OR (principal instanceof T(uk.ac.ebi.tsc.tesk.config.security.User) AND principal.claimAuthorised)") void cancelTask(String taskId); } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 52877519..96015764 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -45,6 +45,9 @@ tesk.api.authorisation.base-group-full=${tesk.api.authorisation.base-group-nosuf tesk.api.authorisation.admin-group-full=${tesk.api.authorisation.base-group-prefix}${tesk.api.authorisation.admin-subgroup}${tesk.api.authorisation.group-suffix} tesk.api.authorisation.admin-subgroup-suffix=${tesk.api.authorisation.delimiter}${tesk.api.authorisation.admin-subgroup} +#authorisation without groups, by a claim of the user info (off by default; when on, set +#tesk.api.authorisation.claim.name and tesk.api.authorisation.claim.values, see docs/access-control.md) +tesk.api.authorisation.claim.enabled=false #server.port=8082 #Swagger UI at /swagger-ui/ (/ redirects to it) with its static resources and configuration (/swagger-resources/**) diff --git a/src/test/java/uk/ac/ebi/tsc/tesk/config/security/ClaimAuthorisationTest.java b/src/test/java/uk/ac/ebi/tsc/tesk/config/security/ClaimAuthorisationTest.java new file mode 100644 index 00000000..21874e2b --- /dev/null +++ b/src/test/java/uk/ac/ebi/tsc/tesk/config/security/ClaimAuthorisationTest.java @@ -0,0 +1,175 @@ +package uk.ac.ebi.tsc.tesk.config.security; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.junit.Test; + +/** + * Authorisation without groups (issue #78): the setting {@code tesk.api.authorisation.claim.*}, the + * claim check of {@link ElixirPrincipalExtractor} and the label selector of {@link User} + */ +public class ClaimAuthorisationTest { + + private static final String CLAIM = "eduperson_assurance"; + private static final String MEDIUM = "https://refeds.org/assurance/IAP/medium"; + private static final String HIGH = "https://refeds.org/assurance/IAP/high"; + + private static AuthorisationProperties properties( + boolean enabled, String name, String... values) { + AuthorisationProperties properties = new AuthorisationProperties(); + properties.setBaseGroupPrefix("BASE_GROUP:"); + properties.setAdminSubgroup("ADMIN"); + properties.setAdminGroupFull("BASE_GROUP:ADMIN"); + properties.getClaim().setEnabled(enabled); + properties.getClaim().setName(name); + properties.getClaim().setValues(Arrays.asList(values)); + return properties; + } + + private static User extract( + AuthorisationProperties properties, Object claimValue, String... groups) { + ElixirPrincipalExtractor extractor = + new ElixirPrincipalExtractor( + new GroupNamesSecurityExtractor("groupNames", "whatever"), properties); + Map map = new HashMap<>(); + map.put("sub", "user@example.org"); + if (claimValue != null) { + map.put(CLAIM, claimValue); + } + if (groups.length > 0) { + map.put("groupNames", Arrays.asList(groups)); + } + return (User) extractor.extractPrincipal(map); + } + + @Test + public void enabled_claimValueInList() { + User user = extract(properties(true, CLAIM, MEDIUM, HIGH), Arrays.asList("low", HIGH)); + assertTrue(user.isClaimAuthorised()); + } + + @Test + public void enabled_claimValueAsString() { + User user = extract(properties(true, CLAIM, MEDIUM, HIGH), MEDIUM); + assertTrue(user.isClaimAuthorised()); + } + + @Test + public void enabled_claimAbsent() { + User user = extract(properties(true, CLAIM, MEDIUM), null); + assertFalse(user.isClaimAuthorised()); + } + + @Test + public void enabled_claimWrongValue() { + assertFalse( + extract(properties(true, CLAIM, MEDIUM), Arrays.asList("low", MEDIUM + "/x")) + .isClaimAuthorised()); + assertFalse(extract(properties(true, CLAIM, MEDIUM), "").isClaimAuthorised()); + assertFalse( + extract(properties(true, CLAIM, MEDIUM), Collections.emptyList()) + .isClaimAuthorised()); + // values are compared exactly, also in case + assertFalse( + extract(properties(true, CLAIM, MEDIUM), MEDIUM.toUpperCase()).isClaimAuthorised()); + } + + @Test + public void disabled_claimValueIgnored() { + User user = extract(properties(false, CLAIM, MEDIUM), MEDIUM); + assertFalse(user.isClaimAuthorised()); + } + + /** Without the setting, the name and values need not be set */ + @Test + public void disabled_noNameOrValues() { + User user = extract(properties(false, null), MEDIUM); + assertFalse(user.isClaimAuthorised()); + } + + @Test(expected = IllegalStateException.class) + public void enabled_noName() { + extract(properties(true, " ", MEDIUM), MEDIUM); + } + + @Test(expected = IllegalStateException.class) + public void enabled_noValues() { + extract(properties(true, CLAIM), MEDIUM); + } + + @Test(expected = IllegalStateException.class) + public void enabled_onlyBlankValues() { + extract(properties(true, CLAIM, ""), MEDIUM); + } + + @Test(expected = IllegalStateException.class) + public void enabled_nullValues() { + AuthorisationProperties properties = properties(true, CLAIM); + properties.getClaim().setValues(null); + extract(properties, MEDIUM); + } + + /** Configured values are trimmed, the claim's values aren't */ + @Test + public void enabled_trimmedValues() { + assertTrue( + extract(properties(true, CLAIM, " " + MEDIUM + " "), MEDIUM).isClaimAuthorised()); + assertFalse(extract(properties(true, CLAIM, MEDIUM), " " + MEDIUM).isClaimAuthorised()); + } + + /** Values of other types than strings never match */ + @Test + public void enabled_nonStringValues() { + assertFalse(extract(properties(true, CLAIM, "1"), 1).isClaimAuthorised()); + assertFalse( + extract(properties(true, CLAIM, "true"), Arrays.asList(true, 1)) + .isClaimAuthorised()); + } + + /** Users differ by the claim, also in their hash code and text */ + @Test + public void equalsHashCodeToString() { + User user = User.builder("123").claimAuthorised(true).build(); + User same = User.builder("123").claimAuthorised(true).build(); + User other = User.builder("123").claimAuthorised(false).build(); + assertEquals(user, same); + assertEquals(user.hashCode(), same.hashCode()); + assertNotEquals(user, other); + assertNotEquals(user.hashCode(), other.hashCode()); + assertTrue(user.toString().contains("claimAuthorised=true")); + } + + /** A user authorised by the claim only sees the own tasks without a group */ + @Test + public void labelSelector_claimUser() { + User user = extract(properties(true, CLAIM, MEDIUM), MEDIUM); + assertEquals( + "creator-user-id=user_example.org,!creator-group-name", user.getLabelSelector()); + } + + /** Admins see all tasks: no selector, also with the claim */ + @Test + public void labelSelector_adminWithClaim() { + User user = extract(properties(true, CLAIM, MEDIUM), MEDIUM, "BASE_GROUP:ADMIN"); + assertTrue(user.isTeskAdmin()); + assertTrue(user.isClaimAuthorised()); + assertNull(user.getLabelSelector()); + } + + /** Group members keep the selector of their groups, also with the claim */ + @Test + public void labelSelector_memberWithClaim() { + User user = extract(properties(true, CLAIM, MEDIUM), MEDIUM, "BASE_GROUP:TEST"); + assertEquals( + "creator-group-name in (TEST),creator-user-id=user_example.org", + user.getLabelSelector()); + } +}