diff --git a/.gitignore b/.gitignore index 6e2a3148..c39f6ba9 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,8 @@ docs/source/rbac/scope-table.md docs/source/reference/metrics.md .ipynb_checkpoints +.virtual_documents + jsx/build/ # ignore config file at the top-level of the repo # but not sub-dirs diff --git a/docs/source/_static/rest-api.yml b/docs/source/_static/rest-api.yml index be222ed9..d7905549 100644 --- a/docs/source/_static/rest-api.yml +++ b/docs/source/_static/rest-api.yml @@ -123,22 +123,8 @@ paths: - inactive - active - ready - - name: offset - in: query - description: | - Return a number users starting at the given offset. - Can be used with limit to paginate. - If unspecified, return all users. - schema: - type: number - - name: limit - in: query - description: | - Return a finite number of users. - Can be used with offset to paginate. - If unspecified, use api_page_default_limit. - schema: - type: number + - $ref: "#/components/parameters/paginationOffset" + - $ref: "#/components/parameters/paginationLimit" - name: include_stopped_servers in: query description: | @@ -203,12 +189,7 @@ paths: get: summary: Get a user by name parameters: - - name: name - in: path - description: username - required: true - schema: - type: string + - $ref: "#/components/parameters/userName" - name: include_stopped_servers in: query description: Include stopped servers in user model(s). @@ -235,12 +216,7 @@ paths: post: summary: Create a single user parameters: - - name: name - in: path - description: username - required: true - schema: - type: string + - $ref: "#/components/parameters/userName" responses: 201: description: The user has been created @@ -254,12 +230,7 @@ paths: delete: summary: Delete a user parameters: - - name: name - in: path - description: username - required: true - schema: - type: string + - $ref: "#/components/parameters/userName" responses: 204: description: The user has been deleted @@ -271,12 +242,7 @@ paths: summary: Modify a user description: Change a user's name or admin status parameters: - - name: name - in: path - description: username - required: true - schema: - type: string + - $ref: "#/components/parameters/userName" requestBody: description: Updated user info. At least one key to be updated (name or admin) @@ -310,17 +276,12 @@ paths: x-codegen-request-body-name: body /users/{name}/activity: post: - summary: Notify Hub of activity for a given user. + summary: Notify Hub of activity for a given user description: Notify the Hub of activity by the user, e.g. accessing a service or (more likely) actively using a server. parameters: - - name: name - in: path - description: username - required: true - schema: - type: string + - $ref: "#/components/parameters/userName" requestBody: content: application/json: @@ -376,12 +337,7 @@ paths: post: summary: Start a user's single-user notebook server parameters: - - name: name - in: path - description: username - required: true - schema: - type: string + - $ref: "#/components/parameters/userName" requestBody: description: | Spawn options can be passed as a JSON body @@ -411,12 +367,7 @@ paths: delete: summary: Stop a user's server parameters: - - name: name - in: path - description: username - required: true - schema: - type: string + - $ref: "#/components/parameters/userName" responses: 202: description: @@ -431,23 +382,10 @@ paths: - servers /users/{name}/servers/{server_name}: post: - summary: Start a user's single-user named-server notebook server + summary: Start a user's named server parameters: - - name: name - in: path - description: username - required: true - schema: - type: string - - name: server_name - in: path - description: | - name given to a named-server. - - Note that depending on your JupyterHub infrastructure there are chracterter size limitation to `server_name`. Default spawner with K8s pod will not allow Jupyter Notebooks to be spawned with a name that contains more than 253 characters (keep in mind that the pod will be spawned with extra characters to identify the user and hub). - required: true - schema: - type: string + - $ref: "#/components/parameters/userName" + - $ref: "#/components/parameters/serverName" requestBody: description: | Spawn options can be passed as a JSON body @@ -482,18 +420,8 @@ paths: {"remove": true} ``` parameters: - - name: name - in: path - description: username - required: true - schema: - type: string - - name: server_name - in: path - description: name given to a named-server - required: true - schema: - type: string + - $ref: "#/components/parameters/userName" + - $ref: "#/components/parameters/serverName" # FIXME: openapi 3.1 is required for requestBody on DELETE # we probably shouldn't have request bodies on DELETE @@ -523,16 +451,82 @@ paths: - oauth2: - servers # x-codegen-request-body-name: body + + /users/{name}/shared: + get: + summary: List servers shared with user + description: + Returns list of Shares granting the user access to servers owned + by others (new in 5.0) + parameters: + - $ref: "#/components/parameters/userName" + + responses: + 200: + description: Shared access granted to the user + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/PaginatedList" + - type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/ShareCode" + security: + - oauth2: + - read:users:shares + + /users/{name}/shared/{owner}/{server_name}: + get: + summary: | + Get user's shared access to server + description: | + Gets the Share representing a single user's access to a single server. + Users generally have access to this endpoint for themselves. + (new in 5.0) + parameters: + - $ref: "#/components/parameters/userName" + - $ref: "#/components/parameters/sharedServerOwner" + - $ref: "#/components/parameters/sharedServerName" + responses: + 200: + description: The permissions granted to `user` on `owner/server`. + content: + application/json: + schema: + $ref: "#/components/schemas/Share" + security: + - oauth2: + - read:users:shares + delete: + summary: | + Leave a shared server + description: | + Revokes a user's access to a shared server by deleting. + Users generally have access to this endpoint for themselves. + (new in 5.0) + parameters: + - $ref: "#/components/parameters/userName" + - $ref: "#/components/parameters/sharedServerOwner" + - $ref: "#/components/parameters/sharedServerName" + responses: + 204: + description: | + Permission has been revoked, + the user no longer has access to the server. + content: {} + security: + - oauth2: + - users:shares + /users/{name}/tokens: get: summary: List tokens for the user parameters: - - name: name - in: path - description: username - required: true - schema: - type: string + - $ref: "#/components/parameters/userName" responses: 200: description: The list of tokens @@ -552,18 +546,14 @@ paths: - oauth2: - read:tokens post: - summary: | - Create a new token for the user. + summary: Create a new token for the user + description: | + Creates a new token owned by the user. Permissions can be limited by specifying a list of `scopes` in the JSON request body - (starting in JupyerHub 3.0; previously, permissions could be specified as `roles` could be specified, + (starting in JupyerHub 3.0; previously, permissions could be specified as `roles`, which is deprecated in 3.0). parameters: - - name: name - in: path - description: username - required: true - schema: - type: string + - $ref: "#/components/parameters/userName" requestBody: content: application/json: @@ -616,14 +606,10 @@ paths: x-codegen-request-body-name: token_params /users/{name}/tokens/{token_id}: get: - summary: Get the model for a token by id + summary: Get one token + description: Get the details for one token by id parameters: - - name: name - in: path - description: username - required: true - schema: - type: string + - $ref: "#/components/parameters/userName" - name: token_id in: path required: true @@ -642,12 +628,7 @@ paths: delete: summary: Delete (revoke) a token by id parameters: - - name: name - in: path - description: username - required: true - schema: - type: string + - $ref: "#/components/parameters/userName" - name: token_id in: path required: true @@ -664,22 +645,8 @@ paths: get: summary: List groups parameters: - - name: offset - in: query - description: | - Return a number of groups starting at the specified offset. - Can be used with limit to paginate. - If unspecified, return all groups. - schema: - type: number - - name: limit - in: query - description: | - Return a finite number of groups. - Can be used with offset to paginate. - If unspecified, use api_page_default_limit. - schema: - type: number + - $ref: "#/components/parameters/paginationOffset" + - $ref: "#/components/parameters/paginationLimit" responses: 200: description: The list of groups @@ -698,12 +665,7 @@ paths: get: summary: Get a group by name parameters: - - name: name - in: path - description: group name - required: true - schema: - type: string + - $ref: "#/components/parameters/groupName" responses: 200: description: The group model @@ -719,12 +681,7 @@ paths: post: summary: Create a group parameters: - - name: name - in: path - description: group name - required: true - schema: - type: string + - $ref: "#/components/parameters/groupName" responses: 201: description: The group has been created @@ -738,12 +695,7 @@ paths: delete: summary: Delete a group parameters: - - name: name - in: path - description: group name - required: true - schema: - type: string + - $ref: "#/components/parameters/groupName" responses: 204: description: The group has been deleted @@ -751,16 +703,77 @@ paths: security: - oauth2: - admin:groups + + /groups/{name}/shared: + get: + summary: List servers shared with group + description: | + Lists shares granting `group` access to shared servers + (new in 5.0) + parameters: + - $ref: "#/components/parameters/groupName" + responses: + 200: + description: Shared access granted to the group + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/PaginatedList" + - type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/Share" + security: + - oauth2: + - read:groups:shares + + /groups/{name}/shared/{owner}/{server_name}: + get: + summary: Get group's shared access + description: | + Get the Share representing a single group's access to a single server + (new in 5.0) + parameters: + - $ref: "#/components/parameters/groupName" + - $ref: "#/components/parameters/sharedServerOwner" + - $ref: "#/components/parameters/sharedServerName" + responses: + 200: + description: The permissions granted to members of `group` on `owner/server` + content: + application/json: + schema: + $ref: "#/components/schemas/Share" + security: + - oauth2: + - read:groups:shares + delete: + summary: Leave a share (group) + description: | + Leave a share by revoking a group's permissions on a single server + (new in 5.0) + parameters: + - $ref: "#/components/parameters/groupName" + - $ref: "#/components/parameters/sharedServerOwner" + - $ref: "#/components/parameters/sharedServerName" + responses: + 204: + description: | + Permission has been revoked, + the group members no longer have access to the server. + content: {} + security: + - oauth2: + - groups:shares + /groups/{name}/users: post: summary: Add users to a group parameters: - - name: name - in: path - description: group name - required: true - schema: - type: string + - $ref: "#/components/parameters/groupName" requestBody: description: The users to add to the group content: @@ -799,12 +812,7 @@ paths: ``` parameters: - - name: name - in: path - description: group name - required: true - schema: - type: string + - $ref: "#/components/parameters/groupName" # requestBody: # description: The users to remove from the group # content: @@ -826,19 +834,15 @@ paths: - oauth2: - groups x-codegen-request-body-name: body + /groups/{name}/properties: put: - summary: | - Set the group properties. - - Added in JupyterHub 3.2. + summary: Set group properties + description: | + Set properties on a group + (new in 3.2) parameters: - - name: name - in: path - description: group name - required: true - schema: - type: string + - $ref: "#/components/parameters/groupName" requestBody: description: The new group properties, as a JSON dict. content: @@ -859,9 +863,324 @@ paths: - oauth2: - groups x-codegen-request-body-name: body + + /shares/{owner}: + get: + summary: List shares by owner + description: | + List shares granting access to any of owner's servers + (new in 5.0) + parameters: + - $ref: "#/components/parameters/sharedServerOwner" + - $ref: "#/components/parameters/paginationOffset" + - $ref: "#/components/parameters/paginationLimit" + responses: + 200: + description: The list of shares for any of the user's servers + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/PaginatedList" + - type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/Share" + security: + - oauth2: + - read:shares + + /shares/{owner}/{server_name}: + get: + summary: List server shares + description: | + List shares granting access to a single server + (new in 5.0) + parameters: + - $ref: "#/components/parameters/sharedServerOwner" + - $ref: "#/components/parameters/sharedServerName" + - $ref: "#/components/parameters/paginationOffset" + - $ref: "#/components/parameters/paginationLimit" + responses: + 200: + description: The list of shares granting access to the given server + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/PaginatedList" + - type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/Share" + security: + - oauth2: + - read:shares + post: + summary: Grant shared access + description: | + Grant shared access to a single server + (new in 5.0) + parameters: + - $ref: "#/components/parameters/sharedServerOwner" + - $ref: "#/components/parameters/sharedServerName" + requestBody: + description: The new group properties, as a JSON dict. + content: + application/json: + schema: + type: object + properties: + # expires_in not implemented, for now at least + # expires_in: + # type: number + # description: | + # expiration in seconds. + # If unspecified, no expiration. + group: + type: string + description: | + group to grant permissions to. + Exactly one of 'user' and 'group' can be specified. + user: + type: string + description: | + user to grant permissions to. + Exactly one of 'user' and 'group' can be specified. + scopes: + type: array + description: scopes to grant + items: + type: string + required: true + x-codegen-request-body-name: body + responses: + 200: + description: The updated Share permissions + content: + application/json: + schema: + $ref: "#/components/schemas/Share" + security: + - oauth2: + - shares + - read:users:name + - read:groups:name + patch: + summary: Revoke shared access + description: | + Revoke shared access to a single server for a single user or group. + If scopes are specified, only the specified scopes are revoked, + allowing the target user or group to retain partial access. + Revocation is idempotent - revoking a scope not held does not result in an error. + The resulting Share model is returned if any scopes remain. + (new in 5.0) + parameters: + - $ref: "#/components/parameters/sharedServerOwner" + - $ref: "#/components/parameters/sharedServerName" + requestBody: + description: The share modifications to be made, as a JSON dict. + content: + application/json: + schema: + type: object + properties: + group: + type: string + description: | + group to revoke permissions from. + Exactly one of 'user' and 'group' must be specified. + user: + type: string + description: | + user to revoke permissions from. + Exactly one of 'user' and 'group' must be specified. + scopes: + type: array + description: | + scopes to revoke. + If no scopes are specified, all permissions are revoked. + items: + type: string + required: true + x-codegen-request-body-name: body + responses: + 200: + description: | + The updated Share permissions. + An empty dict if no permissions remain. + content: + application/json: + schema: + $ref: "#/components/schemas/Share" + security: + - oauth2: + - shares + - read:users:name + - read:groups:name + delete: + summary: Revoke all shared access + description: | + Revoke all shared access to a given server + (new in 5.0) + parameters: + - $ref: "#/components/parameters/sharedServerOwner" + - $ref: "#/components/parameters/sharedServerName" + responses: + 204: + description: | + All shares for teh server have been deleted + security: + - oauth2: + - shares + + /share-codes/{owner}: + get: + summary: List share codes by owner + description: | + List share codes granting access to a user's servers + (new in 5.0) + parameters: + - $ref: "#/components/parameters/sharedServerOwner" + - $ref: "#/components/parameters/paginationOffset" + - $ref: "#/components/parameters/paginationLimit" + responses: + 200: + description: The list of share codes + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/PaginatedList" + - type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/ShareCode" + security: + - oauth2: + - read:shares + + /share-codes/{owner}/{server_name}: + get: + summary: List share codes + description: | + List share codes which can be exchanged for access to a single server + (new in 5.0) + parameters: + - $ref: "#/components/parameters/sharedServerOwner" + - $ref: "#/components/parameters/sharedServerName" + - $ref: "#/components/parameters/paginationOffset" + - $ref: "#/components/parameters/paginationLimit" + responses: + 200: + description: The list of share codes + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/PaginatedList" + - type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/ShareCode" + security: + - oauth2: + - read:shares + post: + summary: Issue share code + description: | + Issue a share code, which can be exchanged for shared access to a single server + (new in 5.0) + parameters: + - $ref: "#/components/parameters/sharedServerOwner" + - $ref: "#/components/parameters/sharedServerName" + requestBody: + description: The new share code properties, as a JSON dict. + content: + application/json: + schema: + type: object + properties: + expires_in: + type: number + description: | + expiration in seconds. + If unspecified, expires in one day (86400) + scopes: + type: array + description: scopes to grant + items: + type: string + required: true + x-codegen-request-body-name: body + responses: + 200: + description: | + The Share code you just created. + The code itself will be in the `code` field. + The code is not stored by JupyterHub and cannot be retrieved a second time. + `accept_url` will be the actual URL to share + (it will look like `/hub/accept-share?code=abc123`). + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ShareCode" + - type: object + properties: + code: + type: string + description: The share code itself + example: abc123 + accept_url: + type: string + description: The URL for acepting the code + example: /hub/accept-share?code=abc123 + security: + - oauth2: + - shares + delete: + summary: Revoke share code + description: | + Revoke a share code by id or code. + Exactly one of `id` or `code` must be specified. + (new in 5.0) + parameters: + - $ref: "#/components/parameters/sharedServerOwner" + - $ref: "#/components/parameters/sharedServerName" + - in: query + name: code + description: the share code to revoke + schema: + type: string + - in: query + name: id + description: the id of the share code to revoke + schema: + type: string + responses: + 204: + description: | + The share code has been revoked. + content: {} + security: + - oauth2: + - shares + /services: get: summary: List services + parameters: + - $ref: "#/components/parameters/paginationOffset" + - $ref: "#/components/parameters/paginationLimit" responses: 200: description: The service list @@ -905,22 +1224,8 @@ paths: A convenience alias for getting the routing table directly from the proxy parameters: - - name: offset - in: query - description: | - Return a number of routes starting at the given offset. - Can be used with limit to paginate. - If unspecified, return all routes. - schema: - type: number - - name: limit - in: query - description: | - Return a finite number of routes. - Can be used with offset to paginate. - If unspecified, use api_page_default_limit - schema: - type: number + - $ref: "#/components/parameters/paginationOffset" + - $ref: "#/components/parameters/paginationLimit" responses: 200: description: Routing table @@ -1176,8 +1481,111 @@ paths: - oauth2: - shutdown x-codegen-request-body-name: body + components: + parameters: + userName: + name: name + in: path + description: username + required: true + schema: + type: string + groupName: + name: name + in: path + description: group name + required: true + schema: + type: string + serverName: + name: server_name + in: path + description: | + name given to a named-server (empty string for default server). + + Note that depending on your JupyterHub infrastructure there are limitations to `server_name`. Default spawner with K8s pod will not allow Jupyter Notebooks to be spawned with a name that contains more than 253 characters (keep in mind that the pod will be spawned with extra characters to identify the user and hub). + required: true + schema: + type: string + paginationOffset: + name: offset + in: query + description: | + Return a number of results, starting at the specified offset. + Can be used with limit to paginate. + If unspecified, return all items. + required: false + schema: + type: number + paginationLimit: + name: limit + in: query + description: | + Return a finite number of results. + Can be used with offset to paginate. + If unspecified, use api_page_default_limit. + required: false + schema: + type: number + sharedServerOwner: + name: owner + in: path + description: name of the user who owns the shared server + required: true + schema: + type: string + sharedServerName: + name: server_name + in: path + description: | + name of the shared server + (empty string for default server, which means the URL ends with a trailing '/', e.g. `/username/`). + required: true + schema: + type: string + schemas: + Pagination: + type: object + description: page info for paginated endpoints + properties: + total: + type: number + description: total number of results for the query + limit: + type: number + description: the maximum number of results + offset: + type: number + description: the starting point for this + next: + description: | + fields for the next page, if any. + Null if this is the last page. + type: object + nullable: true + properties: + offset: + type: number + description: the offset for the next page + limit: + type: number + description: the same as the above limit, for consistency + url: + type: string + description: | + the assembled url for the next page, + with query parameters already included. + PaginatedList: + type: object + properties: + items: + type: array + items: + type: object + _pagination: + $ref: "#/components/schemas/Pagination" User: type: object properties: @@ -1381,6 +1789,97 @@ components: description: | Additional information a deployment can attach to a service. JupyterHub does not use this field. + + SharedServer: + description: Subset of Server model present in Share responses + type: object + properties: + # these are the minimal subset of a server model needed for access + # e.g. where is it (name, url), whose is it (user.name), and is it running (ready) + name: + type: string + description: the server name. '' for the default server. + url: + type: string + description: the server's URL + ready: + type: boolean + description: whether the server is ready + user: + type: object + description: the server's owner + properties: + name: + type: string + + Share: + description: | + A single sharing permission. + There is at most one of these objects per (server, user) or (server, group) combination. + type: object + properties: + server: + description: the server granting shared access + $ref: "#/components/schemas/SharedServer" + scopes: + description: the scopes granted by this Share + type: array + items: + type: string + group: + description: + the group being shared with (exactly one of 'user' or 'group' + will be non-null, the other will be null) + type: object + nullable: true + properties: + name: + type: string + user: + description: + the user being shared with (exactly one of 'user' or 'group' + will be non-null, the other will be null) + type: object + nullable: true + properties: + name: + type: string + created_at: + description: when the share was first granted + type: string + format: date-time + + ShareCode: + description: + A single sharing code. There is at most one of these objects per + (server, user) or (server, group) combination. + type: object + properties: + server: + description: the server granting shared access + $ref: "#/components/schemas/SharedServer" + scopes: + description: the scopes granted by this Share + type: array + items: + type: string + id: + description: | + the share-code's id. + Can be used to revoke share codes. + created_at: + description: when the share code was issued + type: string + format: date-time + expires_at: + description: | + When the share code will expire, + always in the future. + `null` if the code does not expire. + type: string + nullable: true + format: date-time + Token: type: object properties: @@ -1518,6 +2017,12 @@ components: read:hub: Read detailed information about the Hub. access:servers: Access user servers via API or browser. access:services: Access services via API or browser. + users:shares: Read and revoke a user's access to shared servers. + read:users:shares: Read servers shared with a user. + groups:shares: Read and revoke a group's access to shared servers. + read:groups:shares: Read servers shared with a group. + read:shares: Read information about shared access to servers. + shares: Manage access to shared servers. proxy: Read information about the proxy’s routing table, sync the Hub with the proxy and notify the Hub about a new proxy. diff --git a/docs/source/images/sharing-token.png b/docs/source/images/sharing-token.png new file mode 100644 index 00000000..4147adb6 Binary files /dev/null and b/docs/source/images/sharing-token.png differ diff --git a/docs/source/rbac/scopes.md b/docs/source/rbac/scopes.md index c6695e7f..93927e87 100644 --- a/docs/source/rbac/scopes.md +++ b/docs/source/rbac/scopes.md @@ -178,6 +178,57 @@ Note that only the {ref}`horizontal filtering ` can Metascopes `self` and `all`, ``, `:`, `read:`, `admin:`, and `access:` scopes are predefined and cannot be changed otherwise. ``` +(access-scopes)= + +### Access scopes + +An **access scope** is used to govern _access_ to a JupyterHub service or a user's single-user server. +This means making API requests, or visiting via a browser using OAuth. +Without the appropriate access scope, a user or token should not be permitted to make requests of the service. + +When you attempt to access a service or server authenticated with JupyterHub, it will begin the [oauth flow](jupyterhub-oauth) for issuing a token that can be used to access the service. +If the user does not have the access scope for the relevant service or server, JupyterHub will not permit the oauth process to complete. +If oauth completes, the token will have at least the access scope for the service. +For minimal permissions, this is the _only_ scope granted to tokens issued during oauth by default, +but can be expanded via {attr}`.Spawner.oauth_client_allowed_scopes` or a service's [`oauth_client_allowed_scopes`](service-credentials) configuration. + +:::{seealso} +[Further explanation of OAuth in JupyterHub](jupyterhub-oauth) +::: + +If a given service or single-user server can be governed by a single boolean "yes, you can use this service" or "no, you can't," or limiting via other existing scopes, access scopes are enough to manage access to the service. +But you can also further control granular access to servers or services with [custom scopes](custom-scopes), to limit access to particular APIs within the service, e.g. read-only access. + +#### Example access scopes + +Some example access scopes for services: + +access:services +: access to all services + +access:services!service=somename +: access to the service named `somename` + +and for user servers: + +access:servers +: access to all user servers + +access:servers!user +: access to all of a user's _own_ servers (never in _resolved_ scopes, but may be used in configuration) + +access:servers!user=name +: access to all of `name`'s servers + +access:servers!group=groupname +: access to all servers owned by a user in the group `groupname` + +access:servers!server +: access to only the issuing server (only relevant when applied to oauth tokens associated with a particular server, e.g. via the {attr}`Spawner.oauth_client_allowed_scopes` configuration. + +access:servers!server=username/ +: access to only `username`'s _default_ server. + (custom-scopes)= ### Custom scopes diff --git a/docs/source/reference/index.md b/docs/source/reference/index.md index 4c3cc8fe..b2b93c9b 100644 --- a/docs/source/reference/index.md +++ b/docs/source/reference/index.md @@ -21,6 +21,7 @@ services urls event-logging monitoring +sharing gallery-jhub-deployments changelog api/index.md diff --git a/docs/source/reference/services.md b/docs/source/reference/services.md index 1103ea8a..fab3b114 100644 --- a/docs/source/reference/services.md +++ b/docs/source/reference/services.md @@ -189,6 +189,8 @@ c.JupyterHub.services = [ In this case, the `url` field will be passed along to the Service as `JUPYTERHUB_SERVICE_URL`. +(service-credentials)= + ## Service credentials A service has direct access to the Hub API via its `api_token`. diff --git a/docs/source/reference/sharing.md b/docs/source/reference/sharing.md new file mode 100644 index 00000000..34fa9e1f --- /dev/null +++ b/docs/source/reference/sharing.md @@ -0,0 +1,398 @@ +(sharing-reference)= + +# Sharing access to user servers + +In order to make use of features like JupyterLab's real-time collaboration (RTC), multiple users must have access to a single server. +There are a few ways to do this, but ultimately both users must have the appropriate `access:servers` scope. +Prior to JupyterHub 5.0, this could only be granted via static role assignments in JupyterHub configuration. +JupyterHub 5.0 adds the concept of a 'share', allowing _users_ to grant each other limited access to their servers. + +:::{seealso} +Documentation on [roles and scopes](rbac) for more details on how permissions work in JupyterHub, and in particular [access scopes](access-scopes). +::: + +In JupyterHub, shares: + +1. are 'granted' to a user or group +2. grant only limited permissions (e.g. only 'access' or access and start/stop) +3. may be revoked by anyone with the `shares` permissions +4. may always be revoked by the shared-with user or group + +Additionally a "share code" is a random string, which has all the same properties as a Share aside from the user or group. +The code can be exchanged for actual sharing permission, to enable the pattern of sharing permissions without needing to know the username(s) of who you'd like to share with (e.g. email a link). + +There is not yet _UI_ to create shares, but they can be managed via JupyterHub's [REST API](jupyterhub-rest-api). + +In general, with shares you can: + +1. access other users' servers +2. grant access to your servers +3. see servers shared with you +4. review and revoke permissions for servers you manage + +## Enable sharing + +For safety, users do not have permission to share access to their servers by default. +To grant this permission, a user must have the `shares` scope for their servers. +To grant all users permission to share access to their servers: + +```python +c.JupyterHub.load_roles = [ + { + "name": "user", + "scopes": ["self", "shares!user"], + }, +] +``` + +With this, only the sharing via invitation code described below will be available. + +Additionally, to share access with a **specific user or group** (more below), +a user must have permission to read that user or group's name. +To enable the _full_ sharing API for all users: + +```python +c.JupyterHub.load_roles = [ + { + "name": "user", + "scopes": ["self", "shares!user", "read:users:name", "read:groups:name"], + }, +] +``` + +Note that this exposes the ability for all users to _discover_ existing user and group names, +which is part of why we have the share-by-code pattern, +so users don't need this ability to share with each other. + +## Share or revoke access to a server + +To modify who has access to a server, you need the permission `shares` with the appropriate _server_ filter, +and access to read the name of the target user or group (`read:users:name` or `read:groups:name`). +You can only modify access to one server at a time. + +### Granting access to a server + +To grant access to a particular user, in addition to `shares`, the granter must have at least `read:user:name` permission for the target user (or `read:group:name` if it's a group). + +Send a POST request to `/api/shares/:username/:servername` to grant permissions. +The JSON body should specify what permissions to grant and whom to grant them to: + +``` +POST /api/shares/:username/:servername +{ + "scopes": [], + "user": "username", # or: + "group": "groupname", +} +``` + +It should have exactly one of "user" or "group" defined (not both). +The specified user or group will be _granted_ access to the target server. + +If `scopes` is specified, all requested scopes _must_ have the `!server=:username/:servername` filter applied. +The default value for `scopes` is `["access:servers!server=:username/:servername"]` (i.e. the 'access scope' for the server). + +### Revoke access + +To revoke permissions, you need the permission `shares` with the appropriate _server_ filter, +and `read:users:name` (or `read:groups:name`) for the user or group to modify. +You can only modify access to one server at a time. + +Send a PATCH request to `/api/shares/:username/:servername` to revoke permissions. + +``` +PATCH /api/shares/:username/:servername +``` + +The JSON body should specify the scopes to revoke + +``` +POST /api/shares/:username/:servername +{ + "scopes": [], + "user": "username", # or: + "group": "groupname", +} +``` + +If `scopes` is empty or unspecified, _all_ scopes are revoked from the target user or group. + +#### Revoke _all_ permissions + +A DELETE request will revoke all shared access permissions for the given server. + +``` +DELETE /api/shares/:username/:servername +``` + +### View shares for a server + +To view shares for a given server, you need the permission `read:shares` with the appropriate _server_ filter. + +``` +GET /api/shares/:username/:servername +``` + +This is a paginated endpoint, so responses has `items` as a list of Share models, and `_pagination` for information about retrieving all shares if there are many: + +```python +{ + "items": [ + { + "server": {...}, + "scopes": ["access:servers!server=sharer/"], + "user": { + "name": "shared-with", + }, + "group": None, # or {"name": "groupname"}, + ... + }, + ... + ], + "_pagination": { + "total": 5, + "limit": 50, + "offset": 0, + "next": None, + }, +} +``` + +see the [rest-api](rest-api) for full details of the response models. + +### View servers shared with user or group + +To review servers shared with a given user or group, you need the permission `read:users:shares` or `read:groups:shares` with the appropriate _user_ or _group_ filter. + +``` + +GET /api/users/:username/shared + +# or + +GET /api/groups/:groupname/shared + +``` + +These are paginated endpoints. + +### Access permission for a single user on a single server + +``` + +GET /api/users/:username/shared/:ownername/:servername + +# or + +GET /api/groups/:groupname/shared/:ownername/:servername + +``` + +will return the _single_ Share info for the given user or group for the server specified by `ownername/servername`, +or 404 if no access is granted. + +### Revoking one's own permissions for a server + +To revoke sharing permissions from the perspective of the user or group being shared with, +you need the permissions `users:shares` or `groups:shares` with the appropriate _user_ or _group_ filter. +This allows users to 'leave' shared servers, without needing permission to manage the server's sharing permissions. + +``` + +DELETE /api/users/:username/shared/:ownername/:servername + +# or + +DELETE /api/groups/:groupname/shared/:ownername/:servername + +``` + +will revoke all permissions granted to the user or group for the specified server. + +### The Share model + + + +A Share returned in the REST API has the following structure: + +```python +{ + "server": { + "name": "servername", + "user": { + "name": "ownername" + }, + "url": "/users/ownername/servername/", + "ready": True, + + }, + "scopes": ["access:servers!server=username/servername"], + "user": { # or None + "name": "username", + }, + "group": None, # or {"name": "groupname"}, + "created_at": "2023-10-02T13:27Z", +} +``` + +where exactly one of `user` and `group` is not null and the other is null. + +See the [rest-api](rest-api) for full details of the response models. + +## Share via invitation code + +Sometimes you would like to share access to a server with one or more users, +but you don't want to deal with collecting everyone's username. +For this, you can create shares via _share code_. +This is identical to sharing with a user, +only it adds the step where the sharer creates the _code_ and distributes the code to one or more users, +then the users themselves exchange the code for actual sharing permissions. + +Share codes are much like shares, except: + +1. they don't associate with specific users +2. they can be used multiple times, by more than one user (i.e. send one invite email to several recipients) +3. they expire (default: 1 day) +4. they can only be accepted by individual users, not groups + +### Creating share codes + +To create a share code: + +``` +POST /api/share-code/:username/:servername +``` + +where the body should include the scopes to be granted and expiration. +Share codes _must_ expire. + +```python +{ + "scopes": ["access:servers!server=:user/:server"], + "expires_in": 86400, # seconds, default: 1 day +} +``` + +If no scopes are specified, the access scope for the specified server will be used. +If no expiration is specified, the code will expire in one day (86400 seconds). + +The response contains the code itself: + +```python +{ + "code": "abc1234....", + "accept_url": "/hub/accept-share?code=abc1234", + "id": "sc_1234", + "scopes": [...], + ... +} +``` + +See the [rest-api](rest-api) for full details of the response models. + +### Accepting sharing invitations + +Sharing invitations can be accepted by visiting: + +``` +/hub/accept-share/?code=:share-code +``` + +where you will be able to confirm the permissions you would like to accept. +After accepting permissions, you will be redirected to the running server. + +If the server is not running and you have not also been granted permission to start it, +you will need to contact the owner of the server to start it. + +### Listing existing invitations + +You can see existing invitations for + +``` +GET /hub/api/share-codes/:username/:servername +``` + +which produces a paginated list of share codes (_excluding_ the codes themselves, which are not stored by jupyterhub): + +```python +{ + "items": [ + { + "id": "sc_1234", + "exchange_count": 0, + "last_exchanged_at": None, + "scopes": ["access:servers!server=username/servername"], + "server": { + "name": "", + "user": { + "name": "username", + }, + }, + ... + } + ], + "_pagination": { + "total": 5, + "limit": 50, + "offset": 0, + "next": None, + } +} +``` + +see the [rest-api](rest-api) for full details of the response models. + +### Share code model + + + +A Share Code returned in the REST API has most of the same fields as a Share, but lacks the association with a user or group, and adds information about exchanges of the share code, +and the `id` that can be used for revocation: + +```python +{ + + # common share fields + "server": { + "user": { + "name": "sharer" + }, + "name": "", + "url": "/user/sharer/", + "ready": True, + }, + "scopes": [ + "access:servers!server=sharer/" + ], + # share-code-specific fields + "id": "sc_1", + "created_at": "2024-01-23T11:46:32.154416Z", + "expires_at": "2024-01-24T11:46:32.153582Z", + "exchange_count": 1, + "last_exchanged_at": "2024-01-23T11:46:43.589701Z" +} +``` + +see the [rest-api](rest-api) for full details of the response models. + +### Revoking invitations + +If you've finished inviting users to a server, you can revoke all invitations with: + +``` +DELETE /hub/api/share-codes/:username/:servername +``` + +or revoke a single invitation code: + +``` +DELETE /hub/api/share-codes/:username/:servername?code=:thecode +``` + +You can also revoke a code by _id_, if you non longer have the code: + +``` +DELETE /hub/api/share-codes/:username/:servername?id=sc_123 +``` + +where the `id` is retrieved from the share-code model, e.g. when listing current share codes. diff --git a/docs/source/tutorial/index.md b/docs/source/tutorial/index.md index cd826f69..0b6186fa 100644 --- a/docs/source/tutorial/index.md +++ b/docs/source/tutorial/index.md @@ -51,5 +51,6 @@ Further tutorials of configuring JupyterHub for specific tasks ```{toctree} :maxdepth: 1 +sharing collaboration-users ``` diff --git a/docs/source/tutorial/sharing.md b/docs/source/tutorial/sharing.md new file mode 100644 index 00000000..22e1705e --- /dev/null +++ b/docs/source/tutorial/sharing.md @@ -0,0 +1,287 @@ +(sharing-tutorial)= + +# Sharing access to your server + +In JupyterHub 5.0, users can grant each other limited access to their servers without intervention by Hub administrators. +There is not (yet!) any UI for granting shared access, so this tutorial goes through the steps of using the JupyterHub API to grant access to servers. + +For more background on how sharing works in JupyterHub, see the [sharing reference documentation](sharing-reference). + +## Setup: enable sharing (admin) + +First, sharing must be _enabled_ on the JupyterHub deployment. +That is, grant (some) users permission to share their servers with others. +Users cannot share their servers by default. +This is the only step that requires an admin action. +To grant users permission to share access to their servers, +add the `shares!user` scope to the default `user` role: + +```python +c.JupyterHub.load_roles = [ + { + "name": "user", + "scopes": ["self", "shares!user"], + }, +] +``` + +With this, only the sharing via invitation code (described below) will be available. + +Additionally, if you want users to be able to share access with a **specific user or group** (more below), +a user must have permission to read that user or group's name. +To enable the _full_ sharing API for all users: + +```python +c.JupyterHub.load_roles = [ + { + "name": "user", + "scopes": ["self", "shares!user", "read:users:name", "read:groups:name"], + }, +] +``` + +Note that this exposes the ability for all users to _discover_ existing user and group names, +which is part of why we have the share-by-code pattern, +so users don't need this ability to share with each other. +Adding filters lets you limit who can be shared with by name. + +:::{note} +Removing a user's permission to grant shares only prevents _future_ shares. +Any shared permissions previously granted by a user will remain and must be revoked separately, +if desired. +::: + +### Grant servers permission to share themselves (optional, admin) + +The most natural place to want to grant access to a server is when viewing that server. +By default, the tokens used when talking to a server have extremely limited permissions. +You can grant sharing permissions to servers themselves in one of two ways. + +The first is to grant sharing permission to the tokens used by browser requests. +This is what you would do if you had a JupyterLab extension that presented UI for managing shares +(this should exist! We haven't made it yet). +To grant these tokens sharing permissions: + +```python +c.Spawner.oauth_client_allowed_scopes = ["access:servers!server", "shares!server"] +``` + +JupyterHub's `user-sharing` example does it this way. +The nice thing about this approach is that only users who already have those permissions will get a token which can take these actions. +The downside (in terms of convenience) is that the browser token is only accessible to the javascript (e.g. JupyterLab) and/or jupyter-server request handlers, +but not notebooks or terminals. + +The second way, which is less secure, but perhaps more convenient for demonstration purposes, +is to grant the _server itself_ permission to grant access to itself. + +```python +c.Spawner.server_token_scopes = [ + "users:activity!user", + "shares!server", +] +``` + +The security downside of this approach is that anyone who can access the server generally can assume the permissions of the server token. +Effectively, this means anyone who the server is shared _with_ will gain permission to further share the server with others. +This is not the case for the first approach, but this token is accessible to terminals and notebook kernels, making it easier to illustrate. + +## Get a token + +Now, assuming the _user_ has permission to share their server (step 0), we need a token to make the API requests in this tutorial. +You can do this at the token page, or inherit it from the single-user server environment if one of the above configurations has been selected by admins. + +To request a token with only the permissions required (`shares!user`) on the token page: + +![JupyterHub Token page requesting a token with scopes "shares!user"](../images/sharing-token.png) + +This token will be in the `Authorization` header. +To create a {py:class}`requests.Session` that will send this header on every request: + +```python +import requests +from getpass import getpass + +token = getpass.getpass("JupyterHub API token: ") + +session = requests.Session() +session.headers = {"Authorization": f"Bearer {token}"} +``` + +We will make subsequent requests in this tutorial with this session object, so the header is present. + +## Issue a sharing code + +We are going to make a POST request to `/hub/api/share-codes/username/` to issue a _sharing code_. +This is a _code_, which can be _exchanged_ by one or more users for access to the shared service. + +A sharing code: + +- always expires (default: after one day) +- can be _exchanged_ multiple times for shared access to the server + +When the sharing code expires, any permissions granted by the code will remain +(think of it like an invitation to collaborate on a repository or to a chat group - the invitation can expire, but once accepted, access persists). + +To request a share code: + +``` +POST /hub/api/share-codes/:username/:servername +``` + +Assuming your username is `barb` and you want to share access to your default server, this would be: + +``` +POST /hub/api/share-codes/barb/ +``` + +```python +# sample values, replace with your actual hub +hub_url = "http://127.0.0.1:8000" +username = "barb" + +r = session.post(f"{hub_url}/hub/api/share-codes/{username}/") +``` + +which will have a JSON response: + +```python +{ + 'server': {'user': {'name': 'barb'}, + 'name': '', + 'url': '/user/barb/', + 'ready': True, + }, + 'scopes': ['access:servers!server=barb/'], + 'id': 'sc_2', + 'created_at': '2024-01-10T13:01:32.972409Z', + 'expires_at': '2024-01-11T13:01:32.970126Z', + 'exchange_count': 0, + 'last_exchanged_at': None, + 'code': 'U-eYLFT1lGstEqfMHpAIvTZ1MRjZ1Y1a-loGQ0K86to', + 'accept_url': '/hub/accept-share?code=U-eYLFT1lGstEqfMHpAIvTZ1MRjZ1Y1a-loGQ0K86to', +} +``` + +The most relevant fields here are `code`, which contains the code itself, and `accept_url`, which is the URL path for the page another user. +Note: it does not contain the _hostname_ of the hub, which JupyterHub often does not know. + +Share codes are guaranteed to be url-safe, so no encoding is required. + +### Expanding or limiting the share code + +You can specify scopes (must be limited to this specific server) and expiration of the sharing code. + +:::{note} +The granted permissions do not expire, only the code itself. +That means that after expiration, users may not exchange the code anymore, +but any user who has exchanged it will still have those permissions. +::: + +The _default_ scopes are only `access:servers!server=:user/:server`, and the default expiration is one day (86400). +These can be overridden in the JSON body of the POST request that issued the token: + +```python +import json + +options = { + "scopes": [ + f"access:servers!server={username}/", # access the server (default) + f"servers!server={username}/", # start/stop the server + f"shares!server={username}/", # further share the server with others + ], + "expires_in": 3600, # code expires in one hour +} + +session.post(f"{hub_url}/hub/api/share-codes/{username}/", data=json.dumps(options)) +``` + +### Distribute the sharing code + +Now that you have a code and/or a URL, anyone you share the code with will be able to visit `$JUPYTERHUB/hub/accept-share?code=code`. + +### Sharing a link to a specific page + +The `accept-share` page also accepts a `next` URL parameter, which can be a redirect to a specific page, rather than the default page of the server. +For example: + +``` +/hub/accept-code?code=abc123&next=/users/barb/lab/tree/mynotebook.ipynb +``` + +would be a link that can be shared with any JupyterHub user that will take them directly to the file `mynotebook.ipynb` in JupyterLab on barb's server after granting them access to the server. + +## Reviewing shared access + +When you have shared access to your server, it's a good idea to check out who has access. +You can see who has access with: + +```python +session.get() +``` + +which produces a paginated list of who has shared access: + +```python +{'items': [{'server': {'user': {'name': 'barb'}, + 'name': '', + 'url': '/user/barb/', + 'ready': True}, + 'scopes': ['access:servers!server=barb/', + 'servers!server=barb/', + 'shares!server=barb/'], + 'user': {'name': 'shared-with'}, + 'group': None, + 'kind': 'user', + 'created_at': '2024-01-10T13:16:56.432599Z'}], + '_pagination': {'offset': 0, 'limit': 200, 'total': 1, 'next': None}} +``` + +## Revoking shared access + +There are two ways to revoke access to a shared server: + +1. `PATCH` requests can revoke individual permissions from individual users or groups +2. `DELETE` requests revokes all shared permissions from anyone (unsharing the server in one step) + +To revoke one or more scopes from a user: + +```python +options = { + "user": "shared-with", + "scopes": ["shares!server=barb/"], +} + +session.patch(f"{hub_url}/hub/api/shares/{username}/", data=json.dumps(options)) +``` + +The Share model with remaining permissions, if any, will be returned: + +```python +{'server': {'user': {'name': 'barb'}, + 'name': '', + 'url': '/user/barb/', + 'ready': True}, + 'scopes': ['access:servers!server=barb/', 'servers!server=barb/'], + 'user': {'name': 'shared-with'}, + 'group': None, + 'kind': 'user', + 'created_at': '2024-01-10T13:16:56.432599Z'} +``` + +If no permissions remain, the response will be an empty dict (`{}`). + +To revoke all permissions for a single user, leave `scopes` unspecified: + +```python +options = { + "user": "shared-with", +} + +session.patch(f"{hub_url}/hub/api/shares/{username}/", data=json.dumps(options)) +``` + +Or revoke all shared permissions from all users for the server: + +```python +session.delete(f"{hub_url}/hub/api/shares/{username}/") +``` diff --git a/examples/user-sharing/README.md b/examples/user-sharing/README.md new file mode 100644 index 00000000..45d5b56d --- /dev/null +++ b/examples/user-sharing/README.md @@ -0,0 +1,77 @@ +# User-initiated sharing + +This example contains a jupyterhub configuration and sample notebooks demonstrating user-initiated sharing from within a JupyterLab session. + +What _admins_ need to do is enable sharing: + +```python +c.JupyterHub.load_roles = [ + { + "name": "user", + "scopes": ["self", "shares!user"], + } +] +``` + +## Getting a token with sharing permission + +Users can always issue themselves tokens with the desired permissions. +But for a deployment, it's likely that you want to grant sharing permission to something, +be it a service or some part of the single-user application. + +There are two ways to do this in a single-user session, +and for convenience, this example includes both. +In most real deployments, it will only make sense to do one or the other. + +### Sharing via JupyterLab extension + +If you have a JupyterLab javascript sharing extension or server extension, +sharing permissions should be granted to the oauth tokens used to visit the single-user server. +These permissions can be specified: + +```python +# OAuth token should have sharing permissions, +# so JupyterLab javascript can manage shares +c.Spawner.oauth_client_allowed_scopes = ["access:servers!server", "shares!server"] +``` + +The notebook `share-jupyterlab.ipynb` contains a few javascript snippets which will use the JupyterLab configuration to make API requests to JupyterHub from javascript in order to grant access. + +This workflow _should_ be handled by a proper JupyterLab extension, +but this notebook of javascript snippets serves as a proof of concept for what is required to build such an extension. + +## Sharing via API token + +These same permissions can also be granted to the server token itself, +which is available as $JUPYTERHUB_API_TOKEN in the server, +as well as terminals and notebooks. + +```python +# grant $JUPYTERHUB_API_TOKEN sharing permissions +# so that _python_ code can manage shares +c.Spawner.server_token_scopes = [ + "shares!server", # manage shares + "servers!server", # start/stop itself + "users:activity!server", # report activity (default permission) +] +``` + +This method is not preferable, because it means anyone with _access_ to the server also has access to the token to grant further sharing permissions, +which is not the case when using the oauth permissions above, +where each visiting user has their own permissions. + +But it is more convenient for demonstration purposes, because we can write a Python notebook to use it, share-api.ipynb. + +## Run the example + +First, launch jupyterhub: `jupyterhub`. + +Then login as the user `sharer`. + +Run the first couple of cells of the notebook, until you get a `/hub/accept-share` URL. + +Open a new private browser window, and paste this URL. When prompted, login with the username `shared-with`. + +In the end, you should arrive at `sharer`'s server as the user `shared-with`. + +After visiting as `shared-with`, you can proceed in the notebook as `sharer` and view who has permissions, revoke share codes, permissions, etc. diff --git a/examples/user-sharing/jupyterhub_config.py b/examples/user-sharing/jupyterhub_config.py new file mode 100644 index 00000000..5db6efe7 --- /dev/null +++ b/examples/user-sharing/jupyterhub_config.py @@ -0,0 +1,37 @@ +c = get_config() # noqa + + +c.JupyterHub.authenticator_class = 'dummy' +c.JupyterHub.spawner_class = 'simple' + +c.Authenticator.allowed_users = {"sharer", "shared-with"} + +# put the current directory on sys.path for shareextension.py +from pathlib import Path + +here = Path(__file__).parent.absolute() +c.Spawner.notebook_dir = str(here) + +# users need sharing permissions for their own servers +c.JupyterHub.load_roles = [ + { + "name": "user", + "scopes": ["self", "shares!user"], + }, +] + +# below are two ways to grant sharing permission to a single-user server. +# there's no reason to use both + + +# OAuth token should have sharing permissions, +# so JupyterLab javascript can manage shares +c.Spawner.oauth_client_allowed_scopes = ["access:servers!server", "shares!server"] + +# grant $JUPYTERHUB_API_TOKEN sharing permissions +# so that _python_ code can manage shares +c.Spawner.server_token_scopes = [ + "shares!server", # manage shares + "servers!server", # start/stop itself + "users:activity!server", # report activity +] diff --git a/examples/user-sharing/share-api.ipynb b/examples/user-sharing/share-api.ipynb new file mode 100644 index 00000000..7316fbe0 --- /dev/null +++ b/examples/user-sharing/share-api.ipynb @@ -0,0 +1,685 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "8c5bd1ca-3329-4062-8851-9bd33009d805", + "metadata": {}, + "source": [ + "# Using the sharing API from Python \n", + "\n", + "In this example, we use $JUPYTERHUB_API_TOKEN to communicate with the sharing API via Python.\n", + "\n", + "The permissions used here are granted via the `c.Spawner.server_token_scopes` config in jupyterhub_config.py\n", + "\n", + "By using this token, any user who has access to this server has access to sharing permissions.\n", + "\n", + "First, get some useful configuration from the server environment:\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "95fa629b-ac65-46da-86a6-9798f3d0a2ba", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'http://127.0.0.1:8081/hub/api'" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import os\n", + "\n", + "hub_api = os.environ[\"JUPYTERHUB_API_URL\"]\n", + "token = os.environ[\"JUPYTERHUB_API_TOKEN\"]\n", + "username = os.environ[\"JUPYTERHUB_USER\"]\n", + "user_server = f\"{username}/{os.environ['JUPYTERHUB_SERVER_NAME']}\"\n", + "hub_host = os.environ[\"JUPYTERHUB_HOST\"]\n", + "server_base_url = os.environ[\"JUPYTERHUB_SERVICE_PREFIX\"]\n", + "\n", + "hub_api" + ] + }, + { + "cell_type": "markdown", + "id": "456697ab-e7ce-4f75-bc8f-3ce424830e0d", + "metadata": {}, + "source": [ + "Create a requests.Session to make jupyterhub API requests with our token" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "f3335eeb-64e5-4caa-acb5-1032aaf727bb", + "metadata": {}, + "outputs": [], + "source": [ + "import requests\n", + "\n", + "session = requests.Session()\n", + "session.headers = {\"Authorization\": f\"Bearer {token}\"}" + ] + }, + { + "cell_type": "markdown", + "id": "3cf5605b-2022-4d2a-b0cc-de6f80e8f2fe", + "metadata": {}, + "source": [ + "We can check the permissions our token has with a request to /hub/api/user:" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "17529af3-5ec8-4495-9183-bd5d423a1d76", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'kind': 'user',\n", + " 'last_activity': '2024-01-23T11:43:50.800864Z',\n", + " 'groups': [],\n", + " 'admin': False,\n", + " 'name': 'sharer',\n", + " 'servers': {'': {'name': '',\n", + " 'full_name': 'sharer/',\n", + " 'last_activity': '2024-01-23T11:43:50.800864Z',\n", + " 'started': '2024-01-23T11:28:44.948553Z',\n", + " 'pending': None,\n", + " 'ready': True,\n", + " 'stopped': False,\n", + " 'url': '/user/sharer/',\n", + " 'user_options': {},\n", + " 'progress_url': '/hub/api/users/sharer/server/progress'}},\n", + " 'session_id': None,\n", + " 'scopes': ['access:servers!server=sharer/',\n", + " 'delete:servers!server=sharer/',\n", + " 'groups:shares!server=sharer/',\n", + " 'read:groups:shares!server=sharer/',\n", + " 'read:servers!server=sharer/',\n", + " 'read:shares!server=sharer/',\n", + " 'read:users:activity!user=sharer',\n", + " 'read:users:groups!user=sharer',\n", + " 'read:users:name!user=sharer',\n", + " 'servers!server=sharer/',\n", + " 'shares!server=sharer/',\n", + " 'users:activity!server=sharer/',\n", + " 'users:activity!user=sharer',\n", + " 'users:shares!server=sharer/']}" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "r = session.get(f\"{hub_api}/user\")\n", + "r.json()" + ] + }, + { + "cell_type": "markdown", + "id": "a14578b8-577e-4b0a-b74c-40d7a04a1a1a", + "metadata": {}, + "source": [ + "We can see who has access to this server:" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "63520e3c-3621-4ebf-9775-52e6150ccca5", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'items': [],\n", + " '_pagination': {'offset': 0, 'limit': 200, 'total': 0, 'next': None}}" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "shares_url = f\"{hub_api}/shares/{user_server}\"\n", + "share_codes_url = f\"{hub_api}/share-codes/{user_server}\"\n", + "r = session.get(shares_url)\n", + "r.json()" + ] + }, + { + "cell_type": "markdown", + "id": "5c8870d0-5b7e-4065-b227-fd463289cfdf", + "metadata": {}, + "source": [ + "and if there are any outstanding codes:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "6fad5334-6034-48f8-b008-1fd3d6e4d139", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'items': [],\n", + " '_pagination': {'offset': 0, 'limit': 200, 'total': 0, 'next': None}}" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "r = session.get(share_codes_url)\n", + "r.json()" + ] + }, + { + "cell_type": "markdown", + "id": "e6d598b3-5eab-4d56-b2ea-9aec345a3948", + "metadata": {}, + "source": [ + "Next, we can create a code:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "facb1872-44a5-4e4e-84d4-1fd829b1a27b", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'server': {'user': {'name': 'sharer'},\n", + " 'name': '',\n", + " 'url': '/user/sharer/',\n", + " 'ready': True},\n", + " 'scopes': ['access:servers!server=sharer/'],\n", + " 'id': 'sc_1',\n", + " 'created_at': '2024-01-23T11:46:32.154416Z',\n", + " 'expires_at': '2024-01-24T11:46:32.153582Z',\n", + " 'exchange_count': 0,\n", + " 'last_exchanged_at': None,\n", + " 'code': 'gTs7DmIhFu6UG5VQkOzRMk2MN-cdDiQ_RONCnkaq5Cg',\n", + " 'accept_url': '/hub/accept-share?code=gTs7DmIhFu6UG5VQkOzRMk2MN-cdDiQ_RONCnkaq5Cg'}" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "r = session.post(share_codes_url)\n", + "code_info = r.json()\n", + "code_info" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "83e23f83-18cb-4e54-9683-68c2ab0fcfc2", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " share this link to grant access\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from IPython.display import display, HTML\n", + "full_accept_url = f\"{hub_host}{code_info['accept_url']}\"\n", + "\n", + "display(\n", + " HTML(f\"\"\"\n", + " share this link to grant access\n", + " \"\"\")\n", + ")\n" + ] + }, + { + "cell_type": "markdown", + "id": "0ce4f6f8-a0f3-4556-ab25-1f86ef49a6db", + "metadata": {}, + "source": [ + "(in jupyterlab, shift-right-click to copy link)\n", + "\n", + "We can now give this to the shared-with user (i.e. us in another private browsing tab).\n", + "\n", + "After accepting the link, we can see who we've shared with again:" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "e056a9f0-7cec-414b-a3ce-03c8abfbc087", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'items': [{'server': {'user': {'name': 'sharer'},\n", + " 'name': '',\n", + " 'url': '/user/sharer/',\n", + " 'ready': True},\n", + " 'scopes': ['access:servers!server=sharer/'],\n", + " 'user': {'name': 'shared-with'},\n", + " 'group': None,\n", + " 'kind': 'user',\n", + " 'created_at': '2024-01-23T11:46:43.585455Z'}],\n", + " '_pagination': {'offset': 0, 'limit': 200, 'total': 1, 'next': None}}" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "session.get(shares_url).json()" + ] + }, + { + "cell_type": "markdown", + "id": "9933ef79-3f59-45fc-92a9-13e7bc1f3b82", + "metadata": {}, + "source": [ + "The share code can also include a `?next=` url parameter, to enable a link to take users to a specific file or view after accepting the code:" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "9c313d99-4dc9-45af-9643-f2b0e08b2bb4", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "/hub/accept-share?code=gTs7DmIhFu6UG5VQkOzRMk2MN-cdDiQ_RONCnkaq5Cg&next=%2Fuser%2Fsharer%2Flab%2Ftree%2Fshare-api.ipynb\n" + ] + }, + { + "data": { + "text/html": [ + "\n", + " share this link\n", + " to grant access and direct users to this notebook\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from urllib.parse import urlencode\n", + "\n", + "this_notebook_url = server_base_url + \"lab/tree/share-api.ipynb\"\n", + "this_notebook_accept_url = full_accept_url + \"&\" + urlencode({\"next\": this_notebook_url})\n", + "print(this_notebook_accept_url)\n", + "\n", + "display(\n", + " HTML(f\"\"\"\n", + " share this link\n", + " to grant access and direct users to this notebook\n", + " \"\"\")\n", + ")\n" + ] + }, + { + "cell_type": "markdown", + "id": "2455ba21-f558-4391-b8fc-6501feb3bbfb", + "metadata": {}, + "source": [ + "## Reviewing and managing access\n", + "\n", + "Listing share codes doesn't reveal the code - if you need to get a code, issue a new sharing code.\n", + "\n", + "But we can see in `exchange_count` whether and how often the code has been used" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "313116ed-2aa5-4a0a-bc91-f15a9428ae0c", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'items': [{'server': {'user': {'name': 'sharer'},\n", + " 'name': '',\n", + " 'url': '/user/sharer/',\n", + " 'ready': True},\n", + " 'scopes': ['access:servers!server=sharer/'],\n", + " 'id': 'sc_1',\n", + " 'created_at': '2024-01-23T11:46:32.154416Z',\n", + " 'expires_at': '2024-01-24T11:46:32.153582Z',\n", + " 'exchange_count': 1,\n", + " 'last_exchanged_at': '2024-01-23T11:46:43.589701Z'}],\n", + " '_pagination': {'offset': 0, 'limit': 200, 'total': 1, 'next': None}}" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "session.get(share_codes_url).json()" + ] + }, + { + "cell_type": "markdown", + "id": "5f0d4d8e-fabb-4423-b9bd-d96643a9b9f7", + "metadata": {}, + "source": [ + "we can also revoke the code. Codes can be deleted by code or id" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "666ac28c-70f5-4a75-9bfa-6d26c5ea610f", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "session.delete(share_codes_url + f\"?id={code_info['id']}\")" + ] + }, + { + "cell_type": "markdown", + "id": "ce2fdb7f-a2de-4c95-996b-527ce0536794", + "metadata": {}, + "source": [ + "or if you're done sharing via code, you can delete all sharing codes for a server without looking it up their ids:" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "4633b0af-ad75-4e72-9702-9cb16182aecb", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "session.delete(share_codes_url)" + ] + }, + { + "cell_type": "markdown", + "id": "c322e058-bbf5-4058-8e20-415c7da0aa8a", + "metadata": {}, + "source": [ + "scopes and expiration can be customized in the request when creating the share code:" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "462bcec7-a899-4e3e-8e77-7d08adedb7a4", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import json\n", + "\n", + "options = {\n", + " \"scopes\": [\n", + " f\"access:servers!server={user_server}\", # access the server (default)\n", + " f\"servers!server={user_server}\", # start/stop the server\n", + " f\"shares!server={user_server}\", # further share the server with others\n", + " ],\n", + " \"expires_in\": 3600, # code expires in one hour\n", + "}\n", + "\n", + "\n", + "session.post(share_codes_url, data=json.dumps(options))\n" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "c8babe6c-6339-4990-9241-d3db54205108", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'items': [{'server': {'user': {'name': 'sharer'},\n", + " 'name': '',\n", + " 'url': '/user/sharer/',\n", + " 'ready': True},\n", + " 'scopes': ['access:servers!server=sharer/'],\n", + " 'user': {'name': 'shared-with'},\n", + " 'group': None,\n", + " 'kind': 'user',\n", + " 'created_at': '2024-01-23T11:46:43.585455Z'}],\n", + " '_pagination': {'offset': 0, 'limit': 200, 'total': 1, 'next': None}}" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "r = session.get(shares_url)\n", + "r.json()" + ] + }, + { + "cell_type": "markdown", + "id": "616d8f46-940d-4527-98a7-27894a23974f", + "metadata": {}, + "source": [ + "## Revoking permissions\n", + "\n", + "We can revoke specific permssions via a PATCH request\n", + "by specifying the user (or group) and one or more scopes:" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "13d54943-e95a-4222-a67d-8f3832b70e3c", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "options = {\n", + " \"user\": \"shared-with\",\n", + " \"scopes\": ['shares!server=sharer/'],\n", + "}\n", + "\n", + "session.patch(shares_url, data=json.dumps(options))" + ] + }, + { + "cell_type": "markdown", + "id": "0b2b2619-3bb4-41a7-852b-de019f49185e", + "metadata": {}, + "source": [ + "If scopes are unspecified, all permissions are revoked:" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "1d8997fa-7093-44dd-abdf-405fe8cc7fcd", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "options = {\n", + " \"user\": \"shared-with\",\n", + "}\n", + "\n", + "session.patch(shares_url, data=json.dumps(options))\n" + ] + }, + { + "cell_type": "markdown", + "id": "bb4e37a4-d206-420b-ad21-93a15c65bbd9", + "metadata": {}, + "source": [ + "_All_ shared access can be revoked via a DELETE request to the shares URL:" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "203ec184-df77-493b-bd8f-e6bb461abe7e", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 17, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "session.delete(shares_url)" + ] + }, + { + "cell_type": "markdown", + "id": "129d98a3-df16-4607-8a9d-784843a7eeaf", + "metadata": {}, + "source": [ + "and we can see that nobody has shared access anymore" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "d40f97b8-171f-4958-9cbf-bcc08a5f0db7", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'items': [],\n", + " '_pagination': {'offset': 0, 'limit': 200, 'total': 0, 'next': None}}" + ] + }, + "execution_count": 18, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "session.get(shares_url).json()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/user-sharing/share-jupyterlab.ipynb b/examples/user-sharing/share-jupyterlab.ipynb new file mode 100644 index 00000000..362f0a32 --- /dev/null +++ b/examples/user-sharing/share-jupyterlab.ipynb @@ -0,0 +1,403 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "7609a68a-c01b-43a8-90aa-3d004af614dd", + "metadata": {}, + "source": [ + "# Sharing access to a server\n", + "\n", + "This notebook executes some javascript in the browser, using the user's OAuth token.\n", + "\n", + "This code would normally reside in a jupyterlab extension.\n", + "The notebook serves only for demonstration purposes.\n", + "\n", + "First, collect some configuration from the page, so we can talk to the JupyterHub API:" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "b3cf16bd-ff9b-4140-a394-5a7ca5d96d88", + "metadata": {}, + "outputs": [ + { + "data": { + "application/javascript": [ + "// define some globals to share\n", + "\n", + "var configElement = document.getElementById(\"jupyter-config-data\");\n", + "var jupyterConfig = JSON.parse(configElement.innerHTML);\n", + "\n", + "window.token = jupyterConfig.token;\n", + "window.hubOrigin = `${document.location.protocol}//${jupyterConfig.hubHost || window.location.host}`\n", + "window.hubUrl = `${hubOrigin}${jupyterConfig.hubPrefix}`;\n", + "window.shareCodesUrl = `${hubUrl}api/share-codes/${jupyterConfig.hubServerUser}/${jupyterConfig.hubServerName}`\n", + "window.sharesUrl = `${hubUrl}api/shares/${jupyterConfig.hubServerUser}/${jupyterConfig.hubServerName}`\n", + "console.log(shareCodesUrl);\n", + "\n", + "// utility function to make API requests and parse errors\n", + "window.apiRequest = async function (url, options) {\n", + " var element = options.element;\n", + " var okStatus = options.ok || 200;\n", + " var resp = await fetch(url, {headers: {Authorization: `Bearer ${token}`}, method: options.method || 'GET'});\n", + " var replyText = await resp.text();\n", + " var replyJSON = {};\n", + " if (replyText.length) {\n", + " replyJSON = JSON.parse(replyText);\n", + " }\n", + " \n", + " if (resp.status != okStatus) {\n", + " var p = document.createElement('p');\n", + " p.innerText = `Error ${resp.status}: ${replyJSON.message}`;\n", + " element.appendChild(p);\n", + " return;\n", + " }\n", + " return replyJSON;\n", + "}\n", + "\n", + "// `element` is a special variable for the current cell's output area\n", + "element.innerText = `API URL for sharing codes is: ${shareCodesUrl}`;\n" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "%%javascript\n", + "// define some globals to share\n", + "\n", + "var configElement = document.getElementById(\"jupyter-config-data\");\n", + "var jupyterConfig = JSON.parse(configElement.innerHTML);\n", + "\n", + "window.token = jupyterConfig.token;\n", + "window.hubOrigin = `${document.location.protocol}//${jupyterConfig.hubHost || window.location.host}`\n", + "window.hubUrl = `${hubOrigin}${jupyterConfig.hubPrefix}`;\n", + "window.shareCodesUrl = `${hubUrl}api/share-codes/${jupyterConfig.hubServerUser}/${jupyterConfig.hubServerName}`\n", + "window.sharesUrl = `${hubUrl}api/shares/${jupyterConfig.hubServerUser}/${jupyterConfig.hubServerName}`\n", + "console.log(shareCodesUrl);\n", + "\n", + "// utility function to make API requests and parse errors\n", + "window.apiRequest = async function (url, options) {\n", + " var element = options.element;\n", + " var okStatus = options.ok || 200;\n", + " var resp = await fetch(url, {headers: {Authorization: `Bearer ${token}`}, method: options.method || 'GET'});\n", + " var replyText = await resp.text();\n", + " var replyJSON = {};\n", + " if (replyText.length) {\n", + " replyJSON = JSON.parse(replyText);\n", + " }\n", + " \n", + " if (resp.status != okStatus) {\n", + " var p = document.createElement('p');\n", + " p.innerText = `Error ${resp.status}: ${replyJSON.message}`;\n", + " element.appendChild(p);\n", + " return;\n", + " }\n", + " return replyJSON;\n", + "}\n", + "\n", + "// `element` is a special variable for the current cell's output area\n", + "element.innerText = `API URL for sharing codes is: ${shareCodesUrl}`;" + ] + }, + { + "cell_type": "markdown", + "id": "b2049fa1-bb60-4073-9167-2e116b198f0e", + "metadata": {}, + "source": [ + "Next, we can request a share code with\n", + "\n", + "```\n", + "POST $hub/api/share-codes/$user/$server\n", + "```\n", + "\n", + "The URL for _accepting_ a sharing invitation code is `/hub/accept-share?code=abc123...`:" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "ee9430f3-4866-41f7-942d-423bd48dc6b8", + "metadata": {}, + "outputs": [ + { + "data": { + "application/javascript": [ + "\n", + "(async function f() {\n", + " var shareCode = await apiRequest(shareCodesUrl, {method: 'POST', element: element});\n", + "\n", + " // laziest way to display\n", + " var shareCodeUrl = `${hubOrigin}${shareCode.accept_url}`\n", + " var a = document.createElement('a');\n", + " a.href = shareCodeUrl;\n", + " a.innerText = shareCodeUrl;\n", + " var p = document.createElement(p);\n", + " p.append(\"Share this URL to grant access to this server: \");\n", + " p.appendChild(a);\n", + " element.appendChild(p);\n", + "})();\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "%%javascript\n", + "\n", + "(async function f() {\n", + " var shareCode = await apiRequest(shareCodesUrl, {method: 'POST', element: element});\n", + "\n", + " // laziest way to display\n", + " var shareCodeUrl = `${hubOrigin}${shareCode.accept_url}`\n", + " var a = document.createElement('a');\n", + " a.href = shareCodeUrl;\n", + " a.innerText = shareCodeUrl;\n", + " var p = document.createElement(p);\n", + " p.append(\"Share this URL to grant access to this server: \");\n", + " p.appendChild(a);\n", + " element.appendChild(p);\n", + "})();\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "id": "418152a0-2e0f-4ca6-8b53-9295d15c4345", + "metadata": {}, + "source": [ + "Share this URL to grant access to your server (e.g. visit the URL in a private window and login as the user `shared-with`).\n", + "\n", + "After our code has been used, we can see who has access to this server:" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "5c9c6e1b-ccab-4c85-8afb-db141651236a", + "metadata": {}, + "outputs": [ + { + "data": { + "application/javascript": [ + "\n", + "(async function f() {\n", + "\n", + " var shares = await apiRequest(sharesUrl, {element: element});\n", + "\n", + " var list = document.createElement('ul');\n", + " for (var share of shares.items) {\n", + " var p = document.createElement('li');\n", + " p.append(`${share.kind} ${share[share.kind].name} has access: `)\n", + " var scopes = document.createElement('tt');\n", + " scopes.innerText = share.scopes.join(',');\n", + " p.appendChild(scopes);\n", + " list.append(p);\n", + " }\n", + " var p = document.createElement('p');\n", + " p.innerText = `Shared with ${shares.items.length} users:`;\n", + " element.appendChild(p);\n", + " element.appendChild(list);\n", + " return;\n", + "})();\n" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "%%javascript\n", + "\n", + "(async function f() {\n", + "\n", + " var shares = await apiRequest(sharesUrl, {element: element});\n", + "\n", + " var list = document.createElement('ul');\n", + " for (var share of shares.items) {\n", + " var p = document.createElement('li');\n", + " p.append(`${share.kind} ${share[share.kind].name} has access: `)\n", + " var scopes = document.createElement('tt');\n", + " scopes.innerText = share.scopes.join(',');\n", + " p.appendChild(scopes);\n", + " list.append(p);\n", + " }\n", + " var p = document.createElement('p');\n", + " p.innerText = `Shared with ${shares.items.length} users:`;\n", + " element.appendChild(p);\n", + " element.appendChild(list);\n", + " return;\n", + "})();\n" + ] + }, + { + "cell_type": "markdown", + "id": "18165205-67f3-44d4-ad6b-40ab27ec469c", + "metadata": {}, + "source": [ + "We could also use this info to revoke permissions, or share with individuals by name.\n", + "\n", + "We can also review outstanding sharing _codes_:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "40cd1e2d-fc21-4238-8cbc-9ae45be248c9", + "metadata": {}, + "outputs": [ + { + "data": { + "application/javascript": [ + "\n", + "(async function f() {\n", + " var shareCodes = await apiRequest(shareCodesUrl, {element: element});\n", + " var p = document.createElement('pre');\n", + " p.innerText = JSON.stringify(shareCodes.items, null, ' ');\n", + " element.appendChild(p);\n", + "})();\n" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "%%javascript\n", + "\n", + "(async function f() {\n", + " var shareCodes = await apiRequest(shareCodesUrl, {element: element});\n", + " var p = document.createElement('pre');\n", + " p.innerText = JSON.stringify(shareCodes.items, null, ' ');\n", + " element.appendChild(p);\n", + "})();\n" + ] + }, + { + "cell_type": "markdown", + "id": "0effee22-c132-4b44-a677-c4375594c462", + "metadata": {}, + "source": [ + "And finally, when we're done, we can revoke the codes, at which point nobody _new_ can use the code to gain access to this server,\n", + "but anyone who has accepted the code will still have access:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "51a795f5-9a74-49b1-9ef3-e7978da0cc44", + "metadata": {}, + "outputs": [ + { + "data": { + "application/javascript": [ + "\n", + "(async function f() {\n", + " await apiRequest(shareCodesUrl, {method: 'DELETE', element: element, ok: 204});\n", + " var p = document.createElement('p');\n", + " p.innerText = `Deleted all share codes`;\n", + " element.appendChild(p); \n", + "})();\n" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "%%javascript\n", + "\n", + "(async function f() {\n", + " await apiRequest(shareCodesUrl, {method: 'DELETE', element: element, ok: 204});\n", + " var p = document.createElement('p');\n", + " p.innerText = `Deleted all share codes`;\n", + " element.appendChild(p); \n", + "})();" + ] + }, + { + "cell_type": "markdown", + "id": "2ad12718-1f68-4d2f-9934-dd6bd4555a1d", + "metadata": {}, + "source": [ + "Or even revoke all shared access, so anyone who may have used the code no longer has any access:" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "efa5ae15-ac99-4bf4-b7b4-3d93747dba8c", + "metadata": {}, + "outputs": [ + { + "data": { + "application/javascript": [ + "\n", + "(async function f() {\n", + " var resp = await apiRequest(sharesUrl, {method: 'DELETE', element: element, ok: 204});\n", + " var p = document.createElement('p');\n", + " p.innerText = `Deleted all shared access`;\n", + " element.appendChild(p); \n", + "})();\n" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "%%javascript\n", + "\n", + "(async function f() {\n", + " var resp = await apiRequest(sharesUrl, {method: 'DELETE', element: element, ok: 204});\n", + " var p = document.createElement('p');\n", + " p.innerText = `Deleted all shared access`;\n", + " element.appendChild(p); \n", + "})();" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/jupyterhub/apihandlers/__init__.py b/jupyterhub/apihandlers/__init__.py index f72887eb..2e6d8b78 100644 --- a/jupyterhub/apihandlers/__init__.py +++ b/jupyterhub/apihandlers/__init__.py @@ -1,6 +1,6 @@ -from . import auth, groups, hub, proxy, services, users +from . import auth, groups, hub, proxy, services, shares, users from .base import * # noqa default_handlers = [] -for mod in (auth, hub, proxy, users, groups, services): +for mod in (auth, hub, proxy, users, groups, services, shares): default_handlers.extend(mod.default_handlers) diff --git a/jupyterhub/apihandlers/base.py b/jupyterhub/apihandlers/base.py index aa01ba06..5ee37450 100644 --- a/jupyterhub/apihandlers/base.py +++ b/jupyterhub/apihandlers/base.py @@ -200,6 +200,7 @@ class APIHandler(BaseHandler): model = { 'name': orm_spawner.name, + 'full_name': f"{orm_spawner.user.name}/{orm_spawner.name}", 'last_activity': isoformat(orm_spawner.last_activity), 'started': isoformat(orm_spawner.started), 'pending': pending, diff --git a/jupyterhub/apihandlers/shares.py b/jupyterhub/apihandlers/shares.py new file mode 100644 index 00000000..7ff384fb --- /dev/null +++ b/jupyterhub/apihandlers/shares.py @@ -0,0 +1,590 @@ +"""Handlers for Shares and Share Codes""" + +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. +import json +import re +from typing import List, Optional + +from pydantic import ( + BaseModel, + ConfigDict, + ValidationError, + conint, + field_validator, + model_validator, +) +from sqlalchemy import or_ +from sqlalchemy.orm import joinedload +from tornado import web +from tornado.httputil import url_concat + +from .. import orm +from ..scopes import _check_scopes_exist, needs_scope +from ..utils import isoformat +from .base import APIHandler +from .groups import _GroupAPIHandler + +_share_code_id_pat = re.compile(r"sc_(\d+)") + + +class BaseShareRequest(BaseModel): + model_config = ConfigDict(extra='forbid') + scopes: Optional[List[str]] = None + + @field_validator("scopes") + @classmethod + def _check_scopes_exist(cls, scopes): + if not scopes: + return None + _check_scopes_exist(scopes, who_for="share") + return scopes + + +class ShareGrantRequest(BaseShareRequest): + """Validator for requests to grant sharing permission""" + + # directly granted shares don't expire + # since Shares are _modifications_ of permissions, + # expiration can get weird + # if it's going to expire, it must expire in + # at least one minute and at most 10 years (avoids nonsense values) + # expires_in: conint(ge=60, le=10 * 525600 * 60) | None = None + user: Optional[str] = None + group: Optional[str] = None + + @model_validator(mode='after') + def user_group_exclusive(self): + if self.user and self.group: + raise ValueError("Expected exactly one of `user` or `group`, not both.") + if self.user is None and self.group is None: + raise ValueError("Specify exactly one of `user` or `group`") + return self + + +class ShareRevokeRequest(ShareGrantRequest): + """Validator for requests to revoke sharing permission""" + + # currently identical to ShareGrantRequest + + +class ShareCodeGrantRequest(BaseShareRequest): + """Validator for requests to create sharing codes""" + + # must be at least one minute, at most one year, default to one day + expires_in: conint(ge=60, le=525600 * 60) = 86400 + + +class _ShareAPIHandler(APIHandler): + def server_model(self, spawner): + """Truncated server model for use in shares + + - Adds "user" field (just name for now) + - Limits fields to "name", "url", "ready" + from standard server model + """ + user = self.users[spawner.user.id] + if spawner.name in user.spawners: + # use Spawner wrapper if it's active + spawner = user.spawners[spawner.name] + full_model = super().server_model(spawner, user=user) + # filter out subset of fields + server_model = { + "user": { + "name": spawner.user.name, + } + } + # subset keys for sharing + for key in ["name", "url", "ready"]: + if key in full_model: + server_model[key] = full_model[key] + + return server_model + + def share_model(self, share): + """Compute the REST API model for a share""" + return { + "server": self.server_model(share.spawner), + "scopes": share.scopes, + "user": {"name": share.user.name} if share.user else None, + "group": {"name": share.group.name} if share.group else None, + "kind": "group" if share.group else "user", + "created_at": isoformat(share.created_at), + } + + def share_code_model(self, share_code, code=None): + """Compute the REST API model for a share code""" + model = { + "server": self.server_model(share_code.spawner), + "scopes": share_code.scopes, + "id": f"sc_{share_code.id}", + "created_at": isoformat(share_code.created_at), + "expires_at": isoformat(share_code.expires_at), + "exchange_count": share_code.exchange_count, + "last_exchanged_at": isoformat(share_code.last_exchanged_at), + } + if code: + model["code"] = code + model["accept_url"] = url_concat( + self.hub.base_url + "accept-share", {"code": code} + ) + return model + + def _init_share_query(self, kind="share"): + """Initialize a query for Shares + + before applying filters + + A method so we can consolidate joins, etc. + """ + if kind == "share": + class_ = orm.Share + elif kind == "code": + class_ = orm.ShareCode + else: + raise ValueError( + f"kind must be `share` or `code`, not {kind!r}" + ) # pragma: no cover + + query = self.db.query(class_).options( + joinedload(class_.owner).lazyload("*"), + joinedload(class_.spawner).joinedload(orm.Spawner.user).lazyload("*"), + ) + if kind == 'share': + query = query.options( + joinedload(class_.user).joinedload(orm.User.groups).lazyload("*"), + joinedload(class_.group).lazyload("*"), + ) + return query + + def _share_list_model(self, query, kind="share"): + """Finish a share query, returning the _model_""" + offset, limit = self.get_api_pagination() + if kind == "share": + model_method = self.share_model + elif kind == "code": + model_method = self.share_code_model + else: + raise ValueError( + f"kind must be `share` or `code`, not {kind!r}" + ) # pragma: no cover + + if kind == "share": + class_ = orm.Share + elif kind == "code": + class_ = orm.ShareCode + + total_count = query.count() + query = query.order_by(class_.id.asc()).offset(offset).limit(limit) + share_list = [model_method(share) for share in query if not share.expired] + return self.paginated_model(share_list, offset, limit, total_count) + + def _lookup_spawner(self, user_name, server_name, raise_404=True): + """Lookup orm.Spawner for user_name/server_name + + raise 404 if not found + """ + user = self.find_user(user_name) + if user and server_name in user.orm_spawners: + return user.orm_spawners[server_name] + if raise_404: + raise web.HTTPError(404, f"No such server: {user_name}/{server_name}") + else: + return None + + +class UserShareListAPIHandler(_ShareAPIHandler): + """List shares a user has access to + + includes access granted via group membership + """ + + @needs_scope("read:users:shares") + def get(self, user_name): + user = self.find_user(user_name) + if user is None: + raise web.HTTPError(404, f"No such user: {user_name}") + query = self._init_share_query() + filter = orm.Share.user == user + if user.groups: + filter = or_( + orm.Share.user == user, + orm.Share.group_id.in_([group.id for group in user.groups]), + ) + query = query.filter(filter) + self.finish(json.dumps(self._share_list_model(query))) + + +class UserShareAPIHandler(_ShareAPIHandler): + def _lookup_share(self, user_name, owner_name, server_name): + """Lookup the Share this URL represents + + raises 404 if not found + """ + user = self.find_user(user_name) + if user is None: + raise web.HTTPError( + 404, + f"No such share for user {user_name} on {owner_name}/{server_name}", + ) + spawner = self._lookup_spawner(owner_name, server_name, raise_404=False) + share = None + if spawner: + share = orm.Share.find(self.db, spawner, share_with=user.orm_user) + if share is not None: + return share + else: + raise web.HTTPError( + 404, + f"No such share for user {user_name} on {owner_name}/{server_name}", + ) + + @needs_scope("read:users:shares") + def get(self, user_name, owner_name, _server_name): + share = self._lookup_share(user_name, owner_name, _server_name) + self.finish(json.dumps(self.share_model(share))) + + @needs_scope("users:shares") + def delete(self, user_name, owner_name, _server_name): + share = self._lookup_share(user_name, owner_name, _server_name) + self.db.delete(share) + self.db.commit() + self.set_status(204) + + +class GroupShareListAPIHandler(_ShareAPIHandler, _GroupAPIHandler): + """List shares granted to a group""" + + @needs_scope("read:groups:shares") + def get(self, group_name): + group = self.find_group(group_name) + query = self._init_share_query() + query = query.filter(orm.Share.group == group) + self.finish(json.dumps(self._share_list_model(query))) + + +class GroupShareAPIHandler(_ShareAPIHandler, _GroupAPIHandler): + """A single group's access to a single server""" + + def _lookup_share(self, group_name, owner_name, server_name): + """Lookup the Share this URL represents + + raises 404 if not found + """ + group = self.find_group(group_name) + spawner = self._lookup_spawner(owner_name, server_name, raise_404=False) + share = None + if spawner: + share = orm.Share.find(self.db, spawner, share_with=group) + if share is not None: + return share + else: + raise web.HTTPError( + 404, + f"No such share for group {group_name} on {owner_name}/{server_name}", + ) + + @needs_scope("read:groups:shares") + def get(self, group_name, owner_name, _server_name): + share = self._lookup_share(group_name, owner_name, _server_name) + self.finish(json.dumps(self.share_model(share))) + + @needs_scope("groups:shares") + def delete(self, group_name, owner_name, _server_name): + share = self._lookup_share(group_name, owner_name, _server_name) + self.db.delete(share) + self.db.commit() + self.set_status(204) + + +class ServerShareAPIHandler(_ShareAPIHandler): + """Endpoint for shares of a single server + + This is where permissions are granted and revoked + """ + + @needs_scope("read:shares") + def get(self, user_name, server_name=None): + """List all shares for a given owner""" + + # TODO: optimize this query + # we need Share and only the _names_ of users/groups, + # no any other relationships + query = self._init_share_query() + if server_name is not None: + spawner = self._lookup_spawner(user_name, server_name) + query = query.filter_by(spawner_id=spawner.id) + else: + # lookup owner by id + row = ( + self.db.query(orm.User.id) + .where(orm.User.name == user_name) + .one_or_none() + ) + if row is None: + raise web.HTTPError(404) + owner_id = row[0] + query = query.filter_by(owner_id=owner_id) + self.finish(json.dumps(self._share_list_model(query))) + + @needs_scope('shares') + async def post(self, user_name, server_name=None): + """POST grants permissions for a given server""" + + if server_name is None: + # only GET supported `/shares/{user}` without specified server + raise web.HTTPError(405) + + model = self.get_json_body() or {} + try: + request = ShareGrantRequest(**model) + except ValidationError as e: + raise web.HTTPError(400, str(e)) + + scopes = request.scopes + # check scopes + if not scopes: + # default scopes + scopes = [f"access:servers!server={user_name}/{server_name}"] + + # validate that scopes may be granted by requesting user + try: + scopes = orm.Share._apply_filter(frozenset(scopes), user_name, server_name) + except ValueError as e: + raise web.HTTPError(400, str(e)) + + # resolve target spawner + spawner = self._lookup_spawner(user_name, server_name) + + # check permissions + for scope in scopes: + if not self.has_scope(scope): + raise web.HTTPError( + 403, f"Do not have permission to grant share with scope {scope}" + ) + + if request.user: + scope = f"read:users:name!user={request.user}" + if not self.has_scope(scope): + raise web.HTTPError( + 403, "Need scope 'read:users:name' to share with users by name" + ) + share_with = self.find_user(request.user) + if share_with is None: + raise web.HTTPError(400, f"No such user: {request.user}") + share_with = share_with.orm_user + elif request.group: + if not self.has_scope(f"read:groups:name!group={request.group}"): + raise web.HTTPError( + 403, "Need scope 'read:groups:name' to share with groups by name" + ) + share_with = orm.Group.find(self.db, name=request.group) + if share_with is None: + raise web.HTTPError(400, f"No such group: {request.group}") + + share = orm.Share.grant(self.db, spawner, share_with, scopes=scopes) + self.finish(json.dumps(self.share_model(share))) + + @needs_scope('shares') + async def patch(self, user_name, server_name=None): + """PATCH revokes permissions from single shares for a given server""" + + if server_name is None: + # only GET supported `/shares/{user}` without specified server + raise web.HTTPError(405) + + model = self.get_json_body() or {} + try: + request = ShareRevokeRequest(**model) + except ValidationError as e: + raise web.HTTPError(400, str(e)) + + # TODO: check allowed/valid scopes + + scopes = request.scopes + + # resolve target spawner + spawner = self._lookup_spawner(user_name, server_name) + + if request.user: + # don't need to check read:user permissions for revocation + share_with = self.find_user(request.user) + if share_with is None: + # No such user is the same as revoking + self.log.warning(f"No such user: {request.user}") + self.finish("{}") + return + share_with = share_with.orm_user + elif request.group: + share_with = orm.Group.find(self.db, name=request.group) + if share_with is None: + # No such group behaves the same as revoking no permissions + self.log.warning(f"No such group: {request.group}") + self.finish("{}") + return + + share = orm.Share.revoke(self.db, spawner, share_with, scopes=scopes) + if share: + self.finish(json.dumps(self.share_model(share))) + else: + # empty dict if share deleted + self.finish("{}") + + @needs_scope('shares') + async def delete(self, user_name, server_name=None): + if server_name is None: + # only GET supported `/shares/{user}` without specified server + raise web.HTTPError(405) + + spawner = self._lookup_spawner(user_name, server_name) + self.log.info(f"Deleting all shares for {user_name}/{server_name}") + q = self.db.query(orm.Share).filter_by( + spawner_id=spawner.id, + ) + res = q.delete() + self.log.info(f"Deleted {res} shares for {user_name}/{server_name}") + self.db.commit() + assert spawner.shares == [] + self.set_status(204) + + +class ServerShareCodeAPIHandler(_ShareAPIHandler): + """Endpoint for managing sharing codes of a single server + + These codes can be exchanged for actual sharing permissions by the recipient. + """ + + @needs_scope("read:shares") + def get(self, user_name, server_name=None): + """List all share codes for a given owner""" + + query = self._init_share_query(kind="code") + if server_name is None: + # lookup owner by id + row = ( + self.db.query(orm.User.id) + .where(orm.User.name == user_name) + .one_or_none() + ) + if row is None: + raise web.HTTPError(404) + owner_id = row[0] + + query = query.filter_by(owner_id=owner_id) + else: + spawner = self._lookup_spawner(user_name, server_name) + query = query.filter_by(spawner_id=spawner.id) + self.finish(json.dumps(self._share_list_model(query, kind="code"))) + + @needs_scope('shares') + async def post(self, user_name, server_name=None): + """POST creates a new share code""" + + if server_name is None: + # only GET supported `/share-codes/{user}` without specified server + raise web.HTTPError(405) + + model = self.get_json_body() or {} + try: + request = ShareCodeGrantRequest(**model) + except ValidationError as e: + raise web.HTTPError(400, str(e)) + + scopes = request.scopes + # check scopes + if not scopes: + # default scopes + scopes = [f"access:servers!server={user_name}/{server_name}"] + + try: + scopes = orm.ShareCode._apply_filter( + frozenset(scopes), user_name, server_name + ) + except ValueError as e: + raise web.HTTPError(400, str(e)) + + # validate that scopes may be granted by requesting user + for scope in scopes: + if not self.has_scope(scope): + raise web.HTTPError( + 403, f"Do not have permission to grant share with scope {scope}" + ) + + # resolve target spawner + spawner = self._lookup_spawner(user_name, server_name) + + # issue the code + (share_code, code) = orm.ShareCode.new( + self.db, spawner, scopes=scopes, expires_in=request.expires_in + ) + # return the model (including code only this one time when it's created) + self.finish(json.dumps(self.share_code_model(share_code, code=code))) + + @needs_scope('shares') + def delete(self, user_name, server_name=None): + if server_name is None: + # only GET supported `/share-codes/{user}` without specified server + raise web.HTTPError(405) + + code = self.get_argument("code", None) + share_id = self.get_argument("id", None) + spawner = self._lookup_spawner(user_name, server_name) + if code: + # delete one code, identified by the code itself + share_code = orm.ShareCode.find(self.db, code, spawner=spawner) + if share_code is None: + raise web.HTTPError(404, "No matching code found") + else: + self.log.info(f"Deleting share code for {user_name}/{server_name}") + self.db.delete(share_code) + elif share_id: + m = _share_code_id_pat.match(share_id) + four_o_four = f"No code found matching id={share_id}" + if not m: + raise web.HTTPError(404, four_o_four) + share_id = int(m.group(1)) + share_code = ( + self.db.query(orm.ShareCode) + .filter_by( + spawner_id=spawner.id, + id=share_id, + ) + .one_or_none() + ) + if share_code is None: + raise web.HTTPError(404, four_o_four) + else: + self.log.info(f"Deleting share code for {user_name}/{server_name}") + self.db.delete(share_code) + else: + self.log.info(f"Deleting all share codes for {user_name}/{server_name}") + deleted = ( + self.db.query(orm.ShareCode) + .filter_by( + spawner_id=spawner.id, + ) + .delete() + ) + self.log.info( + f"Deleted {deleted} share codes for {user_name}/{server_name}" + ) + self.db.commit() + self.set_status(204) + + +default_handlers = [ + # TODO: not implementing single all-shared endpoint yet, too hard + # (r"/api/shares", ShareListAPIHandler), + # general management of shares + (r"/api/shares/([^/]+)", ServerShareAPIHandler), + (r"/api/shares/([^/]+)/([^/]*)", ServerShareAPIHandler), + # list shared_with_me for users/groups + (r"/api/users/([^/]+)/shared", UserShareListAPIHandler), + (r"/api/groups/([^/]+)/shared", GroupShareListAPIHandler), + # single-share endpoint (only for easy self-revocation, for now) + (r"/api/users/([^/]+)/shared/([^/]+)/([^/]*)", UserShareAPIHandler), + (r"/api/groups/([^/]+)/shared/([^/]+)/([^/]*)", GroupShareAPIHandler), + # manage sharing codes + (r"/api/share-codes/([^/]+)", ServerShareCodeAPIHandler), + (r"/api/share-codes/([^/]+)/([^/]*)", ServerShareCodeAPIHandler), +] diff --git a/jupyterhub/app.py b/jupyterhub/app.py index 987f785a..fde55f6d 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -2491,7 +2491,7 @@ class JupyterHub(Application): run periodically """ # this should be all the subclasses of Expiring - for cls in (orm.APIToken, orm.OAuthCode): + for cls in (orm.APIToken, orm.OAuthCode, orm.Share, orm.ShareCode): self.log.debug(f"Purging expired {cls.__name__}s") cls.purge_expired(self.db) diff --git a/jupyterhub/handlers/base.py b/jupyterhub/handlers/base.py index 8f147d81..d8dd26fb 100644 --- a/jupyterhub/handlers/base.py +++ b/jupyterhub/handlers/base.py @@ -491,6 +491,10 @@ class BaseHandler(RequestHandler): return functools.partial(scopes.check_scope_filter, sub_scope) + def has_scope(self, scope): + """Is the current request being made with the given scope?""" + return scopes.has_scope(scope, self.parsed_scopes, db=self.db) + @property def current_user(self): """Override .current_user accessor from tornado @@ -669,15 +673,14 @@ class BaseHandler(RequestHandler): def authenticate(self, data): return maybe_future(self.authenticator.get_authenticated_user(self, data)) - def get_next_url(self, user=None, default=None): - """Get the next_url for login redirect + def _validate_next_url(self, next_url): + """Validate next_url handling - Default URL after login: + protects against external redirects, etc. - - if redirect_to_server (default): send to user's own server - - else: /hub/home + Returns empty string if next_url is not considered safe, + resulting in same behavior as if next_url is not specified. """ - next_url = self.get_argument('next', default='') # protect against some browsers' buggy handling of backslash as slash next_url = next_url.replace('\\', '%5C') public_url = self.settings.get("public_url") @@ -723,17 +726,18 @@ class BaseHandler(RequestHandler): self.log.warning("Disallowing redirect outside JupyterHub: %r", next_url) next_url = '' - if next_url and next_url.startswith(url_path_join(self.base_url, 'user/')): - # add /hub/ prefix, to ensure we redirect to the right user's server. - # The next request will be handled by SpawnHandler, - # ultimately redirecting to the logged-in user's server. - without_prefix = next_url[len(self.base_url) :] - next_url = url_path_join(self.hub.base_url, without_prefix) - self.log.warning( - "Redirecting %s to %s. For sharing public links, use /user-redirect/", - self.request.uri, - next_url, - ) + return next_url + + def get_next_url(self, user=None, default=None): + """Get the next_url for login redirect + + Default URL after login: + + - if redirect_to_server (default): send to user's own server + - else: /hub/home + """ + next_url = self.get_argument('next', default='') + next_url = self._validate_next_url(next_url) # this is where we know if next_url is coming from ?next= param or we are using a default url if next_url: diff --git a/jupyterhub/handlers/pages.py b/jupyterhub/handlers/pages.py index ac974496..6b651497 100644 --- a/jupyterhub/handlers/pages.py +++ b/jupyterhub/handlers/pages.py @@ -12,9 +12,9 @@ from jinja2 import TemplateNotFound from tornado import web from tornado.httputil import url_concat -from .. import __version__ +from .. import __version__, orm from ..metrics import SERVER_POLL_DURATION_SECONDS, ServerPollStatus -from ..scopes import needs_scope +from ..scopes import describe_raw_scopes, needs_scope from ..utils import maybe_future, url_escape_path, url_path_join, utcnow from .base import BaseHandler @@ -553,6 +553,84 @@ class TokenPageHandler(BaseHandler): self.finish(html) +class AcceptShareHandler(BaseHandler): + + def _get_next_url(self, owner, spawner): + """Get next_url for a given owner/spawner""" + next_url = self.get_argument("next", "") + next_url = self._validate_next_url(next_url) + if next_url: + return next_url + + # default behavior: + # if it's active, redirect to server URL + if spawner.name in owner.spawners: + spawner = owner.spawners[spawner.name] + if spawner.active: + # redirect to spawner url + next_url = owner.server_url(spawner.name) + + if not next_url: + # spawner not active + # TODO: next_url not specified and not running, what do we do? + # for now, redirect as if it's running, + # but that's very likely to fail on "You can't launch this server" + # is there a better experience for this? + next_url = owner.server_url(spawner.name) + # validate again, which strips away host to just absolute path + return self._validate_next_url(next_url) + + @web.authenticated + async def get(self): + code = self.get_argument("code") + share_code = orm.ShareCode.find(self.db, code=code) + if share_code is None: + raise web.HTTPError(404, "Share not found or expired") + if share_code.owner == self.current_user.orm_user: + raise web.HTTPError(403, "You can't share with yourself!") + + scope_descriptions = describe_raw_scopes( + share_code.scopes, + username=self.current_user.name, + ) + owner = self._user_from_orm(share_code.owner) + spawner = share_code.spawner + if spawner.name in owner.spawners: + spawner = owner.spawners[spawner.name] + spawner_ready = spawner.ready + else: + spawner_ready = False + + html = await self.render_template( + 'accept-share.html', + code=code, + owner=owner, + spawner=spawner, + spawner_ready=spawner_ready, + spawner_url=owner.server_url(spawner.name), + scope_descriptions=scope_descriptions, + next_url=self._get_next_url(owner, spawner), + ) + self.finish(html) + + @web.authenticated + def post(self): + code = self.get_argument("code") + self.log.debug("Looking up %s", code) + share_code = orm.ShareCode.find(self.db, code=code) + if share_code is None: + raise web.HTTPError(400, f"Invalid share code: {code}") + if share_code.owner == self.current_user.orm_user: + raise web.HTTPError(400, "You can't share with yourself!") + user = self.current_user + share = share_code.exchange(user.orm_user) + owner = self._user_from_orm(share.owner) + spawner = share.spawner + + next_url = self._get_next_url(owner, spawner) + self.redirect(next_url) + + class ProxyErrorHandler(BaseHandler): """Handler for rendering proxy error pages""" @@ -609,6 +687,7 @@ default_handlers = [ (r'/spawn/([^/]+)', SpawnHandler), (r'/spawn/([^/]+)/([^/]+)', SpawnHandler), (r'/token', TokenPageHandler), + (r'/accept-share', AcceptShareHandler), (r'/error/(\d+)', ProxyErrorHandler), (r'/health$', HealthCheckHandler), (r'/api/health$', HealthCheckHandler), diff --git a/jupyterhub/orm.py b/jupyterhub/orm.py index 554dccfe..d749fe98 100644 --- a/jupyterhub/orm.py +++ b/jupyterhub/orm.py @@ -5,9 +5,11 @@ import enum import json import numbers +import secrets from base64 import decodebytes, encodebytes from datetime import timedelta -from functools import partial +from functools import lru_cache, partial +from itertools import chain import alembic.command import alembic.config @@ -33,6 +35,7 @@ from sqlalchemy import ( from sqlalchemy.orm import ( Session, declarative_base, + declared_attr, interfaces, joinedload, object_session, @@ -94,6 +97,10 @@ class JSONDict(TypeDecorator): class JSONList(JSONDict): """Represents an immutable structure as a json-encoded string (to be used for list type columns). + Accepts list, tuple, sets for assignment + + Always read as a list + Usage:: JSONList(JSONDict) @@ -101,8 +108,12 @@ class JSONList(JSONDict): """ def process_bind_param(self, value, dialect): - if isinstance(value, list) and value is not None: + if isinstance(value, (list, tuple)): value = json.dumps(value) + if isinstance(value, set): + # serialize sets as ordered lists + value = json.dumps(sorted(value)) + return value def process_result_value(self, value, dialect): @@ -226,6 +237,15 @@ class Group(Base): roles = relationship( 'Role', secondary='group_role_map', back_populates='groups', lazy="selectin" ) + shared_with_me = relationship( + "Share", + back_populates="group", + cascade="all, delete-orphan", + lazy="selectin", + ) + + # used in some model fields to differentiate 'whoami' + kind = "group" def __repr__(self): return f"<{self.__class__.__name__} {self.name}>" @@ -296,6 +316,42 @@ class User(Base): oauth_codes = relationship( "OAuthCode", back_populates="user", cascade="all, delete-orphan" ) + + # sharing relationships + shares = relationship( + "Share", + back_populates="owner", + cascade="all, delete-orphan", + foreign_keys="Share.owner_id", + ) + share_codes = relationship( + "ShareCode", + back_populates="owner", + cascade="all, delete-orphan", + foreign_keys="ShareCode.owner_id", + ) + shared_with_me = relationship( + "Share", + back_populates="user", + cascade="all, delete-orphan", + foreign_keys="Share.user_id", + lazy="selectin", + ) + + @property + def all_shared_with_me(self): + """return all shares shared with me, + + including via group + """ + + return list( + chain( + self.shared_with_me, + *[group.shared_with_me for group in self.groups], + ) + ) + cookie_id = Column(Unicode(255), default=new_token, nullable=False, unique=True) # User.state is actually Spawner state # We will need to figure something else out if/when we have multiple spawners per user @@ -304,6 +360,10 @@ class User(Base): # Encryption is handled elsewhere encrypted_auth_state = Column(LargeBinary) + # used in some model fields to differentiate whether an owner or actor + # is a user or service + kind = "user" + def __repr__(self): return "<{cls}({name} {running}/{total} running)>".format( cls=self.__class__.__name__, @@ -345,6 +405,13 @@ class Spawner(Base): cascade="all, delete-orphan", ) + shares = relationship( + "Share", back_populates="spawner", cascade="all, delete-orphan" + ) + share_codes = relationship( + "ShareCode", back_populates="spawner", cascade="all, delete-orphan" + ) + state = Column(JSONDict) name = Column(Unicode(255)) @@ -457,6 +524,9 @@ class Service(Base): single_parent=True, ) + # used in some model fields to differentiate 'whoami' + kind = "service" + def new_api_token(self, token=None, **kwargs): """Create a new API token If `token` is given, load that token. @@ -496,6 +566,14 @@ class Expiring: else: return None + @property + def expired(self): + """Is this object expired?""" + if not self.expires_at: + return False + else: + return self.expires_in <= 0 + @classmethod def purge_expired(cls, db): """Purge expired API Tokens from the database""" @@ -528,7 +606,7 @@ class Hashed(Expiring): @property def token(self): - raise AttributeError("token is write-only") + raise AttributeError(f"{self.__class__.__name__}.token is write-only") @token.setter def token(self, token): @@ -556,12 +634,13 @@ class Hashed(Expiring): """Check if a token is acceptable""" if len(token) < cls.min_length: raise ValueError( - "Tokens must be at least %i characters, got %r" - % (cls.min_length, token) + f"{cls.__name__}.token must be at least {cls.min_length} characters, got {len(token)}: {token[: cls.prefix_length]}..." ) found = cls.find(db, token) if found: - raise ValueError("Collision on token: %s..." % token[: cls.prefix_length]) + raise ValueError( + f"Collision on {cls.__name__}: {token[: cls.prefix_length]}..." + ) @classmethod def find_prefix(cls, db, token): @@ -600,6 +679,339 @@ class Hashed(Expiring): return orm_token +class _Share: + """Common columns for Share and ShareCode""" + + id = Column(Integer, primary_key=True, autoincrement=True) + created_at = Column(DateTime, nullable=False, default=utcnow) + + # TODO: owner_id and spawner_id columns don't need `@declared_attr` when we can require sqlalchemy 2 + + # the owner of the shared server + # this is redundant with spawner.user, but saves a join + @declared_attr + def owner_id(self): + return Column(Integer, ForeignKey('users.id', ondelete="CASCADE")) + + @declared_attr + def owner(self): + # table name happens to be appropriate 'shares', 'share_codes' + # could be another, more explicit attribute, but the values would be the same + return relationship( + "User", + back_populates=self.__tablename__, + foreign_keys=[self.owner_id], + lazy="selectin", + ) + + # the spawner the share is for + @declared_attr + def spawner_id(self): + return Column(Integer, ForeignKey('spawners.id', ondelete="CASCADE")) + + @declared_attr + def spawner(self): + return relationship( + "Spawner", + back_populates=self.__tablename__, + lazy="selectin", + ) + + # the permissions granted (!server filter will always be applied) + scopes = Column(JSONList) + expires_at = Column(DateTime, nullable=True) + + @classmethod + def apply_filter(cls, scopes, spawner): + """Apply our filter, ensures all scopes have appropriate !server filter + + Any other filters will raise ValueError. + """ + return cls._apply_filter(frozenset(scopes), spawner.user.name, spawner.name) + + @staticmethod + @lru_cache() + def _apply_filter(scopes, owner_name, server_name): + """ + implementation of Share.apply_filter + + Static method so @lru_cache is persisted across instances + """ + filtered_scopes = [] + server_filter = f"server={owner_name}/{server_name}" + for scope in scopes: + base_scope, _, filter = scope.partition("!") + if filter and filter != server_filter: + raise ValueError( + f"!{filter} not allowed on sharing {scope}, only !{server_filter}" + ) + filtered_scopes.append(f"{base_scope}!{server_filter}") + return frozenset(filtered_scopes) + + +class Share(_Share, Expiring, Base): + """A single record of a sharing permission + + granted by one user to another user (or group) + + Restricted to a single server. + """ + + __tablename__ = "shares" + + # who the share is granted to (user or group) + user_id = Column(Integer, ForeignKey('users.id', ondelete="CASCADE"), nullable=True) + user = relationship( + "User", back_populates="shared_with_me", foreign_keys=[user_id], lazy="selectin" + ) + + group_id = Column( + Integer, ForeignKey('groups.id', ondelete="CASCADE"), nullable=True + ) + group = relationship("Group", back_populates="shared_with_me", lazy="selectin") + + def __repr__(self): + if self.user: + kind = "user" + name = self.user.name + elif self.group: + kind = "group" + name = self.group.name + else: # pragma: no cover + kind = "deleted" + name = "unknown" + + if self.owner and self.spawner: + server_name = f"{self.owner.name}/{self.spawner.name}" + else: # pragma: n cover + server_name = "unknown/deleted" + + return f"<{self.__class__.__name__}(server={server_name}, scopes={self.scopes}, {kind}={name})>" + + @staticmethod + def _share_with_key(share_with): + """Get the field name for share with + + either group_id or user_id, depending on type of share_with + + raises TypeError if neither User nor Group + """ + if isinstance(share_with, User): + return "user_id" + elif isinstance(share_with, Group): + return "group_id" + else: + raise TypeError( + f"Can only share with orm.User or orm.Group, not {share_with!r}" + ) + + @classmethod + def find(cls, db, spawner, share_with): + """Find an existing + + Shares are unique for a given (spawner, user) + """ + + filter_by = { + cls._share_with_key(share_with): share_with.id, + "spawner_id": spawner.id, + "owner_id": spawner.user.id, + } + return db.query(Share).filter_by(**filter_by).one_or_none() + + @staticmethod + def _get_log_name(spawner, share_with): + """construct log snippet to refer to the share""" + return ( + f"{share_with.kind}:{share_with.name} on {spawner.user.name}/{spawner.name}" + ) + + @property + def _log_name(self): + return self._get_log_name(self.spawner, self.user or self.group) + + @classmethod + def grant(cls, db, spawner, share_with, scopes=None): + """Grant shared permissions for a server + + Updates existing Share if there is one, + otherwise creates a new Share + """ + if scopes is None: + scopes = frozenset( + [f"access:servers!server={spawner.user.name}/{spawner.name}"] + ) + scopes = cls._apply_filter(frozenset(scopes), spawner.user.name, spawner.name) + + if not scopes: + raise ValueError("Must specify scopes to grant.") + + # 1. lookup existing share and update + share = cls.find(db, spawner, share_with) + share_with_log = cls._get_log_name(spawner, share_with) + if share is not None: + # update existing permissions in-place + # extend permissions + existing_scopes = set(share.scopes) + added_scopes = set(scopes).difference(existing_scopes) + if not added_scopes: + app_log.info(f"No new scopes for {share_with_log}") + return share + new_scopes = sorted(existing_scopes | added_scopes) + app_log.info(f"Granting scopes {sorted(added_scopes)} for {share_with_log}") + share.scopes = new_scopes + db.commit() + else: + # no share for (spawner, share_with), create a new one + app_log.info(f"Sharing scopes {sorted(scopes)} for {share_with_log}") + share = cls( + created_at=cls.now(), + # copy shared fields + owner=spawner.user, + spawner=spawner, + scopes=sorted(scopes), + ) + if share_with.kind == "user": + share.user = share_with + elif share_with.kind == "group": + share.group = share_with + else: + raise TypeError(f"Expected user or group, got {share_with!r}") + db.add(share) + db.commit() + return share + + @classmethod + def revoke(cls, db, spawner, share_with, scopes=None): + """Revoke permissions for share_with on `spawner` + + If scopes are not specified, all scopes are revoked + """ + share = cls.find(db, spawner, share_with) + if share is None: + _log_name = cls._get_log_name(spawner, share_with) + app_log.info(f"No permissions to revoke from {_log_name}") + return + else: + _log_name = share._log_name + + if scopes is None: + app_log.info(f"Revoked all permissions from {_log_name}") + db.delete(share) + db.commit() + return None + + # update scopes + new_scopes = [scope for scope in share.scopes if scope not in scopes] + revoked_scopes = [scope for scope in scopes if scope in set(share.scopes)] + if new_scopes == share.scopes: + app_log.info(f"No change in scopes for {_log_name}") + return share + elif not new_scopes: + # revoked all scopes, delete the Share + app_log.info(f"Revoked all permissions from {_log_name}") + db.delete(share) + db.commit() + else: + app_log.info(f"Revoked {revoked_scopes} from {_log_name}") + share.scopes = new_scopes + db.commit() + + if new_scopes: + return share + else: + return None + + +class ShareCode(_Share, Hashed, Base): + """A code that can be exchanged for a Share + + Ultimately, the same as a Share, but has a 'code' + instead of a user or group that it is shared with. + The code can be exchanged to create or update an actual Share. + """ + + __tablename__ = "share_codes" + + hashed = Column(Unicode(255), unique=True) + prefix = Column(Unicode(16), index=True) + exchange_count = Column(Integer, default=0) + last_exchanged_at = Column(DateTime, nullable=True, default=None) + + _code_bytes = 32 + default_expires_in = 86400 + + def __repr__(self): + if self.owner and self.spawner: + server_name = f"{self.owner.name}/{self.spawner.name}" + else: + server_name = "unknown/deleted" + + return f"<{self.__class__.__name__}(server={server_name}, scopes={self.scopes}, expires_at={self.expires_at})>" + + @classmethod + def new( + cls, + db, + spawner, + *, + scopes, + expires_in=None, + **kwargs, + ): + """Create a new ShareCode""" + app_log.info(f"Creating share code for {spawner.user.name}/{spawner.name}") + # verify scopes have the necessary filter + kwargs["scopes"] = sorted(cls.apply_filter(scopes, spawner)) + if not expires_in: + expires_in = cls.default_expires_in + kwargs["expires_at"] = utcnow() + timedelta(seconds=expires_in) + kwargs["spawner"] = spawner + kwargs["owner"] = spawner.user + code = secrets.token_urlsafe(cls._code_bytes) + + # create the ShareCode + share_code = cls(**kwargs) + # setting Hashed.token property sets the `hashed` column in the db + share_code.token = code + # actually put it in the db + db.add(share_code) + db.commit() + return (share_code, code) + + @classmethod + def find(cls, db, code, *, spawner=None): + """Lookup a single ShareCode by code""" + prefix_match = cls.find_prefix(db, code) + if spawner: + prefix_match = prefix_match.filter_by(spawner_id=spawner.id) + for share_code in prefix_match: + if share_code.match(code): + return share_code + + def exchange(self, share_with): + """exchange a ShareCode for a Share + + share_with can be a User or a Group. + """ + db = inspect(self).session + share_code_log = f"Share code {self.prefix}..." + if self.expired: + db.delete(self) + db.commit() + raise ValueError(f"{share_code_log} expired") + + share_with_log = f"{share_with.kind}:{share_with.name} on {self.owner.name}/{self.spawner.name}" + app_log.info(f"Exchanging {share_code_log} for {share_with_log}") + share = Share.grant(db, self.spawner, share_with, self.scopes) + # note: we count exchanges, even if they don't modify the permissions + # (e.g. one user exchanging the same code twice) + self.exchange_count += 1 + self.last_exchanged_at = self.now() + db.commit() + return share + + # ------------------------------------ # OAuth tables # ------------------------------------ diff --git a/jupyterhub/roles.py b/jupyterhub/roles.py index f421016b..b974ccb6 100644 --- a/jupyterhub/roles.py +++ b/jupyterhub/roles.py @@ -47,6 +47,7 @@ def get_default_roles(): 'access:servers', 'read:roles', 'read:metrics', + 'shares', ], }, { diff --git a/jupyterhub/scopes.py b/jupyterhub/scopes.py index 16944618..5f683059 100644 --- a/jupyterhub/scopes.py +++ b/jupyterhub/scopes.py @@ -144,6 +144,36 @@ scope_definitions = { 'access:services': { 'description': 'Access services via API or browser.', }, + 'users:shares': { + 'description': "Read and revoke a user's access to shared servers.", + 'subscopes': [ + 'read:users:shares', + ], + }, + 'read:users:shares': { + 'description': "Read servers shared with a user.", + }, + 'groups:shares': { + 'description': "Read and revoke a group's access to shared servers.", + 'subscopes': [ + 'read:groups:shares', + ], + }, + 'read:groups:shares': { + 'description': "Read servers shared with a group.", + }, + 'read:shares': { + 'description': "Read information about shared access to servers.", + }, + 'shares': { + 'description': "Manage access to shared servers.", + 'subscopes': [ + 'access:servers', + 'read:shares', + 'users:shares', + 'groups:shares', + ], + }, 'proxy': { 'description': 'Read information about the proxy’s routing table, sync the Hub with the proxy and notify the Hub about a new proxy.' }, @@ -178,7 +208,6 @@ def _intersect_expanded_scopes(scopes_a, scopes_b, db=None): Otherwise, it can result in lower than intended permissions, (i.e. users!group=x & users!user=y will be empty, even if user y is in group x.) """ - empty_set = frozenset() scopes_a = frozenset(scopes_a) scopes_b = frozenset(scopes_b) @@ -189,11 +218,12 @@ def _intersect_expanded_scopes(scopes_a, scopes_b, db=None): # if we need a group lookup, the result is not cacheable nonlocal needs_db needs_db = True - user = db.query(orm.User).filter_by(name=username).first() - if user is None: - return empty_set - else: - return {group.name for group in user.groups} + group_query = ( + db.query(orm.Group.name) + .join(orm.User.groups) + .filter(orm.User.name == username) + ) + return {row[0] for row in group_query} @lru_cache() def groups_for_server(server): @@ -348,6 +378,9 @@ def get_scopes_for(orm_object): owner = orm_object.user or orm_object.service owner_roles = roles.get_roles_for(owner) owner_scopes = roles.roles_to_expanded_scopes(owner_roles, owner) + if owner is orm_object.user: + for share in owner.shared_with_me: + owner_scopes |= frozenset(share.scopes) token_scopes = set(orm_object.scopes) if 'inherit' in token_scopes: @@ -401,12 +434,28 @@ def get_scopes_for(orm_object): roles.get_roles_for(orm_object), owner=orm_object, ) - if isinstance(orm_object, (orm.User, orm.Service)): - owner = orm_object + + # add permissions granted from 'shares' + if hasattr(orm_object, "shared_with_me"): + for share in orm_object.shared_with_me: + expanded_scopes |= expand_share_scopes(share) + if isinstance(orm_object, orm.User): + for group in orm_object.groups: + for share in group.shared_with_me: + expanded_scopes |= expand_share_scopes(share) return expanded_scopes +def expand_share_scopes(share): + """Get expanded scopes for a Share""" + return expand_scopes( + share.scopes, + owner=share.user or share.group, + oauth_client=share.spawner.oauth_client, + ) + + @lru_cache() def _expand_self_scope(username): """ @@ -431,6 +480,9 @@ def _expand_self_scope(username): 'read:users', 'read:users:name', 'read:users:groups', + 'users:shares', + 'read:users:shares', + 'read:shares', 'users:activity', 'read:users:activity', 'servers', @@ -647,68 +699,112 @@ def _resolve_requested_scopes(requested_scopes, have_scopes, user, client, db): return (allowed_scopes, disallowed_scopes) -def _needs_scope_expansion(filter_, filter_value, sub_scope): +def _needs_group_expansion(filter_, filter_value, sub_scope): """ Check if there is a requirements to expand the `group` scope to individual `user` scopes. Assumptions: filter_ != Scope.ALL """ - if not (filter_ == 'user' and 'group' in sub_scope): + if not (filter_ in {'user', 'server'} and 'group' in sub_scope): return False - if 'user' in sub_scope: - return filter_value not in sub_scope['user'] + if filter_ in sub_scope: + return filter_value not in sub_scope[filter_] else: return True -def _check_user_in_expanded_scope(handler, user_name, scope_group_names): - """Check if username is present in set of allowed groups""" - user = handler.find_user(user_name) - if user is None: - raise web.HTTPError(404, "No access to resources or resources not found") - group_names = {group.name for group in user.groups} - return bool(set(scope_group_names) & group_names) +def _has_scope_key(scope, have_scopes, *, post_filter=False, db=None): + """Cache key for has_scope""" + if isinstance(have_scopes, dict): + have_scopes = FrozenDict(have_scopes) + else: + have_scopes = frozenset(have_scopes) + return (scope, have_scopes, post_filter) -def _check_scope_access(api_handler, req_scope, **kwargs): - """Check if scopes satisfy requirements - Returns True for (potentially restricted) access, False for refused access +@lru_cache_key(_has_scope_key) +def has_scope(scope, have_scopes, *, post_filter=False, db=None): + """Boolean function for whether we have a given scope + + Args: + scope (str): a single scope + have_scopes: parsed_scopes dict or expanded_scopes set + post_filter (bool): + Allows returning true if _some_ access is granted, + if not full access. + Only allowed if scope has no filter + db (optional): the db session + Required to check group membership, + unused otherwise + Returns: + True if access is allowed, False otherwise. + If post_filer is True and have_scopes contains _filtered_ access, + will return True, assuming filtered-access will be handled later + (e.g. in the listing-users handler) """ - # Parse user name and server name together - try: - api_name = api_handler.request.path - except AttributeError: - api_name = type(api_handler).__name__ - if 'user' in kwargs and 'server' in kwargs: - kwargs['server'] = "{}/{}".format(kwargs['user'], kwargs['server']) - if req_scope not in api_handler.parsed_scopes: - app_log.debug("No access to %s via %s", api_name, req_scope) - return False - if api_handler.parsed_scopes[req_scope] == Scope.ALL: - app_log.debug("Unrestricted access to %s via %s", api_name, req_scope) - return True - # Apply filters - sub_scope = api_handler.parsed_scopes[req_scope] - if not kwargs: - app_log.debug( - "Client has restricted access to %s via %s. Internal filtering may apply", - api_name, - req_scope, + req_scope, _, full_filter = scope.partition("!") + filter_, _, filter_value = full_filter.partition("=") + if filter_ and not filter_value: + raise ValueError( + f"Unexpanded scope filter {scope} not allowed. Use expanded scopes." ) + + if isinstance(have_scopes, dict): + parsed_scopes = have_scopes + else: + parsed_scopes = parse_scopes(have_scopes) + + if req_scope not in parsed_scopes: + return False + have_scope_filters = parsed_scopes[req_scope] + if have_scope_filters == Scope.ALL: + # access to all resources return True - for filter_, filter_value in kwargs.items(): - if filter_ in sub_scope and filter_value in sub_scope[filter_]: - app_log.debug("Argument-based access to %s via %s", api_name, req_scope) + + if not filter_: + if post_filter: + # allow filtering after the fact return True - if _needs_scope_expansion(filter_, filter_value, sub_scope): - group_names = sub_scope['group'] - if _check_user_in_expanded_scope(api_handler, filter_value, group_names): - app_log.debug("Restricted client access supported with group expansion") - return True - app_log.debug( - "Client access refused; filters do not match API endpoint %s request" % api_name - ) - raise web.HTTPError(404, "No access to resources or resources not found") + else: + return False + + if post_filter: + raise ValueError("post_filter=True only allowed for unfiltered scopes") + _db_used = False + + if filter_ in have_scope_filters and filter_value in have_scope_filters[filter_]: + return True + + # server->user + if filter_ == "server" and "user" in have_scope_filters: + user_name = filter_value.partition("/")[0] + if user_name in have_scope_filters["user"]: + return True + + if db and _needs_group_expansion(filter_, filter_value, have_scope_filters): + _db_used = True + if filter_ == "user": + user_name = filter_value + elif filter_ == "server": + user_name = filter_value.partition("/")[0] + else: + raise ValueError( + f"filter_ should be 'user' or 'server' here, not {filter_!r}" + ) + group_names = have_scope_filters['group'] + have_group_query = ( + db.query(orm.Group.name) + .join(orm.User.groups) + .filter(orm.User.name == user_name) + .filter(orm.Group.name.in_(group_names)) + ) + if have_group_query.count() > 0: + return DoNotCache(True) + + if _db_used: + return DoNotCache(False) + else: + return False def _check_scopes_exist(scopes, who_for=None): @@ -813,6 +909,8 @@ def parse_scopes(scope_list): if parsed_scopes[base_scope] != Scope.ALL: key, _, value = filter_.partition('=') + if not value: + raise ValueError(f"Empty string is not a valid filter: {scope}") if key not in parsed_scopes[base_scope]: parsed_scopes[base_scope][key] = {value} else: @@ -884,21 +982,53 @@ def needs_scope(*scopes): if resource_name in bound_sig.arguments: resource_value = bound_sig.arguments[resource_name] s_kwargs[resource] = resource_value + + if "server" in s_kwargs: + # merge user_name, server_name into server=user/server + if "user" not in s_kwargs: + raise ValueError( + "Cannot filter on 'server_name' without 'user_name'" + ) + s_kwargs["server"] = f"{s_kwargs['user']}/{s_kwargs['server']}" + s_kwargs.pop("user") + if len(s_kwargs) > 1: + raise ValueError( + f"Cannot filter on more than one field, got {s_kwargs}" + ) + elif s_kwargs: + filter_, filter_value = next(iter(s_kwargs.items())) + else: + filter_ = filter_value = None + for scope in scopes: + if filter_ is not None: + scope = f"{scope}!{filter_}={filter_value}" app_log.debug("Checking access to %s via scope %s", end_point, scope) - has_access = _check_scope_access(self, scope, **s_kwargs) + has_access = has_scope( + scope, + self.parsed_scopes, + post_filter=filter_ is None, + db=self.db, + ) if has_access: return func(self, *args, **kwargs) app_log.warning( - "Not authorizing access to {}. Requires any of [{}], not derived from scopes [{}]".format( - end_point, ", ".join(scopes), ", ".join(self.expanded_scopes) - ) + "Not authorizing access to %s. Requires any of [%s] on %s, not derived from scopes [%s]", + end_point, + ", ".join(scopes), + "*" if filter_ is None else f"{filter_}={filter_value}", + ", ".join(self.expanded_scopes), ) + if filter_ and any(scope in self.parsed_scopes for scope in scopes): + # not allowed due do filtered access, + # same error for nonexistence as missing permission + raise web.HTTPError( + 404, "No access to resources or resources not found" + ) raise web.HTTPError( 403, - "Action is not authorized with current scopes; requires any of [{}]".format( - ", ".join(scopes) - ), + "Action is not authorized with current scopes;" + f" requires any of [{', '.join(scopes)}]", ) return _auth_func @@ -939,17 +1069,28 @@ def identify_scopes(obj=None): raise TypeError(f"Expected orm.User or orm.Service, got {obj!r}") -@lru_cache_key(lambda oauth_client: oauth_client.identifier) -def access_scopes(oauth_client): +def _access_cache_key(oauth_client=None, *, spawner=None, service=None): + if oauth_client: + return ("oauth", oauth_client.identifier) + elif spawner: + return ("spawner", spawner.user.name, spawner.name) + elif service: + return ("service", service.name) + + +@lru_cache_key(_access_cache_key) +def access_scopes(oauth_client=None, *, spawner=None, service=None): """Return scope(s) required to access an oauth client""" scopes = set() - if oauth_client.identifier == "jupyterhub": + if oauth_client and oauth_client.identifier == "jupyterhub": return frozenset() - spawner = oauth_client.spawner + if spawner is None and oauth_client: + spawner = oauth_client.spawner if spawner: scopes.add(f"access:servers!server={spawner.user.name}/{spawner.name}") else: - service = oauth_client.service + if service is None: + service = oauth_client.service if service: scopes.add(f"access:services!service={service.name}") else: @@ -1009,7 +1150,6 @@ def describe_parsed_scopes(parsed_scopes, username=None): """ descriptions = [] for scope, filters in parsed_scopes.items(): - base_text = scope_definitions[scope]["description"] if filters == Scope.ALL: # no filter filter_text = "" @@ -1019,9 +1159,6 @@ def describe_parsed_scopes(parsed_scopes, username=None): if kind == 'user' and names == {username}: filter_chunks.append("only you") else: - kind_text = kind - if kind == 'group': - kind_text = "users in group" if len(names) == 1: filter_chunks.append(f"{kind}: {list(names)[0]}") else: @@ -1046,7 +1183,6 @@ def describe_raw_scopes(raw_scopes, username=None): descriptions = [] for raw_scope in raw_scopes: scope, _, filter_ = raw_scope.partition("!") - base_text = scope_definitions[scope]["description"] if not filter_: # no filter filter_text = "" diff --git a/jupyterhub/singleuser/extension.py b/jupyterhub/singleuser/extension.py index c2c684f1..4fe2d1dd 100644 --- a/jupyterhub/singleuser/extension.py +++ b/jupyterhub/singleuser/extension.py @@ -225,6 +225,7 @@ class JupyterHubIdentityProvider(IdentityProvider): user = handler.current_user # originally implemented in jupyterlab's LabApp page_config["hubUser"] = user.name if user else "" + page_config["hubServerUser"] = os.environ.get("JUPYTERHUB_USER", "") page_config["hubPrefix"] = hub_prefix = self.hub_auth.hub_prefix page_config["hubHost"] = self.hub_auth.hub_host page_config["shareUrl"] = url_path_join(hub_prefix, "user-redirect") diff --git a/jupyterhub/singleuser/mixins.py b/jupyterhub/singleuser/mixins.py index 5fb55c05..7fb46167 100755 --- a/jupyterhub/singleuser/mixins.py +++ b/jupyterhub/singleuser/mixins.py @@ -713,6 +713,7 @@ class SingleUserNotebookAppMixin(Configurable): Only has effect on jupyterlab_server >=2.9 """ page_config["token"] = self.hub_auth.get_token(handler) or "" + page_config["hubServerUser"] = os.environ.get("JUPYTERHUB_USER", "") return page_config def patch_default_headers(self): diff --git a/jupyterhub/tests/browser/test_browser.py b/jupyterhub/tests/browser/test_browser.py index 9963e68e..00f2e283 100644 --- a/jupyterhub/tests/browser/test_browser.py +++ b/jupyterhub/tests/browser/test_browser.py @@ -17,8 +17,10 @@ from jupyterhub.utils import url_escape_path, url_path_join pytestmark = pytest.mark.browser -async def login(browser, username, password): - """filling the login form by user and pass_w parameters and iniate the login""" +async def login(browser, username, password=None): + """filling the login form by user and pass_w parameters and initiate the login""" + if password is None: + password = username await browser.get_by_label("Username:").click() await browser.get_by_label("Username:").fill(username) diff --git a/jupyterhub/tests/browser/test_share.py b/jupyterhub/tests/browser/test_share.py new file mode 100644 index 00000000..9d86f757 --- /dev/null +++ b/jupyterhub/tests/browser/test_share.py @@ -0,0 +1,64 @@ +import re + +import pytest +from playwright.async_api import expect +from tornado.httputil import url_concat + +from jupyterhub.utils import url_path_join + +from ..conftest import new_username +from ..utils import add_user, api_request, public_host +from .test_browser import login + +pytestmark = pytest.mark.browser + + +async def test_share_code_flow_full(app, browser, full_spawn, create_user_with_scopes): + share_user = add_user(app.db, name=new_username("share_with")) + user = create_user_with_scopes( + "shares!user", "self", f"read:users:name!user={share_user.name}" + ) + # start server + await user.spawn("") + await app.proxy.add_user(user) + spawner = user.spawner + + # issue_code + share_url = f"share-codes/{user.name}/{spawner.name}" + r = await api_request( + app, + share_url, + method="post", + name=user.name, + ) + r.raise_for_status() + share_model = r.json() + print(share_model) + assert "code" in share_model + code = share_model["code"] + + # visit share page + accept_share_url = url_path_join(public_host(app), app.hub.base_url, "accept-share") + accept_share_url = url_concat(accept_share_url, {"code": code}) + await browser.goto(accept_share_url) + # wait for login + await expect(browser).to_have_url(re.compile(r".*/login")) + # login + await login(browser, share_user.name) + # back to accept-share page + await expect(browser).to_have_url(re.compile(r".*/accept-share")) + + header_text = await browser.locator("//h2").first.text_content() + assert f"access {user.name}'s server" in header_text + assert f"You ({share_user.name})" in header_text + # TODO verify form + submit = browser.locator('//input[@type="submit"]') + await submit.click() + + # redirects to server, which triggers oauth approval + await expect(browser).to_have_url(re.compile(r".*/oauth2/authorize")) + submit = browser.locator('//input[@type="submit"]') + await submit.click() + + # finally, we are at the server! + await expect(browser).to_have_url(re.compile(f".*/user/{user.name}/.*")) diff --git a/jupyterhub/tests/conftest.py b/jupyterhub/tests/conftest.py index c94dff54..b0f215fc 100644 --- a/jupyterhub/tests/conftest.py +++ b/jupyterhub/tests/conftest.py @@ -30,7 +30,6 @@ import asyncio import copy import os import sys -from getpass import getuser from subprocess import TimeoutExpired from unittest import mock @@ -42,7 +41,13 @@ from tornado.platform.asyncio import AsyncIOMainLoop import jupyterhub.services.service from .. import crypto, orm, scopes -from ..roles import create_role, get_default_roles, mock_roles, update_roles +from ..roles import ( + assign_default_roles, + create_role, + get_default_roles, + mock_roles, + update_roles, +) from ..utils import random_port from . import mocking from .mocking import MockHub @@ -104,18 +109,18 @@ def auth_state_enabled(app): @fixture def db(): """Get a db session""" - global _db - if _db is None: - # make sure some initial db contents are filled out - # specifically, the 'default' jupyterhub oauth client - app = MockHub(db_url='sqlite:///:memory:') - app.init_db() - _db = app.db - for role in get_default_roles(): - create_role(_db, role) - user = orm.User(name=getuser()) - _db.add(user) - _db.commit() + # make sure some initial db contents are filled out + # specifically, the 'default' jupyterhub oauth client + app = MockHub(db_url='sqlite:///:memory:') + app.init_db() + _db = app.db + for role in get_default_roles(): + create_role(_db, role) + user = orm.User(name="user") + _db.add(user) + _db.commit() + assign_default_roles(_db, user) + _db.commit() return _db @@ -181,10 +186,16 @@ async def cleanup_after(request, io_loop): print(f"Stopping leftover server {spawner._log_name}") await user.stop(name) if user.name not in {'admin', 'user'}: + app.log.debug(f"Deleting test user {user.name}") app.users.delete(user.id) # delete groups for group in app.db.query(orm.Group): + app.log.debug(f"Deleting test group {group.name}") app.db.delete(group) + # delete shares + for share in app.db.query(orm.Share): + app.log.debug(f"Deleting test share {share}") + app.db.delete(share) # clear services for name, service in app._service_map.items(): diff --git a/jupyterhub/tests/test_named_servers.py b/jupyterhub/tests/test_named_servers.py index 4d5219c4..d431568b 100644 --- a/jupyterhub/tests/test_named_servers.py +++ b/jupyterhub/tests/test_named_servers.py @@ -77,6 +77,7 @@ async def test_default_server(app, named_servers): 'server': user.url, 'servers': { '': { + 'full_name': f"{username}/", 'name': '', 'started': TIMESTAMP, 'last_activity': TIMESTAMP, @@ -165,6 +166,7 @@ async def test_create_named_server( 'auth_state': None, 'servers': { servername: { + 'full_name': f"{username}/{servername}", 'name': name, 'started': TIMESTAMP, 'last_activity': TIMESTAMP, diff --git a/jupyterhub/tests/test_orm.py b/jupyterhub/tests/test_orm.py index 577f07e5..5e7e7586 100644 --- a/jupyterhub/tests/test_orm.py +++ b/jupyterhub/tests/test_orm.py @@ -185,7 +185,9 @@ def test_service_server(db): def test_token_find(db): - service = db.query(orm.Service).first() + service = orm.Service(name='sample') + db.add(service) + db.commit() user = db.query(orm.User).first() service_token = service.new_api_token() user_token = user.new_api_token() @@ -238,7 +240,7 @@ async def test_spawn_fails(db): def test_groups(db): - user = orm.User.find(db, name='aeofel') + user = orm.User(name='aeofel') db.add(user) group = orm.Group(name='lives') @@ -496,6 +498,71 @@ def test_group_delete_cascade(db): assert user1 not in group1.users +def test_share_user(db): + user1 = orm.User(name='user1') + user2 = orm.User(name='user2') + spawner = orm.Spawner(user=user1) + db.add(user1) + db.add(user2) + db.add(spawner) + db.commit() + + share = orm.Share( + owner=user1, + spawner=spawner, + user=user2, + ) + db.add(share) + db.commit() + assert user1.shares == [share] + assert spawner.shares == [share] + assert user1.shared_with_me == [] + assert user2.shared_with_me == [share] + db.delete(share) + db.commit() + assert user1.shares == [] + assert spawner.shares == [] + assert user1.shared_with_me == [] + assert user2.shared_with_me == [] + + +def test_share_group(db): + initial_list = list(db.query(orm.User)) + assert len(initial_list) <= 1 + user1 = orm.User(name='user1') + user2 = orm.User(name='user2') + group2 = orm.Group(name='group2') + spawner = orm.Spawner(user=user1) + db.add(user1) + db.add(user2) + db.add(group2) + db.add(spawner) + db.commit() + group2.users.append(user2) + db.commit() + share = orm.Share( + owner=user1, + spawner=spawner, + group=group2, + ) + db.add(share) + db.commit() + assert user1.shares == [share] + assert spawner.shares == [share] + assert user1.shared_with_me == [] + assert user2.shared_with_me == [] + assert group2.shared_with_me == [share] + assert user2.all_shared_with_me == [share] + db.delete(share) + db.commit() + assert user1.shares == [] + assert spawner.shares == [] + assert user1.shared_with_me == [] + assert user2.shared_with_me == [] + assert group2.shared_with_me == [] + assert user2.all_shared_with_me == [] + + def test_expiring_api_token(app, user): db = app.db token = orm.APIToken.new(expires_in=30, user=user) diff --git a/jupyterhub/tests/test_pages.py b/jupyterhub/tests/test_pages.py index 742084e1..52a42d4d 100644 --- a/jupyterhub/tests/test_pages.py +++ b/jupyterhub/tests/test_pages.py @@ -764,7 +764,7 @@ async def test_login_strip(app, form_user, auth_user, form_password): (False, '', '', None), # next_url is respected (False, '/hub/admin', '/hub/admin', None), - (False, '/user/other', '/hub/user/other', None), + (False, '/user/other', '/user/other', None), (False, '/absolute', '/absolute', None), (False, '/has?query#andhash', '/has?query#andhash', None), # :// in query string or fragment diff --git a/jupyterhub/tests/test_roles.py b/jupyterhub/tests/test_roles.py index 28e4b20f..5e0ccead 100644 --- a/jupyterhub/tests/test_roles.py +++ b/jupyterhub/tests/test_roles.py @@ -21,17 +21,10 @@ from .utils import add_user, api_request def test_orm_roles(db): """Test orm roles setup""" user_role = orm.Role.find(db, name='user') + user_role.users = [] token_role = orm.Role.find(db, name='token') - service_role = orm.Role.find(db, name='service') - if not user_role: - user_role = orm.Role(name='user', scopes=['self']) - db.add(user_role) - if not token_role: - token_role = orm.Role(name='token', scopes=['inherit']) - db.add(token_role) - if not service_role: - service_role = orm.Role(name='service', scopes=[]) - db.add(service_role) + service_role = orm.Role(name="service") + db.add(service_role) db.commit() group_role = orm.Role(name='group', scopes=['read:users']) @@ -376,9 +369,10 @@ async def test_creating_roles(app, role, role_def, response_type, response, capl ('default', 'user', 'error', ValueError), ], ) -async def test_delete_roles(db, role_type, rolename, response_type, response, caplog): +async def test_delete_roles(app, role_type, rolename, response_type, response, caplog): """Test raising errors and info when deleting roles""" caplog.set_level(logging.INFO) + db = app.db if response_type == 'info': # add the role to db diff --git a/jupyterhub/tests/test_scopes.py b/jupyterhub/tests/test_scopes.py index c1a29c35..ba2bfae0 100644 --- a/jupyterhub/tests/test_scopes.py +++ b/jupyterhub/tests/test_scopes.py @@ -1,24 +1,24 @@ """Test scopes for API handlers""" +import types from operator import itemgetter from unittest import mock import pytest from pytest import mark from tornado import web -from tornado.httputil import HTTPServerRequest from .. import orm, roles, scopes from .._memoize import FrozenDict -from ..handlers import BaseHandler +from ..apihandlers import APIHandler from ..scopes import ( Scope, - _check_scope_access, _expand_self_scope, _intersect_expanded_scopes, _resolve_requested_scopes, expand_scopes, get_scopes_for, + has_scope, identify_scopes, needs_scope, parse_scopes, @@ -27,7 +27,8 @@ from .utils import add_user, api_request, auth_header def get_handler_with_scopes(scopes): - handler = mock.Mock(spec=BaseHandler) + handler = mock.Mock(spec=APIHandler) + handler.has_scope = types.MethodType(APIHandler.has_scope, handler) handler.parsed_scopes = parse_scopes(scopes) return handler @@ -56,47 +57,39 @@ def test_scope_precendence(): def test_scope_check_present(): handler = get_handler_with_scopes(['read:users']) - assert _check_scope_access(handler, 'read:users') - assert _check_scope_access(handler, 'read:users', user='maeby') + assert handler.has_scope('read:users') + assert handler.has_scope('read:users!user=maeby') def test_scope_check_not_present(): handler = get_handler_with_scopes(['read:users!user=maeby']) - assert _check_scope_access(handler, 'read:users') - with pytest.raises(web.HTTPError): - _check_scope_access(handler, 'read:users', user='gob') - with pytest.raises(web.HTTPError): - _check_scope_access(handler, 'read:users', user='gob', server='server') + assert not handler.has_scope('read:users') + assert not handler.has_scope('read:users!user=gob') + assert not handler.has_scope('read:users!server=gob/server') def test_scope_filters(): handler = get_handler_with_scopes( ['read:users', 'read:users!group=bluths', 'read:users!user=maeby'] ) - assert _check_scope_access(handler, 'read:users', group='bluth') - assert _check_scope_access(handler, 'read:users', user='maeby') - - -def test_scope_multiple_filters(): - handler = get_handler_with_scopes(['read:users!user=george_michael']) - assert _check_scope_access( - handler, 'read:users', user='george_michael', group='bluths' - ) + assert handler.has_scope('read:users!group=bluth') + assert handler.has_scope('read:users!user=maeby') def test_scope_parse_server_name(): handler = get_handler_with_scopes( ['servers!server=maeby/server1', 'read:users!user=maeby'] ) - assert _check_scope_access(handler, 'servers', user='maeby', server='server1') + assert handler.has_scope('servers!server=maeby/server1') class MockAPIHandler: def __init__(self): self.expanded_scopes = {'users'} self.parsed_scopes = {} - self.request = mock.Mock(spec=HTTPServerRequest) + self.request = mock.Mock(spec=APIHandler) self.request.path = '/path' + self.db = None def set_scopes(self, *scopes): self.expanded_scopes = set(scopes) @@ -295,7 +288,7 @@ async def test_exceeding_user_permissions( api_token = user.new_api_token() orm_api_token = orm.APIToken.find(app.db, token=api_token) # store scopes user does not have - orm_api_token.scopes = orm_api_token.scopes + ['list:users', 'read:users'] + orm_api_token.scopes = list(orm_api_token.scopes) + ['list:users', 'read:users'] headers = {'Authorization': 'token %s' % api_token} r = await api_request(app, 'users', headers=headers) assert r.status_code == 200 @@ -490,7 +483,7 @@ async def test_metascope_inherit_expansion(app, create_user_with_scopes): assert user_scope_set == token_scope_set # Check no roles means no permissions - token.scopes.clear() + token.scopes = [] app.db.commit() token_scope_set = get_scopes_for(token) assert isinstance(token_scope_set, frozenset) @@ -556,7 +549,7 @@ async def test_server_state_access( await api_request( app, 'users', user.name, 'servers', server_name, method='post' ) - service = create_service_with_scopes("read:users:name!user=", *scopes) + service = create_service_with_scopes("read:users:name!user=bianca", *scopes) api_token = service.new_api_token() headers = {'Authorization': 'token %s' % api_token} @@ -1309,3 +1302,64 @@ def test_resolve_requested_scopes( ) assert allowed == expected_allowed assert disallowed == expected_disallowed + + +@pytest.mark.parametrize( + "scope, have_scopes, ok", + [ + # exact matches + ("read:users", "read:users", True), + ("read:users!user=USER", "read:users!user=USER", True), + ("read:servers!server=USER/x", "read:servers!server=USER/x", True), + ("read:groups!group=GROUP", "read:groups!group=GROUP", True), + # subscopes + ("read:users:name", "read:users", True), + # subscopes with matching filter + ("read:users:name!user=USER", "read:users!user=USER", True), + ("read:users!user=USER", "read:users!group=GROUP", True), + ("read:users!user=USER", "read:users", True), + ("read:servers!server=USER/x", "read:servers", True), + ("read:servers!server=USER/x", "servers!server=USER/x", True), + ("read:servers!server=USER/x", "servers!user=USER", True), + ("read:servers!server=USER/x", "servers!group=GROUP", True), + # shouldn't match + ("read:users", "read:users!user=USER", False), + ("read:users", "read:users!user=USER", False), + ("read:users!user=USER", "read:users!user=other", False), + ("read:users!user=USER", "read:users!group=other", False), + ("read:servers!server=USER/x", "servers!server=other/x", False), + ("read:servers!server=USER/x", "servers!user=other", False), + ("read:servers!server=USER/x", "servers!group=other", False), + ("servers!server=USER/x", "read:servers!server=USER/x", False), + ], +) +def test_has_scope(app, user, group, scope, have_scopes, ok): + db = app.db + user.groups.append(group) + db.commit() + + def _sub(scope): + return scope.replace("GROUP", group.name).replace("USER", user.name) + + scope = _sub(scope) + have_scopes = [_sub(s) for s in have_scopes.split(",")] + parsed_scopes = parse_scopes(expand_scopes(have_scopes)) + assert has_scope(scope, parsed_scopes, db=db) == ok + + +@pytest.mark.parametrize( + "scope, have_scopes, ok", + [ + ("read:users", "read:users", True), + ("read:users", "read:users!user=x", True), + ("read:users", "read:groups!user=x", False), + ("read:users!user=x", "read:users!group=y", ValueError), + ], +) +def test_has_scope_post_filter(scope, have_scopes, ok): + have_scopes = have_scopes.split(",") + if ok not in (True, False): + with pytest.raises(ok): + has_scope(scope, have_scopes, post_filter=True) + else: + assert has_scope(scope, have_scopes, post_filter=True) == ok diff --git a/jupyterhub/tests/test_shares.py b/jupyterhub/tests/test_shares.py new file mode 100644 index 00000000..db12757f --- /dev/null +++ b/jupyterhub/tests/test_shares.py @@ -0,0 +1,1487 @@ +import json +from datetime import timedelta +from functools import partial +from unittest import mock +from urllib.parse import parse_qs, urlparse, urlunparse + +import pytest +from bs4 import BeautifulSoup +from dateutil.parser import parse as parse_date +from tornado.httputil import url_concat + +from jupyterhub import orm, scopes +from jupyterhub.utils import url_path_join, utcnow + +from .conftest import new_group_name, new_username +from .utils import add_user, api_request, async_requests, get_page, public_url + + +@pytest.fixture +def share_user(app): + """The user to be shared with""" + yield add_user(app.db, name=new_username("share_with")) + + +@pytest.fixture +def share(app, user, share_user): + """Share access to `user`'s default server with `share_user`""" + db = app.db + spawner = user.spawner.orm_spawner + owner = user.orm_user + filter_ = f"server={owner.name}/{spawner.name}" + scopes = [f"access:servers!{filter_}"] + yield orm.Share.grant(db, spawner, share_user, scopes=scopes) + + +@pytest.fixture +def group_share(app, user, group, share_user): + """Share with share_user via group membership""" + db = app.db + app.db.commit() + spawner = user.spawner.orm_spawner + owner = user.orm_user + filter_ = f"server={owner.name}/{spawner.name}" + scopes = [f"read:servers!{filter_}"] + group.users.append(share_user) + yield orm.Share.grant(db, spawner, group, scopes=scopes) + + +@pytest.fixture +def populate_shares(app, user, group, share_user): + group_a = orm.Group(name=new_group_name("a")) + group_b = orm.Group(name=new_group_name("b")) + group_c = orm.Group(name=new_group_name("c")) + app.db.add(group_a) + app.db.add(group_b) + app.db.add(group_c) + app.db.commit() + in_a = add_user(app.db, name=new_username("in-a")) + in_a_b = add_user(app.db, name=new_username("in-a-b")) + in_b = add_user(app.db, name=new_username("in-b")) + not_in = add_user(app.db, name=new_username("not-in")) + + group_a.users = [in_a, in_a_b] + group_b.users = [in_b, in_a_b] + app.db.commit() + + user_1 = add_user(app.db, name=new_username("server")) + user_2 = add_user(app.db, name=new_username("server")) + user_3 = add_user(app.db, name=new_username("server")) + user_4 = add_user(app.db, name=new_username("server")) + + # group a has access to user_1 + # group b has access to user_2 + # both groups have access to user_3 + # user in-a also has access to user_4 + orm.Share.grant( + app.db, + app.users[user_1].spawner.orm_spawner, + group_a, + ) + orm.Share.grant( + app.db, + app.users[user_2].spawner.orm_spawner, + group_b, + ) + orm.Share.grant( + app.db, + app.users[user_3].spawner.orm_spawner, + group_a, + ) + orm.Share.grant( + app.db, + app.users[user_3].spawner.orm_spawner, + group_b, + ) + orm.Share.grant( + app.db, + app.users[user_4].spawner.orm_spawner, + in_a, + ) + orm.Share.grant( + app.db, + app.users[user_4].spawner.orm_spawner, + not_in, + ) + + # return a dict showing who should have access to what + return { + "users": { + in_a.name: [user_1.name, user_3.name, user_4.name], + in_b.name: [user_2.name, user_3.name], + # shares are _not_ deduplicated if granted + # both via user and group + in_a_b.name: [user_1.name, user_2.name, user_3.name, user_3.name], + not_in.name: [user_4.name], + }, + "groups": { + group_a.name: [user_1.name, user_3.name], + group_b.name: [user_2.name, user_3.name], + group_c.name: [], + }, + } + + +def expand_scopes(scope_str, user, group=None, share_with=None): + """utility to expand scopes used in parametrized tests + + Turns "read:users!user=USER,shares!group=SHARE_WITH" + into + [ + "read:users!user=username", + "shares!group=groupname", + ] + """ + scopes = [] + replacements = {} + + def _get_name(str_or_obj): + """Allow a string name or an object with a name attribute + + string names are used for tests where something doesn't exist + """ + if isinstance(str_or_obj, str): + return str_or_obj + else: + return str_or_obj.name + + username = _get_name(user) + replacements["USER"] = username + replacements["SERVER"] = username + "/" + if group: + replacements["GROUP"] = _get_name(group) + if share_with: + replacements["SHARE_WITH"] = _get_name(share_with) + for scope in scope_str.split(","): + for a, b in replacements.items(): + scope = scope.replace(a, b) + scopes.append(scope) + return scopes + + +@pytest.mark.parametrize("share_with", ["user", "group"]) +def test_create_share(app, user, share_user, group, share_with): + db = app.db + spawner = user.spawner.orm_spawner + owner = user.orm_user + share_attr = share_with + if share_with == "group": + share_with = group + elif share_with == "user": + share_with = share_user + scopes = [f"access:servers!server={owner.name}/{spawner.name}"] + before = orm.Share.now() + share = orm.Share.grant(db, spawner, share_with, scopes=scopes) + after = orm.Share.now() + assert share.scopes == scopes + assert share.owner is owner + assert share.spawner is spawner + assert getattr(share, share_attr) is share_with + assert share.created_at + assert before <= share.created_at <= after + assert share in share_with.shared_with_me + assert share in spawner.shares + assert share in owner.shares + if share_attr == 'user': + assert share not in share_with.shares + assert share not in owner.shared_with_me + # compute repr for coverage + repr(share) + db.delete(share_with) + db.commit() + + +def test_create_share_bad(app, user, share_user, mockservice): + db = app.db + service = mockservice + spawner = user.spawner.orm_spawner + owner = user.orm_user + scopes = [f"access:servers!server={owner.name}/{spawner.name}"] + with pytest.raises(ValueError): + orm.Share.grant(db, spawner, share_user, scopes=[]) + with pytest.raises(TypeError): + orm.Share.grant(db, spawner, service, scopes=scopes) + + +def test_update_share(app, share): + db = app.db + # shift into past + share.created_at -= timedelta(hours=1) + created_at = share.created_at + db.commit() + + # grant additional scopes + filter = f"server={share.owner.name}/{share.spawner.name}" + more_scopes = [f"read:servers!{filter}"] + all_scopes = sorted(share.scopes + more_scopes) + + share2 = orm.Share.grant(db, share.spawner, share.user, scopes=more_scopes) + assert share2 is share + assert share.created_at == created_at + assert share.scopes == all_scopes + + # fully overlapping scopes + share3 = orm.Share.grant(db, share.spawner, share.user, scopes=share.scopes[:1]) + assert share3 is share + assert share.created_at == created_at + assert share.scopes == all_scopes + + # revoke scopes not held + share4 = orm.Share.revoke( + db, + share.spawner, + share.user, + scopes=[f"admin:servers!{filter}"], + ) + assert share4 is share + assert share.created_at == created_at + assert share.scopes == all_scopes + + # revoke some scopes + share5 = orm.Share.revoke( + db, + share.spawner, + share.user, + scopes=all_scopes[:1], + ) + remaining_scopes = all_scopes[1:] + assert share5 is share + assert share.created_at == created_at + assert share.scopes == remaining_scopes + + # revoke remaining scopes + share5 = orm.Share.revoke( + db, + share.spawner, + share.user, + scopes=remaining_scopes, + ) + assert share5 is None + found_share = orm.Share.find(db, spawner=share.spawner, share_with=share.user) + assert found_share is None + + +@pytest.mark.parametrize( + "to_delete", + [ + "owner", + "spawner", + "share_with_user", + "share_with_group", + ], +) +def test_share_delete_cascade(to_delete, app, user, group): + db = app.db + if "group" in to_delete: + share_with = group + else: + share_with = add_user(db, app, name=new_username("share_with")).orm_user + spawner = user.spawner.orm_spawner + owner = user.orm_user + scopes = [f"access:servers!server={owner.name}/{spawner.name}"] + assert spawner.name is not None + assert spawner.user.name + assert share_with.name + share = orm.Share.grant(db, spawner, share_with, scopes=scopes) + assert share in share_with.shared_with_me + share_id = share.id + if to_delete == "owner": + app.users.delete(owner) + assert share_with.shared_with_me == [] + elif to_delete == "spawner": + # pass + db.delete(spawner) + user.spawners.pop(spawner.name) + db.commit() + assert owner.shares == [] + assert share_with.shared_with_me == [] + elif to_delete == "share_with_user": + app.users.delete(share_with) + assert owner.shares == [] + assert spawner.shares == [] + elif to_delete == "share_with_group": + db.delete(share_with) + db.commit() + assert owner.shares == [] + assert spawner.shares == [] + else: + raise ValueError(f"unexpected {to_delete=}") + # make sure it's gone + assert db.query(orm.Share).filter_by(id=share_id).one_or_none() is None + + +def test_share_scopes(app, share_user, share): + db = app.db + user_scopes = scopes.get_scopes_for(share_user) + assert set(share.scopes).issubset(user_scopes) + # delete share, no longer have scopes + db.delete(share) + db.commit() + user_scopes = scopes.get_scopes_for(share_user) + assert not set(share.scopes).intersection(user_scopes) + + +def test_share_group_scopes(app, share_user, group_share): + # make sure share is actually in the group (make sure group_share fixture worked) + db = app.db + share = group_share + assert group_share.group in share_user.groups + user_scopes = scopes.get_scopes_for(share_user) + assert set(share.scopes).issubset(user_scopes) + # delete share, no longer have scopes + db.delete(share) + db.commit() + user_scopes = scopes.get_scopes_for(share_user) + assert not set(share.scopes).intersection(user_scopes) + + +def test_share_code(app, user, share_user): + spawner = user.spawner.orm_spawner + user = spawner.user + code_scopes = sorted( + [ + f"read:servers!server={user.name}/", + f"access:servers!server={user.name}/", + ] + ) + orm_code, code = orm.ShareCode.new( + app.db, + spawner, + scopes=code_scopes, + ) + assert sorted(orm_code.scopes) == code_scopes + assert orm_code.owner is user + assert orm_code.spawner is spawner + assert orm_code in spawner.share_codes + assert orm_code in user.share_codes + + share_with_scopes = scopes.get_scopes_for(share_user) + for scope in code_scopes: + assert scope not in share_with_scopes + + assert orm_code.exchange_count == 0 + assert orm_code.last_exchanged_at is None + # do it twice, shouldn't change anything + orm_code.exchange(share_user) + assert orm_code.exchange_count == 1 + assert orm_code.last_exchanged_at is not None + now = orm_code.now() + assert now - timedelta(10) <= orm_code.last_exchanged_at <= now + timedelta(10) + + share_with_scopes = scopes.get_scopes_for(share_user) + for scope in code_scopes: + assert scope in share_with_scopes + + +def test_share_code_expires(app, user, share_user): + db = app.db + spawner = user.spawner.orm_spawner + user = spawner.user + orm_code, code = orm.ShareCode.new( + db, + spawner, + scopes=[ + f"access:servers!server={user.name}/", + ], + ) + # check expiration + assert orm_code.expires_at + now = orm_code.now() + assert ( + now - timedelta(10) + <= orm_code.expires_at + <= now + timedelta(seconds=orm.ShareCode.default_expires_in + 10) + ) + orm.ShareCode.purge_expired(db) + found = orm.ShareCode.find(db, code=code) + assert found + assert found.id == orm_code.id + + with mock.patch( + 'jupyterhub.orm.ShareCode.now', staticmethod(lambda: now + timedelta(hours=1)) + ): + orm.ShareCode.purge_expired(db) + found = orm.ShareCode.find(db, code=code) + assert found + assert found.id == orm_code.id + + with mock.patch( + 'jupyterhub.orm.ShareCode.now', staticmethod(lambda: now + timedelta(hours=25)) + ): + found = orm.ShareCode.find(db, code=code) + assert found is None + # try exchanging expired code + with pytest.raises(ValueError): + orm_code.exchange(share_user) + + # expired code, should have been deleted + found = orm.ShareCode.find(db, code=code) + assert found is None + assert db.query(orm.ShareCode).filter_by(id=orm_code.id).one_or_none() is None + + +# API tests + + +@pytest.mark.parametrize( + "kind", + [ + ("user"), + ("group"), + ], +) +async def test_shares_api_user_group_doesnt_exist( + app, + user, + group, + share_user, + kind, +): + # make sure default spawner exists + spawner = user.spawner # noqa + body = {} + if kind == "user": + body["user"] = "nosuchuser" + elif kind == "group": + body["group"] = "nosuchgroup" + + r = await api_request( + app, f"/shares/{user.name}/", method="post", data=json.dumps(body) + ) + assert r.status_code == 400 + + +@pytest.mark.parametrize( + "which", + [ + ("user"), + ("server"), + ], +) +async def test_shares_api_target_doesnt_exist( + app, + user, + group, + share_user, + which, +): + # make sure default spawner exists + if which == "server": + share_path = f"/shares/{user.name}/nosuchserver" + elif which == "user": + share_path = "/shares/nosuchuser/" + body = {"user": share_user.name} + + r = await api_request(app, share_path, method="post", data=json.dumps(body)) + assert r.status_code == 404 + + +@pytest.mark.parametrize( + "have_scopes, share_scopes, with_user, with_group, status", + [ + (None, None, True, False, 200), + ("shares", None, True, False, 403), + ( + "shares!server=SERVER,servers!server=SERVER,read:users:name!user=SHARE_WITH", + "read:servers!server=SERVER,access:servers!server=SERVER", + True, + False, + 200, + ), + (None, "read:servers!server=other/", False, True, 400), + ( + "shares,access:servers,read:users:name", + "admin:servers!server=SERVER", + False, + True, + 403, + ), + (None, None, False, False, 400), + (None, None, "nosuchuser", False, 400), + (None, None, False, "nosuchgroup", 400), + (None, None, True, True, 400), + (None, None, True, False, 200), + ], +) +async def test_shares_api_create( + app, + user, + group, + share_user, + create_user_with_scopes, + have_scopes, + share_scopes, + with_user, + with_group, + status, +): + # make sure default spawner exists + spawner = user.spawner # noqa + body = {} + share_with = share_user + if with_user: + body["user"] = share_user.name if with_user == True else with_user + if with_group: + body["group"] = group.name if with_group == True else with_group + share_with = group + _expand_scopes = partial(expand_scopes, user=user, share_with=share_with) + + expected_scopes = _expand_scopes("access:servers!server=SERVER") + if share_scopes: + share_scopes = _expand_scopes(share_scopes) + expected_scopes.extend(share_scopes) + body["scopes"] = share_scopes + + expected_scopes = sorted(set(expected_scopes)) + + if have_scopes is None: + # default: needed permissions + have_scopes = "shares,read:users:name,read:groups:name" + + requester = create_user_with_scopes(*_expand_scopes(have_scopes)) + + r = await api_request( + app, + f"/shares/{user.name}/", + method="post", + data=json.dumps(body), + name=requester.name, + ) + assert r.status_code == status + if r.status_code < 300: + share_model = r.json() + assert "scopes" in share_model + assert sorted(share_model["scopes"]) == expected_scopes + + +@pytest.mark.parametrize( + "have_scopes, before_scopes, revoke_scopes, after_scopes, with_user, with_group, status", + [ + ("read:shares", None, None, None, True, False, 403), + ("shares", None, None, None, True, False, 200), + ("shares!user=USER", None, None, None, False, True, 200), + (None, "read:servers!server=SERVER", None, None, True, False, 200), + ( + None, + "access:servers!server=SERVER", + "read:servers!server=SERVER", + "access:servers!server=SERVER", + True, + False, + 200, + ), + (None, None, None, None, "nosuchuser", False, 200), + (None, None, None, None, False, "nosuchgroup", 200), + (None, None, None, None, False, False, 400), + (None, None, None, None, True, True, 400), + ], +) +async def test_shares_api_revoke( + app, + user, + group, + share_user, + create_user_with_scopes, + have_scopes, + before_scopes, + revoke_scopes, + after_scopes, + with_user, + with_group, + status, +): + db = app.db + # make sure default spawner exists + spawner = user.spawner.orm_spawner # noqa + body = {} + share_with = share_user + if with_user: + body["user"] = share_user.name if with_user == True else with_user + if with_group: + body["group"] = group.name if with_group == True else with_group + share_with = group + _expand_scopes = partial(expand_scopes, user=user, share_with=share_with) + + if revoke_scopes: + revoke_scopes = _expand_scopes(revoke_scopes) + body["scopes"] = revoke_scopes + + if after_scopes: + after_scopes = _expand_scopes(after_scopes) + + if before_scopes: + orm.Share.grant(db, spawner, share_with, scopes=_expand_scopes(before_scopes)) + + if have_scopes is None: + # default: needed permissions + have_scopes = "shares,read:users:name,read:groups:name" + + requester = create_user_with_scopes(*_expand_scopes(have_scopes)) + + r = await api_request( + app, + f"/shares/{user.name}/", + method="patch", + data=json.dumps(body), + name=requester.name, + ) + assert r.status_code == status + if r.status_code < 300: + share_model = r.json() + if not after_scopes: + # no scopes specified, full revocation + assert share_model == {} + return + assert share_model["scopes"] == after_scopes + + +@pytest.mark.parametrize( + "have_scopes, status", + [ + ("shares", 204), + ("shares!user=USER", 204), + ("shares!server=SERVER", 204), + ("read:shares", 403), + ("shares!server=USER/other", 404), + ("shares!user=other", 404), + ], +) +async def test_shares_api_revoke_all( + app, + user, + group, + share_user, + create_user_with_scopes, + have_scopes, + status, +): + db = app.db + # make sure default spawner exists + spawner = user.spawner.orm_spawner # noqa + orm.Share.grant(db, spawner, share_user) + orm.Share.grant(db, spawner, group) + _expand_scopes = partial(expand_scopes, user=user) + + if have_scopes is None: + # default: needed permissions + have_scopes = "shares" + + requester = create_user_with_scopes(*_expand_scopes(have_scopes)) + + r = await api_request( + app, + f"/shares/{user.name}/", + method="delete", + name=requester.name, + ) + assert r.status_code == status + + # get updated share list + r = await api_request( + app, + f"/shares/{user.name}/", + ) + share_list = r.json() + + if status >= 400: + assert len(share_list["items"]) == 2 + else: + assert len(share_list["items"]) == 0 + + +@pytest.mark.parametrize( + "kind, case", + [ + ("users", "in-a"), + ("users", "in-b"), + ("users", "in-a-b"), + ("users", "not-in"), + ("users", "notfound"), + ("groups", "a"), + ("groups", "b"), + ("groups", "c"), + ("groups", "notfound"), + ], +) +async def test_shared_api_list_user_group( + app, populate_shares, create_user_with_scopes, kind, case +): + if case == "notfound": + name = "notfound" + else: + # find exact name, which will look like `{kind}-123` + for name, server_names in populate_shares[kind].items(): + if name.rpartition("-")[0] == case: + break + else: + raise ValueError(f"Did not find {case} in {populate_shares[kind].keys()}") + + r = await api_request(app, f"/{kind}/{name}/shared") + if name == "notfound": + assert r.status_code == 404 + return + else: + assert r.status_code == 200 + shares = r.json() + found_shares = sorted( + [share["server"]["user"]["name"] for share in shares["items"]] + ) + expected_shares = sorted(server_names) + assert found_shares == expected_shares + + +@pytest.mark.parametrize( + "kind, have_scopes, get_status, delete_status", + [ + ("users", "users", 403, 403), + ("users", "read:users", 403, 403), + ("users", "read:users!user=USER", 403, 403), + ("users", "read:users:shares!user=SHARE_WITH", 200, 403), + ("users", "users:shares!user=SHARE_WITH", 200, 204), + ("users", "users:shares!user=other", 404, 404), + ("groups", "groups", 403, 403), + ("groups", "read:groups", 403, 403), + ("groups", "read:groups!group=group", 403, 403), + ("groups", "read:groups:shares!group=SHARE_WITH", 200, 403), + ("groups", "groups:shares!group=SHARE_WITH", 200, 204), + ("groups", "groups:shares!group=other", 404, 404), + ], +) +async def test_single_shared_api( + app, + user, + share_user, + group, + create_user_with_scopes, + kind, + have_scopes, + get_status, + delete_status, +): + db = app.db + share_user.groups.append(group) + db.commit() + spawner = user.spawner.orm_spawner + + if kind == "users": + share_with = share_user + else: + share_with = group + + _expand_scopes = partial(expand_scopes, user=user, share_with=share_with) + + requester = create_user_with_scopes(*_expand_scopes(have_scopes)) + + share_scopes = [f"access:servers!server={user.name}/"] + share = orm.Share.grant( + db, spawner=spawner, share_with=share_with, scopes=share_scopes + ) + api_url = f"/{kind}/{share_with.name}/shared/{user.name}/" + + fetch_share = partial(api_request, app, api_url, name=requester.name) + r = await fetch_share() + assert r.status_code == get_status + if get_status < 300: + # check content + pass + + r = await fetch_share(method="delete") + assert r.status_code == delete_status + if delete_status < 300: + assert r.text == "" + else: + # manual delete + db.delete(share) + db.commit() + + # now share doesn't exist, should 404 + assert orm.Share.find(db, spawner=spawner, share_with=share_with) is None + + r = await fetch_share() + assert r.status_code == 404 if get_status < 300 else get_status + r = await fetch_share(method="delete") + assert r.status_code == 404 if delete_status < 300 else delete_status + + +@pytest.mark.parametrize( + "kind, have_scopes, get_status, delete_status", + [ + ("users", "read:users:shares!user=SHARE_WITH", 404, 403), + ("users", "users:shares!user=SHARE_WITH", 404, 404), + ("users", "users:shares!user=other", 404, 404), + ("groups", "read:groups:shares!group=SHARE_WITH", 404, 403), + ("groups", "groups:shares!group=SHARE_WITH", 404, 404), + ("groups", "groups:shares!group=other", 404, 404), + ], +) +async def test_single_shared_api_no_such_owner( + app, + user, + share_user, + group, + create_user_with_scopes, + kind, + have_scopes, + get_status, + delete_status, +): + db = app.db + share_user.groups.append(group) + db.commit() + spawner = user.spawner.orm_spawner # noqa + + if kind == "users": + share_with = share_user + else: + share_with = group + + owner_name = "nosuchname" + + _expand_scopes = partial(expand_scopes, user=owner_name, share_with=share_with) + + requester = create_user_with_scopes(*_expand_scopes(have_scopes)) + + api_url = f"/{kind}/{share_with.name}/shared/{owner_name}/" + + fetch_share = partial(api_request, app, api_url, name=requester.name) + r = await fetch_share() + assert r.status_code == get_status + + r = await fetch_share(method="delete") + assert r.status_code == delete_status + + +@pytest.mark.parametrize( + "kind", + [ + ("users"), + ("groups"), + ], +) +async def test_single_shared_api_no_such_target( + app, user, share_user, group, create_user_with_scopes, kind +): + db = app.db + share_user.groups.append(group) + db.commit() + spawner = user.spawner.orm_spawner # noqa + share_with = "nosuch" + kind + + requester = create_user_with_scopes(f"{kind}:shares") + + api_url = f"/{kind}/{share_with}/shared/{user.name}/" + + fetch_share = partial(api_request, app, api_url, name=requester.name) + r = await fetch_share() + assert r.status_code == 404 + + r = await fetch_share(method="delete") + assert r.status_code == 404 + + +@pytest.mark.parametrize( + "have_scopes, n_groups, n_users, ok", + [ + ("shares", 0, 0, True), + ("read:shares", 0, 2, True), + ("read:shares!user=USER", 3, 0, True), + ("read:shares!server=SERVER", 2, 1, True), + ("read:users:shares", 0, 0, False), + ], +) +async def test_shares_api_list_server( + app, user, share_user, create_user_with_scopes, have_scopes, n_groups, n_users, ok +): + db = app.db + spawner = user.spawner.orm_spawner + + _expand_scopes = partial(expand_scopes, user=user, share_with=share_user) + + requester = create_user_with_scopes(*_expand_scopes(have_scopes)) + + expected_shares = [] + for i in range(n_users): + u = create_user_with_scopes().orm_user + orm.Share.grant(db, spawner, u) + expected_shares.append(f"user:{u.name}") + + for i in range(n_groups): + group = orm.Group(name=new_group_name()) + db.add(group) + db.commit() + orm.Share.grant(db, spawner, group) + expected_shares.append(f"group:{group.name}") + expected_shares = sorted(expected_shares) + r = await api_request(app, f"/shares/{user.name}/", name=requester.name) + if ok: + assert r.status_code == 200 + else: + assert r.status_code == 403 + return + shares = r.json() + found_shares = [] + for share in shares["items"]: + assert share["user"] or share["group"] + if share["user"]: + found_shares.append(f"user:{share['user']['name']}") + elif share["group"]: + found_shares.append(f"group:{share['group']['name']}") + found_shares = sorted(found_shares) + assert found_shares == expected_shares + + +@pytest.mark.parametrize( + "have_scopes, n_groups, n_users, status", + [ + ("shares", 0, 0, 200), + ("read:shares", 0, 2, 200), + ("read:shares!user=USER", 3, 0, 200), + ("read:shares!user=other", 3, 0, 404), + ("read:shares!server=SERVER", 2, 1, 404), + ("read:users:shares", 0, 0, 403), + ], +) +async def test_shares_api_list_user( + app, + user, + share_user, + create_user_with_scopes, + have_scopes, + n_groups, + n_users, + status, +): + db = app.db + spawner = user.spawner.orm_spawner + + _expand_scopes = partial(expand_scopes, user=user, share_with=share_user) + + requester = create_user_with_scopes(*_expand_scopes(have_scopes)) + + expected_shares = [] + for i in range(n_users): + u = create_user_with_scopes().orm_user + orm.Share.grant(db, spawner, u) + expected_shares.append(f"user:{u.name}") + + for i in range(n_groups): + group = orm.Group(name=new_group_name()) + db.add(group) + db.commit() + orm.Share.grant(db, spawner, group) + expected_shares.append(f"group:{group.name}") + expected_shares = sorted(expected_shares) + r = await api_request(app, f"/shares/{user.name}", name=requester.name) + assert r.status_code == status + if status >= 400: + return + shares = r.json() + found_shares = [] + for share in shares["items"]: + assert share["user"] or share["group"] + if share["user"]: + found_shares.append(f"user:{share['user']['name']}") + elif share["group"]: + found_shares.append(f"group:{share['group']['name']}") + found_shares = sorted(found_shares) + assert found_shares == expected_shares + + +async def test_shares_api_list_no_such_owner(app): + r = await api_request(app, "/shares/nosuchuser") + assert r.status_code == 404 + r = await api_request(app, "/shares/nosuchuser/") + assert r.status_code == 404 + r = await api_request(app, "/shares/nosuchuser/namedserver") + assert r.status_code == 404 + + +@pytest.mark.parametrize( + "method", + [ + "post", + "patch", + "delete", + ], +) +async def test_share_api_server_required(app, user, method): + """test methods defined on /shares/:user/:server not defined on /shares/:user""" + r = await api_request(app, f"/shares/{user.name}", method=method) + assert r.status_code == 405 + + +async def test_share_flow_full( + app, full_spawn, user, share_user, create_user_with_scopes +): + """Exercise the full process of sharing and then accessing a shared server""" + user = create_user_with_scopes( + "shares!user", "self", f"read:users:name!user={share_user.name}" + ) + # start server + await user.spawn("") + await app.proxy.add_user(user) + spawner = user.spawner + + # grant access + share_url = f"shares/{user.name}/{spawner.name}" + r = await api_request( + app, + share_url, + method="post", + name=user.name, + data=json.dumps({"user": share_user.name}), + ) + r.raise_for_status() + share_model = r.json() + + # attempt to _use_ access + user_url = public_url(app, user) + "api/contents/" + token = share_user.new_api_token() + r = await async_requests.get(user_url, headers={"Authorization": f"Bearer {token}"}) + r.raise_for_status() + + # revoke access + r = await api_request( + app, + share_url, + method="patch", + name=user.name, + data=json.dumps( + { + "scopes": share_model["scopes"], + "user": share_user.name, + } + ), + ) + r.raise_for_status() + # new request with new token to avoid cache + token = share_user.new_api_token() + r = await async_requests.get(user_url, headers={"Authorization": f"Bearer {token}"}) + assert r.status_code == 403 + + +# share codes + + +@pytest.mark.parametrize( + "method", + [ + "post", + "delete", + ], +) +async def test_share_codes_api_server_required(app, user, method): + """test methods defined on /share-codes/:user/:server not defined on /share-codes/:user""" + r = await api_request(app, f"/share-codes/{user.name}", method=method) + assert r.status_code == 405 + + +@pytest.mark.parametrize( + "have_scopes, n_codes, level, status", + [ + ("shares", 0, 'user', 200), + ("read:shares", 2, 'server', 200), + ("read:shares!user=USER", 3, 'user', 200), + ("read:shares!server=SERVER", 2, 'server', 200), + ("read:shares!server=SERVER", 2, 'user', 404), + ("read:users:shares", 0, 'user', 403), + ("users:shares", 1, 'server', 403), + ], +) +async def test_share_codes_api_list( + app, user, share_user, create_user_with_scopes, have_scopes, n_codes, level, status +): + db = app.db + spawner = user.spawner.orm_spawner + + _expand_scopes = partial(expand_scopes, user=user, share_with=share_user) + requester = create_user_with_scopes(*_expand_scopes(have_scopes)) + + expected_shares = [] + for i in range(n_codes): + code = orm.ShareCode( + spawner=spawner, + owner=spawner.user, + scopes=sorted(scopes.access_scopes(spawner=spawner)), + ) + db.add(code) + db.commit() + expected_shares.append(f"sc_{code.id}") + + expected_shares = sorted(expected_shares) + if level == 'user': + path = f"/share-codes/{user.name}" + else: + path = f"/share-codes/{user.name}/" + r = await api_request(app, path, name=requester.name) + assert r.status_code == status + if status >= 400: + return + share_codes = r.json() + found_shares = [] + for share_code in share_codes["items"]: + assert 'code' not in share_code + assert 'id' in share_code + assert 'server' in share_code + found_shares.append(share_code["id"]) + found_shares = sorted(found_shares) + assert found_shares == expected_shares + + +async def test_share_codes_api_list_no_such_owner(app, user): + spawner = user.spawner.orm_spawner # noqa + r = await api_request(app, "/share-codes/nosuchuser") + assert r.status_code == 404 + r = await api_request(app, "/share-codes/nosuchuser/") + assert r.status_code == 404 + r = await api_request(app, f"/share-codes/{user.name}/nosuchserver") + assert r.status_code == 404 + + +@pytest.mark.parametrize( + "have_scopes, share_scopes, status", + [ + (None, None, 200), + ("shares", None, 200), + ("shares!user=other", None, 404), + ( + "shares!server=SERVER,servers!server=SERVER", + "read:servers!server=SERVER,access:servers!server=SERVER", + 200, + ), + (None, "read:servers!server=other/", 400), + ( + "shares,access:servers", + "admin:servers!server=SERVER", + 403, + ), + (None, None, 200), + ], +) +async def test_share_codes_api_create( + app, + user, + group, + share_user, + create_user_with_scopes, + have_scopes, + share_scopes, + status, +): + # make sure default spawner exists + spawner = user.spawner # noqa + body = {} + share_with = share_user + _expand_scopes = partial(expand_scopes, user=user, share_with=share_with) + + expected_scopes = _expand_scopes("access:servers!server=SERVER") + if share_scopes: + share_scopes = _expand_scopes(share_scopes) + expected_scopes.extend(share_scopes) + body["scopes"] = share_scopes + + expected_scopes = sorted(set(expected_scopes)) + + if have_scopes is None: + # default: needed permissions + have_scopes = "shares" + + requester = create_user_with_scopes(*_expand_scopes(have_scopes)) + + r = await api_request( + app, + f"/share-codes/{user.name}/", + method="post", + data=json.dumps(body), + name=requester.name, + ) + assert r.status_code == status + if r.status_code >= 400: + return + + share_model = r.json() + assert "scopes" in share_model + assert sorted(share_model["scopes"]) == expected_scopes + assert "code" in share_model + assert "accept_url" in share_model + parsed_accept_url = urlparse(share_model["accept_url"]) + accept_query = parse_qs(parsed_accept_url.query) + assert accept_query == {"code": [share_model["code"]]} + assert parsed_accept_url.path == url_path_join(app.base_url, "hub/accept-share") + + +@pytest.mark.parametrize( + "expires_in, status", + [ + (None, 200), + ("notanumber", 400), + (-1, 400), + (60, 200), + (525600 * 59, 200), + (525600 * 60 + 1, 400), + ], +) +async def test_share_codes_api_create_expires_in( + app, + user, + group, + create_user_with_scopes, + expires_in, + status, +): + # make sure default spawner exists + spawner = user.spawner # noqa + body = {} + now = utcnow() + if expires_in: + body["expires_in"] = expires_in + + r = await api_request( + app, + f"/share-codes/{user.name}/", + method="post", + data=json.dumps(body), + ) + assert r.status_code == status + if r.status_code >= 400: + return + + share_model = r.json() + assert "expires_at" in share_model + assert share_model["expires_at"] + expires_at = parse_date(share_model["expires_at"]) + + expected_expires_at = now + timedelta( + seconds=expires_in or orm.ShareCode.default_expires_in + ) + window = timedelta(seconds=60) + assert expected_expires_at - window <= expires_at <= expected_expires_at + window + + async def get_code(): + r = await api_request( + app, + f"/share-codes/{user.name}/", + ) + r.raise_for_status() + codes = r.json()["items"] + assert len(codes) <= 1 + if len(codes) == 1: + return codes[0] + else: + return None + + code = await get_code() + assert code + + with mock.patch( + 'jupyterhub.orm.ShareCode.now', + staticmethod(lambda: (expires_at + timedelta(seconds=1)).replace(tzinfo=None)), + ): + code = await get_code() + assert code is None + + +@pytest.mark.parametrize( + "have_scopes, delete_by, status", + [ + (None, None, 204), + ("shares", "id=ID", 204), + ( + "shares!server=SERVER", + "code=CODE", + 204, + ), + ("shares!user=other", None, 404), + ("read:shares", "code=CODE", 403), + ("shares", "id=invalid", 404), + ("shares", "id=sc_9999", 404), + ("shares", "code=nomatch", 404), + ], +) +async def test_share_codes_api_revoke( + app, + user, + group, + share_user, + create_user_with_scopes, + have_scopes, + delete_by, + status, +): + db = app.db + spawner = user.spawner.orm_spawner + + _expand_scopes = partial(expand_scopes, user=user, share_with=share_user) + # make sure default spawner exists + spawner = user.spawner.orm_spawner + share_code, code = orm.ShareCode.new( + db, spawner, scopes=list(scopes.access_scopes(spawner=spawner)) + ) + + assert orm.ShareCode.find(db, code=code) + other_share_code, other_code = orm.ShareCode.new( + db, spawner, scopes=list(scopes.access_scopes(spawner=spawner)) + ) + + if have_scopes is None: + # default: needed permissions + have_scopes = "shares" + + requester = create_user_with_scopes(*_expand_scopes(have_scopes)) + + url = f"/share-codes/{user.name}/" + if delete_by: + query = delete_by.replace("CODE", code).replace("ID", f"sc_{share_code.id}") + url = f"{url}?{query}" + + r = await api_request( + app, + url, + method="delete", + name=requester.name, + ) + assert r.status_code == status + + # other code unaffected + if r.status_code >= 400: + assert orm.ShareCode.find(db, code=code) + return + # code has been deleted + assert orm.ShareCode.find(db, code=code) is None + if delete_by is None: + assert orm.ShareCode.find(db, code=other_code) is None + else: + assert orm.ShareCode.find(db, code=other_code) + + +@pytest.mark.parametrize( + "who, code_arg, get_status, post_status", + [ + ("share", None, 400, 400), + ("share", "nosuchcode", 404, 400), + ("share", "CODE", 200, 302), + ("self", "CODE", 403, 400), + ], +) +async def test_accept_share_page( + app, user, share_user, who, code_arg, get_status, post_status +): + db = app.db + spawner = user.spawner.orm_spawner + orm_code, code = orm.ShareCode.new( + db, spawner, scopes=list(scopes.access_scopes(spawner=spawner)) + ) + if who == "self": + cookies = await app.login_user(user.name) + else: + cookies = await app.login_user(share_user.name) + + url = "accept-share" + form_data = {"_xsrf": cookies['_xsrf']} + if code_arg: + code_arg = code_arg.replace("CODE", code) + form_data["code"] = code_arg + url = url + f"?code={code_arg}" + + r = await get_page(url, app, cookies=cookies) + assert r.status_code == get_status + + # try submitting the form with the same inputs + accept_url = public_url(app) + "hub/accept-share" + r = await async_requests.post( + accept_url, + cookies=cookies, + data=form_data, + allow_redirects=False, + ) + assert r.status_code == post_status + if post_status < 400: + assert orm_code.exchange_count == 1 + # share is accepted + assert len(share_user.shared_with_me) == 1 + assert share_user.shared_with_me[0].spawner is spawner + else: + assert orm_code.exchange_count == 0 + assert not share_user.shared_with_me + + +@pytest.mark.parametrize( + "running, next_url, expected_next", + [ + (False, None, "{USER_URL}"), + (True, None, "{USER_URL}"), + (False, "https://example.com{BASE_URL}", "{USER_URL}"), + (False, "{BASE_URL}hub", ""), + (True, "{USER_URL}lab/tree/notebook.ipynb?param=5", ""), + (False, "{USER_URL}lab/tree/notebook.ipynb?param=5", ""), + ], +) +async def test_accept_share_page_next_url( + app, + user, + share_user, + running, + next_url, + expected_next, +): + db = app.db + spawner = user.spawner.orm_spawner + orm_code, code = orm.ShareCode.new( + db, spawner, scopes=list(scopes.access_scopes(spawner=spawner)) + ) + cookies = await app.login_user(share_user.name) + + if running: + await user.spawn() + await user.spawner.server.wait_up(http=True) + await app.proxy.add_user(user) + else: + pass + + def expand_url(url): + url = url.format( + USER=user.name, + USER_URL=user.server_url(""), + BASE_URL=app.base_url, + ) + return url + + if next_url: + next_url = expand_url(next_url) + if expected_next: + expected_next = expand_url(expected_next) + else: + # empty: expect match + expected_next = next_url + + url = f"accept-share?code={code}" + form_data = {"_xsrf": cookies['_xsrf']} + if next_url: + url = url_concat(url, {"next": next_url}) + + r = await get_page(url, app, cookies=cookies) + assert r.status_code == 200 + + page = BeautifulSoup(r.text) + page_body = page.find("div", class_="container").get_text() + if running: + assert "not currently running" not in page_body + else: + assert "not currently running" in page_body + + # try submitting the form with the same inputs + accept_url = r.url + r = await async_requests.post( + accept_url, + cookies=cookies, + data=form_data, + allow_redirects=False, + ) + assert r.status_code == 302 + target = r.headers["Location"] + # expect absolute path redirect + expected_next_target = urlunparse( + urlparse(expected_next)._replace(scheme="", netloc="") + ) + assert target == expected_next_target + # is it worth following the redirect? diff --git a/jupyterhub/tests/test_spawner.py b/jupyterhub/tests/test_spawner.py index aa66e99b..b11df693 100644 --- a/jupyterhub/tests/test_spawner.py +++ b/jupyterhub/tests/test_spawner.py @@ -19,7 +19,7 @@ from .. import orm from .. import spawner as spawnermod from ..objects import Hub, Server from ..scopes import access_scopes -from ..spawner import LocalProcessSpawner, Spawner +from ..spawner import SimpleLocalProcessSpawner, Spawner from ..user import User from ..utils import AnyTimeoutError, maybe_future, new_token, url_path_join from .mocking import public_url @@ -47,7 +47,7 @@ def setup(): def new_spawner(db, **kwargs): - user = kwargs.setdefault('user', User(db.query(orm.User).first(), {})) + user = kwargs.setdefault("user", User(db.query(orm.User).one(), {})) kwargs.setdefault('cmd', [sys.executable, '-c', _echo_sleep]) kwargs.setdefault('hub', Hub()) kwargs.setdefault('notebook_dir', os.getcwd()) @@ -57,7 +57,7 @@ def new_spawner(db, **kwargs): kwargs.setdefault('term_timeout', 1) kwargs.setdefault('kill_timeout', 1) kwargs.setdefault('poll_interval', 1) - return user._new_spawner('', spawner_class=LocalProcessSpawner, **kwargs) + return user._new_spawner('', spawner_class=SimpleLocalProcessSpawner, **kwargs) async def test_spawner(db, request): @@ -381,7 +381,11 @@ async def test_spawner_bad_api_token(app): ( ["self", "read:groups!group=x", "users:activity"], ["admin:groups", "users:activity"], - ["read:groups!group=x", "read:groups:name!group=x", "users:activity"], + [ + "read:groups!group=x", + "read:groups:name!group=x", + "users:activity", + ], ), ], ) diff --git a/requirements.txt b/requirements.txt index 0d07a703..80b9c469 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,6 +10,7 @@ packaging pamela>=1.1.0; sys_platform != 'win32' prometheus_client>=0.5.0 psutil>=5.6.5; sys_platform == 'win32' +pydantic>=2 python-dateutil requests SQLAlchemy>=1.4.1 diff --git a/share/jupyterhub/templates/accept-share.html b/share/jupyterhub/templates/accept-share.html new file mode 100644 index 00000000..01cc7e5b --- /dev/null +++ b/share/jupyterhub/templates/accept-share.html @@ -0,0 +1,51 @@ +{% extends "page.html" %} +{% block login_widget %} +{% endblock %} + +{% block main %} +
+

Accept sharing invitation

+ +

+ You ({{ user.name }}) have been invited to access {{ owner.name }}'s server + {%- if spawner.name %} ({{ spawner.name }}){%- endif %} at {{ spawner_url }}. +

+ + {% if not spawner_ready %} +

+ The server at {{ spawner_url }} is not currently running. + After accepting permission, you may need to ask {{ owner.name }} + to start the server before you can access it. +

+ {% endif %} + +

+ By accepting the invitation, you will be granted the following permissions, + restricted to this particular server: +

+ +
+
+ {# these are the 'real' inputs to the form -#} + + + {% for scope_info in scope_descriptions -%} +
+ +
+ {% endfor -%} +

+ After accepting the invitation, you will be redirected to {{ next_url }}. +

+ +
+
+
+ +{% endblock %}