diff --git a/README.md b/README.md
index 71a52e953..8e165568b 100644
--- a/README.md
+++ b/README.md
@@ -5,18 +5,23 @@
The crux of IAM in Sam is a policy. A policy says **who** can **do what** to a **thing**. More technically the who is called a **subject** and can be a user or a group of users, the do what is called an **action** such as read or update, and the thing is called a **resource** such as a workspace or project. Resources have types which specify what actions are available for its resources, roles (which are collections of actions) and which role is the "owner" role. The "owner" role should have the appropriate actions to administer a resource. When a resource is created a policy with the owner role is automatically created and the creator is added.
## Terms
-* Subject - an authenticated user or group
+* Subject - an authenticated user, group or policy (policies contain a set of subjects and can be treated as a group)
* Resource - something to which access is controlled
* Action - may be performed on a resource - meant to be as granular as possible
-* Policy - represents the actions a subject may perform on a resource
+* Policy - represents the actions a set of subjects may perform on a resource
* Role - a collection of actions - meant to aggregate actions into a more meaningful, higher level concept
* Group - a group of subjects (this can include groups)
* Resource type - defines a class of resources. Each resource has a type which defines
* Available actions
* Available roles and actions for each role
- * Of the available roles which is the “owner” role - this is used when creating a resource to give the creator ownership access
+ * Of the available roles which is the “owner” role - this is used make sure resources are not orphaned
-## Requirements
+## Best Practices
+* Use roles to aggregate actions into a more meaningful, higher level concept. Changing roles is a configuration change and affects all resources with that role, easy. Changing policies requires an api call or direct database updates and affects only the resource the policy is attached to, hard in bulk.
+* Define actions to be as granular as possible. This allows for better composability of roles.
+* Check only 1 action per api call. Complex checks involving multiple actions or even roles are code smells indicating poorly modeled access control. How a subject gets an action might be complicated (groups, hierarchy, etc.) but the action itself should be simple. Of course, there are exceptions to this rule, such as apis that deal with more than one resource, but they should be a minority.
+
+## Design
### Guiding Principles
There are no special/super users in this system. All api calls authenticate as subjects with access rights determined by policies in the same way. In other words, this system should use its own policy mechanisms internally for any authorization needs. (Note that this does leave the problem of bootstrapping, i.e. how is the first user created, which can be achieved by scripts outside the system with direct data store level access.)
This system can be publicly facing. This does not mean that it will be in all cases but it should be designed with this in mind.
@@ -29,7 +34,7 @@ Evaluation is the act of determining what a user may access.
1. Given a user and resource, list all the actions the user may perform on that resource
1. Given a user and resource, list all the user’s roles on that resource
-Of these 1 and 2 are the most important from a performance standpoint. Expect 1 to be called for almost every api call in a system. Expect 2 to be called from UI list pages where users generally want a snappy response.
+Of these 1 and 2 are the most important from a performance standpoint. Expect 1 to be called for almost every api call in a system. Expect 2 to be called from UI list pages where users generally want a snappy response. 2 and 4 should never be used to make access decisions because role definitions may change, they are for informational purposes only.
### Resource and Policy Management
A resource may be part of a hierarchy of resources. A parent may be set on a resource. To do so, users must have the set_parent action on the resource and the add_child action on the would be parent. Ancestor resources in the hierarchy control permissions on all descendants.
@@ -41,16 +46,14 @@ A policy is specific to a resource and a resource may have multiple policies. Ea
* A set of descendant permissions - roles and actions applicable to descendant resources
All of the subjects may perform all of the actions/roles in the policy. A policy may also be marked as public effectively meaning all users are members. Each policy has a name that is unique within a resource. Access to actions through policies is additive (i.e. the actions available to a user on a resource is an accumulation of all policies the user is a member of for that resource).
-There must be functions to create, delete and manage policies for resources. There must be access control around deleting resources and managing policies. There must be some built-in actions to do so (delete, read-policies, alter-policies).
-
-There must be functions to create and delete resources. When a resource is created the caller should be the “owner.” The “owner” role generally will include delete action and actions to control sharing but need not always (e.g. if a resource may never be deleted then an owner would not have delete permissions). The actions that make up the “owner” role are defined by the resource type.
+The “owner” role of a resource generally will include delete action and actions to control sharing but need not always (e.g. if a resource may never be deleted then an owner would not have delete permissions). The actions that make up the “owner” role are defined by the resource type.
-Resource types define the set of available actions for all resources of that type. It also defines a set of roles and their associated actions. Roles are useful because it can be cumbersome to deal with granular actions and as a point of extensibility (when new actions are added to resource types, they can be added to roles as well, effectively adding the action to all resources with that role). It is not yet necessary to provide apis to create and maintain resource types, this can be achieved through configuration.
+Resource types define the set of available actions for all resources of that type. It also defines a set of roles and their associated actions. Roles are useful because it can be cumbersome to deal with granular actions and as a point of extensibility (when new actions are added to resource types, they can be added to roles as well, effectively adding the action to all resources with that role). Creating and maintaining resource types is achieved through [configuration](src/main/resources/reference.conf).
### Public Policies
There are some cases where it is desirable to grant actions or roles to all authenticated users. For example, granting read-only access to public workspaces. In this case a policy can be created that has the appropriate actions or roles and set to public. Resources with public policies show up when listing resources for a user. For this reason it is not always desirable to allow everyone to make public policies. Again, the example is public workspaces. Public workspaces show up for everyone and should be curated.
-To change a policy's public status the caller must be able to share the policy (either via `alter_policies` and `share_policy::{policy_name}` actions) _and_ must have the `set_public` action on the resource `resource_type_admin/{resource type name}`. `resource_type_admin` is an internally created resource type. `{resource type name}` is for the resource containing the policy. Note that every resource type in sam has a resource of the same name of type `resource_type_admin` which is automatically created. When these resources are created they do not have owners, permissions must be granted via direct postgres changes.
+To change a policy's public status the caller must be able to share the policy (either via `alter_policies` and `share_policy::{policy_name}` actions) _and_ must have the `set_public` action on the resource `resource_type_admin/{resource type name}`. `resource_type_admin` is an internally created resource type. `{resource type name}` is for the resource containing the policy. Note that every resource type in sam has a resource of the same name of type `resource_type_admin` which is automatically created. When these resources are created they do not have owners, permissions must be granted via admin api calls.
### User and Group Management
User - Create, enable, disable, get status. Disabled users should be rejected from any api calls. Enabling a user should reinstate any prior access.
@@ -123,10 +126,6 @@ class SamClient(samBasePath: String) {
* Proxy groups - each user with access to google resources should have a google group known as a proxy. The proxy is 1-to-1 with the user and the user is member of the proxy. The proxy group should be used in place of the user in Google IAM policies and Google groups. Users should not be added directly. This allows easy enable and disable functionality by adding/removing users to their proxy groups. It also allows creation of service accounts that can act as the user (see pet service accounts below).
* Pet service accounts - Google Compute Engine requires a service account to run compute. Service account credentials are the default credentials on any GCE instance. This is the best way at this time to provide credentials to any processes running on a GCE instance. Pet service accounts correspond with 1 and only 1 user, are added to the user’s proxy group and can call system apis as the user. In this way a pet service account can act as the user in all respects that can be controlled by the system (resources outside control of the system need to be manually shared by the user with the proxy group).
-#### Proposed model for accessing external google resources
-![Data Access](data_access.png)
-
-Note that Sam does not actually launch workflows create VMs but appears to in this diagram in order to simplify interactions. The key concept is the user of service accounts.
#### Google integration requires
* a GSuite domain
* a project with a service account for the sam application
diff --git a/data_access.png b/data_access.png
deleted file mode 100644
index 41ec1c662..000000000
Binary files a/data_access.png and /dev/null differ
diff --git a/env/local.env b/env/local.env
index d35ac44bb..ade39d250 100644
--- a/env/local.env
+++ b/env/local.env
@@ -5,7 +5,7 @@ export ADMIN_SERVICE_ACCOUNT_4="src/main/resources/rendered/admin-service-accoun
export ADMIN_SERVICE_ACCOUNT_5="src/main/resources/rendered/admin-service-account-5.json"
export SERVICE_ACCOUNT_ADMINS="service-admin@dev.test.firecloud.org, service-admin2@dev.test.firecloud.org"
export AZURE_ENABLED="false"
-export AZURE_SERVICE_CATALOG_APPS_ENABLED="false"
+export AZURE_SERVICE_CATALOG_ENABLED="false"
export AZURE_MANAGED_APP_WORKLOAD_CLIENT_ID="661e243c-5ef9-4a9c-9be3-b7f5585828b3"
export EMAIL_DOMAIN="dev.test.firecloud.org"
export ENVIRONMENT="dev"
diff --git a/env/test.env b/env/test.env
index d3125fd67..176fdd6df 100644
--- a/env/test.env
+++ b/env/test.env
@@ -6,7 +6,7 @@ export AZURE_MANAGED_APP_CLIENT_SECRET="foo"
export AZURE_MANAGED_APP_TENANT_ID="foo"
export AZURE_MANAGED_APP_WORKLOAD_CLIENT_ID="foo"
export AZURE_ALLOW_MANAGED_IDENTITY_USER_CREATION="false"
-export AZURE_SERVICE_CATALOG_APPS_ENABLED="false"
+export AZURE_SERVICE_CATALOG_ENABLED="false"
export EMAIL_DOMAIN="dev.test.firecloud.org"
export ENVIRONMENT="local"
export GOOGLE_APPS_DOMAIN="test.firecloud.org"
diff --git a/pact4s/src/test/scala/org/broadinstitute/dsde/workbench/sam/provider/SamProviderSpec.scala b/pact4s/src/test/scala/org/broadinstitute/dsde/workbench/sam/provider/SamProviderSpec.scala
index e972c241c..e924f93ab 100644
--- a/pact4s/src/test/scala/org/broadinstitute/dsde/workbench/sam/provider/SamProviderSpec.scala
+++ b/pact4s/src/test/scala/org/broadinstitute/dsde/workbench/sam/provider/SamProviderSpec.scala
@@ -137,7 +137,7 @@ class SamProviderSpec
when {
googleExt.getArbitraryPetServiceAccountToken(any[SamUser], any[Set[String]], any[SamRequestContext])
} thenReturn {
- Future.successful("aToken")
+ IO.pure("aToken")
}
)
} yield ()
diff --git a/src/main/resources/org/broadinstitute/dsde/sam/liquibase/changelog.xml b/src/main/resources/org/broadinstitute/dsde/sam/liquibase/changelog.xml
index 578a27770..6239c4543 100644
--- a/src/main/resources/org/broadinstitute/dsde/sam/liquibase/changelog.xml
+++ b/src/main/resources/org/broadinstitute/dsde/sam/liquibase/changelog.xml
@@ -31,4 +31,6 @@
+
+
diff --git a/src/main/resources/org/broadinstitute/dsde/sam/liquibase/changesets/20240809_sam_user_favorite_resources_table.xml b/src/main/resources/org/broadinstitute/dsde/sam/liquibase/changesets/20240809_sam_user_favorite_resources_table.xml
new file mode 100644
index 000000000..24c3e4ddf
--- /dev/null
+++ b/src/main/resources/org/broadinstitute/dsde/sam/liquibase/changesets/20240809_sam_user_favorite_resources_table.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/org/broadinstitute/dsde/sam/liquibase/changesets/20240820_descendant_auth_domains.xml b/src/main/resources/org/broadinstitute/dsde/sam/liquibase/changesets/20240820_descendant_auth_domains.xml
new file mode 100644
index 000000000..97a57d865
--- /dev/null
+++ b/src/main/resources/org/broadinstitute/dsde/sam/liquibase/changesets/20240820_descendant_auth_domains.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+ with recursive
+ ancestor_resource(resource_id, group_id) as (
+ select ad.resource_id, ad.group_id
+ from sam_resource_auth_domain ad
+ union
+ select childResource.id, ancestorResource.group_id
+ from SAM_RESOURCE childResource
+ join ancestor_resource ancestorResource on ancestorResource.resource_id = childResource.resource_parent_id
+ )
+
+ insert into sam_resource_auth_domain(resource_id, group_id)
+ select ar.resource_id, ar.group_id
+ from ancestor_resource ar
+ on conflict do nothing
+
+
+
+
diff --git a/src/main/resources/reference.conf b/src/main/resources/reference.conf
index 886c2709f..3dde1994b 100644
--- a/src/main/resources/reference.conf
+++ b/src/main/resources/reference.conf
@@ -36,6 +36,9 @@ resourceTypes = {
admin_read_summary_information = {
description = "view summary information on resources of this resource type"
}
+ admin_specify_acting_user = {
+ description = "specify a different user that is preforming a given action on the resource"
+ }
}
ownerRoleName = "owner"
@@ -227,6 +230,24 @@ resourceTypes = {
google-project = ["pet-creator"]
}
}
+ rawls = {
+ roleActions = [
+ # workspace clone and delete
+ "read_job_result"
+ # workspace clone - create WDS
+ "create_controlled_user_private"
+ # workspace clone - create storage container
+ "create_controlled_user_shared"
+ # workspace delete - leo checks for this action when deleting runtimes
+ "delete"
+ # workspace delete - WSM ensures there are no children before deleting
+ "list_children"
+ # workspace clone - create WDS
+ "add_child"
+ # workspace clone - get storage container, get cloud context and spend profile id
+ "read"
+ ]
+ }
}
authDomainConstrainable = true
allowLeaving = true
@@ -282,6 +303,14 @@ resourceTypes = {
reader = {
roleActions = ["read"]
}
+ rawls = {
+ roleActions = [
+ # workspace clone - read source workspace storage containers
+ "read"
+ # wds clone needs to write db backup to target workspace storage container
+ "write"
+ ]
+ }
}
reuseIds = false
}
@@ -391,6 +420,12 @@ resourceTypes = {
reader = {
roleActions = ["read"]
}
+ rawls = {
+ roleActions = [
+ # workspace delete
+ "delete"
+ ]
+ }
}
reuseIds = false
}
@@ -738,7 +773,14 @@ resourceTypes = {
google-project = ["notebook-user"]
}
}
+ rawls = {
+ roleActions = [
+ # billing project delete
+ "delete"
+ ]
+ }
}
+ allowLeaving = true
reuseIds = true
}
notebook-cluster = {
@@ -1099,6 +1141,12 @@ resourceTypes = {
"read_spend_report" = {
description = "read spend report for this spend profile"
}
+ "read_job_result" = {
+ description = "allows reading the result of a job"
+ }
+ "read_profile" = {
+ description = "read spend profile"
+ }
}
ownerRoleName = "owner"
roles = {
@@ -1106,21 +1154,35 @@ resourceTypes = {
descendantRoles = {
landing-zone = ["owner"]
}
- roleActions = ["update_billing_account", "update_metadata", "delete", "link", "share_policy::owner", "share_policy::user", "share_policy::pet-creator", "read_policies", "add_child", "list_children", "view_journal", "set_managed_resource_group", "create-pet", "read_spend_report"]
+ roleActions = ["update_billing_account", "update_metadata", "delete", "link", "share_policy::owner", "share_policy::user", "share_policy::pet-creator", "read_policies", "add_child", "list_children", "view_journal", "set_managed_resource_group", "create-pet", "read_spend_report", "read_job_result", "read_profile"]
}
user = {
descendantRoles = {
landing-zone = ["user"]
}
- roleActions = ["link", "share_policy::user", "share_policy::pet-creator", "read_policy::pet-creator", "add_child", "create-pet"]
+ roleActions = ["link", "share_policy::user", "share_policy::pet-creator", "read_policy::pet-creator", "add_child", "create-pet", "read_job_result", "read_profile"]
}
admin = {
- roleActions = ["share_policy::owner", "read_policies", "alter_policies", "delete"]
+ roleActions = ["share_policy::owner", "read_policies", "alter_policies", "delete", "read_profile"]
}
pet-creator = {
- roleActions = ["create-pet", "share_policy::pet-creator", "read_policy::pet-creator"]
+ roleActions = ["create-pet", "share_policy::pet-creator", "read_policy::pet-creator", "read_profile"]
+ }
+ system = {
+ roleActions = ["read_profile"]
+ }
+ rawls = {
+ roleActions = [
+ # landing zone creation, billing project delete
+ "read_job_result"
+ # billing project delete
+ "delete"
+ # leonardo creates a pet even for a shared app
+ "create-pet"
+ ]
}
}
+ allowLeaving = true
reuseIds = true
}
study = {
@@ -1421,6 +1483,12 @@ resourceTypes = {
user = {
roleActions = ["list_resources"]
}
+ rawls = {
+ roleActions = [
+ # billing project delete
+ "list_resources"
+ ]
+ }
}
reuseIds = ${?LANDINGZONES_REUSE_IDS}
}
diff --git a/src/main/resources/sam.conf b/src/main/resources/sam.conf
index de6476d54..0951adb47 100644
--- a/src/main/resources/sam.conf
+++ b/src/main/resources/sam.conf
@@ -248,3 +248,175 @@ janitor {
trackResourceTopicId = ${?JANITOR_TRACK_RESOURCE_TOPIC_ID}
}
+terra.support.emails = [
+ // dynamically configuring lists is hard, so 10 slots are provided for support emails
+ // add more if more than 10 are ever needed
+ ${?TERRA_SUPPORT_EMAIL_0}
+ ${?TERRA_SUPPORT_EMAIL_1}
+ ${?TERRA_SUPPORT_EMAIL_2}
+ ${?TERRA_SUPPORT_EMAIL_3}
+ ${?TERRA_SUPPORT_EMAIL_4}
+ ${?TERRA_SUPPORT_EMAIL_5}
+ ${?TERRA_SUPPORT_EMAIL_6}
+ ${?TERRA_SUPPORT_EMAIL_7}
+ ${?TERRA_SUPPORT_EMAIL_8}
+ ${?TERRA_SUPPORT_EMAIL_9}
+]
+
+resourceAccessPolicies {
+ resource_type_admin {
+ workspace {
+ support {
+ memberEmails = ${terra.support.emails}
+ roles = ["support"]
+ }
+ rawls {
+ memberEmails = [
+ ${?RAWLS_SERVICE_ACCOUNT}
+ ]
+ descendantPermissions = [
+ {
+ resourceTypeName = "workspace",
+ roles = [
+ "rawls"
+ # WSM requires one of the roles in its hierarchy, discoverer is the lowest but reader is the lowest that leo understands
+ "reader"
+ ]
+ }
+ ]
+ }
+ }
+ managed-group {
+ support {
+ memberEmails = ${terra.support.emails}
+ roles = ["support"]
+ }
+ }
+ billing-project {
+ support {
+ memberEmails = ${terra.support.emails}
+ roles = ["support"]
+ }
+ rawls {
+ memberEmails = [
+ ${?RAWLS_SERVICE_ACCOUNT}
+ ]
+ descendantPermissions = [
+ {
+ resourceTypeName = "billing-project",
+ roles = ["rawls"]
+ }
+ ]
+ }
+ }
+ dataset {
+ support {
+ memberEmails = ${terra.support.emails}
+ roles = ["support"]
+ }
+ }
+ datasnapshot {
+ support {
+ memberEmails = ${terra.support.emails}
+ roles = ["support"]
+ }
+ }
+ spend-profile {
+ system {
+ memberEmails = [
+ ${?LEONARDO_PET_SERVICE_ACCOUNT}
+ ${?WSM_PET_SERVICE_ACCOUNT}
+ ]
+ descendantPermissions = [
+ {
+ resourceTypeName = "spend-profile",
+ roles = ["system"]
+ }
+ ]
+ }
+ rawls {
+ memberEmails = [
+ ${?RAWLS_SERVICE_ACCOUNT}
+ ]
+ actions = ["admin_specify_acting_user"]
+ descendantPermissions = [
+ {
+ resourceTypeName = "spend-profile",
+ roles = ["rawls"]
+ }
+ ]
+ }
+ }
+ landing-zone {
+ rawls {
+ memberEmails = [
+ ${?RAWLS_SERVICE_ACCOUNT}
+ ]
+ descendantPermissions = [
+ {
+ resourceTypeName = "landing-zone",
+ roles = ["rawls"]
+ }
+ ]
+ }
+ }
+ controlled-application-shared-workspace-resource {
+ rawls {
+ memberEmails = [
+ ${?RAWLS_SERVICE_ACCOUNT}
+ ]
+ descendantPermissions = [
+ {
+ resourceTypeName = "controlled-application-shared-workspace-resource",
+ roles = ["rawls"]
+ }
+ ]
+ }
+ }
+ controlled-user-shared-workspace-resource {
+ rawls {
+ memberEmails = [
+ ${?RAWLS_SERVICE_ACCOUNT}
+ ]
+ descendantPermissions = [
+ {
+ resourceTypeName = "controlled-user-shared-workspace-resource",
+ roles = ["rawls"]
+ }
+ ]
+ }
+ }
+ kubernetes-app {
+ rawls {
+ memberEmails = [
+ ${?RAWLS_SERVICE_ACCOUNT}
+ ]
+ descendantPermissions = [
+ {
+ resourceTypeName = "kubernetes-app",
+ roles = [
+ # leo checks for creator or manager role, rawls really only needs delete and status actions
+ "manager"
+ ]
+ }
+ ]
+ }
+ }
+ kubernetes-app-shared {
+ rawls {
+ memberEmails = [
+ ${?RAWLS_SERVICE_ACCOUNT}
+ ]
+ descendantPermissions = [
+ {
+ resourceTypeName = "kubernetes-app-shared",
+ roles = [
+ # leo checks for user or owner role, rawls really only needs delete and status actions
+ "owner"
+ ]
+ }
+ ]
+ }
+ }
+ }
+}
diff --git a/src/main/resources/swagger/api-docs.yaml b/src/main/resources/swagger/api-docs.yaml
index d58d335b8..432f2ccc8 100755
--- a/src/main/resources/swagger/api-docs.yaml
+++ b/src/main/resources/swagger/api-docs.yaml
@@ -70,7 +70,7 @@ paths:
get:
tags:
- Admin
- summary: Retrieves a user record, by user id
+ summary: Retrieves a user record, by user id. Limited to 1000 user IDs.
operationId: adminGetUser
parameters:
- name: userId
@@ -98,6 +98,34 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorReport'
+ /api/admin/v2/user/{userId}/repairCloudAccess:
+ get:
+ tags:
+ - Admin
+ summary: Ensures that a user's proxy group exists and that it is added to any google groups that it should be in.
+ operationId: adminRepairUserCloudAccess
+ parameters:
+ - name: userId
+ in: path
+ description: User ID of the user to have their cloud access repaired
+ required: true
+ schema:
+ type: string
+ responses:
+ 204:
+ description: User access was repaired successfully (as long as the group sync messages succeed)
+ 403:
+ description: You do not have admin privileges
+ content: { }
+ 404:
+ description: User not found
+ content: { }
+ 500:
+ description: Internal Server Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorReport'
/api/admin/v1/user/email/{email}:
get:
tags:
@@ -168,6 +196,45 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorReport'
+ post:
+ tags:
+ - Admin
+ summary: Gets a list of users for a list of Sam User IDs
+ operationId: adminGetUsersByIDs
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ type: string
+ responses:
+ 200:
+ description: list of matching users
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/User'
+ 400:
+ description: Request invalid. Request body must be a list of less than 1000 Sam User IDs.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorReport'
+ 403:
+ description: You do not have service admin privileges
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorReport'
+ 500:
+ description: Internal Server Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorReport'
/api/admin/v1/user/{userId}/disable:
put:
tags:
@@ -1042,6 +1109,61 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorReport'
+ /api/google/v1/petServiceAccount/{project}/{email}/token:
+ post:
+ tags:
+ - Google
+ summary: gets a token for the user's pet service account, get_pet_private_key
+ action on cloud-extension/google required
+ operationId: getUserPetServiceAccountToken
+ parameters:
+ - name: project
+ in: path
+ description: Google project of the pet
+ required: true
+ schema:
+ type: string
+ - name: email
+ in: path
+ description: User's email address
+ required: true
+ schema:
+ type: string
+ requestBody:
+ description: Scopes for the token
+ content:
+ 'application/json':
+ schema:
+ $ref: '#/components/schemas/ArrayOfScopes'
+ required: true
+ responses:
+ 200:
+ description: an access token for the users pet service account
+ content:
+ application/json:
+ schema:
+ type: string
+ 403:
+ description: caller has some access to cloud-extension/google but not to
+ the get_pet_private_key action
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorReport'
+ 404:
+ description: user does not exist or caller does not have any access to cloud-extension/google
+ resource
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorReport'
+ 500:
+ description: Internal Server Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorReport'
+ x-codegen-request-body-name: scopes
/api/google/v1/petServiceAccount/{email}/key:
get:
tags:
@@ -1082,6 +1204,55 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorReport'
+ /api/google/v1/petServiceAccount/{email}/token:
+ post:
+ tags:
+ - Google
+ summary: gets a token for the user's arbitrary pet service account, get_pet_private_key
+ action on cloud-extension/google required
+ operationId: getUserArbitraryPetServiceAccountToken
+ parameters:
+ - name: email
+ in: path
+ description: User's email address
+ required: true
+ schema:
+ type: string
+ requestBody:
+ description: Scopes for the token
+ content:
+ 'application/json':
+ schema:
+ $ref: '#/components/schemas/ArrayOfScopes'
+ required: true
+ responses:
+ 200:
+ description: an access token for the users pet service account
+ content:
+ application/json:
+ schema:
+ type: string
+ 403:
+ description: caller has some access to cloud-extension/google but not to
+ the get_pet_private_key action
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorReport'
+ 404:
+ description: user does not exist or caller does not have any access to cloud-extension/google
+ resource
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorReport'
+ 500:
+ description: Internal Server Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorReport'
+ x-codegen-request-body-name: scopes
/api/google/v1/user/signedUrlForBlob:
post:
tags:
@@ -1735,8 +1906,8 @@ paths:
tags:
- Resources
summary: List resources the calling user has been granted policies/roles/actions on.
- This endpoint MUST NOT be used to determine if a user has an action on a resource.
- This endpoint is ONLY for determining the state of the user's permissions.
+ This endpoint MUST NOT be used to determine if a user has an action on a resource.
+ This endpoint is ONLY for determining the state of the user's permissions.
It does not take into account enabled status, or Terms of Service Compliance, nor does it take into account Auth Domains.
To check if a user is allowed to execute an action on a resource, use /api/resources/v2/{resourceTypeName}/{resourceId}/action/{action}
By default, public resources are not included. Including public resources incurs a 3x cost of DB performance.
@@ -2067,6 +2238,42 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorReport'
+ /api/resources/v2/{resourceTypeName}/{resourceId}/authDomain/satisfied:
+ get:
+ tags:
+ - Resources
+ summary: Check whether the calling user satisfies all Authoriaztion Domain Constraints
+ operationId: isAuthDomainV2Satisfied
+ parameters:
+ - name: resourceTypeName
+ in: path
+ description: Type of resource
+ required: true
+ schema:
+ type: string
+ - name: resourceId
+ in: path
+ description: Id of resource
+ required: true
+ schema:
+ type: string
+ responses:
+ 200:
+ description: User satisfies all authorization domain constraints.
+ Empty if an Auth Domain has not been set
+ 403:
+ description: User does not satisfy all authorization domain constraints.
+ content: { }
+ 404:
+ description: Resource type or resource does not exist or you are not a member
+ of any policy on the resource
+ content: { }
+ 500:
+ description: Internal Server Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorReport'
/api/resources/v2/{resourceTypeName}/{resourceId}/allUsers:
get:
tags:
@@ -3164,6 +3371,106 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorReport'
+ /api/users/v2/self/favoriteResources:
+ get:
+ tags:
+ - Users
+ summary: Gets a user's favorite resources.
+ operationId: getFavoriteResources
+ responses:
+ 200:
+ description: list of the user's favorite resources
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/FullyQualifiedResourceId'
+ 403:
+ description: user not found or is not allowed to use Terra
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorReport'
+ /api/users/v2/self/favoriteResources/{resourceTypeName}:
+ get:
+ tags:
+ - Users
+ summary: Gets a user's favorite resources of a given resource type.
+ operationId: getFavoriteResourcesOfType
+ parameters:
+ - name: resourceTypeName
+ in: path
+ description: Resource type to narrow favorite resources to
+ required: true
+ schema:
+ type: string
+ responses:
+ 200:
+ description: list of the user's favorite resources of a specific resource type
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/FullyQualifiedResourceId'
+ 404:
+ description: resource type not found
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorReport'
+ /api/users/v2/self/favoriteResources/{resourceTypeName}/{resourceId}:
+ put:
+ tags:
+ - Users
+ summary: Add a resource to the calling user's favorites
+ operationId: addFavoriteResource
+ parameters:
+ - name: resourceTypeName
+ in: path
+ description: Resource type of the resource to favorite
+ required: true
+ schema:
+ type: string
+ - name: resourceId
+ in: path
+ description: Resource ID of the resource to favorite
+ required: true
+ schema:
+ type: string
+ responses:
+ 204:
+ description: Favorite resource added
+ content: { }
+ 404:
+ description: resource not found
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorReport'
+ delete:
+ tags:
+ - Users
+ summary: Remove a resource from the calling user's favorites
+ operationId: removeFavoriteResource
+ parameters:
+ - name: resourceTypeName
+ in: path
+ description: Resource type of the resource to favorite
+ required: true
+ schema:
+ type: string
+ - name: resourceId
+ in: path
+ description: Resource ID of the resource to favorite
+ required: true
+ schema:
+ type: string
+ responses:
+ 204:
+ description: Favorite resource removed
+ content: { }
/api/users/v2/{sam_user_id}:
get:
tags:
@@ -4315,7 +4622,7 @@ components:
type: boolean
description: true if there is a rolling acceptance window active. This
means that users who have not accepted the latest terms of service
- version (and who have accepted the previous version) will be allowed to use the system
+ version (and who have accepted the previous version) will be allowed to use the system
for a period of time after the terms of service have been updated.
UserTermsOfServiceDetails:
required:
diff --git a/src/main/scala/org/broadinstitute/dsde/workbench/sam/Boot.scala b/src/main/scala/org/broadinstitute/dsde/workbench/sam/Boot.scala
index 79c138138..f7b3e9300 100644
--- a/src/main/scala/org/broadinstitute/dsde/workbench/sam/Boot.scala
+++ b/src/main/scala/org/broadinstitute/dsde/workbench/sam/Boot.scala
@@ -33,7 +33,7 @@ import org.broadinstitute.dsde.workbench.google.{
HttpGoogleStorageDAO
}
import org.broadinstitute.dsde.workbench.google2.{GoogleStorageInterpreter, GoogleStorageService}
-import org.broadinstitute.dsde.workbench.model.WorkbenchEmail
+import org.broadinstitute.dsde.workbench.model.{WorkbenchEmail, WorkbenchException}
import org.broadinstitute.dsde.workbench.oauth2.{ClientId, OpenIDConnectConfiguration}
import org.broadinstitute.dsde.workbench.sam.api.{LivenessRoutes, SamRoutes, StandardSamUserDirectives}
import org.broadinstitute.dsde.workbench.sam.azure.{AzureService, CrlService}
@@ -44,6 +44,7 @@ import org.broadinstitute.dsde.workbench.sam.db.DbReference
import org.broadinstitute.dsde.workbench.sam.google._
import org.broadinstitute.dsde.workbench.sam.model._
import org.broadinstitute.dsde.workbench.sam.service._
+import org.broadinstitute.dsde.workbench.sam.util.SamRequestContext
import org.broadinstitute.dsde.workbench.sam.util.Sentry.initSentry
import org.broadinstitute.dsde.workbench.util.DelegatePool
import org.typelevel.log4cats.StructuredLogger
@@ -80,6 +81,23 @@ object Boot extends IOApp with LazyLogging {
_ <- dependencies.policyEvaluatorService.initPolicy()
+ // make sure all users referenced by resourceAccessPolicies exist
+ _ <- appConfig.resourceAccessPolicies.flatMap { case (_, policy) => policy.memberEmails }.toList.traverse { email =>
+ dependencies.samApplication.userService.inviteUser(email, SamRequestContext()).attempt
+ }
+
+ // create resourceAccessPolicies
+ policyTrials <- dependencies.samApplication.resourceService.upsertResourceAccessPolicies(appConfig.resourceAccessPolicies)
+ _ = policyTrials.map {
+ case (policyId, Left(t)) =>
+ logger.error(s"FATAL - failure creating configured policy [$policyId] on startup", t)
+ case (policyId, Right(_)) =>
+ logger.info(s"Upserted configured policy [$policyId] at startup")
+ }
+ _ <- IO.raiseWhen(policyTrials.values.exists(_.isLeft))(
+ new WorkbenchException("FATAL - failure creating configured policy on startup, see above errors")
+ )
+
_ <- dependencies.cloudExtensionsInitializer.onBoot(dependencies.samApplication)
binding <- IO.fromFuture(IO(Http().newServerAt("0.0.0.0", 8080).bind(dependencies.samRoutes.route))).onError { case t: Throwable =>
diff --git a/src/main/scala/org/broadinstitute/dsde/workbench/sam/api/AdminRoutes.scala b/src/main/scala/org/broadinstitute/dsde/workbench/sam/api/AdminRoutes.scala
index 0ac4f07ed..19fde1868 100644
--- a/src/main/scala/org/broadinstitute/dsde/workbench/sam/api/AdminRoutes.scala
+++ b/src/main/scala/org/broadinstitute/dsde/workbench/sam/api/AdminRoutes.scala
@@ -145,6 +145,17 @@ trait AdminRoutes extends SecurityDirectives with SamRequestContextDirectives wi
.map(user => (if (user.isDefined) OK else NotFound) -> user)
}
}
+ } ~
+ pathPrefix("repairCloudAccess") {
+ pathEndOrSingleSlash {
+ putWithTelemetry(samRequestContext, userIdParam(workbenchUserId)) {
+ complete {
+ userService
+ .repairCloudAccess(workbenchUserId, samRequestContext)
+ .map(_ => NoContent)
+ }
+ }
+ }
}
}
}
diff --git a/src/main/scala/org/broadinstitute/dsde/workbench/sam/api/ResourceRoutes.scala b/src/main/scala/org/broadinstitute/dsde/workbench/sam/api/ResourceRoutes.scala
index 04dc142c5..5f17cf02e 100644
--- a/src/main/scala/org/broadinstitute/dsde/workbench/sam/api/ResourceRoutes.scala
+++ b/src/main/scala/org/broadinstitute/dsde/workbench/sam/api/ResourceRoutes.scala
@@ -168,6 +168,15 @@ trait ResourceRoutes extends SamUserDirectives with SecurityDirectives with SamM
pathEndOrSingleSlash {
getResourceAuthDomain(resource, samUser, samRequestContext) ~
patchResourceAuthDomain(resource, samUser, samRequestContext)
+ } ~
+ pathPrefix("satisfied") {
+ pathEndOrSingleSlash {
+ complete {
+ resourceService.satisfiesAuthDomainConstrains(resource, samUser, samRequestContext).map { satisfied =>
+ if (satisfied) StatusCodes.OK else StatusCodes.Forbidden
+ }
+ }
+ }
}
} ~
pathPrefix("roles") {
@@ -399,7 +408,7 @@ trait ResourceRoutes extends SamUserDirectives with SecurityDirectives with SamM
patchWithTelemetry(samRequestContext, resourceParams(resource): _*) {
requireAction(resource, SamResourceActions.updateAuthDomain, samUser.id, samRequestContext) {
entity(as[Set[WorkbenchGroupName]]) { authDomains =>
- complete(resourceService.addResourceAuthDomain(resource, authDomains, samUser.id, samRequestContext).map { response =>
+ complete(resourceService.addResourceAuthDomain(resource, authDomains, Option(samUser.id), samRequestContext).map { response =>
StatusCodes.OK -> response
})
}
diff --git a/src/main/scala/org/broadinstitute/dsde/workbench/sam/api/SecurityDirectives.scala b/src/main/scala/org/broadinstitute/dsde/workbench/sam/api/SecurityDirectives.scala
index 3f5e337f2..662bf31c9 100644
--- a/src/main/scala/org/broadinstitute/dsde/workbench/sam/api/SecurityDirectives.scala
+++ b/src/main/scala/org/broadinstitute/dsde/workbench/sam/api/SecurityDirectives.scala
@@ -145,6 +145,25 @@ trait SecurityDirectives {
}
}
+ def requireAnyAction(
+ resource: FullyQualifiedResourceId,
+ userId: WorkbenchUserId,
+ samRequestContext: SamRequestContext
+ ): Directive0 =
+ Directives.mapInnerRoute { innerRoute =>
+ onSuccess(policyEvaluatorService.listUserResourceActions(resource, userId, samRequestContext)) { actions =>
+ if (actions.nonEmpty) {
+ innerRoute
+ } else {
+ Directives.failWith(
+ new WorkbenchExceptionWithErrorReport(
+ ErrorReport(StatusCodes.Forbidden, s"You do not have access to ${resource.resourceTypeName.value}/${resource.resourceId.value}")
+ )
+ )
+ }
+ }
+ }
+
/** in the case where we don't have the required action, we need to figure out if we should return a Not Found (you have no access) vs a Forbidden (you have
* access, just not the right kind)
*/
diff --git a/src/main/scala/org/broadinstitute/dsde/workbench/sam/api/ServiceAdminRoutes.scala b/src/main/scala/org/broadinstitute/dsde/workbench/sam/api/ServiceAdminRoutes.scala
index 950d4be79..b82c6ad7d 100644
--- a/src/main/scala/org/broadinstitute/dsde/workbench/sam/api/ServiceAdminRoutes.scala
+++ b/src/main/scala/org/broadinstitute/dsde/workbench/sam/api/ServiceAdminRoutes.scala
@@ -1,6 +1,8 @@
-package org.broadinstitute.dsde.workbench.sam.api
+package org.broadinstitute.dsde.workbench.sam
+package api
import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport._
+import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.model.StatusCodes.{NotFound, OK}
import akka.http.scaladsl.server
import akka.http.scaladsl.server.Directives._
@@ -8,6 +10,8 @@ import org.broadinstitute.dsde.workbench.model._
import org.broadinstitute.dsde.workbench.sam.model.api.SamJsonSupport._
import org.broadinstitute.dsde.workbench.sam.service.ResourceService
import org.broadinstitute.dsde.workbench.sam.util.SamRequestContext
+import org.broadinstitute.dsde.workbench.model.WorkbenchIdentityJsonSupport._
+import org.broadinstitute.dsde.workbench.sam.model.api.SamUser
import spray.json.DefaultJsonProtocol._
trait ServiceAdminRoutes extends SecurityDirectives with SamRequestContextDirectives with SamUserDirectives with SamModelDirectives {
@@ -53,7 +57,19 @@ trait ServiceAdminRoutes extends SecurityDirectives with SamRequestContextDirect
.map(users => (if (users.nonEmpty) OK else NotFound) -> users)
}
}
+ } ~
+ postWithTelemetry(samRequestContext) {
+ entity(as[Seq[WorkbenchUserId]]) {
+ case Seq() => complete(OK -> Seq.empty[SamUser])
+ case userIds: Seq[WorkbenchUserId] if userIds.length > 1000 =>
+ throw new WorkbenchExceptionWithErrorReport(
+ ErrorReport(StatusCodes.BadRequest, "Batch request too large. Batch request too large, must be less than 1000")
+ )
+ case userIds: Seq[WorkbenchUserId] =>
+ complete {
+ userService.getUsersByIds(userIds, samRequestContext)
+ }
+ }
}
-
}
}
diff --git a/src/main/scala/org/broadinstitute/dsde/workbench/sam/api/UserRoutesV2.scala b/src/main/scala/org/broadinstitute/dsde/workbench/sam/api/UserRoutesV2.scala
index ba8fc057f..4b987ac3f 100644
--- a/src/main/scala/org/broadinstitute/dsde/workbench/sam/api/UserRoutesV2.scala
+++ b/src/main/scala/org/broadinstitute/dsde/workbench/sam/api/UserRoutesV2.scala
@@ -8,15 +8,17 @@ import akka.http.scaladsl.server.{Directive0, ExceptionHandler, Route}
import cats.effect.IO
import org.broadinstitute.dsde.workbench.model._
import org.broadinstitute.dsde.workbench.sam.model.api.SamUserResponse._
+import org.broadinstitute.dsde.workbench.sam.model.api.SamJsonSupport._
import org.broadinstitute.dsde.workbench.sam.model.api._
-import org.broadinstitute.dsde.workbench.sam.model.{ResourceRoleName, ResourceTypeName, TermsOfServiceDetails}
+import org.broadinstitute.dsde.workbench.sam.model.{FullyQualifiedResourceId, ResourceId, ResourceRoleName, ResourceTypeName, TermsOfServiceDetails}
import org.broadinstitute.dsde.workbench.sam.service.{ResourceService, TosService, UserService}
import org.broadinstitute.dsde.workbench.sam.util.SamRequestContext
import spray.json.enrichAny
+import spray.json.DefaultJsonProtocol._
/** Created by tlangs on 10/12/2023.
*/
-trait UserRoutesV2 extends SamUserDirectives with SamRequestContextDirectives {
+trait UserRoutesV2 extends SamUserDirectives with SamRequestContextDirectives with SamModelDirectives with SecurityDirectives {
val userService: UserService
val tosService: TosService
val resourceService: ResourceService
@@ -81,6 +83,27 @@ trait UserRoutesV2 extends SamUserDirectives with SamRequestContextDirectives {
getSamUserCombinedState(samUser, samRequestContext)
}
}
+ } ~
+ // api/user/v2/self/favoriteResources
+ pathPrefix("favoriteResources") {
+ withActiveUser(samRequestContextWithoutUser) { samUser: SamUser =>
+ val samRequestContext = samRequestContextWithoutUser.copy(samUser = Some(samUser))
+ pathEndOrSingleSlash {
+ getFavoriteResources(samUser, samRequestContext)
+ } ~
+ // api/user/v2/self/favoriteResources/{resourceTypeName}
+ pathPrefix(Segment) { resourceTypeName =>
+ withNonAdminResourceType(ResourceTypeName(resourceTypeName)) { resourceType =>
+ getFavoriteResourcesOfType(samUser, resourceType.name, samRequestContext) ~
+ // api/user/v2/self/favoriteResources/{resourceTypeName}/{resourceId}
+ pathPrefix(Segment) { resourceId =>
+ val resource = FullyQualifiedResourceId(resourceType.name, ResourceId(resourceId))
+ addFavoriteResource(samUser, resource, samRequestContext) ~
+ removeFavoriteResource(samUser, resource, samRequestContext)
+ }
+ }
+ }
+ }
}
} ~
pathPrefix(Segment) { samUserId =>
@@ -194,12 +217,14 @@ trait UserRoutesV2 extends SamUserDirectives with SamRequestContextDirectives {
includePublic = false,
samRequestContext
)
+ favoriteResources <- resourceService.getUserFavoriteResources(samUser.id, samRequestContext)
} yield SamUserCombinedStateResponse(
samUser,
allowances,
maybeAttributes,
termsOfServiceDetails.getOrElse(TermsOfServiceDetails(None, None, permitsSystemUsage = false, isCurrentVersion = false)),
- Map("enterpriseFeatures" -> enterpriseFeatures.toJson)
+ Map("enterpriseFeatures" -> enterpriseFeatures.toJson),
+ favoriteResources
)
}
}
@@ -217,4 +242,39 @@ trait UserRoutesV2 extends SamUserDirectives with SamRequestContextDirectives {
}
}
}
+
+ private def getFavoriteResources(samUser: SamUser, samRequestContext: SamRequestContext): Route =
+ getWithTelemetry(samRequestContext) {
+ complete {
+ resourceService.getUserFavoriteResources(samUser.id, samRequestContext).map(StatusCodes.OK -> _)
+ }
+ }
+
+ private def getFavoriteResourcesOfType(samUser: SamUser, resourceTypeName: ResourceTypeName, samRequestContext: SamRequestContext): Route =
+ getWithTelemetry(samRequestContext) {
+ complete {
+ resourceService.getUserFavoriteResourcesOfType(samUser.id, resourceTypeName, samRequestContext).map(StatusCodes.OK -> _)
+ }
+ }
+
+ private def addFavoriteResource(samUser: SamUser, resource: FullyQualifiedResourceId, samRequestContext: SamRequestContext): Route =
+ putWithTelemetry(samRequestContext) {
+ changeForbiddenToNotFound {
+ requireAnyAction(resource, samUser.id, samRequestContext) {
+ complete {
+ resourceService.addUserFavoriteResource(samUser.id, resource, samRequestContext).map {
+ case true => StatusCodes.NoContent
+ case false => StatusCodes.NotFound
+ }
+ }
+ }
+ }
+ }
+
+ private def removeFavoriteResource(samUser: SamUser, resource: FullyQualifiedResourceId, samRequestContext: SamRequestContext): Route =
+ deleteWithTelemetry(samRequestContext) {
+ complete {
+ resourceService.removeUserFavoriteResource(samUser.id, resource, samRequestContext).map(_ => StatusCodes.NoContent)
+ }
+ }
}
diff --git a/src/main/scala/org/broadinstitute/dsde/workbench/sam/config/AppConfig.scala b/src/main/scala/org/broadinstitute/dsde/workbench/sam/config/AppConfig.scala
index 6a20ddfbc..838e6c01e 100644
--- a/src/main/scala/org/broadinstitute/dsde/workbench/sam/config/AppConfig.scala
+++ b/src/main/scala/org/broadinstitute/dsde/workbench/sam/config/AppConfig.scala
@@ -11,9 +11,11 @@ import org.broadinstitute.dsde.workbench.sam.config.AppConfig.AdminConfig
import org.broadinstitute.dsde.workbench.sam.config.GoogleServicesConfig.googleServicesConfigReader
import org.broadinstitute.dsde.workbench.sam.dataAccess.DistributedLockConfig
import org.broadinstitute.dsde.workbench.sam.model._
+import org.broadinstitute.dsde.workbench.sam.model.api.AccessPolicyMembershipRequest
import java.time.Instant
import scala.concurrent.duration.Duration
+import scala.jdk.CollectionConverters._
/** Created by dvoet on 7/18/17.
*/
@@ -30,7 +32,8 @@ final case class AppConfig(
adminConfig: AdminConfig,
azureServicesConfig: Option[AzureServicesConfig],
prometheusConfig: PrometheusConfig,
- janitorConfig: JanitorConfig
+ janitorConfig: JanitorConfig,
+ resourceAccessPolicies: Map[FullyQualifiedPolicyId, AccessPolicyMembershipRequest]
)
object AppConfig {
@@ -228,6 +231,45 @@ object AppConfig {
)
}
+ implicit val accessPolicyDescendantPermissionsReader: ValueReader[AccessPolicyDescendantPermissions] = ValueReader.relative { config =>
+ AccessPolicyDescendantPermissions(
+ ResourceTypeName(config.as[String]("resourceTypeName")),
+ config.as[Option[Set[String]]]("actions").getOrElse(Set.empty).map(ResourceAction.apply),
+ config.as[Option[Set[String]]]("roles").getOrElse(Set.empty).map(ResourceRoleName.apply)
+ )
+ }
+
+ implicit val policyIdentifiersReader: ValueReader[PolicyIdentifiers] = ValueReader.relative { config =>
+ PolicyIdentifiers(
+ AccessPolicyName(config.as[String]("accessPolicyName")),
+ ResourceTypeName(config.as[String]("resourceTypeName")),
+ ResourceId(config.as[String]("resourceId"))
+ )
+ }
+
+ implicit val accessPolicyMembershipRequestReader: ValueReader[AccessPolicyMembershipRequest] = ValueReader.relative { config =>
+ AccessPolicyMembershipRequest(
+ config.as[Set[String]]("memberEmails").map(WorkbenchEmail),
+ config.as[Option[Set[String]]]("actions").getOrElse(Set.empty).map(ResourceAction.apply),
+ config.as[Option[Set[String]]]("roles").getOrElse(Set.empty).map(ResourceRoleName.apply),
+ config.as[Option[Set[AccessPolicyDescendantPermissions]]]("descendantPermissions"),
+ config.as[Option[Set[PolicyIdentifiers]]]("memberPolicies")
+ )
+ }
+
+ implicit val resourceAccessPoliciesConfigReader: ValueReader[Map[FullyQualifiedPolicyId, AccessPolicyMembershipRequest]] = ValueReader.relative { config =>
+ val policies = for {
+ resourceTypeName <- config.root().keySet().asScala
+ resourceId <- config.getConfig(resourceTypeName).root().keySet().asScala
+ policyName <- config.getConfig(s"$resourceTypeName.$resourceId").root().keySet().asScala
+ } yield {
+ val fullyQualifiedPolicyId =
+ FullyQualifiedPolicyId(FullyQualifiedResourceId(ResourceTypeName(resourceTypeName), ResourceId(resourceId)), AccessPolicyName(policyName))
+ fullyQualifiedPolicyId -> config.as[AccessPolicyMembershipRequest](s"$resourceTypeName.$resourceId.$policyName")
+ }
+ policies.toMap
+ }
+
/** Loads all the configs for the Sam App. All values defined in `src/main/resources/sam.conf` will take precedence over any other configs. In this way, we
* can still use configs rendered by `firecloud-develop` that render to `config/sam.conf` if we want. To do so, you must render `config/sam.conf` and then do
* not populate ENV variables for `src/main/resources/sam.conf`.
@@ -278,7 +320,8 @@ object AppConfig {
adminConfig = config.as[AdminConfig]("admin"),
azureServicesConfig = config.getAs[AzureServicesConfig]("azureServices"),
prometheusConfig = config.as[PrometheusConfig]("prometheus"),
- janitorConfig = config.as[JanitorConfig]("janitor")
+ janitorConfig = config.as[JanitorConfig]("janitor"),
+ resourceAccessPolicies = config.as[Option[Map[FullyQualifiedPolicyId, AccessPolicyMembershipRequest]]]("resourceAccessPolicies").getOrElse(Map.empty)
)
}
}
diff --git a/src/main/scala/org/broadinstitute/dsde/workbench/sam/dataAccess/DirectoryDAO.scala b/src/main/scala/org/broadinstitute/dsde/workbench/sam/dataAccess/DirectoryDAO.scala
index 1be569418..eeca37f18 100644
--- a/src/main/scala/org/broadinstitute/dsde/workbench/sam/dataAccess/DirectoryDAO.scala
+++ b/src/main/scala/org/broadinstitute/dsde/workbench/sam/dataAccess/DirectoryDAO.scala
@@ -12,7 +12,7 @@ import org.broadinstitute.dsde.workbench.sam.azure.{
PetManagedIdentityId
}
import org.broadinstitute.dsde.workbench.sam.model.api.{AdminUpdateUserRequest, SamUser, SamUserAttributes}
-import org.broadinstitute.dsde.workbench.sam.model.{BasicWorkbenchGroup, FullyQualifiedResourceId, ResourceAction, SamUserTos}
+import org.broadinstitute.dsde.workbench.sam.model.{BasicWorkbenchGroup, FullyQualifiedResourceId, ResourceAction, ResourceTypeName, SamUserTos}
import org.broadinstitute.dsde.workbench.sam.util.SamRequestContext
import java.time.Instant
@@ -63,6 +63,11 @@ trait DirectoryDAO {
def loadUser(userId: WorkbenchUserId, samRequestContext: SamRequestContext): IO[Option[SamUser]]
+ def batchLoadUsers(
+ samUserIds: Set[WorkbenchUserId],
+ samRequestContext: SamRequestContext
+ ): IO[Seq[SamUser]]
+
def loadUsersByQuery(
userId: Option[WorkbenchUserId],
googleSubjectId: Option[GoogleSubjectId],
@@ -173,4 +178,16 @@ trait DirectoryDAO {
def setUserAttributes(samUserAttributes: SamUserAttributes, samRequestContext: SamRequestContext): IO[Unit]
def listParentGroups(groupName: WorkbenchGroupName, samRequestContext: SamRequestContext): IO[Set[WorkbenchGroupName]]
+
+ def addUserFavoriteResource(userId: WorkbenchUserId, resourceId: FullyQualifiedResourceId, samRequestContext: SamRequestContext): IO[Boolean]
+
+ def removeUserFavoriteResource(userId: WorkbenchUserId, resourceId: FullyQualifiedResourceId, samRequestContext: SamRequestContext): IO[Unit]
+
+ def getUserFavoriteResources(userId: WorkbenchUserId, samRequestContext: SamRequestContext): IO[Set[FullyQualifiedResourceId]]
+
+ def getUserFavoriteResourcesOfType(
+ userId: WorkbenchUserId,
+ resourceTypeName: ResourceTypeName,
+ samRequestContext: SamRequestContext
+ ): IO[Set[FullyQualifiedResourceId]]
}
diff --git a/src/main/scala/org/broadinstitute/dsde/workbench/sam/dataAccess/PostgresDirectoryDAO.scala b/src/main/scala/org/broadinstitute/dsde/workbench/sam/dataAccess/PostgresDirectoryDAO.scala
index cd9be4e70..8018692e9 100644
--- a/src/main/scala/org/broadinstitute/dsde/workbench/sam/dataAccess/PostgresDirectoryDAO.scala
+++ b/src/main/scala/org/broadinstitute/dsde/workbench/sam/dataAccess/PostgresDirectoryDAO.scala
@@ -421,6 +421,25 @@ class PostgresDirectoryDAO(protected val writeDbRef: DbReference, protected val
.map(UserTable.unmarshalUserRecord)
}
+ override def batchLoadUsers(
+ samUserIds: Set[WorkbenchUserId],
+ samRequestContext: SamRequestContext
+ ): IO[Seq[SamUser]] =
+ if (samUserIds.isEmpty) {
+ IO.pure(Seq.empty)
+ } else {
+ readOnlyTransaction("batchLoadUsers", samRequestContext) { implicit session =>
+ val userTable = UserTable.syntax
+ val loadUserQuery = samsql"select ${userTable.resultAll} from ${UserTable as userTable} where ${userTable.id} in (${samUserIds})"
+
+ loadUserQuery
+ .map(UserTable(userTable))
+ .list()
+ .apply()
+ .map(UserTable.unmarshalUserRecord)
+ }
+ }
+
override def loadUsersByQuery(
userId: Option[WorkbenchUserId],
googleSubjectId: Option[GoogleSubjectId],
@@ -1359,4 +1378,91 @@ class PostgresDirectoryDAO(protected val writeDbRef: DbReference, protected val
loadParentGroupsQuery.map(rs => WorkbenchGroupName(rs.string(parent.resultName.name))).list().apply().toSet
}
+
+ override def addUserFavoriteResource(
+ userId: WorkbenchUserId,
+ fullyQualifiedResourceId: FullyQualifiedResourceId,
+ samRequestContext: SamRequestContext
+ ): IO[Boolean] =
+ serializableWriteTransaction("addUserFavoriteResource", samRequestContext) { implicit session =>
+ val resourceTable = ResourceTable.syntax
+ val resourceTypeTable = ResourceTypeTable.syntax
+ val userFavoriteResourcesTable = UserFavoriteResourcesTable.syntax
+ val userFavoriteResourcesColumns = UserFavoriteResourcesTable.column
+ Try {
+ samsql"""
+ insert into ${UserFavoriteResourcesTable as userFavoriteResourcesTable} (${userFavoriteResourcesColumns.samUserId}, ${userFavoriteResourcesColumns.resourceId})
+ values (
+ $userId,
+ (select ${resourceTable.result.id} from ${ResourceTable as resourceTable} left join ${ResourceTypeTable as resourceTypeTable} on ${resourceTable.resourceTypeId} = ${resourceTypeTable.id} where ${resourceTable.name} = ${fullyQualifiedResourceId.resourceId} and ${resourceTypeTable.name} = ${fullyQualifiedResourceId.resourceTypeName})
+ )
+ on conflict do nothing
+ """.update().apply() > 0
+ }.getOrElse(false)
+ }
+
+ override def removeUserFavoriteResource(
+ userId: WorkbenchUserId,
+ fullyQualifiedResourceId: FullyQualifiedResourceId,
+ samRequestContext: SamRequestContext
+ ): IO[Unit] =
+ serializableWriteTransaction("removeUserFavoriteResource", samRequestContext) { implicit session =>
+ val resourceTable = ResourceTable.syntax
+ val resourceTypeTable = ResourceTypeTable.syntax
+ val userFavoriteResourcesColumns = UserFavoriteResourcesTable.column
+ samsql"""
+ delete from ${UserFavoriteResourcesTable.table}
+ where ${userFavoriteResourcesColumns.samUserId} = $userId
+ and ${userFavoriteResourcesColumns.resourceId} = (select ${resourceTable.result.id} from ${ResourceTable as resourceTable} left join ${ResourceTypeTable as resourceTypeTable} on ${resourceTable.resourceTypeId} = ${resourceTypeTable.id} where ${resourceTable.name} = ${fullyQualifiedResourceId.resourceId} and ${resourceTypeTable.name} = ${fullyQualifiedResourceId.resourceTypeName})
+ """.update().apply()
+ }
+
+ override def getUserFavoriteResources(userId: WorkbenchUserId, samRequestContext: SamRequestContext): IO[Set[FullyQualifiedResourceId]] =
+ readOnlyTransaction("getUserFavoriteResources", samRequestContext) { implicit session =>
+ val resourceTable = ResourceTable.syntax
+ val resourceTypeTable = ResourceTypeTable.syntax
+ val userFavoriteResourcesTable = UserFavoriteResourcesTable.syntax
+
+ val loadUserFavoriteResourcesQuery =
+ samsql"""select ${resourceTable.result.name}, ${resourceTypeTable.result.name}
+ from ${ResourceTable as resourceTable}
+ join ${ResourceTypeTable as resourceTypeTable} on ${resourceTable.resourceTypeId} = ${resourceTypeTable.id}
+ join ${UserFavoriteResourcesTable as userFavoriteResourcesTable} on ${resourceTable.id} = ${userFavoriteResourcesTable.resourceId}
+ where ${userFavoriteResourcesTable.samUserId} = $userId"""
+
+ loadUserFavoriteResourcesQuery
+ .map(rs =>
+ FullyQualifiedResourceId(ResourceTypeName(rs.string(resourceTypeTable.resultName.name)), ResourceId(rs.string(resourceTable.resultName.name)))
+ )
+ .list()
+ .apply()
+ .toSet
+ }
+
+ def getUserFavoriteResourcesOfType(
+ userId: WorkbenchUserId,
+ resourceTypeName: ResourceTypeName,
+ samRequestContext: SamRequestContext
+ ): IO[Set[FullyQualifiedResourceId]] =
+ readOnlyTransaction("getUserFavoriteResourcesOfType", samRequestContext) { implicit session =>
+ val resourceTable = ResourceTable.syntax
+ val resourceTypeTable = ResourceTypeTable.syntax
+ val userFavoriteResourcesTable = UserFavoriteResourcesTable.syntax
+
+ val loadUserFavoriteResourcesQuery =
+ samsql"""select ${resourceTable.result.name}
+ from ${ResourceTable as resourceTable}
+ join ${ResourceTypeTable as resourceTypeTable} on ${resourceTable.resourceTypeId} = ${resourceTypeTable.id}
+ join ${UserFavoriteResourcesTable as userFavoriteResourcesTable} on ${resourceTable.id} = ${userFavoriteResourcesTable.resourceId}
+ where ${userFavoriteResourcesTable.samUserId} = $userId
+ and ${resourceTypeTable.name} = $resourceTypeName
+ """
+
+ loadUserFavoriteResourcesQuery
+ .map(rs => FullyQualifiedResourceId(resourceTypeName, ResourceId(rs.string(resourceTable.resultName.name))))
+ .list()
+ .apply()
+ .toSet
+ }
+
}
diff --git a/src/main/scala/org/broadinstitute/dsde/workbench/sam/db/tables/UserFavoriteResourcesTable.scala b/src/main/scala/org/broadinstitute/dsde/workbench/sam/db/tables/UserFavoriteResourcesTable.scala
new file mode 100644
index 000000000..a13da4373
--- /dev/null
+++ b/src/main/scala/org/broadinstitute/dsde/workbench/sam/db/tables/UserFavoriteResourcesTable.scala
@@ -0,0 +1,26 @@
+package org.broadinstitute.dsde.workbench.sam.db.tables
+
+import org.broadinstitute.dsde.workbench.model._
+import org.broadinstitute.dsde.workbench.sam.db.SamTypeBinders
+import scalikejdbc._
+
+import java.time.Instant
+
+final case class UserFavoriteResourcesRecord(
+ samUserId: WorkbenchUserId,
+ resourceId: ResourcePK,
+ createdAt: Instant
+)
+
+object UserFavoriteResourcesTable extends SQLSyntaxSupportWithDefaultSamDB[UserFavoriteResourcesRecord] {
+ override def tableName: String = "SAM_USER_FAVORITE_RESOURCES"
+
+ import SamTypeBinders._
+ def apply(e: ResultName[UserFavoriteResourcesRecord])(rs: WrappedResultSet): UserFavoriteResourcesRecord = UserFavoriteResourcesRecord(
+ rs.get(e.samUserId),
+ rs.get(e.resourceId),
+ rs.get(e.createdAt)
+ )
+
+ def apply(o: SyntaxProvider[UserFavoriteResourcesRecord])(rs: WrappedResultSet): UserFavoriteResourcesRecord = apply(o.resultName)(rs)
+}
diff --git a/src/main/scala/org/broadinstitute/dsde/workbench/sam/google/GoogleExtensionRoutes.scala b/src/main/scala/org/broadinstitute/dsde/workbench/sam/google/GoogleExtensionRoutes.scala
index 641e73f6d..b1d08eced 100644
--- a/src/main/scala/org/broadinstitute/dsde/workbench/sam/google/GoogleExtensionRoutes.scala
+++ b/src/main/scala/org/broadinstitute/dsde/workbench/sam/google/GoogleExtensionRoutes.scala
@@ -42,27 +42,13 @@ trait GoogleExtensionRoutes extends ExtensionRoutes with SamUserDirectives with
samUser.id,
samRequestContext
) {
- path(Segment / "key") { userEmail =>
+ pathPrefix(Segment) { userEmail =>
val email = WorkbenchEmail(userEmail)
- getWithTelemetry(samRequestContext, "userEmail" -> email) {
- complete {
- import spray.json._
- googleExtensions.getArbitraryPetServiceAccountKey(email, samRequestContext) map {
- // parse json to ensure it is json and tells akka http the right content-type
- case Some(key) => StatusCodes.OK -> key.parseJson
- case None =>
- throw new WorkbenchExceptionWithErrorReport(ErrorReport(StatusCodes.NotFound, "pet service account not found"))
- }
- }
- }
- } ~
- path(Segment / Segment) { (project, userEmail) =>
- val email = WorkbenchEmail(userEmail)
- val googleProject = GoogleProject(project)
- getWithTelemetry(samRequestContext, "userEmail" -> email, "googleProject" -> googleProject) {
+ path("key") {
+ getWithTelemetry(samRequestContext, "userEmail" -> email) {
complete {
import spray.json._
- googleExtensions.getPetServiceAccountKey(email, googleProject, samRequestContext) map {
+ googleExtensions.getArbitraryPetServiceAccountKey(email, samRequestContext) map {
// parse json to ensure it is json and tells akka http the right content-type
case Some(key) => StatusCodes.OK -> key.parseJson
case None =>
@@ -70,6 +56,50 @@ trait GoogleExtensionRoutes extends ExtensionRoutes with SamUserDirectives with
}
}
}
+ } ~
+ path("token") {
+ postWithTelemetry(samRequestContext, "userEmail" -> email) {
+ entity(as[Set[String]]) { scopes =>
+ complete {
+ googleExtensions.getArbitraryPetServiceAccountToken(email, scopes, samRequestContext).map {
+ case Some(token) => StatusCodes.OK -> JsString(token)
+ case None =>
+ throw new WorkbenchExceptionWithErrorReport(ErrorReport(StatusCodes.NotFound, "pet service account not found"))
+ }
+ }
+ }
+ }
+ }
+ } ~
+ pathPrefix(Segment / Segment) { (project, userEmail) =>
+ val email = WorkbenchEmail(userEmail)
+ val googleProject = GoogleProject(project)
+ pathEndOrSingleSlash {
+ getWithTelemetry(samRequestContext, "userEmail" -> email, "googleProject" -> googleProject) {
+ complete {
+ import spray.json._
+ googleExtensions.getPetServiceAccountKey(email, googleProject, samRequestContext) map {
+ // parse json to ensure it is json and tells akka http the right content-type
+ case Some(key) => StatusCodes.OK -> key.parseJson
+ case None =>
+ throw new WorkbenchExceptionWithErrorReport(ErrorReport(StatusCodes.NotFound, "pet service account not found"))
+ }
+ }
+ }
+ } ~
+ path("token") {
+ postWithTelemetry(samRequestContext, "userEmail" -> email) {
+ entity(as[Set[String]]) { scopes =>
+ complete {
+ googleExtensions.getPetServiceAccountToken(email, googleProject, scopes, samRequestContext).map {
+ case Some(token) => StatusCodes.OK -> JsString(token)
+ case None =>
+ throw new WorkbenchExceptionWithErrorReport(ErrorReport(StatusCodes.NotFound, "pet service account not found"))
+ }
+ }
+ }
+ }
+ }
}
}
} ~
diff --git a/src/main/scala/org/broadinstitute/dsde/workbench/sam/google/GoogleExtensions.scala b/src/main/scala/org/broadinstitute/dsde/workbench/sam/google/GoogleExtensions.scala
index b8a51fcf9..518736e39 100644
--- a/src/main/scala/org/broadinstitute/dsde/workbench/sam/google/GoogleExtensions.scala
+++ b/src/main/scala/org/broadinstitute/dsde/workbench/sam/google/GoogleExtensions.scala
@@ -273,6 +273,9 @@ class GoogleExtensions(
accessPolicyDAO.listSyncedAccessPolicyIdsOnResourcesConstrainedByGroup(_, relevantMembers, samRequestContext)
)
+ // Update group versions for all the groups that are ancestors of the managed group so that they can be synced
+ _ <- constrainedResourceAccessPolicyIds.flatten.traverse(p => directoryDAO.updateGroupUpdatedDateAndVersionWithSession(p, samRequestContext))
+
// return messages for all the affected access policies and the original group we started with
} yield constrainedResourceAccessPolicyIds.flatten.map(accessPolicyId => accessPolicyId.toJson.compactPrint)
@@ -354,6 +357,7 @@ class GoogleExtensions(
case None =>
for {
_ <- assertProjectInTerraOrg(project)
+ _ <- assertProjectIsActive(project)
sa <- IO.fromFuture(IO(googleIamDAO.createServiceAccount(project, petSaName, petSaDisplayName)))
_ <- withProxyEmail(user.id) { proxyEmail =>
// Add group member by uniqueId instead of email to avoid race condition
@@ -416,6 +420,14 @@ class GoogleExtensions(
}
}
+ private def assertProjectIsActive(project: GoogleProject): IO[Unit] =
+ for {
+ projectIsActive <- IO.fromFuture(IO(googleProjectDAO.isProjectActive(project.value)))
+ _ <- IO.raiseUnless(projectIsActive)(
+ new WorkbenchExceptionWithErrorReport(ErrorReport(StatusCodes.BadRequest, s"Project ${project.value} is inactive"))
+ )
+ } yield ()
+
private def retrievePetAndSA(
userId: WorkbenchUserId,
petServiceAccountName: ServiceAccountName,
@@ -443,30 +455,55 @@ class GoogleExtensions(
key <- googleKeyCache.getKey(pet)
} yield key
- def getPetServiceAccountToken(user: SamUser, project: GoogleProject, scopes: Set[String], samRequestContext: SamRequestContext): Future[String] =
- getPetServiceAccountKey(user, project, samRequestContext).unsafeToFuture().flatMap { key =>
- getAccessTokenUsingJson(key, scopes)
+ def getPetServiceAccountToken(user: SamUser, project: GoogleProject, scopes: Set[String], samRequestContext: SamRequestContext): IO[String] =
+ getPetServiceAccountKey(user, project, samRequestContext).flatMap { key =>
+ IO.fromFuture(IO(getAccessTokenUsingJson(key, scopes)))
}
+ def getPetServiceAccountToken(
+ userEmail: WorkbenchEmail,
+ project: GoogleProject,
+ scopes: Set[String],
+ samRequestContext: SamRequestContext
+ ): IO[Option[String]] =
+ for {
+ subject <- directoryDAO.loadSubjectFromEmail(userEmail, samRequestContext)
+ token <- subject match {
+ case Some(userId: WorkbenchUserId) =>
+ getPetServiceAccountToken(SamUser(userId, None, userEmail, None, false), project, scopes, samRequestContext).map(Option(_))
+ case _ => IO.pure(None)
+ }
+ } yield token
+
def getArbitraryPetServiceAccountKey(userEmail: WorkbenchEmail, samRequestContext: SamRequestContext): IO[Option[String]] =
for {
subject <- directoryDAO.loadSubjectFromEmail(userEmail, samRequestContext)
key <- subject match {
case Some(userId: WorkbenchUserId) =>
- IO.fromFuture(IO(getArbitraryPetServiceAccountKey(SamUser(userId, None, userEmail, None, false), samRequestContext))).map(Option(_))
+ getArbitraryPetServiceAccountKey(SamUser(userId, None, userEmail, None, false), samRequestContext).map(Option(_))
case _ => IO.none
}
} yield key
- def getArbitraryPetServiceAccountKey(user: SamUser, samRequestContext: SamRequestContext): Future[String] =
- getDefaultServiceAccountForShellProject(user, samRequestContext)
+ def getArbitraryPetServiceAccountKey(user: SamUser, samRequestContext: SamRequestContext): IO[String] =
+ IO.fromFuture(IO(getDefaultServiceAccountForShellProject(user, samRequestContext)))
+
+ def getArbitraryPetServiceAccountToken(userEmail: WorkbenchEmail, scopes: Set[String], samRequestContext: SamRequestContext): IO[Option[String]] =
+ for {
+ subject <- directoryDAO.loadSubjectFromEmail(userEmail, samRequestContext)
+ token <- subject match {
+ case Some(userId: WorkbenchUserId) =>
+ getArbitraryPetServiceAccountToken(SamUser(userId, None, userEmail, None, false), scopes, samRequestContext).map(Option(_))
+ case _ => IO.none
+ }
+ } yield token
- def getArbitraryPetServiceAccountToken(user: SamUser, scopes: Set[String], samRequestContext: SamRequestContext): Future[String] =
+ def getArbitraryPetServiceAccountToken(user: SamUser, scopes: Set[String], samRequestContext: SamRequestContext): IO[String] =
getArbitraryPetServiceAccountKey(user, samRequestContext).flatMap { key =>
- getAccessTokenUsingJson(key, scopes)
+ IO.fromFuture(IO(getAccessTokenUsingJson(key, scopes)))
}
- private def getDefaultServiceAccountForShellProject(user: SamUser, samRequestContext: SamRequestContext): Future[String] = {
+ private[google] def getDefaultServiceAccountForShellProject(user: SamUser, samRequestContext: SamRequestContext): Future[String] = {
val projectName =
s"fc-${googleServicesConfig.environment.substring(0, Math.min(googleServicesConfig.environment.length(), 5))}-${user.id.value}" // max 30 characters. subject ID is 21
for {
@@ -678,7 +715,7 @@ class GoogleExtensions(
val bucket = GcsBucketName(blobId.getBucket)
val objectName = GcsBlobName(blobId.getName)
for {
- petKey <- IO.fromFuture(IO(getArbitraryPetServiceAccountKey(samUser, samRequestContext)))
+ petKey <- getArbitraryPetServiceAccountKey(samUser, samRequestContext)
serviceAccountCredentials = ServiceAccountCredentials.fromStream(new ByteArrayInputStream(petKey.getBytes()))
url <- getSignedUrl(samUser, bucket, objectName, duration, urlParamsMap, serviceAccountCredentials)
} yield url
diff --git a/src/main/scala/org/broadinstitute/dsde/workbench/sam/google/GoogleGroupSyncMessageReceiver.scala b/src/main/scala/org/broadinstitute/dsde/workbench/sam/google/GoogleGroupSyncMessageReceiver.scala
index 6f4e1ad3f..ec290ace9 100644
--- a/src/main/scala/org/broadinstitute/dsde/workbench/sam/google/GoogleGroupSyncMessageReceiver.scala
+++ b/src/main/scala/org/broadinstitute/dsde/workbench/sam/google/GoogleGroupSyncMessageReceiver.scala
@@ -64,20 +64,20 @@ class GoogleGroupSyncMessageReceiver(groupSynchronizer: GoogleGroupSynchronizer)
* @param report
* @param consumer
*/
- private def syncComplete(report: Map[WorkbenchEmail, Seq[SyncReportItem]], consumer: AckReplyConsumer): Unit = {
- val errorReports = report.values.flatten.collect {
+ private def syncComplete(syncedPolicies: Map[WorkbenchEmail, Seq[SyncReportItem]], consumer: AckReplyConsumer): Unit = {
+ val errorReports = syncedPolicies.values.flatten.collect {
case SyncReportItem(_, _, _, errorReports) if errorReports.nonEmpty => errorReports
}.flatten
- import DefaultJsonProtocol._
- import WorkbenchIdentityJsonSupport._
import org.broadinstitute.dsde.workbench.sam.google.SamGoogleModelJsonSupport._
+ val syncReport = SyncReport(syncedPolicies.map { case (email, changes) => SyncedPolicy(email, changes) }.toSeq)
+
if (errorReports.isEmpty) {
- logger.info(s"synchronized google group", StructuredArguments.raw("syncDetail", report.toJson.compactPrint))
+ logger.info(s"synchronized google group", StructuredArguments.raw("syncDetail", syncReport.toJson.compactPrint))
consumer.ack()
} else {
- logger.error(s"synchronized google group with failures", StructuredArguments.raw("syncDetail", report.toJson.compactPrint))
+ logger.error(s"synchronized google group with failures", StructuredArguments.raw("syncDetail", syncReport.toJson.compactPrint))
consumer.nack() // redeliver message to hopefully rectify the failures
}
}
diff --git a/src/main/scala/org/broadinstitute/dsde/workbench/sam/google/GoogleGroupSynchronizer.scala b/src/main/scala/org/broadinstitute/dsde/workbench/sam/google/GoogleGroupSynchronizer.scala
index 31db7a17f..1b95e7964 100644
--- a/src/main/scala/org/broadinstitute/dsde/workbench/sam/google/GoogleGroupSynchronizer.scala
+++ b/src/main/scala/org/broadinstitute/dsde/workbench/sam/google/GoogleGroupSynchronizer.scala
@@ -54,7 +54,9 @@ class GoogleGroupSynchronizer(
IO.pure(Map.empty)
} else {
loadSamGroupForSynchronization(groupId, samRequestContext).flatMap {
- case Left(group) => IO.pure(Map(group.email -> Seq.empty))
+ case Left(group) =>
+ logger.info(s"Group ${group.id}:${group.email} does not need synchronization, skipping.")
+ IO.pure(Map(group.email -> Seq.empty))
case Right(group) =>
for {
members <- calculateAuthDomainIntersectionIfRequired(group, samRequestContext)
diff --git a/src/main/scala/org/broadinstitute/dsde/workbench/sam/google/GoogleModel.scala b/src/main/scala/org/broadinstitute/dsde/workbench/sam/google/GoogleModel.scala
index 932b8b968..922872fb0 100644
--- a/src/main/scala/org/broadinstitute/dsde/workbench/sam/google/GoogleModel.scala
+++ b/src/main/scala/org/broadinstitute/dsde/workbench/sam/google/GoogleModel.scala
@@ -1,16 +1,24 @@
package org.broadinstitute.dsde.workbench.sam.google
import cats.effect.IO
-import org.broadinstitute.dsde.workbench.model.{ErrorReport, ErrorReportSource}
+import org.broadinstitute.dsde.workbench.model.{ErrorReport, ErrorReportSource, WorkbenchEmail, WorkbenchIdentityJsonSupport}
import spray.json.DefaultJsonProtocol
object SamGoogleModelJsonSupport {
import DefaultJsonProtocol._
import org.broadinstitute.dsde.workbench.model.ErrorReportJsonSupport._
+ import WorkbenchIdentityJsonSupport._
implicit val SyncReportItemFormat = jsonFormat4(SyncReportItem.apply)
+ implicit val SyncedPolicyFormat = jsonFormat2(SyncedPolicy.apply)
+ implicit val SyncReportFormat = jsonFormat1(SyncReport.apply)
+
}
+case class SyncReport(syncedPolicies: Seq[SyncedPolicy])
+
+case class SyncedPolicy(policyEmail: WorkbenchEmail, changes: Seq[SyncReportItem])
+
/** A SyncReportItem represents the results of synchronizing a single member of a google group. Synchronizing a google group will result in a collection of
* these.
* @param operation
diff --git a/src/main/scala/org/broadinstitute/dsde/workbench/sam/model/api/SamUserCombinedStateResponse.scala b/src/main/scala/org/broadinstitute/dsde/workbench/sam/model/api/SamUserCombinedStateResponse.scala
index c34d53c84..a0e1e309e 100644
--- a/src/main/scala/org/broadinstitute/dsde/workbench/sam/model/api/SamUserCombinedStateResponse.scala
+++ b/src/main/scala/org/broadinstitute/dsde/workbench/sam/model/api/SamUserCombinedStateResponse.scala
@@ -1,6 +1,6 @@
package org.broadinstitute.dsde.workbench.sam.model.api
-import org.broadinstitute.dsde.workbench.sam.model.TermsOfServiceDetails
+import org.broadinstitute.dsde.workbench.sam.model.{FullyQualifiedResourceId, TermsOfServiceDetails}
import spray.json.DefaultJsonProtocol._
import spray.json._
import org.broadinstitute.dsde.workbench.sam.model.api.SamUserAllowances.SamUserAllowedResponseFormat
@@ -8,12 +8,13 @@ import org.broadinstitute.dsde.workbench.sam.model.api.SamUserAttributes.SamUser
import org.broadinstitute.dsde.workbench.sam.model.api.SamJsonSupport._
object SamUserCombinedStateResponse {
- implicit val SamUserResponseFormat: RootJsonFormat[SamUserCombinedStateResponse] = jsonFormat5(SamUserCombinedStateResponse.apply)
+ implicit val SamUserResponseFormat: RootJsonFormat[SamUserCombinedStateResponse] = jsonFormat6(SamUserCombinedStateResponse.apply)
}
final case class SamUserCombinedStateResponse(
samUser: SamUser,
allowances: SamUserAllowances,
attributes: Option[SamUserAttributes],
termsOfServiceDetails: TermsOfServiceDetails,
- additionalDetails: Map[String, JsValue]
+ additionalDetails: Map[String, JsValue],
+ favoriteResources: Set[FullyQualifiedResourceId]
)
diff --git a/src/main/scala/org/broadinstitute/dsde/workbench/sam/service/ResourceService.scala b/src/main/scala/org/broadinstitute/dsde/workbench/sam/service/ResourceService.scala
index 3ff403b6b..c7adfe304 100644
--- a/src/main/scala/org/broadinstitute/dsde/workbench/sam/service/ResourceService.scala
+++ b/src/main/scala/org/broadinstitute/dsde/workbench/sam/service/ResourceService.scala
@@ -83,6 +83,44 @@ class ResourceService(
} yield resourceTypes.values
}
+ /** Called at startup to create any policies in configuration. Does not create users or resources.
+ * @return
+ * for each requested policy, either the policy created or an error
+ */
+ def upsertResourceAccessPolicies(
+ resourceAccessPolicies: Map[FullyQualifiedPolicyId, AccessPolicyMembershipRequest],
+ samRequestContext: SamRequestContext = SamRequestContext()
+ ): IO[Map[FullyQualifiedPolicyId, Either[Throwable, AccessPolicy]]] =
+ resourceAccessPolicies.toList
+ .traverse { case (fullyQualifiedPolicyId, accessPolicyMembershipRequest) =>
+ val upsertIO = for {
+ resourceTypeOption <- getResourceType(fullyQualifiedPolicyId.resource.resourceTypeName)
+ resourceType = resourceTypeOption.getOrElse(
+ throw new WorkbenchException(s"Resource type ${fullyQualifiedPolicyId.resource.resourceTypeName} not found")
+ )
+ upsertedPolicy <-
+ if (resourceType.name.isResourceTypeAdmin) {
+ overwriteAdminPolicy(
+ resourceType,
+ fullyQualifiedPolicyId.accessPolicyName,
+ fullyQualifiedPolicyId.resource,
+ accessPolicyMembershipRequest,
+ samRequestContext
+ )
+ } else {
+ overwritePolicy(
+ resourceType,
+ fullyQualifiedPolicyId.accessPolicyName,
+ fullyQualifiedPolicyId.resource,
+ accessPolicyMembershipRequest,
+ samRequestContext
+ )
+ }
+ } yield upsertedPolicy
+ upsertIO.attempt.map(fullyQualifiedPolicyId -> _)
+ }
+ .map(_.toMap)
+
def createResourceType(resourceType: ResourceType, samRequestContext: SamRequestContext): IO[ResourceType] =
accessPolicyDAO.createResourceType(resourceType, samRequestContext)
@@ -146,7 +184,8 @@ class ResourceService(
}
/** This method only persists the resource and then overwrites/creates the policies for that resource. Be very careful if calling this method directly because
- * it will not validate the resource or its policies. If you want to create a Resource, use createResource() which will also perform critical validations
+ * it will not validate the resource or its policies. If you want to create a Resource, use createResource() which will also perform critical validations. If
+ * the parent has an auth domain, it will be added the auth domain of the child.
*
* @param resourceType
* @param resourceId
@@ -164,7 +203,13 @@ class ResourceService(
samRequestContext: SamRequestContext
) = {
val accessPolicies = policies.map(constructAccessPolicy(resourceType, resourceId, _, public = false)) // can't set public at create time
- accessPolicyDAO.createResource(Resource(resourceType.name, resourceId, authDomain, accessPolicies = accessPolicies, parent = parentOpt), samRequestContext)
+ for {
+ inheritedAuthDomains <- parentOpt.map(loadResourceAuthDomain(_, samRequestContext)).getOrElse(IO.pure(Set.empty))
+ resource <- accessPolicyDAO.createResource(
+ Resource(resourceType.name, resourceId, inheritedAuthDomains ++ authDomain, accessPolicies = accessPolicies, parent = parentOpt),
+ samRequestContext
+ )
+ } yield resource
}
private def constructAccessPolicy(resourceType: ResourceType, resourceId: ResourceId, validatableAccessPolicy: ValidatableAccessPolicy, public: Boolean) =
@@ -192,7 +237,8 @@ class ResourceService(
ownerPolicyErrors <- IO.pure(validateOwnerPolicyExists(resourceType, policies, parentOpt))
policyErrors <- policies.toList.traverse(policy => validatePolicy(resourceType, resourceId, policy)).map(_.flatten)
authDomainErrors <- validateAuthDomain(resourceType, authDomain, userId, samRequestContext)
- } yield (resourceIdErrors ++ ownerPolicyErrors ++ policyErrors ++ authDomainErrors).toSeq
+ childAuthDomainErrors <- validateChildAuthDomain(authDomain, parentOpt, samRequestContext)
+ } yield (resourceIdErrors ++ ownerPolicyErrors ++ policyErrors ++ authDomainErrors ++ childAuthDomainErrors).toSeq
private val validUrlSafePattern = "[-a-zA-Z0-9._~%]+".r
@@ -240,6 +286,23 @@ class ResourceService(
} else None
}
+ /** If an auth domain is specified for a child resource, it must contain all of the groups of the parent auth domain. Containing additional groups is allowed.
+ */
+ private def validateChildAuthDomain(
+ childAuthDomain: Set[WorkbenchGroupName],
+ parentOpt: Option[FullyQualifiedResourceId],
+ samRequestContext: SamRequestContext
+ ): IO[Option[ErrorReport]] =
+ parentOpt
+ .traverse { parent =>
+ loadResourceAuthDomain(parent, samRequestContext).map { parentAuthDomain =>
+ if (childAuthDomain.nonEmpty && !parentAuthDomain.forall(childAuthDomain.contains)) {
+ Option(ErrorReport("Child resource auth domain must contain all of the groups of the parent auth domain"))
+ } else None
+ }
+ }
+ .map(_.flatten)
+
private def validateAuthDomainPermissions(
authDomain: Set[WorkbenchGroupName],
userId: WorkbenchUserId,
@@ -270,27 +333,74 @@ class ResourceService(
}
)
+ def satisfiesAuthDomainConstrains(resource: FullyQualifiedResourceId, samUser: SamUser, samRequestContext: SamRequestContext): IO[Boolean] =
+ loadResourceAuthDomain(resource, samRequestContext).flatMap { authDomain =>
+ authDomain.toList.traverse { group =>
+ policyEvaluatorService.hasPermission(
+ FullyQualifiedResourceId(ManagedGroupService.managedGroupTypeName, ResourceId(group.value)),
+ ManagedGroupService.useAction,
+ samUser.id,
+ samRequestContext
+ )
+ }
+ } map { listOfUsePermissions => listOfUsePermissions.isEmpty || listOfUsePermissions.forall(identity) }
+
+ /** Adds groups to a resource's auth domain. If the resource is a parent, the auth domain of all children will be updated as well.
+ * @param resource
+ * the resource to add the auth domain groups to
+ * @param authDomains
+ * groups to add to the auth domain
+ * @param userId
+ * optional, if provided, the user must have access to the new auth domain groups
+ * @param samRequestContext
+ * @return
+ * the complete new auth domain of the resource
+ */
def addResourceAuthDomain(
resource: FullyQualifiedResourceId,
authDomains: Set[WorkbenchGroupName],
- userId: WorkbenchUserId,
+ userId: Option[WorkbenchUserId],
samRequestContext: SamRequestContext
): IO[Set[WorkbenchGroupName]] =
for {
resourceType <- getResourceType(resource.resourceTypeName)
- _ <- validateAuthDomain(resourceType.get, authDomains, userId, samRequestContext)
- accessPolicies <- accessPolicyDAO.listAccessPolicies(resource, samRequestContext)
- _ <-
- if (accessPolicies.exists(_.public)) {
- IO.raiseError(new WorkbenchExceptionWithErrorReport(ErrorReport(StatusCodes.BadRequest, "Cannot add an auth domain group to a public resource")))
- } else IO.unit
- policies <- listResourcePolicies(resource, samRequestContext)
- _ <- accessPolicyDAO.addResourceAuthDomain(resource, authDomains, samRequestContext)
- _ <- policies.traverse(p => directoryDAO.updateGroupUpdatedDateAndVersionWithSession(FullyQualifiedPolicyId(resource, p.policyName), samRequestContext))
- _ <- cloudExtensions.onGroupUpdate(policies.map(p => FullyQualifiedPolicyId(resource, p.policyName)), Set.empty, samRequestContext)
+ error <- userId.map(validateAuthDomain(resourceType.get, authDomains, _, samRequestContext)).getOrElse(IO.none)
+ _ <- IO.raiseWhen(error.isDefined)(new WorkbenchExceptionWithErrorReport(error.get.copy(statusCode = Option(StatusCodes.BadRequest))))
+ resourceAndDescendants <- listResourceAndDescendants(resource, samRequestContext)
+ _ <- resourceAndDescendants.traverse(validateNoPublicPolicies(_, samRequestContext))
+ _ <- resourceAndDescendants.traverse(accessPolicyDAO.addResourceAuthDomain(_, authDomains, samRequestContext))
+
+ // sync groups because new auth domain can change group membership
+ policies <- resourceAndDescendants.traverse(accessPolicyDAO.listAccessPolicies(_, samRequestContext)).map(_.flatten)
+ _ <- policies.traverse(p => directoryDAO.updateGroupUpdatedDateAndVersionWithSession(p.id, samRequestContext))
+ _ <- cloudExtensions.onGroupUpdate(policies.map(_.id), Set.empty, samRequestContext)
authDomains <- loadResourceAuthDomain(resource, samRequestContext)
} yield authDomains
+ private def validateNoPublicPolicies(resource: FullyQualifiedResourceId, samRequestContext: SamRequestContext): IO[Unit] =
+ accessPolicyDAO.listAccessPolicies(resource, samRequestContext).map { policies =>
+ if (policies.exists(_.public)) {
+ throw new WorkbenchExceptionWithErrorReport(ErrorReport(StatusCodes.BadRequest, s"Cannot add an auth domain group to a public resource $resource"))
+ }
+ }
+
+ /** List the resource and all of its descendants. Recursively calls accessPolicyDAO.listResourceChildren. An alternate implementation could be to use a
+ * recursive query in the database but this is a little more concise and easier to understand at the expense of more db queries. But there are not likely to
+ * be huge depths of hierarchical resources and this is a seldom called function.
+ *
+ * @param resource
+ * @return
+ * the resource and all of its descendants
+ */
+ private def listResourceAndDescendants(resource: FullyQualifiedResourceId, samRequestContext: SamRequestContext): IO[List[FullyQualifiedResourceId]] =
+ accessPolicyDAO.listResourceChildren(resource, samRequestContext).flatMap { children =>
+ children.toList
+ .traverse { child =>
+ listResourceAndDescendants(child, samRequestContext)
+ }
+ .map(_.flatten.appended(resource))
+ }
+
@VisibleForTesting
def createPolicy(
policyIdentity: FullyQualifiedPolicyId,
@@ -503,14 +613,13 @@ class ResourceService(
_ <- onPolicyUpdate(policyIdentity, originalPolicies, samRequestContext)
} yield result
case Some(existingAccessPolicy) =>
- val newAccessPolicy = AccessPolicy(
- policyIdentity,
- workbenchSubjects,
- existingAccessPolicy.email,
- policy.roles,
- policy.actions,
- policy.descendantPermissions,
- existingAccessPolicy.public
+ // this function updates only the members, roles, actions, and descendantPermissions of the policy
+ // so the new policy is a copy of the existing policy with the updated fields
+ val newAccessPolicy = existingAccessPolicy.copy(
+ members = workbenchSubjects,
+ roles = policy.roles,
+ actions = policy.actions,
+ descendantPermissions = policy.descendantPermissions
)
if (newAccessPolicy == existingAccessPolicy) {
// short cut if access policy is unchanged
@@ -679,6 +788,10 @@ class ResourceService(
changeEvents = createAccessChangeEvents(policyId.resource, originalPolicies, updatedPolicies)
_ <- AuditLogger.logAuditEventIO(samRequestContext, changeEvents.toSeq: _*)
+ _ <- directoryDAO.updateGroupUpdatedDateAndVersionWithSession(
+ FullyQualifiedPolicyId(policyId.resource, policyId.accessPolicyName),
+ samRequestContext
+ )
_ <- cloudExtensions.onGroupUpdate(Seq(policyId), removedMembers ++ addedMembers, samRequestContext).attempt.flatMap {
case Left(regrets) => IO(logger.error(s"error calling cloudExtensions.onGroupUpdate for $policyId", regrets))
case Right(_) => IO.unit
@@ -847,10 +960,6 @@ class ResourceService(
for {
originalPolicies <- accessPolicyDAO.listAccessPolicies(policyId.resource, samRequestContext)
policyChanged <- accessPolicyDAO.setPolicyIsPublic(policyId, public, samRequestContext)
- _ <- directoryDAO.updateGroupUpdatedDateAndVersionWithSession(
- FullyQualifiedPolicyId(policyId.resource, policyId.accessPolicyName),
- samRequestContext
- )
_ <- onPolicyUpdateIfChanged(policyId, originalPolicies, samRequestContext)(policyChanged)
} yield ()
}
@@ -867,32 +976,12 @@ class ResourceService(
def getResourceParent(resourceId: FullyQualifiedResourceId, samRequestContext: SamRequestContext): IO[Option[FullyQualifiedResourceId]] =
accessPolicyDAO.getResourceParent(resourceId, samRequestContext)
- /** In this iteration of hierarchical resources, we do not allow child resources to be in an auth domain because it would introduce additional complications
- * when keeping Sam policies with their Google Groups. For more details, see
- * https://docs.google.com/document/d/10qGxsV9BeM6-N_Zk27_JIayE509B8LUQBGiGrqB0taY/edit#heading=h.dxz6xjtnz9la
- */
def setResourceParent(childResource: FullyQualifiedResourceId, parentResource: FullyQualifiedResourceId, samRequestContext: SamRequestContext): IO[Unit] =
for {
- authDomain <- accessPolicyDAO.loadResourceAuthDomain(childResource, samRequestContext)
- _ <- authDomain match {
- case LoadResourceAuthDomainResult.NotConstrained =>
- for {
- _ <- accessPolicyDAO.setResourceParent(childResource, parentResource, samRequestContext)
- _ <- AuditLogger.logAuditEventIO(samRequestContext, ResourceEvent(ResourceParentUpdated, childResource, Set(ResourceChange(parentResource))))
- } yield ()
- case LoadResourceAuthDomainResult.Constrained(_) =>
- IO.raiseError(
- new WorkbenchExceptionWithErrorReport(
- ErrorReport(StatusCodes.BadRequest, "Cannot set the parent for a constrained resource")
- )
- )
- case LoadResourceAuthDomainResult.ResourceNotFound =>
- IO.raiseError(
- new WorkbenchExceptionWithErrorReport(
- ErrorReport(StatusCodes.NotFound, "Resource not found")
- )
- )
- }
+ parentAuthDomain <- loadResourceAuthDomain(parentResource, samRequestContext)
+ _ <- IO.whenA(parentAuthDomain.nonEmpty)(addResourceAuthDomain(childResource, parentAuthDomain, None, samRequestContext).void)
+ _ <- accessPolicyDAO.setResourceParent(childResource, parentResource, samRequestContext)
+ _ <- AuditLogger.logAuditEventIO(samRequestContext, ResourceEvent(ResourceParentUpdated, childResource, Set(ResourceChange(parentResource))))
} yield ()
def deleteResourceParent(resourceId: FullyQualifiedResourceId, samRequestContext: SamRequestContext): IO[Boolean] =
@@ -909,6 +998,22 @@ class ResourceService(
def listResourceChildren(resourceId: FullyQualifiedResourceId, samRequestContext: SamRequestContext): IO[Set[FullyQualifiedResourceId]] =
accessPolicyDAO.listResourceChildren(resourceId, samRequestContext)
+ def addUserFavoriteResource(userId: WorkbenchUserId, resourceId: FullyQualifiedResourceId, samRequestContext: SamRequestContext): IO[Boolean] =
+ directoryDAO.addUserFavoriteResource(userId, resourceId, samRequestContext)
+
+ def removeUserFavoriteResource(userId: WorkbenchUserId, resourceId: FullyQualifiedResourceId, samRequestContext: SamRequestContext): IO[Unit] =
+ directoryDAO.removeUserFavoriteResource(userId, resourceId, samRequestContext)
+
+ def getUserFavoriteResources(userId: WorkbenchUserId, samRequestContext: SamRequestContext): IO[Set[FullyQualifiedResourceId]] =
+ directoryDAO.getUserFavoriteResources(userId, samRequestContext)
+
+ def getUserFavoriteResourcesOfType(
+ userId: WorkbenchUserId,
+ resourceType: ResourceTypeName,
+ samRequestContext: SamRequestContext
+ ): IO[Set[FullyQualifiedResourceId]] =
+ directoryDAO.getUserFavoriteResourcesOfType(userId, resourceType, samRequestContext)
+
private[service] def createAccessChangeEvents(
resource: FullyQualifiedResourceId,
beforePolicies: Iterable[AccessPolicy],
diff --git a/src/main/scala/org/broadinstitute/dsde/workbench/sam/service/UserService.scala b/src/main/scala/org/broadinstitute/dsde/workbench/sam/service/UserService.scala
index 98d29c576..f7b4fec1a 100644
--- a/src/main/scala/org/broadinstitute/dsde/workbench/sam/service/UserService.scala
+++ b/src/main/scala/org/broadinstitute/dsde/workbench/sam/service/UserService.scala
@@ -173,6 +173,9 @@ class UserService(
def getUser(userId: WorkbenchUserId, samRequestContext: SamRequestContext): IO[Option[SamUser]] =
directoryDAO.loadUser(userId, samRequestContext)
+ def getUsersByIds(samUserIds: Seq[WorkbenchUserId], samRequestContext: SamRequestContext): IO[Seq[SamUser]] =
+ directoryDAO.batchLoadUsers(samUserIds.toSet, samRequestContext)
+
def getUsersByQuery(
userId: Option[WorkbenchUserId],
googleSubjectId: Option[GoogleSubjectId],
@@ -486,6 +489,19 @@ class UserService(
def setUserAttributes(userAttributes: SamUserAttributes, samRequestContext: SamRequestContext): IO[SamUserAttributes] =
directoryDAO.setUserAttributes(userAttributes, samRequestContext).map(_ => userAttributes)
+
+ def repairCloudAccess(workbenchUserId: WorkbenchUserId, samRequestContext: SamRequestContext): IO[Unit] = {
+ val maybeUser = getUser(workbenchUserId, samRequestContext)
+ maybeUser.flatMap {
+ case Some(user) =>
+ for {
+ _ <- cloudExtensions.onUserCreate(user, samRequestContext)
+ groups <- directoryDAO.listUserDirectMemberships(user.id, samRequestContext)
+ _ <- cloudExtensions.onGroupUpdate(groups, Set(user.id), samRequestContext)
+ } yield IO.pure(())
+ case None => IO.raiseError(new WorkbenchExceptionWithErrorReport(ErrorReport(StatusCodes.NotFound, s"User $workbenchUserId not found")))
+ }
+ }
}
object UserService {
diff --git a/src/test/resources/sam.conf b/src/test/resources/sam.conf
index ed600b352..bf30396db 100644
--- a/src/test/resources/sam.conf
+++ b/src/test/resources/sam.conf
@@ -4,4 +4,26 @@ prometheus {
admin {
serviceAccountAdmins = ${?SERVICE_ACCOUNT_ADMINS}
+}
+
+resourceAccessPolicies {
+ resource_type_admin {
+ workspace {
+ rawls-policy {
+ memberEmails = ["rawls@test.firecloud.org"]
+ descendantPermissions = [
+ {
+ resourceTypeName = "workspace",
+ roles = ["owner"]
+ }
+ ]
+ }
+ }
+ kubernetes-app {
+ leo-policy {
+ memberEmails = ["leo@test.firecloud.org"]
+ roles = ["support"]
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/src/test/scala/org/broadinstitute/dsde/workbench/sam/api/AdminUserRoutesSpec.scala b/src/test/scala/org/broadinstitute/dsde/workbench/sam/api/AdminUserRoutesSpec.scala
index feca4bbe0..b5d8b2e1d 100644
--- a/src/test/scala/org/broadinstitute/dsde/workbench/sam/api/AdminUserRoutesSpec.scala
+++ b/src/test/scala/org/broadinstitute/dsde/workbench/sam/api/AdminUserRoutesSpec.scala
@@ -395,4 +395,32 @@ class AdminUserRoutesSpec extends AnyFlatSpec with Matchers with ScalatestRouteT
status shouldEqual StatusCodes.NotFound
}
}
+
+ "PUT /admin/v2/user/{userId}/repairCloudAccess" should "return no content when called as admin user" in {
+ // Arrange
+ val samRoutes = new MockSamRoutesBuilder(allUsersGroup)
+ .callAsAdminUser() // enabled "admin" user who is making the http request
+ .withEnabledUser(defaultUser) // "persisted/enabled" user we will check the status of
+ .withAllowedUser(defaultUser)
+ .build
+
+ // Act and Assert
+ Put(s"/api/admin/v2/user/$defaultUserId/repairCloudAccess") ~> samRoutes.route ~> check {
+ status shouldEqual StatusCodes.NoContent
+ }
+ }
+
+ it should "forbid the request when the requesting user is a non admin" in {
+ // Arrange
+ val samRoutes = new MockSamRoutesBuilder(allUsersGroup)
+ .withEnabledUser(defaultUser) // "persisted/enabled" user we will check the status of
+ .withAllowedUser(defaultUser)
+ .callAsNonAdminUser()
+ .build
+
+ // Act and Assert
+ Put(s"/api/admin/v2/user/$defaultUserId/repairCloudAccess") ~> samRoutes.route ~> check {
+ status shouldEqual StatusCodes.Forbidden
+ }
+ }
}
diff --git a/src/test/scala/org/broadinstitute/dsde/workbench/sam/api/MockSamRoutesBuilder.scala b/src/test/scala/org/broadinstitute/dsde/workbench/sam/api/MockSamRoutesBuilder.scala
index 74486f798..072fdb58d 100644
--- a/src/test/scala/org/broadinstitute/dsde/workbench/sam/api/MockSamRoutesBuilder.scala
+++ b/src/test/scala/org/broadinstitute/dsde/workbench/sam/api/MockSamRoutesBuilder.scala
@@ -137,6 +137,7 @@ class MockSamRoutesBuilder(allUsersGroup: WorkbenchGroup)(implicit system: Actor
val mockUserService = userServiceBuilder.build
val mockCloudExtensions = cloudExtensionsBuilder.build
val mockTosService = mockTosServiceBuilder.build
+ val mockPolicyEvaluatorService = mock[PolicyEvaluatorService]
new SamRoutes(
mockResourceService,
@@ -144,7 +145,7 @@ class MockSamRoutesBuilder(allUsersGroup: WorkbenchGroup)(implicit system: Actor
null,
null,
null,
- null,
+ mockPolicyEvaluatorService,
mockTosService,
null,
null,
diff --git a/src/test/scala/org/broadinstitute/dsde/workbench/sam/api/ResourceRoutesV2Spec.scala b/src/test/scala/org/broadinstitute/dsde/workbench/sam/api/ResourceRoutesV2Spec.scala
index 44a653caa..7b594a888 100644
--- a/src/test/scala/org/broadinstitute/dsde/workbench/sam/api/ResourceRoutesV2Spec.scala
+++ b/src/test/scala/org/broadinstitute/dsde/workbench/sam/api/ResourceRoutesV2Spec.scala
@@ -1764,6 +1764,71 @@ class ResourceRoutesV2Spec extends RetryableAnyFlatSpec with Matchers with TestS
}
}
+ "GET /api/resources/v2/{resourceType}/{resourceId}/authDomain/satisfied" should "200 if the calling user satisfies the auth domain constraints" in {
+ val managedGroupResourceType = initManagedGroupResourceType()
+
+ val authDomain = "authDomain"
+ val resourceType = ResourceType(
+ ResourceTypeName("rt"),
+ Set(SamResourceActionPatterns.readAuthDomain, SamResourceActionPatterns.use),
+ Set(ResourceRole(ResourceRoleName("owner"), Set(SamResourceActions.readAuthDomain, ManagedGroupService.useAction))),
+ ResourceRoleName("owner")
+ )
+ val samRoutes = TestSamRoutes(Map(resourceType.name -> resourceType, managedGroupResourceType.name -> managedGroupResourceType))
+
+ runAndWait(samRoutes.managedGroupService.createManagedGroup(ResourceId(authDomain), defaultUserInfo, samRequestContext = samRequestContext))
+
+ val resourceId = ResourceId("foo")
+ val policiesMap = Map(
+ AccessPolicyName("ap") -> AccessPolicyMembershipRequest(
+ Set(defaultUserInfo.email),
+ Set(SamResourceActions.readAuthDomain, ManagedGroupService.useAction),
+ Set(ResourceRoleName("owner"))
+ )
+ )
+ runAndWait(
+ samRoutes.resourceService
+ .createResource(resourceType, resourceId, policiesMap, Set(WorkbenchGroupName(authDomain)), None, defaultUserInfo.id, samRequestContext)
+ )
+
+ Get(s"/api/resources/v2/${resourceType.name}/${resourceId.value}/authDomain/satisfied") ~> samRoutes.route ~> check {
+ status shouldEqual StatusCodes.OK
+ }
+ }
+
+ it should "403 if the calling user does not satisfy the auth domain constraints" in {
+ val managedGroupResourceType = initManagedGroupResourceType()
+
+ val authDomain = "authDomain"
+ val resourceType = ResourceType(
+ ResourceTypeName("rt"),
+ Set(SamResourceActionPatterns.readAuthDomain, SamResourceActionPatterns.use),
+ Set(ResourceRole(ResourceRoleName("owner"), Set(SamResourceActions.readAuthDomain, ManagedGroupService.useAction))),
+ ResourceRoleName("owner")
+ )
+ val samRoutes = TestSamRoutes(Map(resourceType.name -> resourceType, managedGroupResourceType.name -> managedGroupResourceType))
+ val otherUser = Generator.genWorkbenchUserGoogle.sample.get
+ runAndWait(samRoutes.userService.createUser(otherUser, samRequestContext))
+ runAndWait(samRoutes.managedGroupService.createManagedGroup(ResourceId(authDomain), otherUser, samRequestContext = samRequestContext))
+
+ val resourceId = ResourceId("foo")
+ val policiesMap = Map(
+ AccessPolicyName("ap") -> AccessPolicyMembershipRequest(
+ Set(defaultUserInfo.email),
+ Set(SamResourceActions.readAuthDomain, ManagedGroupService.useAction),
+ Set(ResourceRoleName("owner"))
+ )
+ )
+ runAndWait(
+ samRoutes.resourceService
+ .createResource(resourceType, resourceId, policiesMap, Set(WorkbenchGroupName(authDomain)), None, otherUser.id, samRequestContext)
+ )
+
+ Get(s"/api/resources/v2/${resourceType.name}/${resourceId.value}/authDomain/satisfied") ~> samRoutes.route ~> check {
+ status shouldEqual StatusCodes.Forbidden
+ }
+ }
+
private def initManagedGroupResourceType(): ResourceType = {
val accessPolicyNames = Set(ManagedGroupService.adminPolicyName, ManagedGroupService.memberPolicyName, ManagedGroupService.adminNotifierPolicyName)
val policyActions: Set[ResourceAction] =
diff --git a/src/test/scala/org/broadinstitute/dsde/workbench/sam/api/AdminServiceUserRoutesSpec.scala b/src/test/scala/org/broadinstitute/dsde/workbench/sam/api/ServiceAdminRoutesSpec.scala
similarity index 79%
rename from src/test/scala/org/broadinstitute/dsde/workbench/sam/api/AdminServiceUserRoutesSpec.scala
rename to src/test/scala/org/broadinstitute/dsde/workbench/sam/api/ServiceAdminRoutesSpec.scala
index a21c32533..1c02a2faf 100644
--- a/src/test/scala/org/broadinstitute/dsde/workbench/sam/api/AdminServiceUserRoutesSpec.scala
+++ b/src/test/scala/org/broadinstitute/dsde/workbench/sam/api/ServiceAdminRoutesSpec.scala
@@ -4,6 +4,7 @@ import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport._
import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.testkit.ScalatestRouteTest
import org.broadinstitute.dsde.workbench.model.{WorkbenchEmail, WorkbenchUserId}
+import org.broadinstitute.dsde.workbench.sam.Generator.genWorkbenchUserBoth
import org.broadinstitute.dsde.workbench.sam.model.api.SamJsonSupport._
import org.broadinstitute.dsde.workbench.sam.model._
import org.broadinstitute.dsde.workbench.sam.model.api.SamUser
@@ -12,9 +13,10 @@ import org.broadinstitute.dsde.workbench.sam.{Generator, TestSupport}
import org.mockito.scalatest.MockitoSugar
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
-import spray.json.DefaultJsonProtocol.immSetFormat
+import spray.json.DefaultJsonProtocol._
+import org.broadinstitute.dsde.workbench.model.WorkbenchIdentityJsonSupport._
-class AdminServiceUserRoutesSpec extends AnyFlatSpec with Matchers with ScalatestRouteTest with MockitoSugar with TestSupport {
+class ServiceAdminRoutesSpec extends AnyFlatSpec with Matchers with ScalatestRouteTest with MockitoSugar with TestSupport {
val defaultUser: SamUser = Generator.genWorkbenchUserBoth.sample.get
val defaultUserId: WorkbenchUserId = defaultUser.id
val defaultUserEmail: WorkbenchEmail = defaultUser.email
@@ -127,4 +129,32 @@ class AdminServiceUserRoutesSpec extends AnyFlatSpec with Matchers with Scalates
}
}
+ "POST /admin/v2/users" should "get the matching user records when provided a list of user IDs when called as a service admin" in {
+ // Arrange
+ val users = Seq.range(0, 10).map(_ => genWorkbenchUserBoth.sample.get)
+ val samRoutes = new MockSamRoutesBuilder(allUsersGroup)
+ .callAsAdminServiceUser() // enabled "admin" user who is making the http request
+ .withEnabledUsers(users)
+ .build
+
+ // Act and Assert
+ Post(s"/api/admin/v2/users", users.map(_.id)) ~> samRoutes.route ~> check {
+ status shouldEqual StatusCodes.OK
+ responseAs[Seq[SamUser]] should contain theSameElementsAs users
+ }
+ }
+
+ it should "reject a request for more than 1000 users" in {
+ // Arrange
+ val users = Seq.range(0, 1001).map(_ => genWorkbenchUserBoth.sample.get)
+ val samRoutes = new MockSamRoutesBuilder(allUsersGroup)
+ .callAsAdminServiceUser() // enabled "admin" user who is making the http request
+ .withEnabledUsers(users)
+ .build
+
+ // Act and Assert
+ Post(s"/api/admin/v2/users", users.map(_.id)) ~> samRoutes.route ~> check {
+ status shouldEqual StatusCodes.BadRequest
+ }
+ }
}
diff --git a/src/test/scala/org/broadinstitute/dsde/workbench/sam/api/UserRoutesV2Spec.scala b/src/test/scala/org/broadinstitute/dsde/workbench/sam/api/UserRoutesV2Spec.scala
index f166e3839..a8821151f 100644
--- a/src/test/scala/org/broadinstitute/dsde/workbench/sam/api/UserRoutesV2Spec.scala
+++ b/src/test/scala/org/broadinstitute/dsde/workbench/sam/api/UserRoutesV2Spec.scala
@@ -8,6 +8,7 @@ import org.broadinstitute.dsde.workbench.model.{ErrorReport, WorkbenchEmail, Wor
import org.broadinstitute.dsde.workbench.model.ErrorReportJsonSupport._
import org.broadinstitute.dsde.workbench.sam.matchers.BeForSamUserResponseMatcher.beForUser
import org.broadinstitute.dsde.workbench.sam.model._
+import org.broadinstitute.dsde.workbench.sam.model.api.SamJsonSupport._
import org.broadinstitute.dsde.workbench.sam.model.api.{
FilteredResourceFlat,
FilteredResourcesFlat,
@@ -28,8 +29,10 @@ import org.scalatest.matchers.should.Matchers
import java.time.Instant
import org.broadinstitute.dsde.workbench.sam.matchers.TimeMatchers
import org.broadinstitute.dsde.workbench.sam.util.SamRequestContext
+import org.mockito.ArgumentMatchers
import org.mockito.Mockito.lenient
import spray.json.enrichAny
+import spray.json.DefaultJsonProtocol._
class UserRoutesV2Spec extends AnyFlatSpec with Matchers with TimeMatchers with ScalatestRouteTest with MockitoSugar with TestSupport {
val defaultUser: SamUser = Generator.genWorkbenchUserGoogle.sample.get
@@ -260,6 +263,7 @@ class UserRoutesV2Spec extends AnyFlatSpec with Matchers with TimeMatchers with
"GET /api/users/v2/self/combinedState" should "get the user combined state of the calling user" in {
// Arrange
val userAttributes = SamUserAttributes(defaultUser.id, marketingConsent = true)
+ val favoriteResources = Set(FullyQualifiedResourceId(ResourceTypeName("workspaceType"), ResourceId("workspaceName")))
val enterpriseFeature = FilteredResourceFlat(
resourceType = ResourceTypeName("enterprise-feature"),
resourceId = ResourceId("enterprise-feature"),
@@ -275,7 +279,8 @@ class UserRoutesV2Spec extends AnyFlatSpec with Matchers with TimeMatchers with
SamUserAllowances(enabled = true, termsOfService = true),
Option(SamUserAttributes(defaultUser.id, marketingConsent = true)),
TermsOfServiceDetails(Option("v1"), Option(Instant.now()), permitsSystemUsage = true, isCurrentVersion = true),
- Map("enterpriseFeatures" -> FilteredResourcesFlat(Set(enterpriseFeature)).toJson)
+ Map("enterpriseFeatures" -> FilteredResourcesFlat(Set(enterpriseFeature)).toJson),
+ favoriteResources
)
val samRoutes = new MockSamRoutesBuilder(allUsersGroup)
@@ -298,6 +303,11 @@ class UserRoutesV2Spec extends AnyFlatSpec with Matchers with TimeMatchers with
any[SamRequestContext]
)
+ lenient()
+ .doReturn(IO.pure(favoriteResources))
+ .when(samRoutes.resourceService)
+ .getUserFavoriteResources(any[WorkbenchUserId], any[SamRequestContext])
+
// Act and Assert
Get(s"/api/users/v2/self/combinedState") ~> samRoutes.route ~> check {
status shouldEqual StatusCodes.OK
@@ -310,6 +320,7 @@ class UserRoutesV2Spec extends AnyFlatSpec with Matchers with TimeMatchers with
response.termsOfServiceDetails.permitsSystemUsage should be(userCombinedStateResponse.termsOfServiceDetails.permitsSystemUsage)
response.termsOfServiceDetails.latestAcceptedVersion should be(userCombinedStateResponse.termsOfServiceDetails.latestAcceptedVersion)
response.additionalDetails should be(Map("enterpriseFeatures" -> filteresResourcesFlat.toJson))
+ response.favoriteResources should be(favoriteResources)
}
}
@@ -330,7 +341,8 @@ class UserRoutesV2Spec extends AnyFlatSpec with Matchers with TimeMatchers with
SamUserAllowances(enabled = true, termsOfService = true),
Option(SamUserAttributes(defaultUser.id, marketingConsent = true)),
TermsOfServiceDetails(Option("v1"), Option(Instant.now()), permitsSystemUsage = true, isCurrentVersion = true),
- Map("enterpriseFeatures" -> FilteredResourcesFlat(Set(enterpriseFeature)).toJson)
+ Map("enterpriseFeatures" -> FilteredResourcesFlat(Set(enterpriseFeature)).toJson),
+ Set.empty
)
val samRoutes = new MockSamRoutesBuilder(allUsersGroup)
@@ -352,6 +364,11 @@ class UserRoutesV2Spec extends AnyFlatSpec with Matchers with TimeMatchers with
any[SamRequestContext]
)
+ lenient()
+ .doReturn(IO.pure(Set.empty))
+ .when(samRoutes.resourceService)
+ .getUserFavoriteResources(any[WorkbenchUserId], any[SamRequestContext])
+
// Act and Assert
Get(s"/api/users/v2/self/combinedState") ~> samRoutes.route ~> check {
status shouldEqual StatusCodes.OK
@@ -364,18 +381,20 @@ class UserRoutesV2Spec extends AnyFlatSpec with Matchers with TimeMatchers with
response.termsOfServiceDetails.permitsSystemUsage should be(userCombinedStateResponse.termsOfServiceDetails.permitsSystemUsage)
response.termsOfServiceDetails.latestAcceptedVersion should be(userCombinedStateResponse.termsOfServiceDetails.latestAcceptedVersion)
response.additionalDetails should be(Map("enterpriseFeatures" -> filteresResourcesFlat.toJson))
+
}
}
it should "return falsy terms of service if the user has no tos history" in {
// Arrange
- val filteresResourcesFlat = FilteredResourcesFlat(Set.empty)
+ val filteredResourcesFlat = FilteredResourcesFlat(Set.empty)
val userCombinedStateResponse = SamUserCombinedStateResponse(
defaultUser,
SamUserAllowances(enabled = false, termsOfService = false),
Option(SamUserAttributes(defaultUser.id, marketingConsent = true)),
TermsOfServiceDetails(None, None, permitsSystemUsage = false, isCurrentVersion = false),
- Map("enterpriseFeatures" -> filteresResourcesFlat.toJson)
+ Map("enterpriseFeatures" -> filteredResourcesFlat.toJson),
+ Set.empty
)
val samRoutes = new MockSamRoutesBuilder(allUsersGroup)
@@ -397,6 +416,11 @@ class UserRoutesV2Spec extends AnyFlatSpec with Matchers with TimeMatchers with
any[SamRequestContext]
)
+ lenient()
+ .doReturn(IO.pure(Set.empty))
+ .when(samRoutes.resourceService)
+ .getUserFavoriteResources(any[WorkbenchUserId], any[SamRequestContext])
+
// Act and Assert
Get(s"/api/users/v2/self/combinedState") ~> samRoutes.route ~> check {
status shouldEqual StatusCodes.OK
@@ -408,7 +432,184 @@ class UserRoutesV2Spec extends AnyFlatSpec with Matchers with TimeMatchers with
response.termsOfServiceDetails.isCurrentVersion should be(userCombinedStateResponse.termsOfServiceDetails.isCurrentVersion)
response.termsOfServiceDetails.permitsSystemUsage should be(userCombinedStateResponse.termsOfServiceDetails.permitsSystemUsage)
response.termsOfServiceDetails.latestAcceptedVersion shouldBe None
- response.additionalDetails should be(Map("enterpriseFeatures" -> filteresResourcesFlat.toJson))
+ response.additionalDetails should be(Map("enterpriseFeatures" -> filteredResourcesFlat.toJson))
+ response.favoriteResources should be(Set.empty)
+
+ }
+ }
+
+ "GET /api/user/v2/self/favoriteResources" should "return the user's favorite resources" in {
+ // Arrange
+ val user = Generator.genWorkbenchUserGoogle.sample.get
+ val resourceType = ResourceTypeName("workspace")
+ val resourceId = ResourceId("workspace")
+ val resource = FullyQualifiedResourceId(resourceType, resourceId)
+ val favoriteResources = Set(resource)
+ val samRoutes = new MockSamRoutesBuilder(allUsersGroup)
+ .withEnabledUser(user)
+ .withAllowedUser(user)
+ .callAsNonAdminUser(Some(user))
+ .build
+
+ when(samRoutes.resourceService.getUserFavoriteResources(ArgumentMatchers.eq(user.id), any[SamRequestContext]))
+ .thenReturn(IO.pure(favoriteResources))
+
+ // Act and Assert
+ Get(s"/api/users/v2/self/favoriteResources") ~> samRoutes.route ~> check {
+ status shouldEqual StatusCodes.OK
+ responseAs[Set[FullyQualifiedResourceId]] shouldEqual favoriteResources
+ }
+ }
+
+ "GET /api/user/v2/self/favoriteResources/{resourceTypeName}" should "return the user's favorite resources of a given type" in {
+ // Arrange
+ val user = Generator.genWorkbenchUserGoogle.sample.get
+ val resourceType = ResourceTypeName("workspace")
+ val resourceId = ResourceId("workspace")
+ val resource = FullyQualifiedResourceId(resourceType, resourceId)
+ val favoriteResources = Set(resource)
+ val samRoutes = new MockSamRoutesBuilder(allUsersGroup)
+ .withEnabledUser(user)
+ .withAllowedUser(user)
+ .callAsNonAdminUser(Some(user))
+ .build
+
+ when(samRoutes.resourceService.getResourceType(ArgumentMatchers.eq(resourceType)))
+ .thenReturn(IO.pure(Some(ResourceType(resourceType, Set.empty, Set.empty, ResourceRoleName("owner")))))
+
+ when(samRoutes.resourceService.getUserFavoriteResourcesOfType(ArgumentMatchers.eq(user.id), ArgumentMatchers.eq(resourceType), any[SamRequestContext]))
+ .thenReturn(IO.pure(favoriteResources))
+
+ // Act and Assert
+ Get(s"/api/users/v2/self/favoriteResources/$resourceType") ~> samRoutes.route ~> check {
+ status shouldEqual StatusCodes.OK
+ responseAs[Set[FullyQualifiedResourceId]] shouldEqual favoriteResources
+ }
+ }
+
+ "PUT /api/user/v2/self/favoriteResources/{resourceTypeName}/{resourceId}" should "add a resource to a users favorites" in {
+ // Arrange
+ val user = Generator.genWorkbenchUserGoogle.sample.get
+ val resourceType = ResourceTypeName("workspace")
+ val resourceId = ResourceId("workspace")
+ val resource = FullyQualifiedResourceId(resourceType, resourceId)
+ val samRoutes = new MockSamRoutesBuilder(allUsersGroup)
+ .withEnabledUser(user)
+ .withAllowedUser(user)
+ .callAsNonAdminUser(Some(user))
+ .build
+
+ when(samRoutes.resourceService.getResourceType(ArgumentMatchers.eq(resourceType)))
+ .thenReturn(IO.pure(Some(ResourceType(resourceType, Set.empty, Set.empty, ResourceRoleName("owner")))))
+
+ when(samRoutes.policyEvaluatorService.listUserResourceActions(ArgumentMatchers.eq(resource), ArgumentMatchers.eq(user.id), any[SamRequestContext]))
+ .thenReturn(IO.pure(Set(ResourceAction("read"))))
+
+ when(samRoutes.resourceService.addUserFavoriteResource(ArgumentMatchers.eq(user.id), ArgumentMatchers.eq(resource), any[SamRequestContext]))
+ .thenReturn(IO.pure(true))
+
+ // Act and Assert
+ Put(s"/api/users/v2/self/favoriteResources/$resourceType/$resourceId") ~> samRoutes.route ~> check {
+ status shouldEqual StatusCodes.NoContent
+ }
+ }
+
+ it should "forbid adding a resource to a users favorites if they have no access to it, but return Not Found" in {
+ // Arrange
+ val user = Generator.genWorkbenchUserGoogle.sample.get
+ val resourceType = ResourceTypeName("workspace")
+ val resourceId = ResourceId("workspace")
+ val resource = FullyQualifiedResourceId(resourceType, resourceId)
+ val samRoutes = new MockSamRoutesBuilder(allUsersGroup)
+ .withEnabledUser(user)
+ .withAllowedUser(user)
+ .callAsNonAdminUser(Some(user))
+ .build
+
+ when(samRoutes.resourceService.getResourceType(ArgumentMatchers.eq(resourceType)))
+ .thenReturn(IO.pure(Some(ResourceType(resourceType, Set.empty, Set.empty, ResourceRoleName("owner")))))
+
+ when(samRoutes.policyEvaluatorService.listUserResourceActions(ArgumentMatchers.eq(resource), ArgumentMatchers.eq(user.id), any[SamRequestContext]))
+ .thenReturn(IO.pure(Set(ResourceAction("read"))))
+
+ when(samRoutes.resourceService.addUserFavoriteResource(ArgumentMatchers.eq(user.id), ArgumentMatchers.eq(resource), any[SamRequestContext]))
+ .thenReturn(IO.pure(false))
+
+ // Act and Assert
+ Put(s"/api/users/v2/self/favoriteResources/$resourceType/$resourceId") ~> samRoutes.route ~> check {
+ status shouldEqual StatusCodes.NotFound
+ }
+ }
+
+ it should "return Not Found if adding a resource that doesn't exist" in {
+ // Arrange
+ val user = Generator.genWorkbenchUserGoogle.sample.get
+ val resourceType = ResourceTypeName("workspace")
+ val resourceId = ResourceId("workspace")
+ val resource = FullyQualifiedResourceId(resourceType, resourceId)
+ val samRoutes = new MockSamRoutesBuilder(allUsersGroup)
+ .withEnabledUser(user)
+ .withAllowedUser(user)
+ .callAsNonAdminUser(Some(user))
+ .build
+
+ when(samRoutes.resourceService.getResourceType(ArgumentMatchers.eq(resourceType)))
+ .thenReturn(IO.pure(Some(ResourceType(resourceType, Set.empty, Set.empty, ResourceRoleName("owner")))))
+
+ when(samRoutes.policyEvaluatorService.listUserResourceActions(ArgumentMatchers.eq(resource), ArgumentMatchers.eq(user.id), any[SamRequestContext]))
+ .thenReturn(IO.pure(Set.empty))
+
+ // Act and Assert
+ Put(s"/api/users/v2/self/favoriteResources/$resourceType/$resourceId") ~> samRoutes.route ~> check {
+ status shouldEqual StatusCodes.NotFound
+ }
+ }
+
+ "DELETE /api/user/v2/self/favoriteResources/{resourceTypeName}/{resourceId}" should "remove a resource to a users favorites" in {
+ // Arrange
+ val user = Generator.genWorkbenchUserGoogle.sample.get
+ val resourceType = ResourceTypeName("workspace")
+ val resourceId = ResourceId("workspace")
+ val resource = FullyQualifiedResourceId(resourceType, resourceId)
+ val samRoutes = new MockSamRoutesBuilder(allUsersGroup)
+ .withEnabledUser(user)
+ .withAllowedUser(user)
+ .callAsNonAdminUser(Some(user))
+ .build
+
+ when(samRoutes.resourceService.getResourceType(ArgumentMatchers.eq(resourceType)))
+ .thenReturn(IO.pure(Some(ResourceType(resourceType, Set.empty, Set.empty, ResourceRoleName("owner")))))
+
+ when(samRoutes.resourceService.removeUserFavoriteResource(ArgumentMatchers.eq(user.id), ArgumentMatchers.eq(resource), any[SamRequestContext]))
+ .thenReturn(IO.pure(()))
+
+ // Act and Assert
+ Delete(s"/api/users/v2/self/favoriteResources/$resourceType/$resourceId") ~> samRoutes.route ~> check {
+ status shouldEqual StatusCodes.NoContent
+ }
+ }
+
+ it should "allow removing a resource to a users favorites if they have no access to it" in {
+ // Arrange
+ val user = Generator.genWorkbenchUserGoogle.sample.get
+ val resourceType = ResourceTypeName("workspace")
+ val resourceId = ResourceId("workspace")
+ val resource = FullyQualifiedResourceId(resourceType, resourceId)
+ val samRoutes = new MockSamRoutesBuilder(allUsersGroup)
+ .withEnabledUser(user)
+ .withAllowedUser(user)
+ .callAsNonAdminUser(Some(user))
+ .build
+
+ when(samRoutes.resourceService.getResourceType(ArgumentMatchers.eq(resourceType)))
+ .thenReturn(IO.pure(Some(ResourceType(resourceType, Set.empty, Set.empty, ResourceRoleName("owner")))))
+
+ when(samRoutes.resourceService.removeUserFavoriteResource(ArgumentMatchers.eq(user.id), ArgumentMatchers.eq(resource), any[SamRequestContext]))
+ .thenReturn(IO.pure(()))
+
+ // Act and Assert
+ Delete(s"/api/users/v2/self/favoriteResources/$resourceType/$resourceId") ~> samRoutes.route ~> check {
+ status shouldEqual StatusCodes.NoContent
}
}
}
diff --git a/src/test/scala/org/broadinstitute/dsde/workbench/sam/config/AppConfigSpec.scala b/src/test/scala/org/broadinstitute/dsde/workbench/sam/config/AppConfigSpec.scala
index 74ed65911..8cbd625e0 100644
--- a/src/test/scala/org/broadinstitute/dsde/workbench/sam/config/AppConfigSpec.scala
+++ b/src/test/scala/org/broadinstitute/dsde/workbench/sam/config/AppConfigSpec.scala
@@ -1,6 +1,18 @@
package org.broadinstitute.dsde.workbench.sam.config
import com.typesafe.config.{ConfigException, ConfigFactory, ConfigValueFactory}
+import org.broadinstitute.dsde.workbench.model.WorkbenchEmail
+import org.broadinstitute.dsde.workbench.sam.model.api.AccessPolicyMembershipRequest
+import org.broadinstitute.dsde.workbench.sam.model.{
+ AccessPolicyDescendantPermissions,
+ AccessPolicyName,
+ FullyQualifiedPolicyId,
+ FullyQualifiedResourceId,
+ ResourceId,
+ ResourceRoleName,
+ ResourceTypeName,
+ SamResourceTypes
+}
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
@@ -36,4 +48,28 @@ class AppConfigSpec extends AnyFlatSpec with Matchers {
AppConfig.readConfig(combinedConfig.withoutPath("googleServices.appName"))
}
}
+
+ it should "load resourceAccessPolicies" in {
+ val appConfig = AppConfig.load
+ appConfig.resourceAccessPolicies should contain allElementsOf Map(
+ FullyQualifiedPolicyId(FullyQualifiedResourceId(SamResourceTypes.resourceTypeAdminName, ResourceId("workspace")), AccessPolicyName("rawls-policy")) ->
+ AccessPolicyMembershipRequest(
+ Set(WorkbenchEmail("rawls@test.firecloud.org")),
+ Set.empty,
+ Set.empty,
+ Some(
+ Set(
+ AccessPolicyDescendantPermissions(
+ ResourceTypeName("workspace"),
+ Set.empty,
+ Set(ResourceRoleName("owner"))
+ )
+ )
+ ),
+ None
+ ),
+ FullyQualifiedPolicyId(FullyQualifiedResourceId(SamResourceTypes.resourceTypeAdminName, ResourceId("kubernetes-app")), AccessPolicyName("leo-policy")) ->
+ AccessPolicyMembershipRequest(Set(WorkbenchEmail("leo@test.firecloud.org")), Set.empty, Set(ResourceRoleName("support")), None, None)
+ )
+ }
}
diff --git a/src/test/scala/org/broadinstitute/dsde/workbench/sam/dataAccess/MockDirectoryDAO.scala b/src/test/scala/org/broadinstitute/dsde/workbench/sam/dataAccess/MockDirectoryDAO.scala
index 2bc11b176..ed417cd39 100644
--- a/src/test/scala/org/broadinstitute/dsde/workbench/sam/dataAccess/MockDirectoryDAO.scala
+++ b/src/test/scala/org/broadinstitute/dsde/workbench/sam/dataAccess/MockDirectoryDAO.scala
@@ -16,7 +16,7 @@ import org.broadinstitute.dsde.workbench.sam.azure.{
}
import org.broadinstitute.dsde.workbench.sam.db.tables.TosTable
import org.broadinstitute.dsde.workbench.sam.model.api.{AdminUpdateUserRequest, SamUser, SamUserAttributes}
-import org.broadinstitute.dsde.workbench.sam.model.{AccessPolicy, BasicWorkbenchGroup, FullyQualifiedResourceId, ResourceAction, SamUserTos}
+import org.broadinstitute.dsde.workbench.sam.model.{AccessPolicy, BasicWorkbenchGroup, FullyQualifiedResourceId, ResourceAction, ResourceTypeName, SamUserTos}
import org.broadinstitute.dsde.workbench.sam.util.SamRequestContext
import java.time.Instant
@@ -43,6 +43,8 @@ class MockDirectoryDAO(val groups: mutable.Map[WorkbenchGroupIdentity, Workbench
private val petManagedIdentitiesByUser: mutable.Map[PetManagedIdentityId, PetManagedIdentity] = new TrieMap()
+ private val userFavoriteResources: mutable.Map[WorkbenchUserId, Set[FullyQualifiedResourceId]] = new TrieMap()
+
override def createGroup(
group: BasicWorkbenchGroup,
accessInstruction: Option[String] = None,
@@ -115,6 +117,11 @@ class MockDirectoryDAO(val groups: mutable.Map[WorkbenchGroupIdentity, Workbench
users.get(userId)
}
+ override def batchLoadUsers(
+ samUserIds: Set[WorkbenchUserId],
+ samRequestContext: SamRequestContext
+ ): IO[Seq[SamUser]] = IO(samUserIds.flatMap(users.get).toSeq)
+
override def loadUsersByQuery(
userId: Option[WorkbenchUserId],
googleSubjectId: Option[GoogleSubjectId],
@@ -475,4 +482,33 @@ class MockDirectoryDAO(val groups: mutable.Map[WorkbenchGroupIdentity, Workbench
}
override def listParentGroups(groupName: WorkbenchGroupName, samRequestContext: SamRequestContext): IO[Set[WorkbenchGroupName]] = IO.pure(Set.empty)
+
+ override def addUserFavoriteResource(userId: WorkbenchUserId, resourceId: FullyQualifiedResourceId, samRequestContext: SamRequestContext): IO[Boolean] = {
+ if (userFavoriteResources.keySet.contains(userId)) {
+ val updatedResources = userFavoriteResources(userId) + resourceId
+ userFavoriteResources += userId -> updatedResources
+ } else {
+ userFavoriteResources += userId -> Set(resourceId)
+ }
+ IO.pure(true)
+ }
+
+ override def removeUserFavoriteResource(userId: WorkbenchUserId, resourceId: FullyQualifiedResourceId, samRequestContext: SamRequestContext): IO[Unit] = {
+ if (userFavoriteResources.keySet.contains(userId)) {
+ val updatedResources = userFavoriteResources(userId) - resourceId
+ userFavoriteResources += userId -> updatedResources
+ }
+ IO.unit
+ }
+
+ override def getUserFavoriteResources(userId: WorkbenchUserId, samRequestContext: SamRequestContext): IO[Set[FullyQualifiedResourceId]] = IO {
+ userFavoriteResources.getOrElse(userId, Set.empty)
+ }
+
+ override def getUserFavoriteResourcesOfType(
+ userId: WorkbenchUserId,
+ resourceTypeName: ResourceTypeName,
+ samRequestContext: SamRequestContext
+ ): IO[Set[FullyQualifiedResourceId]] =
+ IO.pure(userFavoriteResources.getOrElse(userId, Set.empty).filter(_.resourceTypeName == resourceTypeName))
}
diff --git a/src/test/scala/org/broadinstitute/dsde/workbench/sam/dataAccess/PostgresDirectoryDAOSpec.scala b/src/test/scala/org/broadinstitute/dsde/workbench/sam/dataAccess/PostgresDirectoryDAOSpec.scala
index 9d4ba0e84..fcb0d910a 100644
--- a/src/test/scala/org/broadinstitute/dsde/workbench/sam/dataAccess/PostgresDirectoryDAOSpec.scala
+++ b/src/test/scala/org/broadinstitute/dsde/workbench/sam/dataAccess/PostgresDirectoryDAOSpec.scala
@@ -625,6 +625,16 @@ class PostgresDirectoryDAOSpec extends RetryableAnyFreeSpec with Matchers with B
}
}
+ "batchLoadUsers" - {
+ "loads a list of users" in {
+ assume(databaseEnabled, databaseEnabledClue)
+ val users = Seq.range(0, 10).map(_ => Generator.genWorkbenchUserBoth.sample.get)
+ users.foreach(user => dao.createUser(user, samRequestContext).unsafeRunSync())
+ val loadedUsers = dao.batchLoadUsers(users.map(_.id).toSet, samRequestContext).unsafeRunSync()
+ loadedUsers should contain theSameElementsAs users
+ }
+ }
+
"deleteUser" - {
"delete users" in {
assume(databaseEnabled, databaseEnabledClue)
@@ -2033,5 +2043,83 @@ class PostgresDirectoryDAOSpec extends RetryableAnyFreeSpec with Matchers with B
dao.listParentGroups(WorkbenchGroupName("nonexistentGroup"), samRequestContext).unsafeRunSync() shouldBe empty
}
}
+
+ "UserFavoriteResources" - {
+ "add a favorite resource for a user" in {
+ assume(databaseEnabled, databaseEnabledClue)
+ val user = Generator.genWorkbenchUserGoogle.sample.get
+ dao.createUser(user, samRequestContext).unsafeRunSync()
+ policyDAO.createResourceType(resourceType, samRequestContext).unsafeRunSync()
+ policyDAO.createResource(defaultResource, samRequestContext).unsafeRunSync()
+ val result = dao.addUserFavoriteResource(user.id, defaultResource.fullyQualifiedId, samRequestContext).unsafeRunSync()
+ result should be(true)
+
+ val loadedFavoriteResources = dao.getUserFavoriteResources(user.id, samRequestContext).unsafeRunSync()
+ loadedFavoriteResources should contain theSameElementsAs Set(defaultResource.fullyQualifiedId)
+ }
+
+ "return false if adding a favorite resource for a user that doesn't exist" in {
+ assume(databaseEnabled, databaseEnabledClue)
+ val user = Generator.genWorkbenchUserGoogle.sample.get
+ dao.createUser(user, samRequestContext).unsafeRunSync()
+ policyDAO.createResourceType(resourceType, samRequestContext).unsafeRunSync()
+ policyDAO.createResource(defaultResource, samRequestContext).unsafeRunSync()
+
+ val otherResource = Resource(resourceTypeName, ResourceId("otherResource"), Set.empty)
+ val result = dao.addUserFavoriteResource(user.id, otherResource.fullyQualifiedId, samRequestContext).unsafeRunSync()
+ result should be(false)
+ }
+
+ "remove a favorite resource for a user" in {
+ assume(databaseEnabled, databaseEnabledClue)
+ val user = Generator.genWorkbenchUserGoogle.sample.get
+ dao.createUser(user, samRequestContext).unsafeRunSync()
+ val otherResource = Resource(resourceType.name, ResourceId("otherResource"), Set.empty)
+ policyDAO.createResourceType(resourceType, samRequestContext).unsafeRunSync()
+ policyDAO.createResource(defaultResource, samRequestContext).unsafeRunSync()
+ policyDAO.createResource(otherResource, samRequestContext).unsafeRunSync()
+
+ dao.addUserFavoriteResource(user.id, defaultResource.fullyQualifiedId, samRequestContext).unsafeRunSync()
+ dao.addUserFavoriteResource(user.id, otherResource.fullyQualifiedId, samRequestContext).unsafeRunSync()
+ dao.removeUserFavoriteResource(user.id, defaultResource.fullyQualifiedId, samRequestContext).unsafeRunSync()
+
+ val loadedFavoriteResources = dao.getUserFavoriteResources(user.id, samRequestContext).unsafeRunSync()
+ loadedFavoriteResources should contain theSameElementsAs Set(otherResource.fullyQualifiedId)
+ }
+
+ "remove a favorite resource for a user when the resource doesn't exist" in {
+ assume(databaseEnabled, databaseEnabledClue)
+ val user = Generator.genWorkbenchUserGoogle.sample.get
+ dao.createUser(user, samRequestContext).unsafeRunSync()
+ val otherResource = Resource(resourceType.name, ResourceId("otherResource"), Set.empty)
+ policyDAO.createResourceType(resourceType, samRequestContext).unsafeRunSync()
+ policyDAO.createResource(defaultResource, samRequestContext).unsafeRunSync()
+
+ dao.removeUserFavoriteResource(user.id, otherResource.fullyQualifiedId, samRequestContext).unsafeRunSync()
+
+ val loadedFavoriteResources = dao.getUserFavoriteResources(user.id, samRequestContext).unsafeRunSync()
+ loadedFavoriteResources should contain theSameElementsAs Set.empty
+ }
+
+ "get the favorite resources of a specific resource type for a user" in {
+ assume(databaseEnabled, databaseEnabledClue)
+ val user = Generator.genWorkbenchUserGoogle.sample.get
+ dao.createUser(user, samRequestContext).unsafeRunSync()
+ policyDAO.createResourceType(resourceType, samRequestContext).unsafeRunSync()
+ policyDAO.createResource(defaultResource, samRequestContext).unsafeRunSync()
+
+ val otherResourceTypeName: ResourceTypeName = ResourceTypeName("awesomeType2")
+ val otherResourceType: ResourceType = ResourceType(otherResourceTypeName, actionPatterns, roles, ownerRoleName)
+ val otherResource = Resource(otherResourceTypeName, ResourceId("otherResource"), Set.empty)
+ policyDAO.createResourceType(otherResourceType, samRequestContext).unsafeRunSync()
+ policyDAO.createResource(otherResource, samRequestContext).unsafeRunSync()
+
+ dao.addUserFavoriteResource(user.id, defaultResource.fullyQualifiedId, samRequestContext).unsafeRunSync()
+ dao.addUserFavoriteResource(user.id, otherResource.fullyQualifiedId, samRequestContext).unsafeRunSync()
+
+ val loadedFavoriteResources = dao.getUserFavoriteResourcesOfType(user.id, otherResourceTypeName, samRequestContext).unsafeRunSync()
+ loadedFavoriteResources should contain theSameElementsAs Set(otherResource.fullyQualifiedId)
+ }
+ }
}
}
diff --git a/src/test/scala/org/broadinstitute/dsde/workbench/sam/dataAccess/StatefulMockDirectoryDaoBuilder.scala b/src/test/scala/org/broadinstitute/dsde/workbench/sam/dataAccess/StatefulMockDirectoryDaoBuilder.scala
index 7500d9d84..c4ce74a19 100644
--- a/src/test/scala/org/broadinstitute/dsde/workbench/sam/dataAccess/StatefulMockDirectoryDaoBuilder.scala
+++ b/src/test/scala/org/broadinstitute/dsde/workbench/sam/dataAccess/StatefulMockDirectoryDaoBuilder.scala
@@ -162,6 +162,9 @@ case class StatefulMockDirectoryDaoBuilder() extends MockitoSugar {
.toSet
)
)
+ mockedDirectoryDAO.batchLoadUsers(any[Set[WorkbenchUserId]], any[SamRequestContext]) answers ((samUserIds: Set[WorkbenchUserId], _: SamRequestContext) =>
+ IO(samUsers.filter(user => samUserIds.contains(user.id)).toSeq)
+ )
this
}
diff --git a/src/test/scala/org/broadinstitute/dsde/workbench/sam/google/GoogleExtensionSpec.scala b/src/test/scala/org/broadinstitute/dsde/workbench/sam/google/GoogleExtensionSpec.scala
index 9b53ae49f..3456f2e38 100644
--- a/src/test/scala/org/broadinstitute/dsde/workbench/sam/google/GoogleExtensionSpec.scala
+++ b/src/test/scala/org/broadinstitute/dsde/workbench/sam/google/GoogleExtensionSpec.scala
@@ -1080,6 +1080,12 @@ class GoogleExtensionSpec(_system: ActorSystem)
when(mockDirectoryDAO.getSynchronizedDate(any[FullyQualifiedPolicyId], any[SamRequestContext]))
.thenReturn(IO.pure(Some(new GregorianCalendar(2018, 8, 26).getTime())))
when(mockGoogleGroupSyncPubSubDAO.publishMessages(any[String], any[Seq[MessageRequest]])).thenReturn(Future.successful(()))
+ when(
+ mockDirectoryDAO.updateGroupUpdatedDateAndVersionWithSession(
+ any[WorkbenchGroupIdentity],
+ any[SamRequestContext]
+ )
+ ).thenReturn(IO.unit)
// mock responses for onManagedGroupUpdate
when(mockAccessPolicyDAO.listSyncedAccessPolicyIdsOnResourcesConstrainedByGroup(WorkbenchGroupName(managedGroupId), Set.empty, samRequestContext))
@@ -1088,6 +1094,8 @@ class GoogleExtensionSpec(_system: ActorSystem)
runAndWait(googleExtensions.onGroupUpdate(Seq(managedGroupRPN), Set.empty, samRequestContext))
verify(mockGoogleGroupSyncPubSubDAO, times(1)).publishMessages(any[String], any[Seq[MessageRequest]])
+
+ verify(mockDirectoryDAO, times(2)).updateGroupUpdatedDateAndVersionWithSession(any[WorkbenchGroupIdentity], any[SamRequestContext])
}
it should "trigger updates to constrained policies when updating a group that is a part of a managed group" in {
@@ -1130,6 +1138,12 @@ class GoogleExtensionSpec(_system: ActorSystem)
when(mockDirectoryDAO.getSynchronizedDate(any[FullyQualifiedPolicyId], any[SamRequestContext]))
.thenReturn(IO.pure(Some(new GregorianCalendar(2018, 8, 26).getTime())))
when(mockGoogleGroupSyncPubSubDAO.publishMessages(any[String], any[Seq[MessageRequest]])).thenReturn(Future.successful(()))
+ when(
+ mockDirectoryDAO.updateGroupUpdatedDateAndVersionWithSession(
+ any[WorkbenchGroupIdentity],
+ any[SamRequestContext]
+ )
+ ).thenReturn(IO.unit)
// mock ancestor call to establish subgroup relationship to managed group
when(mockDirectoryDAO.listAncestorGroups(WorkbenchGroupName(subGroupId), samRequestContext))
@@ -1184,6 +1198,12 @@ class GoogleExtensionSpec(_system: ActorSystem)
when(mockDirectoryDAO.getSynchronizedDate(any[FullyQualifiedPolicyId], any[SamRequestContext]))
.thenReturn(IO.pure(Some(new GregorianCalendar(2018, 8, 26).getTime())))
when(mockGoogleGroupSyncPubSubDAO.publishMessages(any[String], any[Seq[MessageRequest]])).thenReturn(Future.successful(()))
+ when(
+ mockDirectoryDAO.updateGroupUpdatedDateAndVersionWithSession(
+ any[WorkbenchGroupIdentity],
+ any[SamRequestContext]
+ )
+ ).thenReturn(IO.unit)
// mock ancestor call to establish nested group structure for owner policy and subgroup in managed group
when(mockDirectoryDAO.listAncestorGroups(WorkbenchGroupName(subGroupId), samRequestContext))
@@ -2047,6 +2067,50 @@ class GoogleExtensionSpec(_system: ActorSystem)
report.errorReport.statusCode shouldEqual Some(StatusCodes.BadRequest)
}
+ it should "return a failed IO when the google project is inactive" in {
+ assume(databaseEnabled, databaseEnabledClue)
+
+ val dirDAO = newDirectoryDAO()
+
+ clearDatabase()
+
+ val mockGoogleIamDAO = new MockGoogleIamDAO
+ val mockGoogleDirectoryDAO = new MockGoogleDirectoryDAO
+ val mockGoogleProjectDAO = new MockGoogleProjectDAO {
+ override def isProjectActive(projectName: String): Future[Boolean] =
+ Future.successful(false)
+ }
+ val googleExtensions = new GoogleExtensions(
+ TestSupport.distributedLock,
+ dirDAO,
+ null,
+ mockGoogleDirectoryDAO,
+ null,
+ null,
+ null,
+ mockGoogleIamDAO,
+ null,
+ mockGoogleProjectDAO,
+ null,
+ null,
+ null,
+ null,
+ googleServicesConfig,
+ petServiceAccountConfig,
+ configResourceTypes,
+ superAdminsGroup
+ )
+
+ val defaultUser = Generator.genWorkbenchUserBoth.sample.get
+
+ val googleProject = GoogleProject("testproject")
+ val report = intercept[WorkbenchExceptionWithErrorReport] {
+ googleExtensions.createUserPetServiceAccount(defaultUser, googleProject, samRequestContext).unsafeRunSync()
+ }
+
+ report.errorReport.statusCode shouldEqual Some(StatusCodes.BadRequest)
+ }
+
"fireAndForgetNotifications" should "not fail" in {
val mockGoogleNotificationPubSubDAO = new MockGooglePubSubDAO
val topicName = "neat_topic"
diff --git a/src/test/scala/org/broadinstitute/dsde/workbench/sam/google/NewGoogleExtensionsSpec.scala b/src/test/scala/org/broadinstitute/dsde/workbench/sam/google/NewGoogleExtensionsSpec.scala
index c25faefbe..918f47f99 100644
--- a/src/test/scala/org/broadinstitute/dsde/workbench/sam/google/NewGoogleExtensionsSpec.scala
+++ b/src/test/scala/org/broadinstitute/dsde/workbench/sam/google/NewGoogleExtensionsSpec.scala
@@ -4,34 +4,38 @@ import akka.actor.ActorSystem
import akka.testkit.TestKit
import cats.effect.IO
import com.google.auth.oauth2.ServiceAccountCredentials
+import fs2.Stream
import org.broadinstitute.dsde.workbench.RetryConfig
import org.broadinstitute.dsde.workbench.dataaccess.NotificationDAO
import org.broadinstitute.dsde.workbench.google.{GoogleDirectoryDAO, GoogleIamDAO, GoogleKmsService, GoogleProjectDAO, GooglePubSubDAO, GoogleStorageDAO}
import org.broadinstitute.dsde.workbench.google2.{GcsBlobName, GoogleStorageService}
import org.broadinstitute.dsde.workbench.model.TraceId
+import org.broadinstitute.dsde.workbench.model.google.GcsBucketName
import org.broadinstitute.dsde.workbench.sam.Generator.{
genFirecloudEmail,
genGcsBlobName,
genGcsBucketName,
genGoogleProject,
+ genNonPetEmail,
+ genOAuth2BearerToken,
genPetServiceAccount,
- genWorkbenchUserGoogle
+ genWorkbenchUserGoogle,
+ genWorkbenchUserId
}
import org.broadinstitute.dsde.workbench.sam.TestSupport
import org.broadinstitute.dsde.workbench.sam.dataAccess.{AccessPolicyDAO, DirectoryDAO, PostgresDistributedLockDAO}
import org.broadinstitute.dsde.workbench.sam.mock.RealKeyMockGoogleIamDAO
+import org.broadinstitute.dsde.workbench.sam.model.api.SamUser
import org.broadinstitute.dsde.workbench.sam.model.{ResourceType, ResourceTypeName}
import org.broadinstitute.dsde.workbench.sam.util.SamRequestContext
-import org.mockito.IdiomaticMockito
-import org.mockito.Mockito.{RETURNS_SMART_NULLS, doReturn}
-import fs2.Stream
-import org.broadinstitute.dsde.workbench.model.google.GcsBucketName
-import org.scalatest.{Inside, OptionValues}
-import org.scalatest.concurrent.ScalaFutures
-import org.scalatest.matchers.should.Matchers
import org.mockito.ArgumentMatchersSugar._
+import org.mockito.IdiomaticMockito
+import org.mockito.Mockito.{RETURNS_SMART_NULLS, clearInvocations, doReturn, verifyNoInteractions}
import org.mockito.MockitoSugar.verify
+import org.scalatest.concurrent.ScalaFutures
import org.scalatest.freespec.AnyFreeSpecLike
+import org.scalatest.matchers.should.Matchers
+import org.scalatest.{Inside, OptionValues}
import java.net.URL
import java.util.concurrent.TimeUnit
@@ -166,7 +170,7 @@ class NewGoogleExtensionsSpec(_system: ActorSystem)
val arbitraryPetServiceAccount = genPetServiceAccount.sample.get
val arbitraryPetServiceAccountKey = RealKeyMockGoogleIamDAO.generateNewRealKey(arbitraryPetServiceAccount.serviceAccount.email)._2
- doReturn(Future.successful(arbitraryPetServiceAccountKey))
+ doReturn(IO.pure(arbitraryPetServiceAccountKey))
.when(googleExtensions)
.getArbitraryPetServiceAccountKey(eqTo(newGoogleUser), any[SamRequestContext])
@@ -206,4 +210,168 @@ class NewGoogleExtensionsSpec(_system: ActorSystem)
}
}
}
+ "GoogleExtensions: Pet Service Accounts" - {
+ val subject = genWorkbenchUserId.sample.get
+ val email = genNonPetEmail.sample.get
+ val user = SamUser(subject, None, email, None, false)
+ val googleProject = genGoogleProject.sample.get
+ val scopes = Set("scope1", "scope2")
+ val petServiceAccount = genPetServiceAccount.sample.get
+ val expectedKey = RealKeyMockGoogleIamDAO.generateNewRealKey(petServiceAccount.serviceAccount.email)._2
+ val expectedToken = genOAuth2BearerToken.sample.get.token
+
+ val mockDirectoryDAO = mock[DirectoryDAO](RETURNS_SMART_NULLS)
+ val mockGoogleKeyCache = mock[GoogleKeyCache](RETURNS_SMART_NULLS)
+
+ val googleExtensions: GoogleExtensions = spy(
+ new GoogleExtensions(
+ mock[PostgresDistributedLockDAO[IO]](RETURNS_SMART_NULLS),
+ mockDirectoryDAO,
+ mock[AccessPolicyDAO](RETURNS_SMART_NULLS),
+ mock[GoogleDirectoryDAO](RETURNS_SMART_NULLS),
+ mock[GooglePubSubDAO](RETURNS_SMART_NULLS),
+ mock[GooglePubSubDAO](RETURNS_SMART_NULLS),
+ mock[GooglePubSubDAO](RETURNS_SMART_NULLS),
+ mock[GoogleIamDAO](RETURNS_SMART_NULLS),
+ mock[GoogleStorageDAO](RETURNS_SMART_NULLS),
+ mock[GoogleProjectDAO](RETURNS_SMART_NULLS),
+ mockGoogleKeyCache,
+ mock[NotificationDAO](RETURNS_SMART_NULLS),
+ mock[GoogleKmsService[IO]](RETURNS_SMART_NULLS),
+ mock[GoogleStorageService[IO]](RETURNS_SMART_NULLS),
+ TestSupport.googleServicesConfig,
+ TestSupport.petServiceAccountConfig,
+ Map.empty[ResourceTypeName, ResourceType],
+ genFirecloudEmail.sample.get
+ )
+ )
+
+ doReturn(IO.some(subject))
+ .when(mockDirectoryDAO)
+ .loadSubjectFromEmail(eqTo(email), any[SamRequestContext])
+
+ doReturn(IO.pure(petServiceAccount))
+ .when(googleExtensions)
+ .createUserPetServiceAccount(eqTo(user), eqTo(googleProject), any[SamRequestContext])
+
+ doReturn(Future.successful(expectedToken))
+ .when(googleExtensions)
+ .getAccessTokenUsingJson(eqTo(expectedKey), eqTo(scopes))
+
+ doReturn(Future.successful(expectedKey))
+ .when(googleExtensions)
+ .getDefaultServiceAccountForShellProject(eqTo(user), any[SamRequestContext])
+
+ doReturn(IO.pure(expectedKey))
+ .when(mockGoogleKeyCache)
+ .getKey(eqTo(petServiceAccount))
+
+ "getPetServiceAccountKey" - {
+ "gets a key for an email" in {
+ clearInvocations(mockDirectoryDAO, googleExtensions, mockGoogleKeyCache)
+
+ val key = runAndWait(googleExtensions.getPetServiceAccountKey(email, googleProject, samRequestContext))
+
+ key should be(Some(expectedKey))
+
+ verify(mockDirectoryDAO).loadSubjectFromEmail(eqTo(email), any[SamRequestContext])
+ verify(googleExtensions).createUserPetServiceAccount(eqTo(user), eqTo(googleProject), any[SamRequestContext])
+ verify(mockGoogleKeyCache).getKey(eqTo(petServiceAccount))
+ }
+
+ "gets a key for a SamUser" in {
+ clearInvocations(mockDirectoryDAO, googleExtensions, mockGoogleKeyCache)
+
+ val key = runAndWait(googleExtensions.getPetServiceAccountKey(user, googleProject, samRequestContext))
+
+ key should be(expectedKey)
+
+ verifyNoInteractions(mockDirectoryDAO)
+ verify(googleExtensions).createUserPetServiceAccount(eqTo(user), eqTo(googleProject), any[SamRequestContext])
+ verify(mockGoogleKeyCache).getKey(eqTo(petServiceAccount))
+ }
+
+ "getPetServiceAccountToken" - {
+ "gets a token for an email" in {
+ clearInvocations(mockDirectoryDAO, googleExtensions, mockGoogleKeyCache)
+
+ val token = runAndWait(googleExtensions.getPetServiceAccountToken(email, googleProject, scopes, samRequestContext))
+
+ token should be(Some(expectedToken))
+
+ verify(mockDirectoryDAO).loadSubjectFromEmail(eqTo(email), any[SamRequestContext])
+ verify(googleExtensions).createUserPetServiceAccount(eqTo(user), eqTo(googleProject), any[SamRequestContext])
+ verify(mockGoogleKeyCache).getKey(eqTo(petServiceAccount))
+ verify(googleExtensions).getAccessTokenUsingJson(eqTo(expectedKey), eqTo(scopes))
+
+ }
+ "gets a token for a SamUser" in {
+ clearInvocations(mockDirectoryDAO, googleExtensions, mockGoogleKeyCache)
+
+ val token = runAndWait(googleExtensions.getPetServiceAccountToken(user, googleProject, scopes, samRequestContext))
+
+ token should be(expectedToken)
+
+ verifyNoInteractions(mockDirectoryDAO)
+ verify(googleExtensions).createUserPetServiceAccount(eqTo(user), eqTo(googleProject), any[SamRequestContext])
+ verify(mockGoogleKeyCache).getKey(eqTo(petServiceAccount))
+ verify(googleExtensions).getAccessTokenUsingJson(eqTo(expectedKey), eqTo(scopes))
+ }
+ }
+
+ "getArbitraryPetServiceAccountKey" - {
+ "gets a key for an email" in {
+ clearInvocations(mockDirectoryDAO, googleExtensions, mockGoogleKeyCache)
+
+ val key = runAndWait(googleExtensions.getArbitraryPetServiceAccountKey(email, samRequestContext))
+
+ key should be(Some(expectedKey))
+
+ verify(mockDirectoryDAO).loadSubjectFromEmail(eqTo(email), any[SamRequestContext])
+ verifyNoInteractions(mockGoogleKeyCache)
+ verify(googleExtensions).getDefaultServiceAccountForShellProject(eqTo(user), any[SamRequestContext])
+ }
+
+ "gets a key for a SamUser" in {
+ clearInvocations(mockDirectoryDAO, googleExtensions, mockGoogleKeyCache)
+
+ val key = runAndWait(googleExtensions.getArbitraryPetServiceAccountKey(user, samRequestContext))
+
+ key should be(expectedKey)
+
+ verifyNoInteractions(mockDirectoryDAO)
+ verifyNoInteractions(mockGoogleKeyCache)
+ verify(googleExtensions).getDefaultServiceAccountForShellProject(eqTo(user), any[SamRequestContext])
+ }
+ }
+
+ "getArbitraryPetServiceAccountToken" - {
+ "gets a token for an email" in {
+ clearInvocations(mockDirectoryDAO, googleExtensions, mockGoogleKeyCache)
+
+ val token = runAndWait(googleExtensions.getArbitraryPetServiceAccountToken(email, scopes, samRequestContext))
+
+ token should be(Some(expectedToken))
+
+ verify(mockDirectoryDAO).loadSubjectFromEmail(eqTo(email), any[SamRequestContext])
+ verifyNoInteractions(mockGoogleKeyCache)
+ verify(googleExtensions).getDefaultServiceAccountForShellProject(eqTo(user), any[SamRequestContext])
+ verify(googleExtensions).getAccessTokenUsingJson(eqTo(expectedKey), eqTo(scopes))
+ }
+
+ "gets a token for a SamUser" in {
+ clearInvocations(mockDirectoryDAO, googleExtensions, mockGoogleKeyCache)
+
+ val token = runAndWait(googleExtensions.getArbitraryPetServiceAccountToken(user, scopes, samRequestContext))
+
+ token should be(expectedToken)
+
+ verifyNoInteractions(mockDirectoryDAO)
+ verifyNoInteractions(mockGoogleKeyCache)
+ verify(googleExtensions).getDefaultServiceAccountForShellProject(eqTo(user), any[SamRequestContext])
+ verify(googleExtensions).getAccessTokenUsingJson(eqTo(expectedKey), eqTo(scopes))
+ }
+ }
+ }
+ }
}
diff --git a/src/test/scala/org/broadinstitute/dsde/workbench/sam/service/MockUserService.scala b/src/test/scala/org/broadinstitute/dsde/workbench/sam/service/MockUserService.scala
index 4e608a733..376270bbf 100644
--- a/src/test/scala/org/broadinstitute/dsde/workbench/sam/service/MockUserService.scala
+++ b/src/test/scala/org/broadinstitute/dsde/workbench/sam/service/MockUserService.scala
@@ -123,9 +123,6 @@ class MockUserService(
def setGoogleSubjectId(userId: WorkbenchUserId, googleSubjectId: GoogleSubjectId, samRequestContext: SamRequestContext): IO[Unit] =
directoryDAO.setGoogleSubjectId(userId, googleSubjectId, samRequestContext)
- def listUserDirectMemberships(userId: WorkbenchUserId, samRequestContext: SamRequestContext): IO[LazyList[WorkbenchGroupIdentity]] =
- directoryDAO.listUserDirectMemberships(userId, samRequestContext)
-
def listFlattenedGroupMembers(groupName: WorkbenchGroupName, samRequestContext: SamRequestContext): IO[Set[WorkbenchUserId]] =
directoryDAO.listFlattenedGroupMembers(groupName, samRequestContext)
diff --git a/src/test/scala/org/broadinstitute/dsde/workbench/sam/service/MockUserServiceBuilder.scala b/src/test/scala/org/broadinstitute/dsde/workbench/sam/service/MockUserServiceBuilder.scala
index 066bd4c4e..beced2ce3 100644
--- a/src/test/scala/org/broadinstitute/dsde/workbench/sam/service/MockUserServiceBuilder.scala
+++ b/src/test/scala/org/broadinstitute/dsde/workbench/sam/service/MockUserServiceBuilder.scala
@@ -92,6 +92,8 @@ case class MockUserServiceBuilder() extends IdiomaticMockito {
SamUserAllowances(enabled = false, termsOfService = false)
)
mockUserService.getUserAttributes(any[WorkbenchUserId], any[SamRequestContext]) returns IO(None)
+
+ mockUserService.repairCloudAccess(any[WorkbenchUserId], any[SamRequestContext]) returns IO(())
}
private def makeUser(samUser: SamUser, mockUserService: UserService): Unit = {
@@ -174,6 +176,10 @@ case class MockUserServiceBuilder() extends IdiomaticMockito {
.toSet
)
)
+
+ mockUserService.getUsersByIds(any[Seq[WorkbenchUserId]], any[SamRequestContext]) answers ((userIds: Seq[WorkbenchUserId]) =>
+ IO(samUsers.filter(user => userIds.contains(user.id)).toSeq)
+ )
}
private def makeUserAppearEnabled(samUser: SamUser, mockUserService: UserService): Unit = {
diff --git a/src/test/scala/org/broadinstitute/dsde/workbench/sam/service/ResourceServiceSpec.scala b/src/test/scala/org/broadinstitute/dsde/workbench/sam/service/ResourceServiceSpec.scala
index ef8f251ec..081d1c80e 100644
--- a/src/test/scala/org/broadinstitute/dsde/workbench/sam/service/ResourceServiceSpec.scala
+++ b/src/test/scala/org/broadinstitute/dsde/workbench/sam/service/ResourceServiceSpec.scala
@@ -4,6 +4,7 @@ import akka.http.scaladsl.model.StatusCodes
import cats.data.NonEmptyList
import cats.effect.IO
import cats.effect.unsafe.implicits.{global => globalEc}
+import cats.implicits.toTraverseOps
import ch.qos.logback.classic.spi.ILoggingEvent
import ch.qos.logback.classic.{Level, Logger}
import ch.qos.logback.core.read.ListAppender
@@ -52,8 +53,8 @@ import org.slf4j.LoggerFactory
import java.util.UUID
import scala.concurrent.ExecutionContext.Implicits.global
-import scala.concurrent.duration._
import scala.jdk.CollectionConverters._
+import scala.util.Random
/** Created by dvoet on 6/27/17.
*/
@@ -81,7 +82,7 @@ class ResourceServiceSpec
private val resourceTypeAdmin = ResourceType(
SamResourceTypes.resourceTypeAdminName,
Set.empty,
- Set.empty,
+ Set(ResourceRole(ownerRoleName, Set.empty)),
ownerRoleName
)
@@ -286,6 +287,8 @@ class ResourceServiceSpec
service.setPublic(policyToUpdate, false, samRequestContext).unsafeRunSync()
service.isPublic(policyToUpdate, samRequestContext).unsafeRunSync() should equal(false)
+ service.loadPolicy(policyToUpdate, samRequestContext).unsafeRunSync().get.version shouldEqual 3
+
// cleanup
runAndWait(service.deleteResource(resource, samRequestContext))
}
@@ -879,7 +882,7 @@ class ResourceServiceSpec
}
}
- it should "fail when user does not have access to at least 1 of the auth domain groups" in {
+ it should "fail when user does not have access all of the auth domain groups" in {
assume(databaseEnabled, databaseEnabledClue)
constrainableResourceType.isAuthDomainConstrainable shouldEqual true
@@ -911,6 +914,171 @@ class ResourceServiceSpec
}
}
+ it should "say auth domain is satisfied when a user is in all auth domain groups, and not satified when a user isn't" in {
+ assume(databaseEnabled, databaseEnabledClue)
+
+ constrainableResourceType.isAuthDomainConstrainable shouldEqual true
+ constrainableService.createResourceType(constrainableResourceType, samRequestContext).unsafeRunSync()
+
+ val bender = Generator.genWorkbenchUserBoth.sample.get
+ dirDAO.createUser(bender, samRequestContext).unsafeRunSync()
+
+ val fry = Generator.genWorkbenchUserBoth.sample.get
+ dirDAO.createUser(fry, samRequestContext).unsafeRunSync()
+
+ constrainableService.createResourceType(managedGroupResourceType, samRequestContext).unsafeRunSync()
+ val managedGroupName1 = "firstGroup"
+ runAndWait(managedGroupService.createManagedGroup(ResourceId(managedGroupName1), dummyUser, samRequestContext = samRequestContext))
+ runAndWait(managedGroupService.addSubjectToPolicy(ResourceId(managedGroupName1), ManagedGroupService.adminPolicyName, bender.id, samRequestContext))
+ runAndWait(managedGroupService.addSubjectToPolicy(ResourceId(managedGroupName1), ManagedGroupService.adminPolicyName, fry.id, samRequestContext))
+ val managedGroupName2 = "benderIsGreat"
+ runAndWait(managedGroupService.createManagedGroup(ResourceId(managedGroupName2), bender, samRequestContext = samRequestContext))
+ runAndWait(managedGroupService.addSubjectToPolicy(ResourceId(managedGroupName2), ManagedGroupService.adminPolicyName, bender.id, samRequestContext))
+
+ val authDomain = Set(WorkbenchGroupName(managedGroupName1), WorkbenchGroupName(managedGroupName2))
+ val viewPolicyName = AccessPolicyName(constrainableReaderRoleName.value)
+ val resource = runAndWait(
+ constrainableService.createResource(
+ constrainableResourceType,
+ ResourceId(UUID.randomUUID().toString),
+ Map(viewPolicyName -> constrainablePolicyMembership),
+ authDomain,
+ None,
+ bender.id,
+ samRequestContext
+ )
+ )
+
+ val benderAccess = constrainableService.satisfiesAuthDomainConstrains(resource.fullyQualifiedId, bender, samRequestContext).unsafeRunSync()
+ val fryAccess = constrainableService.satisfiesAuthDomainConstrains(resource.fullyQualifiedId, fry, samRequestContext).unsafeRunSync()
+
+ benderAccess shouldEqual true
+ fryAccess shouldEqual false
+ }
+
+ it should "inherit parent auth domains" in {
+ assume(databaseEnabled, databaseEnabledClue)
+
+ constrainableResourceType.isAuthDomainConstrainable shouldEqual true
+ constrainableService.createResourceType(constrainableResourceType, samRequestContext).unsafeRunSync()
+ constrainableService.createResourceType(managedGroupResourceType, samRequestContext).unsafeRunSync()
+
+ val parentAuthDomain = Set(WorkbenchGroupName("parentGroup"))
+ managedGroupService.createManagedGroup(ResourceId("parentGroup"), dummyUser, samRequestContext = samRequestContext).unsafeRunSync()
+
+ val parentResource = service
+ .createResource(
+ constrainableResourceType,
+ ResourceId(UUID.randomUUID().toString),
+ Map.newBuilder
+ .addOne(AccessPolicyName("policy") -> AccessPolicyMembershipRequest(Set(dummyUser.email), Set.empty, Set(constrainableReaderRoleName)))
+ .result(),
+ parentAuthDomain,
+ None,
+ dummyUser.id,
+ samRequestContext
+ )
+ .unsafeRunSync()
+ val childResource =
+ service
+ .createResource(
+ constrainableResourceType,
+ ResourceId(UUID.randomUUID().toString),
+ Map.empty,
+ Set.empty,
+ Option(parentResource.fullyQualifiedId),
+ dummyUser.id,
+ samRequestContext
+ )
+ .unsafeRunSync()
+
+ constrainableService
+ .loadResourceAuthDomain(childResource.fullyQualifiedId, samRequestContext)
+ .unsafeRunSync() should contain theSameElementsAs parentAuthDomain
+ }
+
+ it should "pass if auth domains includes parent's" in {
+ assume(databaseEnabled, databaseEnabledClue)
+
+ constrainableResourceType.isAuthDomainConstrainable shouldEqual true
+ constrainableService.createResourceType(constrainableResourceType, samRequestContext).unsafeRunSync()
+ constrainableService.createResourceType(managedGroupResourceType, samRequestContext).unsafeRunSync()
+
+ val parentAuthDomain = Set(WorkbenchGroupName("parentGroup"))
+ managedGroupService.createManagedGroup(ResourceId("parentGroup"), dummyUser, samRequestContext = samRequestContext).unsafeRunSync()
+ val childAuthDomain = parentAuthDomain + WorkbenchGroupName("childGroup")
+ managedGroupService.createManagedGroup(ResourceId("childGroup"), dummyUser, samRequestContext = samRequestContext).unsafeRunSync()
+
+ val parentResource = service
+ .createResource(
+ constrainableResourceType,
+ ResourceId(UUID.randomUUID().toString),
+ Map(AccessPolicyName("policy") -> constrainablePolicyMembership),
+ parentAuthDomain,
+ None,
+ dummyUser.id,
+ samRequestContext
+ )
+ .unsafeRunSync()
+ val childResource =
+ service
+ .createResource(
+ constrainableResourceType,
+ ResourceId(UUID.randomUUID().toString),
+ Map.empty,
+ childAuthDomain,
+ Option(parentResource.fullyQualifiedId),
+ dummyUser.id,
+ samRequestContext
+ )
+ .unsafeRunSync()
+
+ constrainableService
+ .loadResourceAuthDomain(childResource.fullyQualifiedId, samRequestContext)
+ .unsafeRunSync() should contain theSameElementsAs childAuthDomain
+ }
+
+ it should "fail if auth domains conflict with parent" in {
+ assume(databaseEnabled, databaseEnabledClue)
+
+ constrainableResourceType.isAuthDomainConstrainable shouldEqual true
+ constrainableService.createResourceType(constrainableResourceType, samRequestContext).unsafeRunSync()
+ constrainableService.createResourceType(managedGroupResourceType, samRequestContext).unsafeRunSync()
+
+ val parentAuthDomain = Set(WorkbenchGroupName("parentGroup"))
+ managedGroupService.createManagedGroup(ResourceId("parentGroup"), dummyUser, samRequestContext = samRequestContext).unsafeRunSync()
+ val childAuthDomain = Set(WorkbenchGroupName("childGroup"))
+ managedGroupService.createManagedGroup(ResourceId("childGroup"), dummyUser, samRequestContext = samRequestContext).unsafeRunSync()
+
+ val parentResource = service
+ .createResource(
+ constrainableResourceType,
+ ResourceId(UUID.randomUUID().toString),
+ Map.newBuilder
+ .addOne(AccessPolicyName("policy") -> AccessPolicyMembershipRequest(Set(dummyUser.email), Set.empty, Set(constrainableReaderRoleName)))
+ .result(),
+ parentAuthDomain,
+ None,
+ dummyUser.id,
+ samRequestContext
+ )
+ .unsafeRunSync()
+ val error = intercept[WorkbenchExceptionWithErrorReport] {
+ service
+ .createResource(
+ constrainableResourceType,
+ ResourceId(UUID.randomUUID().toString),
+ Map.empty,
+ childAuthDomain,
+ Option(parentResource.fullyQualifiedId),
+ dummyUser.id,
+ samRequestContext
+ )
+ .unsafeRunSync()
+ }
+ error.errorReport.statusCode shouldEqual Option(StatusCodes.BadRequest)
+ }
+
"Loading an auth domain" should "fail when the resource does not exist" in {
assume(databaseEnabled, databaseEnabledClue)
@@ -922,6 +1090,82 @@ class ResourceServiceSpec
e.getMessage should include("not found")
}
+ "Checking auth domain satisfaction" should "say auth domain is satisfied when a user is in all auth domain groups, and not satisfied when a user isn't" in {
+ assume(databaseEnabled, databaseEnabledClue)
+
+ constrainableResourceType.isAuthDomainConstrainable shouldEqual true
+ constrainableService.createResourceType(constrainableResourceType, samRequestContext).unsafeRunSync()
+
+ val bender = Generator.genWorkbenchUserBoth.sample.get
+ dirDAO.createUser(bender, samRequestContext).unsafeRunSync()
+
+ val fry = Generator.genWorkbenchUserBoth.sample.get
+ dirDAO.createUser(fry, samRequestContext).unsafeRunSync()
+
+ constrainableService.createResourceType(managedGroupResourceType, samRequestContext).unsafeRunSync()
+ val managedGroupName1 = "firstGroup"
+ runAndWait(managedGroupService.createManagedGroup(ResourceId(managedGroupName1), dummyUser, samRequestContext = samRequestContext))
+ runAndWait(managedGroupService.addSubjectToPolicy(ResourceId(managedGroupName1), ManagedGroupService.adminPolicyName, bender.id, samRequestContext))
+ runAndWait(managedGroupService.addSubjectToPolicy(ResourceId(managedGroupName1), ManagedGroupService.adminPolicyName, fry.id, samRequestContext))
+ val managedGroupName2 = "benderIsGreat"
+ runAndWait(managedGroupService.createManagedGroup(ResourceId(managedGroupName2), bender, samRequestContext = samRequestContext))
+ runAndWait(managedGroupService.addSubjectToPolicy(ResourceId(managedGroupName2), ManagedGroupService.adminPolicyName, bender.id, samRequestContext))
+
+ val authDomain = Set(WorkbenchGroupName(managedGroupName1), WorkbenchGroupName(managedGroupName2))
+ val viewPolicyName = AccessPolicyName(constrainableReaderRoleName.value)
+ val resource = runAndWait(
+ constrainableService.createResource(
+ constrainableResourceType,
+ ResourceId(UUID.randomUUID().toString),
+ Map(viewPolicyName -> constrainablePolicyMembership),
+ authDomain,
+ None,
+ bender.id,
+ samRequestContext
+ )
+ )
+
+ val benderAccess = constrainableService.satisfiesAuthDomainConstrains(resource.fullyQualifiedId, bender, samRequestContext).unsafeRunSync()
+ val fryAccess = constrainableService.satisfiesAuthDomainConstrains(resource.fullyQualifiedId, fry, samRequestContext).unsafeRunSync()
+
+ benderAccess shouldEqual true
+ fryAccess shouldEqual false
+ }
+
+ it should "say the auth domain is satisfied if there are no auth domain constraints" in {
+ assume(databaseEnabled, databaseEnabledClue)
+
+ constrainableResourceType.isAuthDomainConstrainable shouldEqual true
+ constrainableService.createResourceType(constrainableResourceType, samRequestContext).unsafeRunSync()
+
+ val bender = Generator.genWorkbenchUserBoth.sample.get
+ dirDAO.createUser(bender, samRequestContext).unsafeRunSync()
+
+ val fry = Generator.genWorkbenchUserBoth.sample.get
+ dirDAO.createUser(fry, samRequestContext).unsafeRunSync()
+
+ constrainableService.createResourceType(managedGroupResourceType, samRequestContext).unsafeRunSync()
+
+ val viewPolicyName = AccessPolicyName(constrainableReaderRoleName.value)
+ val resource = runAndWait(
+ constrainableService.createResource(
+ constrainableResourceType,
+ ResourceId(UUID.randomUUID().toString),
+ Map(viewPolicyName -> constrainablePolicyMembership),
+ Set.empty,
+ None,
+ bender.id,
+ samRequestContext
+ )
+ )
+
+ val benderAccess = constrainableService.satisfiesAuthDomainConstrains(resource.fullyQualifiedId, bender, samRequestContext).unsafeRunSync()
+ val fryAccess = constrainableService.satisfiesAuthDomainConstrains(resource.fullyQualifiedId, fry, samRequestContext).unsafeRunSync()
+
+ benderAccess shouldEqual true
+ fryAccess shouldEqual true
+ }
+
"Creating a resource that has 0 constrainable action patterns" should "fail when an auth domain is provided" in {
assume(databaseEnabled, databaseEnabledClue)
@@ -984,7 +1228,7 @@ class ResourceServiceSpec
policy.version shouldEqual 1
val resourceWithAuthDomain =
- runAndWait(constrainableService.addResourceAuthDomain(resource.fullyQualifiedId, authDomain.toList.toSet, dummyUser.id, samRequestContext))
+ runAndWait(constrainableService.addResourceAuthDomain(resource.fullyQualifiedId, authDomain.toList.toSet, None, samRequestContext))
resourceWithAuthDomain shouldEqual authDomain.toList.toSet
val updatedPolicy =
@@ -993,6 +1237,145 @@ class ResourceServiceSpec
}
+ it should "add auth domains to resource and descendants" in {
+ assume(databaseEnabled, databaseEnabledClue)
+
+ val accessPolicies = Map(
+ AccessPolicyName("constrainable") -> constrainablePolicyMembership
+ )
+
+ val authDomain = Set(WorkbenchGroupName("authDomain"))
+ val authDomain2 = Set(WorkbenchGroupName("authDomain2"))
+ val testResult = for {
+ _ <- service.createResourceType(constrainableResourceType, samRequestContext)
+ _ <- service.createResourceType(managedGroupResourceType, samRequestContext)
+
+ _ <- managedGroupService.createManagedGroup(ResourceId("authDomain"), dummyUser, samRequestContext = samRequestContext)
+ _ <- managedGroupService.createManagedGroup(ResourceId("authDomain2"), dummyUser, samRequestContext = samRequestContext)
+ parentResource <- service.createResource(
+ constrainableResourceType,
+ ResourceId("parent"),
+ accessPolicies,
+ authDomain,
+ None,
+ dummyUser.id,
+ samRequestContext
+ )
+ childResource <- service.createResource(
+ constrainableResourceType,
+ ResourceId("child"),
+ accessPolicies,
+ Set.empty,
+ Option(parentResource.fullyQualifiedId),
+ dummyUser.id,
+ samRequestContext
+ )
+ grandchild <- service.createResource(
+ constrainableResourceType,
+ ResourceId("grandchild"),
+ accessPolicies,
+ Set.empty,
+ Option(childResource.fullyQualifiedId),
+ dummyUser.id,
+ samRequestContext
+ )
+ updated <- constrainableService.addResourceAuthDomain(parentResource.fullyQualifiedId, authDomain2, Option(dummyUser.id), samRequestContext)
+ allResourceIds = List(parentResource.fullyQualifiedId, childResource.fullyQualifiedId, grandchild.fullyQualifiedId)
+ allADs <- allResourceIds.traverse(constrainableService.loadResourceAuthDomain(_, samRequestContext))
+ allPolicies <- allResourceIds.traverse(policyDAO.listAccessPolicies(_, samRequestContext))
+ } yield {
+ updated shouldBe authDomain2 ++ authDomain
+ allADs.foreach(_ shouldBe updated)
+ allPolicies.flatten.foreach(_.version shouldBe 2)
+ }
+
+ testResult.unsafeRunSync()
+ }
+
+ it should "throw if any has public policies" in {
+ assume(databaseEnabled, databaseEnabledClue)
+
+ val accessPolicies = Map(
+ AccessPolicyName("constrainable") -> constrainablePolicyMembership
+ )
+
+ val authDomain = Set(WorkbenchGroupName("authDomain"))
+ val testResult = for {
+ _ <- service.createResourceType(constrainableResourceType, samRequestContext)
+ _ <- service.createResourceType(managedGroupResourceType, samRequestContext)
+
+ _ <- managedGroupService.createManagedGroup(ResourceId("authDomain"), dummyUser, samRequestContext = samRequestContext)
+ parentResource <- service.createResource(
+ constrainableResourceType,
+ ResourceId("parent"),
+ accessPolicies,
+ Set.empty,
+ None,
+ dummyUser.id,
+ samRequestContext
+ )
+ childResource <- service.createResource(
+ constrainableResourceType,
+ ResourceId("child"),
+ accessPolicies,
+ Set.empty,
+ Option(parentResource.fullyQualifiedId),
+ dummyUser.id,
+ samRequestContext
+ )
+ grandchild <- service.createResource(
+ constrainableResourceType,
+ ResourceId("grandchild"),
+ accessPolicies,
+ Set.empty,
+ Option(childResource.fullyQualifiedId),
+ dummyUser.id,
+ samRequestContext
+ )
+ // pick a random resource to set public, this test should work for any of the 3
+ probeResourceId = Random.shuffle(List(parentResource.fullyQualifiedId, childResource.fullyQualifiedId, grandchild.fullyQualifiedId)).head
+ _ <- constrainableService.setPublic(FullyQualifiedPolicyId(probeResourceId, accessPolicies.head._1), true, samRequestContext)
+ _ <- constrainableService.addResourceAuthDomain(parentResource.fullyQualifiedId, authDomain, Option(dummyUser.id), samRequestContext)
+ } yield {}
+
+ val error = intercept[WorkbenchExceptionWithErrorReport] {
+ testResult.unsafeRunSync()
+ }
+
+ error.errorReport.statusCode shouldEqual Option(StatusCodes.BadRequest)
+ }
+
+ it should "throw if auth domain does not exist" in {
+ assume(databaseEnabled, databaseEnabledClue)
+
+ val accessPolicies = Map(
+ AccessPolicyName("constrainable") -> constrainablePolicyMembership
+ )
+
+ val authDomain = Set(WorkbenchGroupName("authDomain"))
+ val testResult = for {
+ _ <- service.createResourceType(constrainableResourceType, samRequestContext)
+ _ <- service.createResourceType(managedGroupResourceType, samRequestContext)
+
+ resource <- service.createResource(
+ constrainableResourceType,
+ ResourceId("parent"),
+ accessPolicies,
+ Set.empty,
+ None,
+ dummyUser.id,
+ samRequestContext
+ )
+ _ <- constrainableService.addResourceAuthDomain(resource.fullyQualifiedId, authDomain, Option(dummyUser.id), samRequestContext)
+ } yield {}
+
+ val error = intercept[WorkbenchExceptionWithErrorReport] {
+ testResult.unsafeRunSync()
+ }
+
+ error.errorReport.statusCode shouldEqual Option(StatusCodes.BadRequest)
+ }
+
"listUserResourceRoles" should "list the user's role when they have at least one role" in {
assume(databaseEnabled, databaseEnabledClue)
@@ -1164,7 +1547,7 @@ class ResourceServiceSpec
val policies =
policyDAO.listAccessPolicies(resource, samRequestContext).unsafeRunSync().map(_.copy(email = WorkbenchEmail("policy-randomuuid@example.com")))
- assert(policies.contains(newPolicy))
+ assert(policies.contains(newPolicy.copy(version = 2)))
}
it should "should add a memberPolicy as a member when specified through policy identifiers" in {
@@ -1285,6 +1668,13 @@ class ResourceServiceSpec
)
).thenReturn(IO.unit)
+ when(
+ mockDirectoryDAO.updateGroupUpdatedDateAndVersionWithSession(
+ any[WorkbenchGroupIdentity],
+ any[SamRequestContext]
+ )
+ ).thenReturn(IO.unit)
+
runAndWait(
resourceService.overwritePolicy(
defaultResourceType,
@@ -1325,6 +1715,12 @@ class ResourceServiceSpec
// function calls that should pass but what they return does not matter
when(mockAccessPolicyDAO.overwritePolicy(ArgumentMatchers.eq(accessPolicy), any[SamRequestContext])).thenReturn(IO.pure(accessPolicy))
when(mockCloudExtensions.onGroupUpdate(ArgumentMatchers.eq(Seq(policyId)), ArgumentMatchers.eq(Set(member)), any[SamRequestContext])).thenReturn(IO.unit)
+ when(
+ mockDirectoryDAO.updateGroupUpdatedDateAndVersionWithSession(
+ any[WorkbenchGroupIdentity],
+ any[SamRequestContext]
+ )
+ ).thenReturn(IO.unit)
// overwrite policy with no members
runAndWait(
@@ -1341,7 +1737,7 @@ class ResourceServiceSpec
.onGroupUpdate(ArgumentMatchers.eq(Seq(policyId)), ArgumentMatchers.eq(Set(member)), any[SamRequestContext])
}
- it should "not call CloudExtensions.onGroupUpdate when members don't change" in {
+ it should "not do anything when policy is unchanged" in {
val mockCloudExtensions: CloudExtensions = mock[CloudExtensions](RETURNS_SMART_NULLS)
val mockDirectoryDAO: DirectoryDAO = mock[DirectoryDAO](RETURNS_SMART_NULLS)
val mockAccessPolicyDAO = mock[AccessPolicyDAO](RETURNS_SMART_NULLS)
@@ -1356,13 +1752,12 @@ class ResourceServiceSpec
)
val policyId = FullyQualifiedPolicyId(FullyQualifiedResourceId(defaultResourceType.name, ResourceId("testR")), AccessPolicyName("testA"))
- val accessPolicy = AccessPolicy(policyId, Set.empty, WorkbenchEmail(""), Set.empty, Set.empty, Set.empty, false)
+ val policyVersion = Random.between(10, 100) // random version to ensure it is neither the default nor fixed
+ val accessPolicy = AccessPolicy(policyId, Set.empty, WorkbenchEmail(""), Set.empty, Set.empty, Set.empty, false, version = policyVersion)
- // setup existing policy with no members
when(mockAccessPolicyDAO.listAccessPolicies(ArgumentMatchers.eq(policyId.resource), any[SamRequestContext])).thenReturn(IO.pure(LazyList(accessPolicy)))
- // overwrite policy with no members
- runAndWait(
+ val updatedPolicy = runAndWait(
resourceService.overwritePolicy(
defaultResourceType,
policyId.accessPolicyName,
@@ -1372,7 +1767,9 @@ class ResourceServiceSpec
)
)
- verify(mockCloudExtensions, Mockito.after(500).never).onGroupUpdate(ArgumentMatchers.eq(Seq(policyId)), any[Set[WorkbenchSubject]], any[SamRequestContext])
+ // no changes to policy, so no calls to overwritePolicy and version should not change
+ updatedPolicy.version shouldEqual policyVersion
+ verify(mockAccessPolicyDAO, Mockito.never).overwritePolicy(any[AccessPolicy], any[SamRequestContext])
}
"overwriteAdminPolicy" should "succeed with a valid request" in {
@@ -1412,7 +1809,7 @@ class ResourceServiceSpec
val policies =
policyDAO.listAccessPolicies(resource, samRequestContext).unsafeRunSync().map(_.copy(email = WorkbenchEmail("policy-randomuuid@example.com")))
- assert(policies.contains(newPolicy))
+ assert(policies.contains(newPolicy.copy(version = 2)))
}
it should "fail if any members are not test.firecloud.org accounts" in {
@@ -1486,7 +1883,7 @@ class ResourceServiceSpec
val policies =
policyDAO.listAccessPolicies(resource, samRequestContext).unsafeRunSync().map(_.copy(email = WorkbenchEmail("policy-randomuuid@example.com")))
- assert(policies.contains(newPolicy))
+ assert(policies.contains(newPolicy.copy(version = 2)))
}
it should "call CloudExtensions.onGroupUpdate when members change" in {
@@ -1516,6 +1913,12 @@ class ResourceServiceSpec
// function calls that should pass but what they return does not matter
when(mockAccessPolicyDAO.overwritePolicyMembers(ArgumentMatchers.eq(policyId), ArgumentMatchers.eq(Set.empty), any[SamRequestContext])).thenReturn(IO.unit)
when(mockCloudExtensions.onGroupUpdate(ArgumentMatchers.eq(Seq(policyId)), ArgumentMatchers.eq(Set(member)), any[SamRequestContext])).thenReturn(IO.unit)
+ when(
+ mockDirectoryDAO.updateGroupUpdatedDateAndVersionWithSession(
+ any[WorkbenchGroupIdentity],
+ any[SamRequestContext]
+ )
+ ).thenReturn(IO.unit)
// overwrite policy members with empty set
runAndWait(resourceService.overwritePolicyMembers(policyId, Set.empty, samRequestContext))
@@ -1576,7 +1979,7 @@ class ResourceServiceSpec
val policies = policyDAO.listAccessPolicies(resource, samRequestContext).unsafeRunSync()
- assert(policies.contains(newPolicy))
+ assert(policies.contains(newPolicy.copy(version = 2)))
}
it should "fail when given an invalid action" in {
@@ -1981,7 +2384,7 @@ class ResourceServiceSpec
runAndWait(service.validatePolicy(defaultResourceType, ResourceId(""), policy)) shouldBe empty
}
- "validatePolicy" should "fail with an incorrect policy" in {
+ it should "fail with an incorrect policy" in {
val emailToMaybeSubject = Map(dummyUser.email -> Option(dummyUser.id.asInstanceOf[WorkbenchSubject]))
val policy =
service.ValidatableAccessPolicy(AccessPolicyName("a"), emailToMaybeSubject, Set(ResourceRoleName("bad_name")), Set(ResourceAction("bad_action")), Set())
@@ -1995,7 +2398,7 @@ class ResourceServiceSpec
maybeErrorReport.value.message should include("invalid role")
}
- "validateRoles" should "succeed with role included in listed roles" in {
+ it should "succeed with role included in listed roles" in {
service.validateRoles(defaultResourceType, Set(ownerRoleName)) shouldBe empty
}
@@ -2006,6 +2409,10 @@ class ResourceServiceSpec
maybeErrorReport.value.message should include("invalid action")
}
+ it should "succeed with action included in listed actions" in {
+ service.validateActions(defaultResourceType, Set(ResourceAction("alter_policies"))) shouldBe empty
+ }
+
"validateResourceTypeAdminDescendantPermissions" should "succeed if resource type admin matches resource" in {
service.validateResourceTypeAdminDescendantPermissions(
resourceTypeAdmin,
@@ -2040,10 +2447,6 @@ class ResourceServiceSpec
)
}
- "validateActions" should "succeed with action included in listed actions" in {
- service.validateActions(defaultResourceType, Set(ResourceAction("alter_policies"))) shouldBe empty
- }
-
"add/remove SubjectToPolicy" should "add/remove subject and tolerate prior (non)existence" in {
assume(databaseEnabled, databaseEnabledClue)
@@ -2107,6 +2510,13 @@ class ResourceServiceSpec
IO.pure(LazyList(AccessPolicy(policyId, Set.empty, WorkbenchEmail(""), Set.empty, Set.empty, Set.empty, false))),
IO.pure(LazyList(AccessPolicy(policyId, Set(member), WorkbenchEmail(""), Set.empty, Set.empty, Set.empty, false)))
)
+ when(
+ mockDirectoryDAO.updateGroupUpdatedDateAndVersionWithSession(
+ any[WorkbenchGroupIdentity],
+ any[SamRequestContext]
+ )
+ ).thenReturn(IO.unit)
+
runAndWait(resourceService.addSubjectToPolicy(policyId, member, samRequestContext))
verify(mockCloudExtensions, Mockito.timeout(500))
@@ -2164,6 +2574,13 @@ class ResourceServiceSpec
IO.pure(LazyList(AccessPolicy(policyId, Set.empty, WorkbenchEmail(""), Set.empty, Set.empty, Set.empty, false))),
IO.pure(LazyList(AccessPolicy(policyId, Set(member), WorkbenchEmail(""), Set.empty, Set.empty, Set.empty, false)))
)
+ when(
+ mockDirectoryDAO.updateGroupUpdatedDateAndVersionWithSession(
+ any[WorkbenchGroupIdentity],
+ any[SamRequestContext]
+ )
+ ).thenReturn(IO.unit)
+
runAndWait(resourceService.removeSubjectFromPolicy(policyId, member, samRequestContext))
verify(mockCloudExtensions, Mockito.timeout(1000))
@@ -2599,40 +3016,106 @@ class ResourceServiceSpec
testPolicy.email
)
- implicit val patienceConfig = PatienceConfig(5.seconds)
testResult.unsafeRunSync()
}
- "setResourceParent" should "throw if the child resource has an auth domain" in {
+ "setResourceParent" should "inherit parent's auth domain for all descendants" in {
assume(databaseEnabled, databaseEnabledClue)
- val childAccessPolicies = Map(
+ val accessPolicies = Map(
AccessPolicyName("constrainable") -> constrainablePolicyMembership
)
+ val authDomain = Set(WorkbenchGroupName("authDomain"))
val testResult = for {
_ <- service.createResourceType(constrainableResourceType, samRequestContext)
_ <- service.createResourceType(managedGroupResourceType, samRequestContext)
_ <- managedGroupService.createManagedGroup(ResourceId("authDomain"), dummyUser, samRequestContext = samRequestContext)
+ parentResource <- service.createResource(
+ constrainableResourceType,
+ ResourceId("parent"),
+ accessPolicies,
+ authDomain,
+ None,
+ dummyUser.id,
+ samRequestContext
+ )
childResource <- service.createResource(
constrainableResourceType,
ResourceId("child"),
- childAccessPolicies,
- Set(WorkbenchGroupName("authDomain")),
+ accessPolicies,
+ Set.empty,
None,
dummyUser.id,
samRequestContext
)
- parentResource <- service.createResource(constrainableResourceType, ResourceId("parent"), dummyUser, samRequestContext)
+ grandchild <- service.createResource(
+ constrainableResourceType,
+ ResourceId("grandchild"),
+ accessPolicies,
+ Set.empty,
+ Option(childResource.fullyQualifiedId),
+ dummyUser.id,
+ samRequestContext
+ )
_ <- service.setResourceParent(childResource.fullyQualifiedId, parentResource.fullyQualifiedId, samRequestContext)
- } yield ()
-
- val exception = intercept[WorkbenchExceptionWithErrorReport] {
- testResult.unsafeRunSync()
+ childAD <- constrainableService.loadResourceAuthDomain(childResource.fullyQualifiedId, samRequestContext)
+ grandchildAD <- constrainableService.loadResourceAuthDomain(grandchild.fullyQualifiedId, samRequestContext)
+ } yield {
+ childAD shouldBe authDomain
+ grandchildAD shouldBe authDomain
}
- exception.errorReport.statusCode shouldBe Option(StatusCodes.BadRequest)
+ testResult.unsafeRunSync()
+ }
+
+ it should "fail when parent has auth domain and descendant has public policy" in {
+ assume(databaseEnabled, databaseEnabledClue)
+
+ val accessPolicies = Map(
+ AccessPolicyName("constrainable") -> constrainablePolicyMembership
+ )
+
+ val authDomain = Set(WorkbenchGroupName("authDomain"))
+ val testResult = for {
+ _ <- service.createResourceType(constrainableResourceType, samRequestContext)
+ _ <- service.createResourceType(managedGroupResourceType, samRequestContext)
+
+ _ <- managedGroupService.createManagedGroup(ResourceId("authDomain"), dummyUser, samRequestContext = samRequestContext)
+ parentResource <- service.createResource(
+ constrainableResourceType,
+ ResourceId("parent"),
+ accessPolicies,
+ authDomain,
+ None,
+ dummyUser.id,
+ samRequestContext
+ )
+ childResource <- service.createResource(
+ constrainableResourceType,
+ ResourceId("child"),
+ accessPolicies,
+ Set.empty,
+ None,
+ dummyUser.id,
+ samRequestContext
+ )
+ grandchild <- service.createResource(
+ constrainableResourceType,
+ ResourceId("grandchild"),
+ accessPolicies,
+ Set.empty,
+ Option(childResource.fullyQualifiedId),
+ dummyUser.id,
+ samRequestContext
+ )
+ _ <- service.setPublic(FullyQualifiedPolicyId(grandchild.fullyQualifiedId, accessPolicies.head._1), public = true, samRequestContext)
+ _ <- service.setResourceParent(childResource.fullyQualifiedId, parentResource.fullyQualifiedId, samRequestContext)
+ } yield ()
+
+ val error = intercept[WorkbenchExceptionWithErrorReport](testResult.unsafeRunSync())
+ error.errorReport.statusCode shouldBe Some(StatusCodes.BadRequest)
}
"deletePolicy" should "delete the policy" in {
@@ -3093,6 +3576,118 @@ class ResourceServiceSpec
runAuditLogTest(service.overwritePolicyMembers(policy.id, Set(dummyUser.email), samRequestContext), List(AccessAdded))
}
+ "upsertResourceAccessPolicies" should "upsert a policy" in {
+ assume(databaseEnabled, databaseEnabledClue)
+
+ val resourceName = ResourceId("resource")
+ val resource = FullyQualifiedResourceId(defaultResourceType.name, resourceName)
+
+ service.createResourceType(defaultResourceType, samRequestContext).unsafeRunSync()
+
+ service.createResource(defaultResourceType, resourceName, dummyUser, samRequestContext).unsafeRunSync()
+
+ val testPolicyId = FullyQualifiedPolicyId(resource, AccessPolicyName(UUID.randomUUID().toString))
+ val expectedPolicy = AccessPolicy(testPolicyId, Set(dummyUser.id), WorkbenchEmail(""), Set.empty, Set.empty, Set.empty, false)
+ val returnedPolicies = service
+ .upsertResourceAccessPolicies(
+ Map(
+ testPolicyId -> AccessPolicyMembershipRequest(
+ Set(dummyUser.email),
+ Set.empty,
+ Set.empty
+ )
+ )
+ )
+ .unsafeRunSync()
+ .collect { case (_, Right(policy)) =>
+ policy.copy(email = WorkbenchEmail(""))
+ }
+
+ returnedPolicies should contain theSameElementsAs Set(expectedPolicy)
+
+ policyDAO.loadPolicy(testPolicyId, samRequestContext).unsafeRunSync().map(_.copy(email = WorkbenchEmail(""))) shouldBe Some(
+ expectedPolicy.copy(version = 2)
+ )
+ }
+
+ it should "validate admin policies" in {
+ assume(databaseEnabled, databaseEnabledClue)
+
+ val resourceName = ResourceId(defaultResourceType.name.value)
+ val resource = FullyQualifiedResourceId(resourceTypeAdmin.name, resourceName)
+
+ service.createResourceType(resourceTypeAdmin, samRequestContext).unsafeRunSync()
+ service.createResourceType(defaultResourceType, samRequestContext).unsafeRunSync()
+
+ service.createResource(resourceTypeAdmin, resourceName, dummyUser, samRequestContext).unsafeRunSync()
+ service.createResource(defaultResourceType, resourceName, dummyUser, samRequestContext).unsafeRunSync()
+
+ val testPolicyId = FullyQualifiedPolicyId(resource, AccessPolicyName(UUID.randomUUID().toString))
+ val returnedPolicies = service
+ .upsertResourceAccessPolicies(
+ Map(
+ testPolicyId -> AccessPolicyMembershipRequest(
+ Set(dummyUser.email),
+ Set.empty,
+ Set.empty
+ )
+ )
+ )
+ .unsafeRunSync()
+
+ returnedPolicies.size shouldBe 1
+ returnedPolicies.head._2.isLeft shouldBe true
+ }
+
+ "UserFavoriteResource" should "add, remove, and list favorite resources for a user" in {
+ assume(databaseEnabled, databaseEnabledClue)
+
+ val resourceName = ResourceId("resource")
+ val resource2Name = ResourceId("resource2")
+ val resource = FullyQualifiedResourceId(defaultResourceType.name, resourceName)
+ val resource2 = FullyQualifiedResourceId(otherResourceType.name, resource2Name)
+
+ service.createResourceType(defaultResourceType, samRequestContext).unsafeRunSync()
+ service.createResourceType(otherResourceType, samRequestContext).unsafeRunSync()
+
+ service.createResource(defaultResourceType, resourceName, dummyUser, samRequestContext).unsafeRunSync()
+ service.createResource(otherResourceType, resource2Name, dummyUser, samRequestContext).unsafeRunSync()
+
+ service.addUserFavoriteResource(dummyUser.id, resource, samRequestContext).unsafeRunSync()
+
+ service.getUserFavoriteResources(dummyUser.id, samRequestContext).unsafeRunSync() should contain theSameElementsAs Set(resource)
+
+ service.addUserFavoriteResource(dummyUser.id, resource2, samRequestContext).unsafeRunSync()
+
+ service.getUserFavoriteResources(dummyUser.id, samRequestContext).unsafeRunSync() should contain theSameElementsAs Set(resource, resource2)
+
+ service.removeUserFavoriteResource(dummyUser.id, resource, samRequestContext).unsafeRunSync()
+
+ service.getUserFavoriteResources(dummyUser.id, samRequestContext).unsafeRunSync() should contain theSameElementsAs Set(resource2)
+
+ service.removeUserFavoriteResource(dummyUser.id, resource2, samRequestContext).unsafeRunSync()
+
+ service.getUserFavoriteResources(dummyUser.id, samRequestContext).unsafeRunSync() shouldBe empty
+ }
+
+ it should "not return favorite resources for another user" in {
+ assume(databaseEnabled, databaseEnabledClue)
+
+ def otherUser = Generator.genWorkbenchUserBoth.sample.get
+ dirDAO.createUser(otherUser, samRequestContext).unsafeRunSync()
+
+ val resourceName = ResourceId("resource")
+ val resource = FullyQualifiedResourceId(defaultResourceType.name, resourceName)
+
+ service.createResourceType(defaultResourceType, samRequestContext).unsafeRunSync()
+
+ service.createResource(defaultResourceType, resourceName, dummyUser, samRequestContext).unsafeRunSync()
+
+ service.addUserFavoriteResource(dummyUser.id, resource, samRequestContext).unsafeRunSync()
+
+ service.getUserFavoriteResources(otherUser.id, samRequestContext).unsafeRunSync() shouldBe empty
+ }
+
/** Sets up a test log appender attached to the audit logger, runs the `test` IO, ensures that `events` were appended. If tryTwice` run `test` again to make
* sure subsequent calls to no log more messages. Ends by tearing down the log appender.
*/
diff --git a/src/test/scala/org/broadinstitute/dsde/workbench/sam/service/UserServiceSpec.scala b/src/test/scala/org/broadinstitute/dsde/workbench/sam/service/UserServiceSpec.scala
index 33f62462b..3e53480ac 100644
--- a/src/test/scala/org/broadinstitute/dsde/workbench/sam/service/UserServiceSpec.scala
+++ b/src/test/scala/org/broadinstitute/dsde/workbench/sam/service/UserServiceSpec.scala
@@ -8,7 +8,7 @@ import cats.effect.IO
import cats.effect.unsafe.implicits.{global => globalEc}
import org.broadinstitute.dsde.workbench.model._
import org.broadinstitute.dsde.workbench.sam.Generator.{arbNonPetEmail => _, _}
-import org.broadinstitute.dsde.workbench.sam.TestSupport.{databaseEnabled, databaseEnabledClue, truncateAll}
+import org.broadinstitute.dsde.workbench.sam.TestSupport.{databaseEnabled, databaseEnabledClue, googleServicesConfig, truncateAll}
import org.broadinstitute.dsde.workbench.sam.dataAccess.{DirectoryDAO, PostgresDirectoryDAO}
import org.broadinstitute.dsde.workbench.sam.google.GoogleExtensions
import org.broadinstitute.dsde.workbench.sam.matchers.BeSameUserMatcher.beSameUserAs
@@ -715,4 +715,40 @@ class OldUserServiceSpec(_system: ActorSystem)
assert(service.validateEmailAddress(WorkbenchEmail("foo@splat.bar.com"), Seq.empty, Seq("bar.com")).attempt.unsafeRunSync().isLeft)
assert(service.validateEmailAddress(WorkbenchEmail("foo@bar.com"), Seq.empty, Seq("bar.com")).attempt.unsafeRunSync().isLeft)
}
+
+ "UserService repairCloudAccess" should "create a proxy group for a user and add it to any groups they are a member of" in {
+ assume(databaseEnabled, databaseEnabledClue)
+
+ // Create user
+ val inviteeEmail = genNonPetEmail.sample.get
+ service.inviteUser(inviteeEmail, samRequestContext).unsafeRunSync()
+ val invitedUserId = dirDAO.loadSubjectFromEmail(inviteeEmail, samRequestContext).unsafeRunSync().value.asInstanceOf[WorkbenchUserId]
+
+ val userInPostgres = dirDAO.loadUser(invitedUserId, samRequestContext).unsafeRunSync()
+ userInPostgres.value should {
+ equal(SamUser(invitedUserId, None, inviteeEmail, None, false))
+ }
+
+ val registeringUser = genWorkbenchUserGoogle.sample.get.copy(email = inviteeEmail)
+ runAndWait(service.createUser(registeringUser, samRequestContext))
+
+ verify(googleExtensions).onUserCreate(SamUser(invitedUserId, None, inviteeEmail, None, false), samRequestContext)
+
+ verify(googleExtensions).onGroupUpdate(Seq.empty, Set(invitedUserId), samRequestContext)
+
+ val updatedUserInPostgres = dirDAO.loadUser(invitedUserId, samRequestContext).unsafeRunSync()
+ updatedUserInPostgres.value shouldBe SamUser(invitedUserId, registeringUser.googleSubjectId, inviteeEmail, None, true)
+
+ val proxyGroup =
+ WorkbenchGroupName(s"${googleServicesConfig.resourceNamePrefix.getOrElse("")}PROXY_${invitedUserId.value}@${googleServicesConfig.appsDomain}")
+ // delete proxy group
+ runAndWait(dirDAO.deleteGroup(proxyGroup, samRequestContext))
+
+ // Run test
+ service.repairCloudAccess(invitedUserId, samRequestContext).unsafeRunSync()
+
+ verify(googleExtensions).onUserCreate(SamUser(invitedUserId, registeringUser.googleSubjectId, inviteeEmail, None, true), samRequestContext)
+
+ verify(googleExtensions).onGroupUpdate(Seq(allUsersGroup.id), Set(invitedUserId), samRequestContext)
+ }
}
diff --git a/src/test/scala/org/broadinstitute/dsde/workbench/sam/service/UserServiceSpecs/GetUsersByIdsSpec.scala b/src/test/scala/org/broadinstitute/dsde/workbench/sam/service/UserServiceSpecs/GetUsersByIdsSpec.scala
new file mode 100644
index 000000000..6414122ed
--- /dev/null
+++ b/src/test/scala/org/broadinstitute/dsde/workbench/sam/service/UserServiceSpecs/GetUsersByIdsSpec.scala
@@ -0,0 +1,50 @@
+package org.broadinstitute.dsde.workbench.sam.service.UserServiceSpecs
+
+import org.broadinstitute.dsde.workbench.model.WorkbenchEmail
+import org.broadinstitute.dsde.workbench.sam.Generator.genWorkbenchUserBoth
+import org.broadinstitute.dsde.workbench.sam.model.BasicWorkbenchGroup
+import org.broadinstitute.dsde.workbench.sam.service.{CloudExtensions, TestUserServiceBuilder}
+
+import scala.concurrent.ExecutionContextExecutor
+
+class GetUsersByIdsSpec extends UserServiceTestTraits {
+ implicit val ec: ExecutionContextExecutor = scala.concurrent.ExecutionContext.global
+
+ val allUsersGroup: BasicWorkbenchGroup = BasicWorkbenchGroup(CloudExtensions.allUsersGroupName, Set(), WorkbenchEmail("all_users@fake.com"))
+
+ describe("When getting") {
+ describe("users by IDs") {
+ it("should be successful") {
+ // Arrange
+ val users = Seq.range(0, 10).map(_ => genWorkbenchUserBoth.sample.get)
+ val userService = TestUserServiceBuilder()
+ .withAllUsersGroup(allUsersGroup)
+ .withExistingUsers(users)
+ .withEnabledUsers(users)
+ .build
+ // Act
+ val response = runAndWait(userService.getUsersByIds(users.map(_.id), samRequestContext))
+
+ // Assert
+ assert(response.nonEmpty, "Getting users by ID should return a list of users")
+ response should contain theSameElementsAs users
+ }
+ }
+
+ }
+ describe("a user that does not exist") {
+ it("should be unsuccessful") {
+ // Arrange
+ val userWithBothIds = genWorkbenchUserBoth.sample.get
+ val userService = TestUserServiceBuilder()
+ .withAllUsersGroup(allUsersGroup)
+ .build
+ // Act
+ val response = runAndWait(userService.getUser(userWithBothIds.id, samRequestContext))
+
+ // Assert
+ assert(response.isEmpty, "Getting a nonexistent user should not find a user")
+ }
+ }
+
+}